diff --git a/docs/superpowers/plans/2026-07-23-insin-integration.md b/docs/superpowers/plans/2026-07-23-insin-integration.md new file mode 100644 index 0000000..bb64e2b --- /dev/null +++ b/docs/superpowers/plans/2026-07-23-insin-integration.md @@ -0,0 +1,1399 @@ +# Insin Integration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship the Epson print service as an Insin `.pkg` and emit runtime telemetry (events) to the on-device Insin agent's loopback listener. + +**Architecture:** New `Inspectron.Epson.PrintServer.Telemetry` namespace holds a small `IInsinTelemetry` interface (`Emit(kind, message)`) with two implementations (loopback HTTP; null). Event firing is wired at four points: bootstrap end (`service.started`), print outcome in `PrinterQueue` (`job.printed`, `job.failed.`), and a decorator around the discovery receiver (`printer.discovered`, `printer.online`, `printer.offline`). Package delivery is a plain zip with two shell hooks (`postinst`, `prem`) that install/remove the systemd units; publish is a hand-run script (no CI). See `docs/superpowers/specs/2026-07-23-insin-integration-design.md` for background. + +**Tech Stack:** C# / .NET 8, xUnit, Ninject, systemd, bash, `insin` CLI. + +--- + +## File Structure + +**New source files:** +- `Inspectron.Epson/PrintServer/Telemetry/IInsinTelemetry.cs` — interface, one method `void Emit(string kind, string message)`. +- `Inspectron.Epson/PrintServer/Telemetry/NullInsinTelemetry.cs` — no-op implementation (for emulation mode). +- `Inspectron.Epson/PrintServer/Telemetry/LoopbackInsinTelemetry.cs` — POSTs to `http://127.0.0.1:47823/events`, fire-and-forget, rate-limited warn log on failure. +- `Inspectron.Epson/PrintServer/Telemetry/InsinMessageFormatter.cs` — pure static helper: builds `key=value key=value` strings, quotes values containing spaces. +- `Inspectron.Epson/PrintServer/Telemetry/InsinEventKinds.cs` — constants for all kind strings and `MapErrorTypeToKindSuffix(PrintErrorType)`. +- `Inspectron.Epson/PrintServer/DiscoveredPrintersReceiver/InsinTelemetryDiscoveredPrintersReceiver.cs` — decorator that wraps an inner `IDiscoveredPrintersReceiver` and emits transition events. + +**New test files (in a new `Inspectron.Epson.Tests` xUnit project):** +- `Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj` +- `Inspectron.Epson.Tests/GlobalUsings.cs` +- `Inspectron.Epson.Tests/Telemetry/InsinMessageFormatterTests.cs` +- `Inspectron.Epson.Tests/Telemetry/InsinEventKindsTests.cs` +- `Inspectron.Epson.Tests/Telemetry/NullInsinTelemetryTests.cs` +- `Inspectron.Epson.Tests/Telemetry/LoopbackInsinTelemetryTests.cs` +- `Inspectron.Epson.Tests/Telemetry/InsinTelemetryDiscoveredPrintersReceiverTests.cs` +- `Inspectron.Epson.Tests/Configuration/ConfigurationPathsTests.cs` + +**Modified source files:** +- `Inspectron.Epson/Queue/PrinterQueue.cs` — add `IInsinTelemetry` dependency, emit `job.printed` / `job.failed.*`. +- `Inspectron.Epson/Queue/PrintServer.cs` — thread `IInsinTelemetry` through to `PrinterQueue`. +- `EpsonPrintService/PrintServerBootstrapper.cs` — DI binding, decorator wrapping, `service.started` emit. +- `EpsonPrintService/ConfigurationPaths.cs` — change `LinuxConfigDir` from `/root/epsonprintservice` to `/opt/epson-print-service`. +- `EpsonPrintService/EpsonPrintService.csproj` — bump `` (done per publish). +- `Inspectron.Epson.slnx` — add the new test project. + +**New package assets (git-tracked):** +- `deploy/insin/install.cfg` +- `deploy/insin/postinst` +- `deploy/insin/prem` +- `deploy/insin/epson.service` +- `deploy/insin/print_server.service` + +**New scripts (git-tracked):** +- `scripts/publish-insin.sh` + +**Modified misc:** +- `.gitignore` — add `.tools/` and `publish/`. + +--- + +## Task 1: Bootstrap the `Inspectron.Epson.Tests` xUnit project + +**Files:** +- Create: `Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj` +- Create: `Inspectron.Epson.Tests/GlobalUsings.cs` +- Create: `Inspectron.Epson.Tests/Sanity/SanityTests.cs` +- Modify: `Inspectron.Epson.slnx` + +- [ ] **Step 1: Create the csproj** + +Write `Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj`: + +```xml + + + + net8.0 + enable + enable + false + true + Major + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + +``` + +- [ ] **Step 2: Create GlobalUsings.cs** + +Write `Inspectron.Epson.Tests/GlobalUsings.cs`: + +```csharp +global using Xunit; +``` + +- [ ] **Step 3: Add a sanity test** + +Write `Inspectron.Epson.Tests/Sanity/SanityTests.cs`: + +```csharp +namespace Inspectron.Epson.Tests.Sanity; + +public class SanityTests +{ + [Fact] + public void Truth_is_true() => Assert.True(true); +} +``` + +- [ ] **Step 4: Add the project to the solution** + +Edit `Inspectron.Epson.slnx`; add a `` line after the existing test project reference: + +```xml + +``` + +- [ ] **Step 5: Run the tests** + +Run: `dotnet test Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj` +Expected: `Passed! - Failed: 0, Passed: 1`. + +- [ ] **Step 6: Commit** + +```bash +git add Inspectron.Epson.Tests/ Inspectron.Epson.slnx +git commit -m "add Inspectron.Epson.Tests xUnit project" +``` + +--- + +## Task 2: `InsinMessageFormatter` — pure key=value builder + +**Files:** +- Create: `Inspectron.Epson/PrintServer/Telemetry/InsinMessageFormatter.cs` +- Create: `Inspectron.Epson.Tests/Telemetry/InsinMessageFormatterTests.cs` + +- [ ] **Step 1: Write the failing tests** + +Write `Inspectron.Epson.Tests/Telemetry/InsinMessageFormatterTests.cs`: + +```csharp +using Inspectron.Epson.PrintServer.Telemetry; + +namespace Inspectron.Epson.Tests.Telemetry; + +public class InsinMessageFormatterTests +{ + [Fact] + public void Format_single_pair_no_spaces() + { + var result = InsinMessageFormatter.Format(("printer", "192.168.1.10")); + Assert.Equal("printer=192.168.1.10", result); + } + + [Fact] + public void Format_multiple_pairs_joined_by_space() + { + var result = InsinMessageFormatter.Format( + ("printer", "192.168.1.10"), + ("jobId", "42")); + Assert.Equal("printer=192.168.1.10 jobId=42", result); + } + + [Fact] + public void Format_quotes_value_containing_space() + { + var result = InsinMessageFormatter.Format(("error", "paper end detected")); + Assert.Equal("error=\"paper end detected\"", result); + } + + [Fact] + public void Format_escapes_embedded_double_quote() + { + var result = InsinMessageFormatter.Format(("error", "he said \"nope\"")); + Assert.Equal("error=\"he said \\\"nope\\\"\"", result); + } + + [Fact] + public void Format_treats_null_or_empty_value_as_empty_string() + { + var result = InsinMessageFormatter.Format(("model", (string?)null), ("name", "")); + Assert.Equal("model= name=", result); + } + + [Fact] + public void Format_omits_pairs_with_null_or_empty_key() + { + var result = InsinMessageFormatter.Format( + ("", "ignored"), + ("printer", "192.168.1.10")); + Assert.Equal("printer=192.168.1.10", result); + } +} +``` + +- [ ] **Step 2: Verify the tests fail** + +Run: `dotnet test Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj --filter FullyQualifiedName~InsinMessageFormatter` +Expected: build error — `InsinMessageFormatter` does not exist. + +- [ ] **Step 3: Implement** + +Write `Inspectron.Epson/PrintServer/Telemetry/InsinMessageFormatter.cs`: + +```csharp +using System.Text; + +namespace Inspectron.Epson.PrintServer.Telemetry; + +public static class InsinMessageFormatter +{ + public static string Format(params (string Key, string? Value)[] pairs) + { + var sb = new StringBuilder(); + foreach (var (key, value) in pairs) + { + if (string.IsNullOrEmpty(key)) continue; + if (sb.Length > 0) sb.Append(' '); + sb.Append(key).Append('=').Append(FormatValue(value)); + } + return sb.ToString(); + } + + private static string FormatValue(string? value) + { + if (string.IsNullOrEmpty(value)) return string.Empty; + if (value.IndexOf(' ') < 0 && value.IndexOf('"') < 0) return value; + var escaped = value.Replace("\\", "\\\\").Replace("\"", "\\\""); + return $"\"{escaped}\""; + } +} +``` + +- [ ] **Step 4: Verify tests pass** + +Run: `dotnet test Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj --filter FullyQualifiedName~InsinMessageFormatter` +Expected: `Passed: 6, Failed: 0`. + +- [ ] **Step 5: Commit** + +```bash +git add Inspectron.Epson/PrintServer/Telemetry/InsinMessageFormatter.cs \ + Inspectron.Epson.Tests/Telemetry/InsinMessageFormatterTests.cs +git commit -m "add InsinMessageFormatter" +``` + +--- + +## Task 3: `IInsinTelemetry` + `NullInsinTelemetry` + +**Files:** +- Create: `Inspectron.Epson/PrintServer/Telemetry/IInsinTelemetry.cs` +- Create: `Inspectron.Epson/PrintServer/Telemetry/NullInsinTelemetry.cs` +- Create: `Inspectron.Epson.Tests/Telemetry/NullInsinTelemetryTests.cs` + +- [ ] **Step 1: Write the failing test** + +Write `Inspectron.Epson.Tests/Telemetry/NullInsinTelemetryTests.cs`: + +```csharp +using Inspectron.Epson.PrintServer.Telemetry; + +namespace Inspectron.Epson.Tests.Telemetry; + +public class NullInsinTelemetryTests +{ + [Fact] + public void Emit_does_nothing_and_does_not_throw() + { + var telemetry = new NullInsinTelemetry(); + var ex = Record.Exception(() => telemetry.Emit("service.started", "version=1.0.0")); + Assert.Null(ex); + } +} +``` + +- [ ] **Step 2: Verify the test fails** + +Run: `dotnet test Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj --filter FullyQualifiedName~NullInsinTelemetry` +Expected: build error — `IInsinTelemetry` and `NullInsinTelemetry` do not exist. + +- [ ] **Step 3: Implement the interface** + +Write `Inspectron.Epson/PrintServer/Telemetry/IInsinTelemetry.cs`: + +```csharp +namespace Inspectron.Epson.PrintServer.Telemetry; + +public interface IInsinTelemetry +{ + void Emit(string kind, string message); +} +``` + +- [ ] **Step 4: Implement the null adapter** + +Write `Inspectron.Epson/PrintServer/Telemetry/NullInsinTelemetry.cs`: + +```csharp +namespace Inspectron.Epson.PrintServer.Telemetry; + +public sealed class NullInsinTelemetry : IInsinTelemetry +{ + public void Emit(string kind, string message) { } +} +``` + +- [ ] **Step 5: Verify test passes** + +Run: `dotnet test Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj --filter FullyQualifiedName~NullInsinTelemetry` +Expected: `Passed: 1, Failed: 0`. + +- [ ] **Step 6: Commit** + +```bash +git add Inspectron.Epson/PrintServer/Telemetry/IInsinTelemetry.cs \ + Inspectron.Epson/PrintServer/Telemetry/NullInsinTelemetry.cs \ + Inspectron.Epson.Tests/Telemetry/NullInsinTelemetryTests.cs +git commit -m "add IInsinTelemetry and NullInsinTelemetry" +``` + +--- + +## Task 4: `InsinEventKinds` — constants and error-type mapping + +**Files:** +- Create: `Inspectron.Epson/PrintServer/Telemetry/InsinEventKinds.cs` +- Create: `Inspectron.Epson.Tests/Telemetry/InsinEventKindsTests.cs` + +- [ ] **Step 1: Write the failing tests** + +Write `Inspectron.Epson.Tests/Telemetry/InsinEventKindsTests.cs`: + +```csharp +using Inspectron.Epson.PrintServer.Telemetry; +using Inspectron.Epson.Queue; + +namespace Inspectron.Epson.Tests.Telemetry; + +public class InsinEventKindsTests +{ + [Theory] + [InlineData(PrintErrorType.OutOfPaper, "job.failed.paper_out")] + [InlineData(PrintErrorType.Offline, "job.failed.connection")] + [InlineData(PrintErrorType.ConversionError, "job.failed.other")] + [InlineData(PrintErrorType.Other, "job.failed.other")] + [InlineData(PrintErrorType.None, "job.failed.other")] + public void MapErrorTypeToKind(PrintErrorType input, string expected) + { + Assert.Equal(expected, InsinEventKinds.MapErrorTypeToKind(input)); + } + + [Fact] + public void Constants_have_expected_values() + { + Assert.Equal("service.started", InsinEventKinds.ServiceStarted); + Assert.Equal("job.printed", InsinEventKinds.JobPrinted); + Assert.Equal("printer.discovered", InsinEventKinds.PrinterDiscovered); + Assert.Equal("printer.online", InsinEventKinds.PrinterOnline); + Assert.Equal("printer.offline", InsinEventKinds.PrinterOffline); + } +} +``` + +Note: `PrintErrorType.CoverOpen` does not exist in the current enum (only `None`, `ConversionError`, `Offline`, `OutOfPaper`, `Other`) — the spec's `cover_open` suffix is deferred until the enum grows. `Offline` maps to `connection` because from the service's perspective a printer that stopped responding is a connection failure. + +- [ ] **Step 2: Verify the tests fail** + +Run: `dotnet test Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj --filter FullyQualifiedName~InsinEventKinds` +Expected: build error — `InsinEventKinds` does not exist. + +- [ ] **Step 3: Implement** + +Write `Inspectron.Epson/PrintServer/Telemetry/InsinEventKinds.cs`: + +```csharp +using Inspectron.Epson.Queue; + +namespace Inspectron.Epson.PrintServer.Telemetry; + +public static class InsinEventKinds +{ + public const string ServiceStarted = "service.started"; + public const string JobPrinted = "job.printed"; + public const string PrinterDiscovered = "printer.discovered"; + public const string PrinterOnline = "printer.online"; + public const string PrinterOffline = "printer.offline"; + + public static string MapErrorTypeToKind(PrintErrorType errorType) => errorType switch + { + PrintErrorType.OutOfPaper => "job.failed.paper_out", + PrintErrorType.Offline => "job.failed.connection", + _ => "job.failed.other", + }; +} +``` + +- [ ] **Step 4: Verify tests pass** + +Run: `dotnet test Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj --filter FullyQualifiedName~InsinEventKinds` +Expected: `Passed: 6, Failed: 0`. + +- [ ] **Step 5: Commit** + +```bash +git add Inspectron.Epson/PrintServer/Telemetry/InsinEventKinds.cs \ + Inspectron.Epson.Tests/Telemetry/InsinEventKindsTests.cs +git commit -m "add InsinEventKinds and PrintErrorType mapping" +``` + +--- + +## Task 5: `LoopbackInsinTelemetry` — HTTP loopback emitter + +**Files:** +- Create: `Inspectron.Epson/PrintServer/Telemetry/LoopbackInsinTelemetry.cs` +- Create: `Inspectron.Epson.Tests/Telemetry/LoopbackInsinTelemetryTests.cs` + +- [ ] **Step 1: Write the failing test — happy path** + +Write `Inspectron.Epson.Tests/Telemetry/LoopbackInsinTelemetryTests.cs`: + +```csharp +using System.Net; +using System.Text; +using System.Text.Json; +using Inspectron.Epson.PrintServer.Telemetry; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Inspectron.Epson.Tests.Telemetry; + +public class LoopbackInsinTelemetryTests +{ + private const int TestPort = 47823; + + [Fact] + public async Task Emit_posts_json_payload_to_loopback() + { + using var listener = new HttpListener(); + listener.Prefixes.Add($"http://127.0.0.1:{TestPort}/"); + listener.Start(); + + using var http = new HttpClient(); + var telemetry = new LoopbackInsinTelemetry(http, NullLogger.Instance); + telemetry.Emit("service.started", "version=1.0.14"); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3)); + var ctxTask = listener.GetContextAsync(); + var completed = await Task.WhenAny(ctxTask, Task.Delay(Timeout.Infinite, cts.Token)); + Assert.Same(ctxTask, completed); + var ctx = ctxTask.Result; + + Assert.Equal("POST", ctx.Request.HttpMethod); + Assert.Equal("/events", ctx.Request.Url!.AbsolutePath); + using var sr = new StreamReader(ctx.Request.InputStream, Encoding.UTF8); + var body = await sr.ReadToEndAsync(); + using var doc = JsonDocument.Parse(body); + Assert.Equal("service.started", doc.RootElement.GetProperty("kind").GetString()); + Assert.Equal("version=1.0.14", doc.RootElement.GetProperty("message").GetString()); + var at = doc.RootElement.GetProperty("at").GetString(); + Assert.False(string.IsNullOrEmpty(at)); + Assert.EndsWith("Z", at); + + ctx.Response.StatusCode = 200; + ctx.Response.Close(); + listener.Stop(); + } + + [Fact] + public async Task Emit_swallows_connection_refused_and_does_not_throw() + { + using var http = new HttpClient(); + var telemetry = new LoopbackInsinTelemetry(http, NullLogger.Instance); + var ex = Record.Exception(() => telemetry.Emit("service.started", "version=1.0.14")); + Assert.Null(ex); + await Task.Delay(200); + } + + [Fact] + public async Task Emit_swallows_500_response_and_does_not_throw() + { + using var listener = new HttpListener(); + listener.Prefixes.Add($"http://127.0.0.1:{TestPort}/"); + listener.Start(); + + using var http = new HttpClient(); + var telemetry = new LoopbackInsinTelemetry(http, NullLogger.Instance); + telemetry.Emit("service.started", "version=1.0.14"); + + var ctx = await listener.GetContextAsync(); + ctx.Response.StatusCode = 500; + ctx.Response.Close(); + + await Task.Delay(200); + listener.Stop(); + } +} +``` + +(`Microsoft.Extensions.Logging.Abstractions` is already brought in transitively via the reference to `Inspectron.Epson` — no NuGet edit needed.) + +- [ ] **Step 2: Verify the tests fail** + +Run: `dotnet test Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj --filter FullyQualifiedName~LoopbackInsinTelemetry` +Expected: build error — `LoopbackInsinTelemetry` does not exist. + +- [ ] **Step 3: Implement** + +Write `Inspectron.Epson/PrintServer/Telemetry/LoopbackInsinTelemetry.cs`: + +```csharp +using System.Net.Http.Json; +using Microsoft.Extensions.Logging; + +namespace Inspectron.Epson.PrintServer.Telemetry; + +public sealed class LoopbackInsinTelemetry : IInsinTelemetry +{ + private const string Endpoint = "http://127.0.0.1:47823/events"; + private static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(2); + private static readonly long WarnIntervalTicks = TimeSpan.FromMinutes(5).Ticks / TimeSpan.TicksPerMillisecond; + + private readonly HttpClient _http; + private readonly ILogger _logger; + private long _lastWarnMs = -WarnIntervalTicks; + + public LoopbackInsinTelemetry(HttpClient http, ILogger logger) + { + _http = http; + _logger = logger; + } + + public void Emit(string kind, string message) + { + var payload = new + { + kind, + message, + at = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffZ") + }; + _ = Task.Run(() => SendAsync(payload)); + } + + private async Task SendAsync(object payload) + { + try + { + using var cts = new CancellationTokenSource(RequestTimeout); + using var resp = await _http.PostAsJsonAsync(Endpoint, payload, cts.Token); + if (!resp.IsSuccessStatusCode) + MaybeWarn($"insin loopback returned {(int)resp.StatusCode}"); + } + catch (Exception ex) + { + MaybeWarn($"insin loopback unreachable at 127.0.0.1:47823 ({ex.GetType().Name}), dropping events"); + } + } + + private void MaybeWarn(string message) + { + var now = Environment.TickCount64; + var last = Interlocked.Read(ref _lastWarnMs); + if (now - last < WarnIntervalTicks) return; + if (Interlocked.CompareExchange(ref _lastWarnMs, now, last) != last) return; + _logger.LogWarning("{Message}", message); + } +} +``` + +- [ ] **Step 4: Verify tests pass** + +Run: `dotnet test Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj --filter FullyQualifiedName~LoopbackInsinTelemetry` +Expected: `Passed: 3, Failed: 0`. + +- [ ] **Step 5: Commit** + +```bash +git add Inspectron.Epson/PrintServer/Telemetry/LoopbackInsinTelemetry.cs \ + Inspectron.Epson.Tests/Telemetry/LoopbackInsinTelemetryTests.cs +git commit -m "add LoopbackInsinTelemetry with rate-limited warn logging" +``` + +--- + +## Task 6: `InsinTelemetryDiscoveredPrintersReceiver` — decorator + +**Files:** +- Create: `Inspectron.Epson/PrintServer/DiscoveredPrintersReceiver/InsinTelemetryDiscoveredPrintersReceiver.cs` +- Create: `Inspectron.Epson.Tests/Telemetry/InsinTelemetryDiscoveredPrintersReceiverTests.cs` + +- [ ] **Step 1: Write the failing tests** + +Write `Inspectron.Epson.Tests/Telemetry/InsinTelemetryDiscoveredPrintersReceiverTests.cs`: + +```csharp +using EpsonPrintService; +using Inspectron.Epson; +using Inspectron.Epson.PrintServer.Telemetry; + +namespace Inspectron.Epson.Tests.Telemetry; + +public class InsinTelemetryDiscoveredPrintersReceiverTests +{ + private sealed class RecordingTelemetry : IInsinTelemetry + { + public List<(string Kind, string Message)> Events { get; } = new(); + public void Emit(string kind, string message) => Events.Add((kind, message)); + } + + private sealed class CountingInner : IDiscoveredPrintersReceiver + { + public int Calls; + public Task OnPrintersDiscoveredAsync(IReadOnlyList printers) + { + Calls++; + return Task.CompletedTask; + } + } + + private static DiscoveredPrinter Printer(string ip, string model = "TM-T30III") => + new() { IPAddress = ip, ModelName = model }; + + [Fact] + public async Task First_sighting_emits_discovered_and_online() + { + var telemetry = new RecordingTelemetry(); + var inner = new CountingInner(); + var sut = new InsinTelemetryDiscoveredPrintersReceiver(inner, telemetry); + + await sut.OnPrintersDiscoveredAsync(new[] { Printer("192.168.1.10") }); + + Assert.Equal(1, inner.Calls); + Assert.Collection(telemetry.Events, + e => Assert.Equal("printer.discovered", e.Kind), + e => Assert.Equal("printer.online", e.Kind)); + Assert.Contains("printer=192.168.1.10", telemetry.Events[0].Message); + Assert.Contains("model=TM-T30III", telemetry.Events[0].Message); + } + + [Fact] + public async Task Repeat_sighting_emits_nothing() + { + var telemetry = new RecordingTelemetry(); + var sut = new InsinTelemetryDiscoveredPrintersReceiver(new CountingInner(), telemetry); + await sut.OnPrintersDiscoveredAsync(new[] { Printer("192.168.1.10") }); + telemetry.Events.Clear(); + + await sut.OnPrintersDiscoveredAsync(new[] { Printer("192.168.1.10") }); + + Assert.Empty(telemetry.Events); + } + + [Fact] + public async Task Disappearance_emits_offline() + { + var telemetry = new RecordingTelemetry(); + var sut = new InsinTelemetryDiscoveredPrintersReceiver(new CountingInner(), telemetry); + await sut.OnPrintersDiscoveredAsync(new[] { Printer("192.168.1.10") }); + telemetry.Events.Clear(); + + await sut.OnPrintersDiscoveredAsync(Array.Empty()); + + Assert.Single(telemetry.Events); + Assert.Equal("printer.offline", telemetry.Events[0].Kind); + Assert.Equal("printer=192.168.1.10", telemetry.Events[0].Message); + } + + [Fact] + public async Task Reappearance_emits_online_only_not_discovered() + { + var telemetry = new RecordingTelemetry(); + var sut = new InsinTelemetryDiscoveredPrintersReceiver(new CountingInner(), telemetry); + await sut.OnPrintersDiscoveredAsync(new[] { Printer("192.168.1.10") }); + await sut.OnPrintersDiscoveredAsync(Array.Empty()); + telemetry.Events.Clear(); + + await sut.OnPrintersDiscoveredAsync(new[] { Printer("192.168.1.10") }); + + Assert.Single(telemetry.Events); + Assert.Equal("printer.online", telemetry.Events[0].Kind); + } + + [Fact] + public async Task Telemetry_failures_do_not_break_inner() + { + var telemetry = new ThrowingTelemetry(); + var inner = new CountingInner(); + var sut = new InsinTelemetryDiscoveredPrintersReceiver(inner, telemetry); + + await sut.OnPrintersDiscoveredAsync(new[] { Printer("192.168.1.10") }); + + Assert.Equal(1, inner.Calls); + } + + private sealed class ThrowingTelemetry : IInsinTelemetry + { + public void Emit(string kind, string message) => throw new InvalidOperationException("boom"); + } +} +``` + +- [ ] **Step 2: Verify the tests fail** + +Run: `dotnet test Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj --filter FullyQualifiedName~InsinTelemetryDiscoveredPrintersReceiver` +Expected: build error — decorator does not exist. + +- [ ] **Step 3: Implement** + +Write `Inspectron.Epson/PrintServer/DiscoveredPrintersReceiver/InsinTelemetryDiscoveredPrintersReceiver.cs`: + +```csharp +using Inspectron.Epson; +using Inspectron.Epson.PrintServer.Telemetry; + +namespace EpsonPrintService; + +public sealed class InsinTelemetryDiscoveredPrintersReceiver : IDiscoveredPrintersReceiver +{ + private readonly IDiscoveredPrintersReceiver _inner; + private readonly IInsinTelemetry _telemetry; + private readonly HashSet _seenIps = new(); + private readonly Dictionary _present = new(); + private readonly object _stateLock = new(); + + public InsinTelemetryDiscoveredPrintersReceiver( + IDiscoveredPrintersReceiver inner, + IInsinTelemetry telemetry) + { + _inner = inner; + _telemetry = telemetry; + } + + public async Task OnPrintersDiscoveredAsync(IReadOnlyList printers) + { + await _inner.OnPrintersDiscoveredAsync(printers); + + List<(string kind, string message)> toEmit; + lock (_stateLock) + { + toEmit = ComputeEvents(printers); + } + + foreach (var (kind, message) in toEmit) + { + try { _telemetry.Emit(kind, message); } + catch { /* swallow: telemetry must never break discovery */ } + } + } + + private List<(string, string)> ComputeEvents(IReadOnlyList printers) + { + var events = new List<(string, string)>(); + var currentIps = new HashSet(); + foreach (var p in printers) + { + if (string.IsNullOrEmpty(p.IPAddress)) continue; + currentIps.Add(p.IPAddress); + + if (_seenIps.Add(p.IPAddress)) + events.Add((InsinEventKinds.PrinterDiscovered, + InsinMessageFormatter.Format(("printer", p.IPAddress), ("model", p.ModelName)))); + + if (!_present.TryGetValue(p.IPAddress, out var wasPresent) || !wasPresent) + events.Add((InsinEventKinds.PrinterOnline, + InsinMessageFormatter.Format(("printer", p.IPAddress)))); + + _present[p.IPAddress] = true; + } + + foreach (var kv in _present.ToList()) + { + if (kv.Value && !currentIps.Contains(kv.Key)) + { + events.Add((InsinEventKinds.PrinterOffline, + InsinMessageFormatter.Format(("printer", kv.Key)))); + _present[kv.Key] = false; + } + } + + return events; + } +} +``` + +- [ ] **Step 4: Verify tests pass** + +Run: `dotnet test Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj --filter FullyQualifiedName~InsinTelemetryDiscoveredPrintersReceiver` +Expected: `Passed: 5, Failed: 0`. + +- [ ] **Step 5: Commit** + +```bash +git add Inspectron.Epson/PrintServer/DiscoveredPrintersReceiver/InsinTelemetryDiscoveredPrintersReceiver.cs \ + Inspectron.Epson.Tests/Telemetry/InsinTelemetryDiscoveredPrintersReceiverTests.cs +git commit -m "add InsinTelemetryDiscoveredPrintersReceiver decorator" +``` + +--- + +## Task 7: Thread `IInsinTelemetry` into `PrinterQueue` and emit job outcomes + +**Files:** +- Modify: `Inspectron.Epson/Queue/PrinterQueue.cs` +- Modify: `Inspectron.Epson/Queue/PrintServer.cs` + +Note: no unit test for the queue — the queue's readers-writer lock + background loop makes a fast, deterministic unit test disproportionately expensive. Correctness of the mapping is covered by `InsinEventKindsTests`; end-to-end wiring is covered by the manual acceptance test in the spec. If a bug surfaces we can add an integration test then. + +- [ ] **Step 1: Add the constructor parameter to `PrinterQueue`** + +Edit `Inspectron.Epson/Queue/PrinterQueue.cs`: + +Change the `using` block at the top and add: + +```csharp +using Inspectron.Epson.PrintServer.Telemetry; +``` + +Change the field block (after `_statusReporter`) — add: + +```csharp + private readonly IInsinTelemetry _telemetry; +``` + +Change the constructor signature and body: + +```csharp + public PrinterQueue(string printerIp, IPrintService printService, ILogger logger, PrintServer printServer, IJobStatusReporter statusReporter, IInsinTelemetry telemetry) + { + PrinterIp = printerIp; + _queue = new ConcurrentQueue(); + _priorityQueue = new ConcurrentStack(); + _signal = new SemaphoreSlim(0); + _cancellationTokenSource = new CancellationTokenSource(); + _printService = printService; + _logger = logger; + _printServer = printServer; + _statusReporter = statusReporter; + _telemetry = telemetry; + } +``` + +- [ ] **Step 2: Emit on success and failure in `ProcessQueueAsync`** + +Still in `PrinterQueue.cs`, replace the `_printService.PrintAsync(...)` block. Find: + +```csharp + _printServer.EnterPrintLock(); + try + { + // Call the actual print function + var result = await _printService.PrintAsync(PrinterIp, job); +``` + +Replace with: + +```csharp + _printServer.EnterPrintLock(); + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + try + { + // Call the actual print function + var result = await _printService.PrintAsync(PrinterIp, job); + stopwatch.Stop(); +``` + +Then, inside the existing `if (!result.Success)` branch, after the existing `else` (Re-queue with retry) branch — right at the end of the `else` before the closing `}`— add the emit call. Locate the current success branch: + +```csharp + else + { + _logger.LogInformation("Job completed successfully on printer {PrinterId}", PrinterIp); + await _statusReporter.ReportStatusAsync(job, PrintJobStatus.Completed); + } +``` + +Replace with: + +```csharp + else + { + _logger.LogInformation("Job completed successfully on printer {PrinterId}", PrinterIp); + await _statusReporter.ReportStatusAsync(job, PrintJobStatus.Completed); + SafeEmit(InsinEventKinds.JobPrinted, InsinMessageFormatter.Format( + ("printer", PrinterIp), + ("receiptType", job.Document?.ReceiptType.ToString()), + ("durationMs", stopwatch.ElapsedMilliseconds.ToString()))); + } +``` + +Similarly, inside `if (!result.Success)`, after the three-branch mapping, ADD an emit call right after the closing `}` of the outermost success/failure decision. To keep this simple, replace the entire `if (!result.Success)` block with: + +```csharp + if (!result.Success) + { + var failKind = InsinEventKinds.MapErrorTypeToKind(result.ErrorType); + SafeEmit(failKind, InsinMessageFormatter.Format( + ("printer", PrinterIp), + ("receiptType", job.Document?.ReceiptType.ToString()), + ("retry", job.RetryCount.ToString()), + ("error", result.ErrorType.ToString()))); + + if (result.ErrorType == PrintErrorType.ConversionError) + { + _logger.LogWarning("Job failed due to conversion error, not re-queuing"); + await _statusReporter.ReportStatusAsync(job, PrintJobStatus.Failed); + } + else if (result.ErrorType == PrintErrorType.Offline) + { + _logger.LogWarning("Printer is offline, job failed"); + await _statusReporter.ReportStatusAsync(job, PrintJobStatus.Failed); + } + else + { + // Re-queue with retry logic + job.RetryCount++; + if (job.RetryCount == 1) + { + await _statusReporter.ReportStatusAsync(job, PrintJobStatus.OutOfPaper); + } + _logger.LogWarning("Job failed, retrying ({RetryCount}/3)", job.RetryCount); + await Task.Delay(5000, cancellationToken); // Wait before retry + EnqueuePriority(job); + } + } +``` + +- [ ] **Step 3: Add the `SafeEmit` helper** + +Add this method at the bottom of `PrinterQueue` (just before the closing brace of the class): + +```csharp + private void SafeEmit(string kind, string message) + { + try { _telemetry.Emit(kind, message); } + catch (Exception ex) { _logger.LogWarning(ex, "insin telemetry emit failed"); } + } +``` + +- [ ] **Step 4: Confirm `PrintJobFromSignalR.ReceiptType` is still an `int`** + +Run: `grep -n "ReceiptType" Inspectron.Epson/PrintServer/JobSources/SignalRPrintJobSource.cs` + +Expected: hit for `public int ReceiptType`. The `.ToString()` in the emits above assumes `int`. If the field is now a `string`, drop `.ToString()`. If the field was renamed or removed, adapt the emit's `receiptType` argument. + +- [ ] **Step 5: Update `PrintServer.RegisterPrinter` to pass the telemetry** + +Edit `Inspectron.Epson/Queue/PrintServer.cs`. Change the field block (after `_statusReporter`) — add: + +```csharp + private readonly Inspectron.Epson.PrintServer.Telemetry.IInsinTelemetry _telemetry; +``` + +Change the constructor: + +```csharp + public PrintServer(IPrintService printService, ILogger logger, IJobStatusReporter statusReporter, Inspectron.Epson.PrintServer.Telemetry.IInsinTelemetry telemetry) + { + _printService = printService; + _logger = logger; + _statusReporter = statusReporter; + _telemetry = telemetry; + _printerQueues = new ConcurrentDictionary(); + } +``` + +Change the `RegisterPrinter` call: + +```csharp + public void RegisterPrinter(string printerIp) + { + var queue = new PrinterQueue(printerIp, _printService, _logger, this, _statusReporter, _telemetry); + // ... rest unchanged +``` + +- [ ] **Step 6: Verify the solution builds** + +Run: `dotnet build Inspectron.Epson.slnx` +Expected: build succeeds with 0 errors. (Bootstrapper will still work because Ninject resolves `IInsinTelemetry` at runtime — we bind it in Task 8.) + +- [ ] **Step 7: Verify tests still pass** + +Run: `dotnet test Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj` +Expected: all previous tests still pass. + +- [ ] **Step 8: Commit** + +```bash +git add Inspectron.Epson/Queue/PrinterQueue.cs Inspectron.Epson/Queue/PrintServer.cs +git commit -m "emit insin telemetry for job outcomes in PrinterQueue" +``` + +--- + +## Task 8: DI wiring + `service.started` emit in `PrintServerBootstrapper` + +**Files:** +- Modify: `EpsonPrintService/PrintServerBootstrapper.cs` + +- [ ] **Step 1: Add usings** + +Edit `EpsonPrintService/PrintServerBootstrapper.cs`, add near the top: + +```csharp +using Inspectron.Epson.PrintServer.Telemetry; +using System.Reflection; +``` + +- [ ] **Step 2: Bind `IInsinTelemetry`** + +Inside `StartAsync()`, right after `_kernel.Bind()...`, add: + +```csharp + if (_config.EmulationMode) + _kernel.Bind().To().InSingletonScope(); + else + _kernel.Bind().To().InSingletonScope(); +``` + +- [ ] **Step 3: Wrap `IDiscoveredPrintersReceiver` with the decorator** + +In `StartAsync()`, find: + +```csharp + _kernel.Bind().To().InSingletonScope(); +``` + +Replace with: + +```csharp + _kernel.Bind().ToSelf().InSingletonScope(); + _kernel.Bind().ToMethod(ctx => + new InsinTelemetryDiscoveredPrintersReceiver( + ctx.Kernel.Get(), + ctx.Kernel.Get())) + .InSingletonScope(); +``` + +- [ ] **Step 4: Emit `service.started` at the end of `StartAsync()`** + +At the end of `StartAsync()`, right after the existing `Console.WriteLine("Print server started.");`, add: + +```csharp + var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown"; + var telemetry = _kernel.Get(); + try + { + telemetry.Emit(InsinEventKinds.ServiceStarted, InsinMessageFormatter.Format( + ("version", version), + ("restaurantId", _config.RestaurantId))); + } + catch { /* telemetry must never fail startup */ } +``` + +- [ ] **Step 5: Verify emulation-mode default binding still works** + +Run: `dotnet build Inspectron.Epson.slnx` +Expected: build succeeds. Ninject will resolve `IInsinTelemetry` because we always bind exactly one implementation. + +- [ ] **Step 6: Commit** + +```bash +git add EpsonPrintService/PrintServerBootstrapper.cs +git commit -m "bind IInsinTelemetry in DI and emit service.started" +``` + +--- + +## Task 9: Move Linux config path to `/opt/epson-print-service/` + +**Files:** +- Modify: `EpsonPrintService/ConfigurationPaths.cs` +- Create: `Inspectron.Epson.Tests/Configuration/ConfigurationPathsTests.cs` + +- [ ] **Step 1: Write the failing test** + +Write `Inspectron.Epson.Tests/Configuration/ConfigurationPathsTests.cs`: + +```csharp +using System.Runtime.InteropServices; +using EpsonPrintService; + +namespace Inspectron.Epson.Tests.Configuration; + +public class ConfigurationPathsTests +{ + [Fact] + public void GetConfigPath_uses_opt_dir_on_linux_when_env_unset_and_no_local_file() + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) return; + var previous = Environment.GetEnvironmentVariable("EPSON_CONFIG_PATH"); + Environment.SetEnvironmentVariable("EPSON_CONFIG_PATH", null); + try + { + var path = ConfigurationPaths.GetConfigPath(); + Assert.Equal("/opt/epson-print-service/config.txt", path); + } + finally + { + Environment.SetEnvironmentVariable("EPSON_CONFIG_PATH", previous); + } + } + + [Fact] + public void EPSON_CONFIG_PATH_override_still_wins() + { + var previous = Environment.GetEnvironmentVariable("EPSON_CONFIG_PATH"); + Environment.SetEnvironmentVariable("EPSON_CONFIG_PATH", "/tmp/override.txt"); + try + { + var path = ConfigurationPaths.GetConfigPath(); + Assert.Equal("/tmp/override.txt", path); + } + finally + { + Environment.SetEnvironmentVariable("EPSON_CONFIG_PATH", previous); + } + } +} +``` + +The first test guards against local checkouts having a stray `./config.txt` in the working directory that would otherwise satisfy `File.Exists(LocalConfigFileName)` and trigger the migration branch — that branch is unavoidably filesystem-dependent, so the test is written to only verify the *default* path when neither env nor local file exists. In a clean CI env that's the case; on a dev machine ensure no `./config.txt` is next to the test binary output before running. + +- [ ] **Step 2: Verify the tests fail** + +Run: `dotnet test Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj --filter FullyQualifiedName~ConfigurationPathsTests` +Expected: on Linux, first test fails with `/root/epsonprintservice/config.txt` != `/opt/epson-print-service/config.txt`. + +- [ ] **Step 3: Implement** + +Edit `EpsonPrintService/ConfigurationPaths.cs`. Change line 7: + +```csharp + private const string LinuxConfigDir = "/opt/epson-print-service"; +``` + +- [ ] **Step 4: Verify tests pass** + +Run: `dotnet test Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj --filter FullyQualifiedName~ConfigurationPathsTests` +Expected: `Passed: 2, Failed: 0` (or the Linux-only test skipped on Windows). + +- [ ] **Step 5: Commit** + +```bash +git add EpsonPrintService/ConfigurationPaths.cs \ + Inspectron.Epson.Tests/Configuration/ConfigurationPathsTests.cs +git commit -m "move linux config path to /opt/epson-print-service" +``` + +--- + +## Task 10: Package assets — `deploy/insin/` + +**Files:** +- Create: `deploy/insin/install.cfg` +- Create: `deploy/insin/postinst` +- Create: `deploy/insin/prem` +- Create: `deploy/insin/epson.service` +- Create: `deploy/insin/print_server.service` + +- [ ] **Step 1: Create `install.cfg`** + +Write `deploy/insin/install.cfg`: + +```ini +install_path=/opt/epson-print-service +``` + +- [ ] **Step 2: Create `postinst`** + +Write `deploy/insin/postinst`: + +```bash +#!/usr/bin/env bash +set -euo pipefail +INSTALL_DIR="$(pwd)" +chmod +x "$INSTALL_DIR/EpsonPrintService" +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 +``` + +Note the binary name: `EpsonPrintService` (default `dotnet publish` output for the `EpsonPrintService` project — no `-p:AssemblyName` override). + +- [ ] **Step 3: Create `prem`** + +Write `deploy/insin/prem`: + +```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 +``` + +- [ ] **Step 4: Create `epson.service`** + +Write `deploy/insin/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/EpsonPrintService +Restart=on-failure +RestartSec=5s + +[Install] +WantedBy=multi-user.target +``` + +- [ ] **Step 5: Create `print_server.service`** + +Copy the existing `EpsonPrintService/print_server.service` into `deploy/insin/print_server.service`, then rewrite `ExecStart` / `WorkingDirectory` to the new install path if they aren't already: + +Write `deploy/insin/print_server.service`: + +```ini +[Unit] +Description=Epson Print Server (companion) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +WorkingDirectory=/opt/epson-print-service +ExecStart=/opt/epson-print-service/EpsonPrintService +Restart=on-failure +RestartSec=5s + +[Install] +WantedBy=multi-user.target +``` + +If the original `print_server.service` has a different purpose than a duplicate of `epson.service` (check the file), preserve its `ExecStart` but retarget the working directory. When in doubt, treat it as a duplicate — this file exists in the current repo but is likely legacy. + +- [ ] **Step 6: Set executable bits and commit** + +```bash +chmod +x deploy/insin/postinst deploy/insin/prem +git add deploy/insin/ +git commit -m "add insin package assets (install.cfg, hooks, systemd units)" +``` + +Confirm executable bits landed in git: + +Run: `git ls-files -s deploy/insin/postinst deploy/insin/prem` +Expected: mode `100755` on both. + +--- + +## Task 11: Publish script + .gitignore + +**Files:** +- Create: `scripts/publish-insin.sh` +- Modify: `.gitignore` + +- [ ] **Step 1: Create the script** + +Write `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}" +``` + +- [ ] **Step 2: Make it executable** + +Run: `chmod +x scripts/publish-insin.sh` + +- [ ] **Step 3: Update `.gitignore`** + +Read `.gitignore` and append (only if not already present): + +```gitignore + +# Insin publish artifacts +.tools/ +publish/ +``` + +- [ ] **Step 4: Commit** + +```bash +git add scripts/publish-insin.sh .gitignore +git commit -m "add scripts/publish-insin.sh for manual insin package publish" +``` + +Confirm executable bit landed: + +Run: `git ls-files -s scripts/publish-insin.sh` +Expected: mode `100755`. + +--- + +## Task 12: Final build + acceptance-test rehearsal + +**Files:** (none modified) + +- [ ] **Step 1: Full solution build** + +Run: `dotnet build Inspectron.Epson.slnx -c Release` +Expected: 0 errors, 0 warnings from new code. + +- [ ] **Step 2: Full test run** + +Run: `dotnet test Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj` +Expected: all tests pass. Sum of previous tasks: `Passed: 24+, Failed: 0` (1 sanity + 6 formatter + 1 null + 6 kinds + 3 loopback + 5 decorator + 2 config paths). + +- [ ] **Step 3: Dry-run the publish script — build phase only** + +Run: + +```bash +dotnet publish EpsonPrintService/EpsonPrintService.csproj \ + -c Release -r linux-arm64 --self-contained true \ + -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true \ + -o publish/payload +ls -lh publish/payload/EpsonPrintService +``` + +Expected: a single ELF binary around 50-90 MB in size. + +Clean up: `rm -rf publish/` + +- [ ] **Step 4: Print the acceptance-test checklist** + +The remaining acceptance steps are manual and hardware-bound (see spec, section "Manual acceptance test"): publish → assign in Insin admin UI → wait one heartbeat → confirm systemd status → confirm events in admin UI → trigger real print → simulate paper-out → bump version + upgrade. + +Do NOT run `./scripts/publish-insin.sh` as part of this task — publishing is a deliberate, hand-triggered step that ships to production. + +- [ ] **Step 5: Commit any remaining formatting changes and confirm clean tree** + +Run: `git status` +Expected: `nothing to commit, working tree clean`.