From 042b9c963317c4bb7f1d6a7487c32c66ca244a16 Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Thu, 23 Jul 2026 07:21:07 +0000 Subject: [PATCH] add Insin integration spec and CLAUDE.md notes --- CLAUDE.md | 64 ++++ .../2026-07-23-insin-integration-design.md | 287 ++++++++++++++++++ 2 files changed, 351 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-23-insin-integration-design.md diff --git a/CLAUDE.md b/CLAUDE.md index 57cac85..0bcfff5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -287,3 +287,67 @@ Font magnification is 1-8x for both width and height: - Check RestaurantId and ApiKey in config.txt (base64-encoded) - Verify network connectivity to the API URL - SignalR auto-reconnects with infinite retry; check logs for reconnection attempts + +## Insin Integration + +This service is deployed as an Insin package and emits telemetry via the on-device Insin agent's **local loopback listener**. Env vars `INSIN_URL` and `INSIN_TOKEN` are provided by the deployment environment. + +### Publishing events (local loopback — the path we use) + +Because this service runs on Insin-managed devices where `insin monitor` (the `insin.service` systemd unit) is active, events go to the loopback ingest — **not** a remote HTTP endpoint. The on-device agent persists locally and forwards on the next device heartbeat. + +- **Endpoint:** `POST http://127.0.0.1:47823/events` (loopback-only, no auth). +- **Single event payload:** `{"kind": ".[.]", "message": "", "at": ""}`. +- **Batch payload:** `{"events": [ {...}, {...} ]}`. +- **Event `kind` conventions** for this service: + - `job.printed` — successful print, message includes printer + jobId + - `job.failed.` — e.g. `job.failed.paper_out`, `job.failed.cover_open`, `job.failed.connection` + - `printer.online` / `printer.offline` — status transitions + - `printer.discovered` — new printer found by discovery +- **Metrics** use the same shape at `POST /metrics` with `{name, value, unit}` (e.g. `printer.head.temperature`). +- Delivery: at-least-once, server ring-trims to ~1000 per device. Admin UI polls every 5s. No rate limiting or 429s; each POST commits to local SQLite before returning 200. +- **If `127.0.0.1:47823` is unreachable**, `insin monitor` isn't running — do not swallow this silently in production; log it. In dev, just no-op. +- Full reference: `docs/device-telemetry-api.md` in the Insin repo. + +### Deploying / publishing this service as an Insin package + +Insin uses a flat global package namespace with the `AdminToken` auth header (NOT `Bearer`). Publish flow for CI or a release script: + +```bash +set -euo pipefail +: "${INSIN_URL:?}" "${INSIN_TOKEN:?}" + +# 1. Fetch latest CLI (linux-arm64 shown; use win-x64 on Windows CI). +LATEST=$(curl -fsSL "$INSIN_URL/api/v1/downloads/cli" \ + | python3 -c 'import json,sys; m=json.load(sys.stdin)[0]; a=next(x for x in m["artifacts"] if x["rid"]=="linux-arm64"); print(m["version"], a["filename"])') +VERSION=${LATEST% *}; FILENAME=${LATEST#* } +curl -fsSL "$INSIN_URL/api/v1/downloads/cli/$VERSION/$FILENAME" -o /tmp/insin.tar.gz +mkdir -p /tmp/insin && tar -xzf /tmp/insin.tar.gz -C /tmp/insin +chmod +x /tmp/insin/insin + +# 2. Pack. NOTE: pack zips CWD recursively (minus *.pkg) and writes to +# ../packages/@.pkg — one directory UP from CWD. +# cd into the build output first; don't run from repo root (would bundle .git/). +cd EpsonPrintService/bin/Release/net8.0/publish +/tmp/insin/insin pack epson-print-service@1.2.3 + +# 3. Publish. Reads INSIN_URL + INSIN_TOKEN from env; --url/--token override. +/tmp/insin/insin publish ../packages/epson-print-service@1.2.3.pkg +``` + +Under the hood `publish` = `POST $INSIN_URL/api/v1/admin/packages` (multipart form, field `file`) with header `Authorization: AdminToken $INSIN_TOKEN`. + +### Insin rules & gotchas + +- **Auth header:** `Authorization: AdminToken ` — NOT `Bearer`. Same header for master admin token AND service tokens. +- **Always use a service token** (minted in admin UI → Service Tokens → New token, plaintext shown once). Never ship the master `INSIN_ADMIN_TOKEN`. +- **`INSIN_URL` is a bare origin:** no trailing slash, no `/api` suffix. Just `https://insin.example.com`. +- **First publish auto-creates** the package name. No separate registration. +- **Versions are immutable.** Re-publishing the same `(name, version)` returns **409 Conflict**. Bump the version. +- **Flat global namespace** — no scopes. Pick a distinctive name (we use `epson-print-service`). +- **CLI artifacts:** only `linux-arm64` and `win-x64` today. On `linux-x64` CI, run under qemu (`--platform linux/arm64`) — the CLI is just packer + uploader so emulation is fine. +- **`insin pack` output is one dir UP** (`../packages/`), not `./`. Look there if the `.pkg` seems missing. +- If both `INSIN_ADMIN_TOKEN` and `INSIN_TOKEN` are set, `publish` reads `INSIN_TOKEN` first (service token wins). +- **List packages:** `curl -H "Authorization: AdminToken $INSIN_TOKEN" $INSIN_URL/api/v1/admin/packages`. +- **Delete a version:** `DELETE /api/v1/admin/packages/{name}/{version}` (same header). +- No shared public staging — stand up a scratch instance if you need one. diff --git a/docs/superpowers/specs/2026-07-23-insin-integration-design.md b/docs/superpowers/specs/2026-07-23-insin-integration-design.md new file mode 100644 index 0000000..b4886b8 --- /dev/null +++ b/docs/superpowers/specs/2026-07-23-insin-integration-design.md @@ -0,0 +1,287 @@ +# Insin Integration — Design + +**Status:** design (approved via brainstorming) +**Date:** 2026-07-23 +**Scope:** Combined runtime telemetry emission and manual publish flow for shipping this service as an Insin package. + +## Purpose + +Two integrations with the Insin package server + on-device agent: + +1. **Runtime telemetry** — emit a small set of events to Insin so operators can see live service and printer state in the Insin admin UI. +2. **Package delivery** — package the service as an Insin `.pkg` so it can be delivered, installed, and upgraded on Raspberry Pi devices by the on-device `insin monitor` agent, replacing the current hand-copy-to-Pi workflow. + +## Context + +- Runs on Raspberry Pi (linux-arm64). Currently deployed manually via `scp` + hand-installed systemd units (`epson.service`, `print_server.service`). +- Insin is *delivery + installer + telemetry ingest*, not a process supervisor. Systemd stays on the device. +- The on-device agent (`insin monitor`, running under `insin.service`) exposes a loopback HTTP listener at `http://127.0.0.1:47823/events` — this is the only usable event-ingest path for a service that isn't a device itself. +- Environment secrets `INSIN_URL` and `INSIN_TOKEN` are provided on the machine that runs the publish script (developer machine, not the Pi). + +## Non-goals + +- No CI automation. Publish is a hand-run script triggered by the developer. +- No metrics (numeric samples), only events. Metrics can be added later if a specific need appears. +- No status-poll background task. Online/offline signal is driven purely by the existing discovery cycle. +- No persistence of "seen printer IPs" — restart re-fires `printer.discovered` for already-known printers. Noise is acceptable. +- No fleet or rollback automation. Reassigning group versions in the Insin admin UI is the operator's job. + +## Runtime — Telemetry Emission + +### Event catalog + +| Kind | When | Message shape | +|---|---|---| +| `service.started` | Once, at end of `PrintServerBootstrapper.StartAsync()` | `version= restaurantId=` | +| `job.printed` | `PrinterQueue.ProcessQueueAsync()` on `PrintResult.Ok` | `printer= receiptType= durationMs=` | +| `job.failed.` | `PrinterQueue.ProcessQueueAsync()` on `PrintResult.Fail` | `printer= receiptType= retry= error=""` | +| `printer.discovered` | First time an IP is seen after service start | `printer= model=` | +| `printer.online` | Discovery cycle where an IP transitions from absent → present | `printer=` | +| `printer.offline` | Discovery cycle where an IP transitions from present → absent | `printer=` | + +**`PrintErrorType` → suffix mapping** (in `PrinterQueue`): + +- `PaperOut` → `paper_out` +- `CoverOpen` → `cover_open` +- `Connection` → `connection` +- anything else → `other` + +**Message format**: flat `key=value` pairs, space-separated. Values containing spaces are double-quoted (`error="paper end detected"`). No structured JSON in the message body. + +### Interface + +New namespace: `Inspectron.Epson.PrintServer.Telemetry`. + +```csharp +public interface IInsinTelemetry +{ + void Emit(string kind, string message); +} +``` + +### Implementations + +- **`LoopbackInsinTelemetry`** — posts `{"kind": , "message": , "at": }` to `http://127.0.0.1:47823/events`. 2-second timeout, fire-and-forget on a background task. Failures are caught and logged at `LogLevel.Warning` at most once per 5-minute window with `"insin loopback unreachable at 127.0.0.1:47823, dropping events"`. Never re-throws. Uses the singleton `HttpClient` already bound in `PrintServerBootstrapper`. +- **`NullInsinTelemetry`** — no-op. Bound when `EpsonPrintServiceConfiguration.EmulationMode` is true. + +### DI binding + +In `PrintServerBootstrapper.StartAsync()`: + +```csharp +if (_config.EmulationMode) + _kernel.Bind().To().InSingletonScope(); +else + _kernel.Bind().To().InSingletonScope(); +``` + +### Wiring — where events fire from + +- **`service.started`** — one-line call at the end of `PrintServerBootstrapper.StartAsync()`, after `IsRunning = true`, reading `_config` for restaurant id and reflecting the running assembly's `InformationalVersion` / `Version`. +- **`job.printed` / `job.failed.`** — inside `PrinterQueue.ProcessQueueAsync()`, at the point where `PrintResult` is inspected. Pass through the `PrintJob` (for printer ip, receipt type, retry count) and the `PrintErrorType` on failure. `durationMs` is measured from just before `IPrintService.PrintAsync` to just after. +- **`printer.discovered` / `printer.online` / `printer.offline`** — new decorator `InsinTelemetryDiscoveredPrintersReceiver` that wraps `JamesDiscoveredPrintersReceiver`. Holds: + - `HashSet _seenIps` — IPs seen at least once during this process's lifetime. + - `Dictionary _present` — last-known presence per IP, keyed by IP. + On each `Handle(...)` call: + 1. Delegate to the wrapped receiver first (behavior preserved). + 2. Compute the diff between the incoming set of IPs and `_present`. + 3. For each new IP not in `_seenIps`: `Emit("printer.discovered", ...)`, then add to `_seenIps`. + 4. For each IP transitioning absent → present: `Emit("printer.online", ...)`. + 5. For each IP transitioning present → absent: `Emit("printer.offline", ...)`. + 6. Update `_present`. + Bound in DI as `IDiscoveredPrintersReceiver`, taking `JamesDiscoveredPrintersReceiver` as a constructor dependency. + +### Error handling + +- All `Emit` call sites are wrapped in `try/catch (Exception)` so a bug in message formatting can't break the caller. Caught exceptions log at `LogLevel.Warning`. +- `LoopbackInsinTelemetry.Emit` never throws. HTTP failures are handled internally with rate-limited warn logging. +- Print flow is never blocked on telemetry — every send is fire-and-forget on `Task.Run`. + +## Package Delivery + +### `.pkg` layout + +``` +epson-print-service@.pkg (zip) +├── install.cfg +├── epson-print-service # self-contained single-file linux-arm64 binary +├── epson.service # systemd unit — main service +├── print_server.service # systemd unit — companion +├── postinst +└── prem +``` + +### `install.cfg` + +```ini +install_path=/opt/epson-print-service +``` + +### `postinst` (CWD = install dir) + +```bash +#!/usr/bin/env bash +set -euo pipefail +INSTALL_DIR="$(pwd)" +chmod +x "$INSTALL_DIR/epson-print-service" +mkdir -p /var/lib/epson-print-service +install -m 644 epson.service /etc/systemd/system/epson.service +install -m 644 print_server.service /etc/systemd/system/print_server.service +systemctl daemon-reload +systemctl enable --now epson.service print_server.service +``` + +Idempotent: safe to re-run on upgrade over an existing install. + +### `prem` (CWD = temp dir on install, install dir on remove) + +```bash +#!/usr/bin/env bash +set -euo pipefail +systemctl disable --now epson.service print_server.service 2>/dev/null || true +rm -f /etc/systemd/system/epson.service /etc/systemd/system/print_server.service +systemctl daemon-reload 2>/dev/null || true +``` + +Idempotent: safe to run when the unit was never installed or has already been removed. Critical: this MUST stop the systemd unit before install-time file copy, otherwise files get overwritten under a running process. + +### `epson.service` + +```ini +[Unit] +Description=Epson Print Service +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +WorkingDirectory=/opt/epson-print-service +ExecStart=/opt/epson-print-service/epson-print-service +Restart=on-failure +RestartSec=5s + +[Install] +WantedBy=multi-user.target +``` + +`print_server.service` is rewritten to the same shape (companion service kept from current deploy). + +### Config-path change + +`ConfigurationPaths.Linux` currently returns `/root/epsonprintservice/config.txt`. This is incompatible with running from `/opt/epson-print-service/` under systemd. **Change:** `ConfigurationPaths.Linux` returns `/opt/epson-print-service/config.txt`. Users generate `config.txt` and drop it into the install directory post-install. + +### Missing-config-at-first-install behavior + +The service fails to start. Systemd's `Restart=on-failure RestartSec=5s` retries every 5s. When the operator drops `config.txt` into `/opt/epson-print-service/`, the next restart picks it up. No code change needed — this is already the current behavior. + +## Publish Script + +### Trigger + +Developer runs `./scripts/publish-insin.sh` from the repo root when they choose to ship. **No CI. No Gitea workflow. No scheduled automation.** + +### Version source of truth + +`` in `EpsonPrintService/EpsonPrintService.csproj`. Human bumps it in the PR that ships the change (matches existing cadence: `bump EpsonPrintService version to 1.0.14`). The script reads it via `dotnet msbuild -getProperty:Version`. + +Re-publishing the same version returns 409 Conflict from Insin — this is the "you forgot to bump" reminder. The script surfaces the error and exits non-zero. + +### Repo additions + +- `scripts/publish-insin.sh` — the script below (git-tracked, executable). +- `deploy/insin/` — `install.cfg`, `postinst`, `prem`, `epson.service`, `print_server.service` (git-tracked). +- `.gitignore` — add `.tools/` (insin CLI cache) and `publish/` (build output). + +### `scripts/publish-insin.sh` + +```bash +#!/usr/bin/env bash +set -euo pipefail + +: "${INSIN_URL:?INSIN_URL not set}" +: "${INSIN_TOKEN:?INSIN_TOKEN not set}" + +repo_root="$(cd "$(dirname "$0")/.." && pwd)" +cd "$repo_root" + +version=$(dotnet msbuild EpsonPrintService/EpsonPrintService.csproj \ + -getProperty:Version -nologo | tr -d '[:space:]') +echo "Publishing epson-print-service@${version}" + +payload="publish/payload" +rm -rf publish && mkdir -p "$payload" +dotnet publish EpsonPrintService/EpsonPrintService.csproj \ + -c Release -r linux-arm64 --self-contained true \ + -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true \ + -o "$payload" + +cp deploy/insin/install.cfg "$payload/" +cp deploy/insin/postinst "$payload/" +cp deploy/insin/prem "$payload/" +cp deploy/insin/epson.service "$payload/" +cp deploy/insin/print_server.service "$payload/" +chmod +x "$payload/postinst" "$payload/prem" + +mkdir -p .tools +manifest=$(curl -fsSL "$INSIN_URL/api/v1/downloads/cli") +cli_version=$(printf '%s' "$manifest" \ + | python3 -c 'import json,sys; print(json.load(sys.stdin)[0]["version"])') +cli_filename=$(printf '%s' "$manifest" \ + | python3 -c 'import json,sys; a=next(x for x in json.load(sys.stdin)[0]["artifacts"] if x["rid"]=="linux-arm64"); print(a["filename"])') +if [ ! -x ".tools/insin/insin" ] || [ "$(cat .tools/insin/.version 2>/dev/null)" != "$cli_version" ]; then + echo "Fetching insin CLI $cli_version" + curl -fsSL "$INSIN_URL/api/v1/downloads/cli/$cli_version/$cli_filename" -o /tmp/insin.tar.gz + rm -rf .tools/insin && mkdir -p .tools/insin + tar -xzf /tmp/insin.tar.gz -C .tools/insin + chmod +x .tools/insin/insin + echo "$cli_version" > .tools/insin/.version +fi +insin_bin="$repo_root/.tools/insin/insin" + +( cd "$payload" && "$insin_bin" pack "epson-print-service@${version}" ) +pkg="publish/packages/epson-print-service@${version}.pkg" +[ -f "$pkg" ] || { echo "expected $pkg missing"; exit 1; } + +"$insin_bin" publish "$pkg" +echo "Published epson-print-service@${version}" +``` + +Prerequisite for the developer running publish: the machine must be able to execute the `insin` CLI (arm64 or win-x64 build available). On x86_64 Linux, that means `qemu-user-static` installed. Outside the scope of this spec. + +## Testing + +### Runtime unit tests (in `EpsonTest`) + +- **`LoopbackInsinTelemetryTests`** — spin up an in-test `HttpListener` on `127.0.0.1:47823`, capture posted payloads, assert `kind`, `message`, ISO-8601 `at`. Cases: happy path, request timeout, connection refused (no listener bound), 500 response. Verify the rate-limited warn log fires once per 5-minute window and not more. +- **`InsinTelemetryDiscoveredPrintersReceiverTests`** — mock `IDiscoveredPrintersReceiver` inner, feed sequences of discovery cycles. Assertions: + - First-see: `printer.discovered` + `printer.online` in that order. + - Re-see: no events. + - Absent after present: `printer.offline`. + - Re-appearance after offline: `printer.online` only (not `discovered` again). + - Wrapped receiver's `Handle` is called every cycle regardless of telemetry. +- **`PrinterQueueTelemetryTests`** — mock `IInsinTelemetry`, run one success and one failure of each `PrintErrorType`, assert `kind` suffix mapping and message content (printer, receipt type, retry count). + +### Manual acceptance test (single Pi with real hardware) + +1. `./scripts/publish-insin.sh` (with bumped version). +2. Assign package to device group in Insin admin UI. +3. Wait one monitor tick (~60s). +4. `systemctl status epson.service` shows `active (running)`. +5. `service.started` and `printer.discovered` events appear in the Insin admin UI. +6. Trigger a real print → `job.printed` event. +7. Pull paper roll, trigger a print → `job.failed.paper_out`. +8. Bump version, re-publish, reassign to new version. Confirm rolling upgrade: old process stopped, new process running, no lingering unit files, no telemetry gaps beyond the restart window. + +### Not tested (deferred) + +- Multi-Pi fleet. +- Rollback smoke (reassigning to an older version). Same code path as upgrade — worth doing once but not blocking. +- Automated end-to-end. + +## Rollout plan + +1. Land runtime telemetry (interface, implementations, wiring, tests). +2. Land package assets (`deploy/insin/`, `.csproj` `` review, `ConfigurationPaths.Linux` change). +3. Land publish script. +4. First manual publish + smoke on a single Pi. +5. Assign to the rest of the fleet if smoke passes.