diff --git a/.gitignore b/.gitignore
index 9491a2f..7027678 100644
--- a/.gitignore
+++ b/.gitignore
@@ -360,4 +360,8 @@ MigrationBackup/
.ionide/
# Fody - auto-generated XML schema
-FodyWeavers.xsd
\ No newline at end of file
+FodyWeavers.xsd
+
+# Insin publish artifacts
+.tools/
+publish/
\ No newline at end of file
diff --git a/CLAUDE.md b/CLAUDE.md
index 0bcfff5..598e933 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -317,9 +317,9 @@ Insin uses a flat global package namespace with the `AdminToken` auth header (NO
set -euo pipefail
: "${INSIN_URL:?}" "${INSIN_TOKEN:?}"
-# 1. Fetch latest CLI (linux-arm64 shown; use win-x64 on Windows CI).
+# 1. Fetch latest CLI (pick RID matching host — linux-x64 shown).
LATEST=$(curl -fsSL "$INSIN_URL/api/v1/downloads/cli" \
- | python3 -c 'import json,sys; m=json.load(sys.stdin)[0]; a=next(x for x in m["artifacts"] if x["rid"]=="linux-arm64"); print(m["version"], a["filename"])')
+ | python3 -c 'import json,sys; m=json.load(sys.stdin)[0]; a=next(x for x in m["artifacts"] if x["rid"]=="linux-x64"); print(m["version"], a["filename"])')
VERSION=${LATEST% *}; FILENAME=${LATEST#* }
curl -fsSL "$INSIN_URL/api/v1/downloads/cli/$VERSION/$FILENAME" -o /tmp/insin.tar.gz
mkdir -p /tmp/insin && tar -xzf /tmp/insin.tar.gz -C /tmp/insin
@@ -345,7 +345,7 @@ Under the hood `publish` = `POST $INSIN_URL/api/v1/admin/packages` (multipart fo
- **First publish auto-creates** the package name. No separate registration.
- **Versions are immutable.** Re-publishing the same `(name, version)` returns **409 Conflict**. Bump the version.
- **Flat global namespace** — no scopes. Pick a distinctive name (we use `epson-print-service`).
-- **CLI artifacts:** only `linux-arm64` and `win-x64` today. On `linux-x64` CI, run under qemu (`--platform linux/arm64`) — the CLI is just packer + uploader so emulation is fine.
+- **CLI artifacts** ship for `linux-x64`, `linux-arm64`, `linux-arm`, and `win-x64` (as of CLI 1.3.1). Pick the RID that matches the host; no qemu required.
- **`insin pack` output is one dir UP** (`../packages/`), not `./`. Look there if the `.pkg` seems missing.
- If both `INSIN_ADMIN_TOKEN` and `INSIN_TOKEN` are set, `publish` reads `INSIN_TOKEN` first (service token wins).
- **List packages:** `curl -H "Authorization: AdminToken $INSIN_TOKEN" $INSIN_URL/api/v1/admin/packages`.
diff --git a/EpsonPrintService/ConfigurationPaths.cs b/EpsonPrintService/ConfigurationPaths.cs
index 2243a8d..1ddf821 100644
--- a/EpsonPrintService/ConfigurationPaths.cs
+++ b/EpsonPrintService/ConfigurationPaths.cs
@@ -4,7 +4,7 @@ namespace EpsonPrintService;
public static class ConfigurationPaths
{
- private const string LinuxConfigDir = "/root/epsonprintservice";
+ private const string LinuxConfigDir = "/opt/epson-print-service";
private const string LocalConfigFileName = "config.txt";
public static string GetConfigPath()
diff --git a/EpsonPrintService/EpsonPrintService.csproj b/EpsonPrintService/EpsonPrintService.csproj
index c490f69..87346a8 100644
--- a/EpsonPrintService/EpsonPrintService.csproj
+++ b/EpsonPrintService/EpsonPrintService.csproj
@@ -5,7 +5,7 @@
net8.0
enable
enable
- 1.0.14
+ 1.0.15
diff --git a/EpsonPrintService/PrintServerBootstrapper.cs b/EpsonPrintService/PrintServerBootstrapper.cs
index 40f5906..8b1fa54 100644
--- a/EpsonPrintService/PrintServerBootstrapper.cs
+++ b/EpsonPrintService/PrintServerBootstrapper.cs
@@ -8,9 +8,11 @@ using Inspectron.Epson.PrintServer.PrinterAssinment;
using Inspectron.Epson.PrintServer.Printers;
using Inspectron.Epson.PrintServer.Printers.Utils;
using Inspectron.Epson.PrintServer.PrintServices;
+using Inspectron.Epson.PrintServer.Telemetry;
using Inspectron.Epson.Queue;
using Microsoft.Extensions.Logging;
using Ninject;
+using System.Reflection;
namespace EpsonPrintService;
@@ -45,6 +47,10 @@ public class PrintServerBootstrapper
_kernel = new StandardKernel();
_kernel.Bind().ToConstant(new HttpClient { Timeout = TimeSpan.FromSeconds(30) }).InSingletonScope();
+ if (_config.EmulationMode)
+ _kernel.Bind().To().InSingletonScope();
+ else
+ _kernel.Bind().To().InSingletonScope();
_kernel.Bind().ToConstant(_config);
if (_config.EmulationMode)
@@ -97,7 +103,12 @@ public class PrintServerBootstrapper
{
_kernel.Bind().ToConstant(handler);
}
- _kernel.Bind().To().InSingletonScope();
+ _kernel.Bind().ToSelf().InSingletonScope();
+ _kernel.Bind().ToMethod(ctx =>
+ new InsinTelemetryDiscoveredPrintersReceiver(
+ ctx.Kernel.Get(),
+ ctx.Kernel.Get()))
+ .InSingletonScope();
_kernel.Bind().ToSelf().InSingletonScope();
_kernel.Bind().ToSelf().InSingletonScope();
@@ -120,5 +131,15 @@ public class PrintServerBootstrapper
IsRunning = true;
Console.WriteLine("Print server started.");
+
+ 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 */ }
}
}
diff --git a/Inspectron.Epson.Tests/Configuration/ConfigurationPathsTests.cs b/Inspectron.Epson.Tests/Configuration/ConfigurationPathsTests.cs
new file mode 100644
index 0000000..e9eb198
--- /dev/null
+++ b/Inspectron.Epson.Tests/Configuration/ConfigurationPathsTests.cs
@@ -0,0 +1,40 @@
+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);
+ }
+ }
+}
diff --git a/Inspectron.Epson.Tests/GlobalUsings.cs b/Inspectron.Epson.Tests/GlobalUsings.cs
new file mode 100644
index 0000000..c802f44
--- /dev/null
+++ b/Inspectron.Epson.Tests/GlobalUsings.cs
@@ -0,0 +1 @@
+global using Xunit;
diff --git a/Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj b/Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj
new file mode 100644
index 0000000..fc64d8b
--- /dev/null
+++ b/Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj
@@ -0,0 +1,26 @@
+
+
+
+ net8.0
+ enable
+ enable
+ false
+ true
+ Major
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
+
diff --git a/Inspectron.Epson.Tests/Sanity/SanityTests.cs b/Inspectron.Epson.Tests/Sanity/SanityTests.cs
new file mode 100644
index 0000000..abb1753
--- /dev/null
+++ b/Inspectron.Epson.Tests/Sanity/SanityTests.cs
@@ -0,0 +1,7 @@
+namespace Inspectron.Epson.Tests.Sanity;
+
+public class SanityTests
+{
+ [Fact]
+ public void Truth_is_true() => Assert.True(true);
+}
diff --git a/Inspectron.Epson.Tests/Telemetry/InsinEventKindsTests.cs b/Inspectron.Epson.Tests/Telemetry/InsinEventKindsTests.cs
new file mode 100644
index 0000000..ca56f1d
--- /dev/null
+++ b/Inspectron.Epson.Tests/Telemetry/InsinEventKindsTests.cs
@@ -0,0 +1,28 @@
+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);
+ }
+}
diff --git a/Inspectron.Epson.Tests/Telemetry/InsinMessageFormatterTests.cs b/Inspectron.Epson.Tests/Telemetry/InsinMessageFormatterTests.cs
new file mode 100644
index 0000000..2645ca4
--- /dev/null
+++ b/Inspectron.Epson.Tests/Telemetry/InsinMessageFormatterTests.cs
@@ -0,0 +1,52 @@
+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);
+ }
+}
diff --git a/Inspectron.Epson.Tests/Telemetry/InsinTelemetryDiscoveredPrintersReceiverTests.cs b/Inspectron.Epson.Tests/Telemetry/InsinTelemetryDiscoveredPrintersReceiverTests.cs
new file mode 100644
index 0000000..526eab9
--- /dev/null
+++ b/Inspectron.Epson.Tests/Telemetry/InsinTelemetryDiscoveredPrintersReceiverTests.cs
@@ -0,0 +1,104 @@
+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");
+ }
+}
diff --git a/Inspectron.Epson.Tests/Telemetry/LoopbackInsinTelemetryTests.cs b/Inspectron.Epson.Tests/Telemetry/LoopbackInsinTelemetryTests.cs
new file mode 100644
index 0000000..d6d2ddb
--- /dev/null
+++ b/Inspectron.Epson.Tests/Telemetry/LoopbackInsinTelemetryTests.cs
@@ -0,0 +1,74 @@
+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();
+ }
+}
diff --git a/Inspectron.Epson.Tests/Telemetry/NullInsinTelemetryTests.cs b/Inspectron.Epson.Tests/Telemetry/NullInsinTelemetryTests.cs
new file mode 100644
index 0000000..973776b
--- /dev/null
+++ b/Inspectron.Epson.Tests/Telemetry/NullInsinTelemetryTests.cs
@@ -0,0 +1,14 @@
+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);
+ }
+}
diff --git a/Inspectron.Epson.slnx b/Inspectron.Epson.slnx
index 6cb719b..4839700 100644
--- a/Inspectron.Epson.slnx
+++ b/Inspectron.Epson.slnx
@@ -6,6 +6,7 @@
+
diff --git a/Inspectron.Epson/PrintServer/DiscoveredPrintersReceiver/InsinTelemetryDiscoveredPrintersReceiver.cs b/Inspectron.Epson/PrintServer/DiscoveredPrintersReceiver/InsinTelemetryDiscoveredPrintersReceiver.cs
new file mode 100644
index 0000000..e52dc92
--- /dev/null
+++ b/Inspectron.Epson/PrintServer/DiscoveredPrintersReceiver/InsinTelemetryDiscoveredPrintersReceiver.cs
@@ -0,0 +1,71 @@
+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;
+ }
+}
diff --git a/Inspectron.Epson/PrintServer/Telemetry/IInsinTelemetry.cs b/Inspectron.Epson/PrintServer/Telemetry/IInsinTelemetry.cs
new file mode 100644
index 0000000..a15c788
--- /dev/null
+++ b/Inspectron.Epson/PrintServer/Telemetry/IInsinTelemetry.cs
@@ -0,0 +1,6 @@
+namespace Inspectron.Epson.PrintServer.Telemetry;
+
+public interface IInsinTelemetry
+{
+ void Emit(string kind, string message);
+}
diff --git a/Inspectron.Epson/PrintServer/Telemetry/InsinEventKinds.cs b/Inspectron.Epson/PrintServer/Telemetry/InsinEventKinds.cs
new file mode 100644
index 0000000..5aad7ae
--- /dev/null
+++ b/Inspectron.Epson/PrintServer/Telemetry/InsinEventKinds.cs
@@ -0,0 +1,19 @@
+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",
+ };
+}
diff --git a/Inspectron.Epson/PrintServer/Telemetry/InsinMessageFormatter.cs b/Inspectron.Epson/PrintServer/Telemetry/InsinMessageFormatter.cs
new file mode 100644
index 0000000..2c1770c
--- /dev/null
+++ b/Inspectron.Epson/PrintServer/Telemetry/InsinMessageFormatter.cs
@@ -0,0 +1,26 @@
+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}\"";
+ }
+}
diff --git a/Inspectron.Epson/PrintServer/Telemetry/LoopbackInsinTelemetry.cs b/Inspectron.Epson/PrintServer/Telemetry/LoopbackInsinTelemetry.cs
new file mode 100644
index 0000000..d780462
--- /dev/null
+++ b/Inspectron.Epson/PrintServer/Telemetry/LoopbackInsinTelemetry.cs
@@ -0,0 +1,56 @@
+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);
+ }
+}
diff --git a/Inspectron.Epson/PrintServer/Telemetry/NullInsinTelemetry.cs b/Inspectron.Epson/PrintServer/Telemetry/NullInsinTelemetry.cs
new file mode 100644
index 0000000..ce15cac
--- /dev/null
+++ b/Inspectron.Epson/PrintServer/Telemetry/NullInsinTelemetry.cs
@@ -0,0 +1,6 @@
+namespace Inspectron.Epson.PrintServer.Telemetry;
+
+public sealed class NullInsinTelemetry : IInsinTelemetry
+{
+ public void Emit(string kind, string message) { }
+}
diff --git a/Inspectron.Epson/Queue/PrintServer.cs b/Inspectron.Epson/Queue/PrintServer.cs
index 19f5409..f5f8d31 100644
--- a/Inspectron.Epson/Queue/PrintServer.cs
+++ b/Inspectron.Epson/Queue/PrintServer.cs
@@ -8,18 +8,19 @@ public class PrintServer
private readonly IPrintService _printService;
private readonly ILogger _logger;
private readonly IJobStatusReporter _statusReporter;
+ private readonly Inspectron.Epson.PrintServer.Telemetry.IInsinTelemetry _telemetry;
private readonly ConcurrentDictionary _printerQueues;
private readonly SemaphoreSlim _printDiscoveryLock = new(1, 1);
private int _readerCount = 0;
private readonly object _readerCountLock = new();
- public PrintServer(IPrintService printService, ILogger logger, IJobStatusReporter statusReporter)
+ 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();
-
}
///
@@ -80,7 +81,7 @@ public class PrintServer
public void RegisterPrinter(string printerIp)
{
- var queue = new PrinterQueue(printerIp, _printService, _logger, this, _statusReporter);
+ var queue = new PrinterQueue(printerIp, _printService, _logger, this, _statusReporter, _telemetry);
if (_printerQueues.TryAdd(printerIp, queue))
{
queue.Start();
diff --git a/Inspectron.Epson/Queue/PrinterQueue.cs b/Inspectron.Epson/Queue/PrinterQueue.cs
index d823d9b..d3bf6cf 100644
--- a/Inspectron.Epson/Queue/PrinterQueue.cs
+++ b/Inspectron.Epson/Queue/PrinterQueue.cs
@@ -1,4 +1,5 @@
using System.Collections.Concurrent;
+using Inspectron.Epson.PrintServer.Telemetry;
using Microsoft.Extensions.Logging;
namespace Inspectron.Epson.Queue;
@@ -15,11 +16,12 @@ public class PrinterQueue
private readonly ILogger _logger;
private readonly PrintServer _printServer;
private readonly IJobStatusReporter _statusReporter;
+ private readonly IInsinTelemetry _telemetry;
public bool IsProcessing { get; private set; }
public int QueueLength => _queue.Count + _priorityQueue.Count;
- public PrinterQueue(string printerIp, IPrintService printService, ILogger logger, PrintServer printServer, IJobStatusReporter statusReporter)
+ public PrinterQueue(string printerIp, IPrintService printService, ILogger logger, PrintServer printServer, IJobStatusReporter statusReporter, IInsinTelemetry telemetry)
{
PrinterIp = printerIp;
_queue = new ConcurrentQueue();
@@ -30,6 +32,7 @@ public class PrinterQueue
_logger = logger;
_printServer = printServer;
_statusReporter = statusReporter;
+ _telemetry = telemetry;
}
public void Enqueue(PrintJob job)
@@ -74,13 +77,23 @@ public class PrinterQueue
_logger.LogInformation("Processing job on printer {PrinterId}", PrinterIp);
_printServer.EnterPrintLock();
+ var stopwatch = System.Diagnostics.Stopwatch.StartNew();
try
{
// Call the actual print function
var result = await _printService.PrintAsync(PrinterIp, job);
+ stopwatch.Stop();
if (!result.Success)
{
+ var failKind = InsinEventKinds.MapErrorTypeToKind(result.ErrorType);
+ SafeEmit(failKind, InsinMessageFormatter.Format(
+ ("printer", PrinterIp),
+ ("receiptType", job.Document?.ReceiptType.ToString()),
+ ("retry", job.RetryCount.ToString()),
+ ("durationMs", stopwatch.ElapsedMilliseconds.ToString()),
+ ("error", result.ErrorType.ToString())));
+
if (result.ErrorType == PrintErrorType.ConversionError)
{
_logger.LogWarning("Job failed due to conversion error, not re-queuing");
@@ -108,12 +121,23 @@ public class PrinterQueue
{
_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())));
}
}
catch (Exception ex)
{
+ stopwatch.Stop();
_logger.LogError(ex, "Error processing job");
await _statusReporter.ReportStatusAsync(job, PrintJobStatus.Failed);
+ SafeEmit(InsinEventKinds.MapErrorTypeToKind(PrintErrorType.Other), InsinMessageFormatter.Format(
+ ("printer", PrinterIp),
+ ("receiptType", job.Document?.ReceiptType.ToString()),
+ ("retry", job.RetryCount.ToString()),
+ ("durationMs", stopwatch.ElapsedMilliseconds.ToString()),
+ ("error", ex.GetType().Name)));
}
finally
{
@@ -146,4 +170,10 @@ public class PrinterQueue
{
return new List(_queue);
}
+
+ private void SafeEmit(string kind, string message)
+ {
+ try { _telemetry.Emit(kind, message); }
+ catch (Exception ex) { _logger.LogWarning(ex, "insin telemetry emit failed"); }
+ }
}
\ No newline at end of file
diff --git a/deploy/insin/epson.service b/deploy/insin/epson.service
new file mode 100644
index 0000000..7c532ab
--- /dev/null
+++ b/deploy/insin/epson.service
@@ -0,0 +1,14 @@
+[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
diff --git a/deploy/insin/install.cfg b/deploy/insin/install.cfg
new file mode 100644
index 0000000..f7380ba
--- /dev/null
+++ b/deploy/insin/install.cfg
@@ -0,0 +1 @@
+install_path=/opt/epson-print-service
diff --git a/deploy/insin/postinst b/deploy/insin/postinst
new file mode 100755
index 0000000..6d2a475
--- /dev/null
+++ b/deploy/insin/postinst
@@ -0,0 +1,8 @@
+#!/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
+systemctl daemon-reload
+systemctl enable --now epson.service
diff --git a/deploy/insin/prem b/deploy/insin/prem
new file mode 100755
index 0000000..0618224
--- /dev/null
+++ b/deploy/insin/prem
@@ -0,0 +1,5 @@
+#!/usr/bin/env bash
+set -euo pipefail
+systemctl disable --now epson.service 2>/dev/null || true
+rm -f /etc/systemd/system/epson.service
+systemctl daemon-reload 2>/dev/null || true
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`.
diff --git a/scripts/publish-insin.sh b/scripts/publish-insin.sh
new file mode 100755
index 0000000..d993077
--- /dev/null
+++ b/scripts/publish-insin.sh
@@ -0,0 +1,54 @@
+#!/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/"
+chmod +x "$payload/postinst" "$payload/prem"
+
+mkdir -p .tools
+case "$(uname -m)" in
+ x86_64) cli_rid=linux-x64 ;;
+ aarch64|arm64) cli_rid=linux-arm64 ;;
+ armv7l|armv6l) cli_rid=linux-arm ;;
+ *) echo "unsupported host arch: $(uname -m)" >&2; exit 1 ;;
+esac
+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']=='$cli_rid'); 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}"