Files
Print_server/docs/superpowers/specs/2026-07-23-insin-integration-design.md
2026-07-23 07:21:07 +00:00

14 KiB

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):

  • PaperOutpaper_out
  • CoverOpencover_open
  • Connectionconnection
  • 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.

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():

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

install_path=/opt/epson-print-service

postinst (CWD = install dir)

#!/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)

#!/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

[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

#!/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.