add Insin integration spec and CLAUDE.md notes
This commit is contained in:
287
docs/superpowers/specs/2026-07-23-insin-integration-design.md
Normal file
287
docs/superpowers/specs/2026-07-23-insin-integration-design.md
Normal file
@@ -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=<v> restaurantId=<id>` |
|
||||
| `job.printed` | `PrinterQueue.ProcessQueueAsync()` on `PrintResult.Ok` | `printer=<ip> receiptType=<type> durationMs=<n>` |
|
||||
| `job.failed.<reason>` | `PrinterQueue.ProcessQueueAsync()` on `PrintResult.Fail` | `printer=<ip> receiptType=<type> retry=<n> error="<short msg>"` |
|
||||
| `printer.discovered` | First time an IP is seen after service start | `printer=<ip> model=<name>` |
|
||||
| `printer.online` | Discovery cycle where an IP transitions from absent → present | `printer=<ip>` |
|
||||
| `printer.offline` | Discovery cycle where an IP transitions from present → absent | `printer=<ip>` |
|
||||
|
||||
**`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": <k>, "message": <m>, "at": <ISO-8601 UTC>}` 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<IInsinTelemetry>().To<NullInsinTelemetry>().InSingletonScope();
|
||||
else
|
||||
_kernel.Bind<IInsinTelemetry>().To<LoopbackInsinTelemetry>().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.<reason>`** — 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<string> _seenIps` — IPs seen at least once during this process's lifetime.
|
||||
- `Dictionary<string, bool> _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@<version>.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
|
||||
|
||||
`<Version>` 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` `<Version>` 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.
|
||||
Reference in New Issue
Block a user