# 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`.