1400 lines
48 KiB
Markdown
1400 lines
48 KiB
Markdown
# 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`:
|
|
|
|
```xml
|
|
<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`:
|
|
|
|
```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 `<Project>` line after the existing test project reference:
|
|
|
|
```xml
|
|
<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**
|
|
|
|
```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<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`:
|
|
|
|
```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<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**
|
|
|
|
```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<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:
|
|
|
|
```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<string, PrinterQueue>();
|
|
}
|
|
```
|
|
|
|
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<HttpClient>()...`, add:
|
|
|
|
```csharp
|
|
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:
|
|
|
|
```csharp
|
|
_kernel.Bind<IDiscoveredPrintersReceiver>().To<JamesDiscoveredPrintersReceiver>().InSingletonScope();
|
|
```
|
|
|
|
Replace with:
|
|
|
|
```csharp
|
|
_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:
|
|
|
|
```csharp
|
|
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**
|
|
|
|
```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`.
|