Files
Print_server/docs/superpowers/plans/2026-07-23-insin-integration.md
2026-07-23 07:34:32 +00:00

48 KiB

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.<reason>), 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 <Version> (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:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <IsPackable>false</IsPackable>
    <IsTestProject>true</IsTestProject>
    <RollForward>Major</RollForward>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
    <PackageReference Include="xunit" Version="2.9.3" />
    <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
      <PrivateAssets>all</PrivateAssets>
    </PackageReference>
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\Inspectron.Epson\Inspectron.Epson.csproj" />
    <ProjectReference Include="..\EpsonPrintService\EpsonPrintService.csproj" />
  </ItemGroup>

</Project>
  • Step 2: Create GlobalUsings.cs

Write Inspectron.Epson.Tests/GlobalUsings.cs:

global using Xunit;
  • Step 3: Add a sanity test

Write Inspectron.Epson.Tests/Sanity/SanityTests.cs:

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 <Project> line after the existing test project reference:

<Project Path="Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj" />
  • Step 5: Run the tests

Run: dotnet test Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj Expected: Passed! - Failed: 0, Passed: 1.

  • Step 6: Commit
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:

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:

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

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:

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:

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

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:

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

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:

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

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<DiscoveredPrinter> 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<DiscoveredPrinter>());

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

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<string> _seenIps = new();
    private readonly Dictionary<string, bool> _present = new();
    private readonly object _stateLock = new();

    public InsinTelemetryDiscoveredPrintersReceiver(
        IDiscoveredPrintersReceiver inner,
        IInsinTelemetry telemetry)
    {
        _inner = inner;
        _telemetry = telemetry;
    }

    public async Task OnPrintersDiscoveredAsync(IReadOnlyList<DiscoveredPrinter> 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<DiscoveredPrinter> printers)
    {
        var events = new List<(string, string)>();
        var currentIps = new HashSet<string>();
        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
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:

using Inspectron.Epson.PrintServer.Telemetry;

Change the field block (after _statusReporter) — add:

    private readonly IInsinTelemetry _telemetry;

Change the constructor signature and body:

    public PrinterQueue(string printerIp, IPrintService printService, ILogger logger, PrintServer printServer, IJobStatusReporter statusReporter, IInsinTelemetry telemetry)
    {
        PrinterIp = printerIp;
        _queue = new ConcurrentQueue<PrintJob>();
        _priorityQueue = new ConcurrentStack<PrintJob>();
        _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:

                    _printServer.EnterPrintLock();
                    try
                    {
                        // Call the actual print function
                        var result = await _printService.PrintAsync(PrinterIp, job);

Replace with:

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

                        else
                        {
                            _logger.LogInformation("Job completed successfully on printer {PrinterId}",  PrinterIp);
                            await _statusReporter.ReportStatusAsync(job, PrintJobStatus.Completed);
                        }

Replace with:

                        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:

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

    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:

    private readonly Inspectron.Epson.PrintServer.Telemetry.IInsinTelemetry _telemetry;

Change the constructor:

    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<string, PrinterQueue>();
    }

Change the RegisterPrinter call:

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

using Inspectron.Epson.PrintServer.Telemetry;
using System.Reflection;
  • Step 2: Bind IInsinTelemetry

Inside StartAsync(), right after _kernel.Bind<HttpClient>()..., add:

        if (_config.EmulationMode)
            _kernel.Bind<IInsinTelemetry>().To<NullInsinTelemetry>().InSingletonScope();
        else
            _kernel.Bind<IInsinTelemetry>().To<LoopbackInsinTelemetry>().InSingletonScope();
  • Step 3: Wrap IDiscoveredPrintersReceiver with the decorator

In StartAsync(), find:

        _kernel.Bind<IDiscoveredPrintersReceiver>().To<JamesDiscoveredPrintersReceiver>().InSingletonScope();

Replace with:

        _kernel.Bind<JamesDiscoveredPrintersReceiver>().ToSelf().InSingletonScope();
        _kernel.Bind<IDiscoveredPrintersReceiver>().ToMethod(ctx =>
            new InsinTelemetryDiscoveredPrintersReceiver(
                ctx.Kernel.Get<JamesDiscoveredPrintersReceiver>(),
                ctx.Kernel.Get<IInsinTelemetry>()))
            .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:

        var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown";
        var telemetry = _kernel.Get<IInsinTelemetry>();
        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
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:

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:

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

install_path=/opt/epson-print-service
  • Step 2: Create postinst

Write deploy/insin/postinst:

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

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

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

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

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


# Insin publish artifacts
.tools/
publish/
  • Step 4: Commit
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:

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.