Files
Print_server/docs/superpowers/plans/2026-09-03-pos895-integration.md
EugeneTes 2f26a933cb amend POS895 plan: merge discovery tasks, drop network-dependent test
Pre-flight scan found two things the plan mandated that a reviewer would
rightly flag: Task 1 committed a DiscoverPrintersAsync that threw
NotImplementedException until Task 2 replaced it, and a unit test asserted
an empty result from a real UDP broadcast, which passes on a laptop and
fails on any network with a POS895 on it.

Tasks 1 and 2 are now one task delivering the complete service, and the
network-dependent test is gone. The deterministic bind-failure test still
covers the degradation path. Six tasks now.

Claude-Session: https://claude.ai/code/session_01E6N3P4nSZLtp4BC2LXVref
2026-09-03 11:46:25 +02:00

1280 lines
44 KiB
Markdown

# POS895 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:** A POS895 on the network is discovered automatically, reported to the backend, and prints every existing receipt type correctly at 48 columns with a logo.
**Architecture:** The POS895 answers `GS I 1` with `0x20`, so model identity stays a `byte` and the existing per-job identification flow is untouched. Two things are genuinely new: a `SprtDiscoveryService` speaking the vendor's UDP broadcast protocol (the printer ignores Epson's ENPC), and a `POS895` `IPrinter` adapter that differs from the TM-T30III one in exactly three respects. Everything else is registering `0x20` in three existing switches.
**Tech Stack:** C# / .NET 8.0, xunit for tests, Ninject for DI, SixLabors.ImageSharp for images.
## Global Constraints
- Target framework `net8.0`, `Nullable` enabled, `ImplicitUsings` enabled — match the other projects.
- Do not modify `EpsonPrinter` or `IEpsonPrinter`. The Epson models depend on current behaviour, notably `FeedLinesAsync` emitting `ESC d`.
- Do not change `NextCourse` in `ReceiptConverterFactory`. It is hardcoded to 33/20 for every printer and looks like a bug, but changing it alters TM-T30III behaviour and is out of scope.
- Do not change the CP852 selection in `EpsonPrinter.PrintTextAsync:339`. It is a real fleet-wide bug (CP852 cannot encode `è à ù ê ì ò`) but needs its own spec.
- The printer's ESC/POS quirks, all confirmed on hardware: `GS I 1` = `0x20`; `ESC d n` is **ignored** (feed with `LF`); `GS ( L` prints **nothing** (use `ESC *` via `PrintImageBitModeAsync`); `GS !` magnification works; 576 dots per line, Font A = 48 columns.
- Build with `dotnet build Inspectron.Epson.slnx`. The solution has 153 pre-existing warnings in `Inspectron.Epson`; do not attempt to fix them, but add no new ones.
---
### Task 1: `SprtDiscoveryService`, test project, and `DiscoveredPrinter.Serial`
Delivers the complete SPRT discovery service: the test project the later tasks need, the one new DTO field, the protocol's pure functions, and the network path.
**Files:**
- Create: `Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj`
- Create: `Inspectron.Epson.Tests/SprtDiscoveryServiceTests.cs`
- Create: `Inspectron.Epson/SprtDiscoveryService.cs`
- Modify: `Inspectron.Epson/DiscoveredPrinter.cs`
- Modify: `Inspectron.Epson/PreconfiguredDiscoveryService.cs`
- Modify: `Inspectron.Epson.slnx`
**Interfaces:**
- Consumes: `DiscoveredPrinter`, `IDiscoveryService` (existing, `Inspectron.Epson`).
- Produces: `SprtDiscoveryService(ILogger? logger = null)`; `SprtDiscoveryService.BuildProbe()` returning `byte[]`; `SprtDiscoveryService.TryParseReply(byte[] payload, string ipAddress, out DiscoveredPrinter? printer)` returning `bool`; a working `DiscoverPrintersAsync`. Task 6 constructs it for DI. `DiscoveredPrinter.Serial` (`string?`).
- [ ] **Step 1: Create the test project**
Create `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" />
</ItemGroup>
</Project>
```
Then register it in `Inspectron.Epson.slnx` by adding this line after the `Inspectron.Epson.TemplateEngine.Tests` entry:
```xml
<Project Path="Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj" />
```
- [ ] **Step 2: Write the failing tests**
Create `Inspectron.Epson.Tests/SprtDiscoveryServiceTests.cs`:
```csharp
using System.Text;
using Inspectron.Epson;
namespace Inspectron.Epson.Tests;
public class SprtDiscoveryServiceTests
{
// The exact 39 bytes the printer returned on the bench:
// 17 chars of dashed MAC, 12 spaces, 10 chars of serial.
private static byte[] RealReply() =>
Encoding.ASCII.GetBytes("28-0e-8b-3d-ca-f1 2608120478");
[Fact]
public void BuildProbe_matches_the_captured_easyset_query()
{
byte[] expected =
{
0x53, 0x50, 0x52, 0x54, 0x2D, 0x49, 0x6E, 0x66, 0x6F, 0x72,
0x00, 0x00, 0x0D
};
Assert.Equal(expected, SprtDiscoveryService.BuildProbe());
}
[Fact]
public void TryParseReply_extracts_mac_and_serial()
{
bool ok = SprtDiscoveryService.TryParseReply(RealReply(), "10.1.252.219", out var printer);
Assert.True(ok);
Assert.NotNull(printer);
Assert.Equal("10.1.252.219", printer!.IPAddress);
Assert.Equal("28-0e-8b-3d-ca-f1", printer.MACAddress);
Assert.Equal("2608120478", printer.Serial);
Assert.Equal("POS895", printer.ModelName);
Assert.Equal(9100, printer.Port);
Assert.Equal("SPRT", printer.ServiceType);
Assert.Equal(RealReply(), printer.RawResponse);
}
[Fact]
public void TryParseReply_rejects_a_payload_shorter_than_the_mac()
{
bool ok = SprtDiscoveryService.TryParseReply(
Encoding.ASCII.GetBytes("28-0e-8b"), "10.1.252.219", out var printer);
Assert.False(ok);
Assert.Null(printer);
}
[Fact]
public void TryParseReply_rejects_a_payload_whose_head_is_not_a_mac()
{
// Right length, wrong shape - an unrelated broadcast landing on our port.
bool ok = SprtDiscoveryService.TryParseReply(
Encoding.ASCII.GetBytes("hello there, not a mac at all, nope!!!!!"),
"10.1.252.219", out var printer);
Assert.False(ok);
Assert.Null(printer);
}
[Fact]
public void TryParseReply_rejects_a_mac_with_non_hex_digits()
{
bool ok = SprtDiscoveryService.TryParseReply(
Encoding.ASCII.GetBytes("zz-0e-8b-3d-ca-f1 2608120478"),
"10.1.252.219", out var printer);
Assert.False(ok);
Assert.Null(printer);
}
[Fact]
public void TryParseReply_handles_a_reply_with_no_serial()
{
bool ok = SprtDiscoveryService.TryParseReply(
Encoding.ASCII.GetBytes("28-0e-8b-3d-ca-f1"), "10.1.252.219", out var printer);
Assert.True(ok);
Assert.Equal("", printer!.Serial);
}
[Fact]
public async Task DiscoverPrintersAsync_returns_empty_when_port_9100_is_taken()
{
// Hold UDP 9100 so the service cannot bind it. It must degrade, not throw:
// ENPC discovery has to keep working when this one cannot start.
using var blocker = new System.Net.Sockets.UdpClient();
blocker.Client.Bind(new System.Net.IPEndPoint(System.Net.IPAddress.Any, 9100));
var service = new SprtDiscoveryService();
var result = await service.DiscoverPrintersAsync(TimeSpan.FromMilliseconds(200));
Assert.Empty(result);
}
}
```
Note there is deliberately no "nothing answers" test. Asserting an empty list from
a real broadcast would pass on a developer laptop and fail on any network with a
POS895 on it, including the print server's own.
- [ ] **Step 3: Run the tests to verify they fail**
Run: `dotnet test Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj`
Expected: FAIL to compile — `SprtDiscoveryService` does not exist, `DiscoveredPrinter` has no `Serial`.
- [ ] **Step 4: Add the `Serial` field**
In `Inspectron.Epson/DiscoveredPrinter.cs`, add after the `MACAddress` property:
```csharp
/// <summary>
/// Printer serial number. Populated by SPRT discovery; null for ENPC.
/// Matches what GS I 68 returns over TCP, so it correlates a discovered
/// printer with a connected one.
/// </summary>
public string? Serial { get; set; }
```
In `Inspectron.Epson/PreconfiguredDiscoveryService.cs`, inside the object initialiser in `DiscoverPrintersAsync`, add alongside the other copied fields so the value survives emulation mode:
```csharp
Serial = p.Serial,
```
- [ ] **Step 5: Write the minimal implementation**
Create `Inspectron.Epson/SprtDiscoveryService.cs`:
```csharp
using System.Net;
using System.Net.Sockets;
using System.Text;
using Microsoft.Extensions.Logging;
namespace Inspectron.Epson;
/// <summary>
/// Discovery for SPRT-family ESC/POS printers (POS895 / POS896), which do not
/// answer Epson's ENPC broadcast.
///
/// Protocol, reverse engineered from a packet capture of the vendor's EasySet
/// tool and documented in POS895_POS896_integration_notes.md:
///
/// query UDP broadcast 255.255.255.255:3289, sent FROM source port 9100
/// 53 50 52 54 2D 49 6E 66 6F 72 00 00 0D "SPRT-Infor" 00 00 0D
///
/// reply from &lt;printer&gt;:3289 back to &lt;querier&gt;:9100, 39 ASCII bytes
/// "28-0e-8b-3d-ca-f1 2608120478"
/// |&lt;-- MAC, 17 --&gt;||&lt;- 12 sp -&gt;||&lt;- serial -&gt;|
/// </summary>
public class SprtDiscoveryService : IDiscoveryService
{
private const string ProbeMagic = "SPRT-Infor";
private const int MacLength = 17;
private const int DiscoveryPort = 3289;
private const int ReplyPort = 9100;
/// <summary>Model reported for anything answering SPRT. The reply carries no
/// model name, and a POS896 is indistinguishable from a POS895 here.</summary>
public const string SprtModelName = "POS895";
private readonly ILogger? _logger;
public SprtDiscoveryService(ILogger? logger = null)
{
_logger = logger;
}
/// <summary>The 13-byte discovery query, byte-identical to EasySet's.</summary>
public static byte[] BuildProbe()
{
var probe = new List<byte>(Encoding.ASCII.GetBytes(ProbeMagic));
probe.AddRange(new byte[] { 0x00, 0x00, 0x0D });
return probe.ToArray();
}
/// <summary>
/// Parses a discovery reply. Returns false for anything that is not a
/// dashed 17-character MAC followed by an optional serial, so unrelated
/// broadcasts landing on our port are ignored rather than thrown on.
/// </summary>
public static bool TryParseReply(byte[] payload, string ipAddress, out DiscoveredPrinter? printer)
{
printer = null;
if (payload.Length < MacLength)
{
return false;
}
string text = Encoding.ASCII.GetString(payload);
string mac = text[..MacLength];
string[] octets = mac.Split('-');
if (octets.Length != 6 || !octets.All(o => o.Length == 2 && o.All(Uri.IsHexDigit)))
{
return false;
}
printer = new DiscoveredPrinter
{
IPAddress = ipAddress,
MACAddress = mac,
Serial = text[MacLength..].Trim(),
ModelName = SprtModelName,
Port = 9100,
ServiceType = "SPRT",
RawResponse = payload,
DiscoveredAt = DateTime.UtcNow
};
return true;
}
public async Task<List<DiscoveredPrinter>> DiscoverPrintersAsync(
TimeSpan? timeout = null,
Action<DiscoveredPrinter>? onPrinterDiscovered = null)
{
timeout ??= TimeSpan.FromSeconds(5);
var discovered = new List<DiscoveredPrinter>();
using var udpClient = new UdpClient(AddressFamily.InterNetwork);
udpClient.EnableBroadcast = true;
try
{
// EasySet sends from 9100 and the printer replies to 9100. Whether the
// firmware honours the sender's port or hard-codes 9100 is unknown, so
// bind the port that is proven to work.
udpClient.Client.Bind(new IPEndPoint(IPAddress.Any, ReplyPort));
}
catch (SocketException ex)
{
_logger?.LogWarning(ex,
"SPRT discovery could not bind UDP {Port}; skipping this pass", ReplyPort);
return discovered;
}
var probe = BuildProbe();
try
{
await udpClient.SendAsync(probe, probe.Length,
new IPEndPoint(IPAddress.Broadcast, DiscoveryPort));
}
catch (SocketException ex)
{
_logger?.LogWarning(ex, "SPRT discovery broadcast failed");
return discovered;
}
DateTime deadline = DateTime.UtcNow.Add(timeout.Value);
while (DateTime.UtcNow < deadline)
{
using var cts = new CancellationTokenSource(deadline - DateTime.UtcNow);
UdpReceiveResult result;
try
{
result = await udpClient.ReceiveAsync(cts.Token);
}
catch (OperationCanceledException)
{
break;
}
catch (SocketException ex)
{
_logger?.LogDebug(ex, "SPRT discovery receive error");
continue;
}
string ip = result.RemoteEndPoint.Address.ToString();
if (discovered.Any(p => p.IPAddress == ip))
{
continue;
}
if (!TryParseReply(result.Buffer, ip, out var printer) || printer is null)
{
_logger?.LogDebug("Ignoring {Length}-byte non-SPRT datagram from {Ip}",
result.Buffer.Length, ip);
continue;
}
_logger?.LogInformation("SPRT discovery found {Model} at {Ip} (MAC {Mac}, serial {Serial})",
printer.ModelName, printer.IPAddress, printer.MACAddress, printer.Serial);
discovered.Add(printer);
onPrinterDiscovered?.Invoke(printer);
}
return discovered;
}
}
```
- [ ] **Step 6: Run the tests to verify they pass**
Run: `dotnet test Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj`
Expected: PASS, 7 tests.
- [ ] **Step 7: Commit**
```bash
git add Inspectron.Epson.Tests Inspectron.Epson.slnx Inspectron.Epson/SprtDiscoveryService.cs Inspectron.Epson/DiscoveredPrinter.cs Inspectron.Epson/PreconfiguredDiscoveryService.cs
git commit -m "add SPRT discovery service for POS895 printers"
```
---
### Task 2: `CompositeDiscoveryService`
Runs several discovery services concurrently and merges their results, so ENPC and SPRT printers appear in one list.
**Files:**
- Create: `Inspectron.Epson/CompositeDiscoveryService.cs`
- Create: `Inspectron.Epson.Tests/CompositeDiscoveryServiceTests.cs`
**Interfaces:**
- Consumes: `IDiscoveryService` (existing).
- Produces: `CompositeDiscoveryService(IEnumerable<IDiscoveryService> services, ILogger? logger = null)`. Task 6 constructs it for DI.
- [ ] **Step 1: Write the failing tests**
Create `Inspectron.Epson.Tests/CompositeDiscoveryServiceTests.cs`:
```csharp
using Inspectron.Epson;
namespace Inspectron.Epson.Tests;
public class CompositeDiscoveryServiceTests
{
private sealed class StubDiscovery : IDiscoveryService
{
private readonly List<DiscoveredPrinter> _printers;
private readonly Exception? _throws;
public StubDiscovery(params string[] ips)
{
_printers = ips.Select(ip => new DiscoveredPrinter { IPAddress = ip }).ToList();
}
public StubDiscovery(Exception throws)
{
_printers = new List<DiscoveredPrinter>();
_throws = throws;
}
public Task<List<DiscoveredPrinter>> DiscoverPrintersAsync(
TimeSpan? timeout = null,
Action<DiscoveredPrinter>? onPrinterDiscovered = null)
{
if (_throws is not null)
{
throw _throws;
}
foreach (var p in _printers)
{
onPrinterDiscovered?.Invoke(p);
}
return Task.FromResult(_printers);
}
}
[Fact]
public async Task Merges_results_from_every_child()
{
var composite = new CompositeDiscoveryService(new IDiscoveryService[]
{
new StubDiscovery("10.0.0.1"),
new StubDiscovery("10.0.0.2", "10.0.0.3")
});
var result = await composite.DiscoverPrintersAsync(TimeSpan.FromMilliseconds(50));
Assert.Equal(3, result.Count);
Assert.Contains(result, p => p.IPAddress == "10.0.0.1");
Assert.Contains(result, p => p.IPAddress == "10.0.0.2");
Assert.Contains(result, p => p.IPAddress == "10.0.0.3");
}
[Fact]
public async Task Deduplicates_by_ip_first_wins()
{
// A printer answering both ENPC and SPRT must appear once.
var composite = new CompositeDiscoveryService(new IDiscoveryService[]
{
new StubDiscovery("10.0.0.1"),
new StubDiscovery("10.0.0.1")
});
var result = await composite.DiscoverPrintersAsync(TimeSpan.FromMilliseconds(50));
Assert.Single(result);
}
[Fact]
public async Task One_child_throwing_does_not_suppress_the_others()
{
var composite = new CompositeDiscoveryService(new IDiscoveryService[]
{
new StubDiscovery(new InvalidOperationException("boom")),
new StubDiscovery("10.0.0.7")
});
var result = await composite.DiscoverPrintersAsync(TimeSpan.FromMilliseconds(50));
Assert.Single(result);
Assert.Equal("10.0.0.7", result[0].IPAddress);
}
[Fact]
public async Task Forwards_the_discovered_callback()
{
var seen = new List<string>();
var composite = new CompositeDiscoveryService(new IDiscoveryService[]
{
new StubDiscovery("10.0.0.1", "10.0.0.2")
});
await composite.DiscoverPrintersAsync(TimeSpan.FromMilliseconds(50),
p => seen.Add(p.IPAddress));
Assert.Equal(2, seen.Count);
}
[Fact]
public async Task Callback_fires_once_per_ip_even_when_two_children_report_it()
{
var seen = new List<string>();
var composite = new CompositeDiscoveryService(new IDiscoveryService[]
{
new StubDiscovery("10.0.0.1"),
new StubDiscovery("10.0.0.1")
});
await composite.DiscoverPrintersAsync(TimeSpan.FromMilliseconds(50),
p => seen.Add(p.IPAddress));
Assert.Single(seen);
}
}
```
- [ ] **Step 2: Run the tests to verify they fail**
Run: `dotnet test Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj --filter CompositeDiscoveryServiceTests`
Expected: FAIL to compile — `CompositeDiscoveryService` does not exist.
- [ ] **Step 3: Write the implementation**
Create `Inspectron.Epson/CompositeDiscoveryService.cs`:
```csharp
using Microsoft.Extensions.Logging;
namespace Inspectron.Epson;
/// <summary>
/// Runs several discovery services concurrently and merges their results by IP.
///
/// Epson printers answer ENPC (DiscoveryService) and SPRT-family printers answer
/// SPRT (SprtDiscoveryService); both broadcast on UDP 3289. A child that fails is
/// logged and skipped so one broken protocol cannot blind the others.
/// </summary>
public class CompositeDiscoveryService : IDiscoveryService
{
private readonly IReadOnlyList<IDiscoveryService> _services;
private readonly ILogger? _logger;
public CompositeDiscoveryService(IEnumerable<IDiscoveryService> services, ILogger? logger = null)
{
_services = services.ToList();
_logger = logger;
}
public async Task<List<DiscoveredPrinter>> DiscoverPrintersAsync(
TimeSpan? timeout = null,
Action<DiscoveredPrinter>? onPrinterDiscovered = null)
{
var merged = new List<DiscoveredPrinter>();
var seenIps = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
// Children run concurrently, so the caller's timeout still bounds the
// whole pass rather than the sum of the children.
var tasks = _services.Select(async service =>
{
try
{
return await service.DiscoverPrintersAsync(timeout);
}
catch (Exception ex)
{
_logger?.LogWarning(ex, "Discovery service {Service} failed",
service.GetType().Name);
return new List<DiscoveredPrinter>();
}
});
var results = await Task.WhenAll(tasks);
// Task.WhenAll has completed, so this merge is single-threaded.
foreach (var printer in results.SelectMany(r => r))
{
if (printer.IPAddress is null || !seenIps.Add(printer.IPAddress))
{
continue;
}
merged.Add(printer);
onPrinterDiscovered?.Invoke(printer);
}
return merged;
}
}
```
Note the children are called **without** the `onPrinterDiscovered` callback. The composite raises it itself after deduplication, so a printer answering both protocols notifies once.
- [ ] **Step 4: Run the tests to verify they pass**
Run: `dotnet test Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj`
Expected: PASS, 12 tests.
- [ ] **Step 5: Commit**
```bash
git add Inspectron.Epson/CompositeDiscoveryService.cs Inspectron.Epson.Tests/CompositeDiscoveryServiceTests.cs
git commit -m "add CompositeDiscoveryService merging ENPC and SPRT results"
```
---
### Task 3: `POS895` printer adapter
The adapter differs from `TM_T30IIITranslated` in exactly three ways, each confirmed on hardware. A recording fake asserts the two that would otherwise fail silently on paper.
**Files:**
- Create: `Inspectron.Epson/PrintServer/Printers/POS895.cs`
- Create: `Inspectron.Epson.Tests/RecordingEpsonPrinter.cs`
- Create: `Inspectron.Epson.Tests/POS895Tests.cs`
**Interfaces:**
- Consumes: `IPrinter` (`Inspectron.Epson.PrintServer.PrintServices`), `IEpsonPrinter` (`Inspectron.Epson`), `PrintCommand` (`Inspectron.Epson.PrintServer.Printers.Utils`).
- Produces: `POS895(IEpsonPrinter printer)` with a `bool SkipDelays { get; set; }`. Task 4 constructs it.
- [ ] **Step 1: Write the recording test double**
Create `Inspectron.Epson.Tests/RecordingEpsonPrinter.cs`:
```csharp
using Inspectron.Epson;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
namespace Inspectron.Epson.Tests;
/// <summary>
/// Records what an IPrinter adapter sends. Only the calls the POS895 adapter
/// makes are recorded; the rest satisfy the interface and do nothing.
/// </summary>
public sealed class RecordingEpsonPrinter : IEpsonPrinter
{
public List<byte[]> RawCommands { get; } = new();
public List<int> FeedLineCalls { get; } = new();
public List<string> Text { get; } = new();
public List<(string Path, EpsonCommands.BitImageMode Mode, int MaxWidth)> BitModeImages { get; } = new();
public List<(string Path, int MaxWidth)> RasterImages { get; } = new();
public List<int> AbsolutePositions { get; } = new();
public int CutCount { get; private set; }
/// <summary>Total LF bytes across every raw command sent.</summary>
public int LineFeedBytes => RawCommands.Sum(c => c.Count(b => b == 0x0A));
public bool IsConnected => true;
public Task SendRawCommandAsync(byte[] command)
{
RawCommands.Add(command);
return Task.CompletedTask;
}
public Task FeedLinesAsync(int lines)
{
FeedLineCalls.Add(lines);
return Task.CompletedTask;
}
public Task PrintTextAsync(string text)
{
Text.Add(text);
return Task.CompletedTask;
}
public Task PrintImageBitModeAsync(string imagePath, EpsonCommands.BitImageMode mode = EpsonCommands.BitImageMode.SingleDensity8Dot, int maxWidth = 10)
{
BitModeImages.Add((imagePath, mode, maxWidth));
return Task.CompletedTask;
}
public Task LoadImageAsync(string imagePath, int maxWidth = 384)
{
RasterImages.Add((imagePath, maxWidth));
return Task.CompletedTask;
}
public Task SetAbsolutePrintPosition(int x)
{
AbsolutePositions.Add(x);
return Task.CompletedTask;
}
public Task CutAsync(bool fullCut = true)
{
CutCount++;
return Task.CompletedTask;
}
// Remaining interface members: no-ops.
public Task ConnectAsync(string ip, int port = 9100, int timeoutSeconds = 10) => Task.CompletedTask;
// 0x16 / 0x12 are what the POS895 actually returns when idle and ready.
public Task<PrinterStatus> GetPrinterStatusAsync() => Task.FromResult(PrinterStatus.Parse(0x16));
public Task<OfflineStatus> GetOfflineStatusAsync() => Task.FromResult(OfflineStatus.Parse(0x12));
public Task<ErrorStatus> GetErrorStatusAsync() => Task.FromResult(ErrorStatus.Parse(0x12));
public Task<PaperSensorStatus> GetPaperSensorStatusAsync() => Task.FromResult(PaperSensorStatus.Parse(0x12));
public Task<byte> GetTM220StatusAsync() => Task.FromResult((byte)0);
public Task<byte?> GetPrinterIdAsync() => Task.FromResult<byte?>(0x20);
public Task<OverallStatus> GetOverallStatusAsync() => Task.FromResult(new OverallStatus());
public Task InitAsync() => Task.CompletedTask;
public Task SetQuadrupleMode(bool enable) => Task.CompletedTask;
public Task SetRedColor(bool enable) => Task.CompletedTask;
public Task SetEmphasized(bool enable) => Task.CompletedTask;
public Task SelectFont(EpsonCommands.PrinterFont font) => Task.CompletedTask;
public Task SetBiggerFontTM220(bool biggerWidth, bool biggerHeight = false, bool secondaryFont = true) => Task.CompletedTask;
public Task SetFontSizeAsync(int width, int height) => Task.CompletedTask;
public Task PrintTextAndCutAsync(string text, int feedLines = 5) => Task.CompletedTask;
public Task PrintBuffer() => Task.CompletedTask;
public Task LoadImageAsync(Stream imageStream, int maxWidth = 384) => Task.CompletedTask;
public Task LoadImageAsync(Image<Rgba32> image, int maxWidth = 384) => Task.CompletedTask;
public Task PrintLoadedImage() => Task.CompletedTask;
public Task PrintImageBitModeAsync(Stream imageStream, EpsonCommands.BitImageMode mode = EpsonCommands.BitImageMode.DoubleDensity24Dot, int maxWidth = 384) => Task.CompletedTask;
public Task PrintImageBitModeAsync(Image<Rgba32> image, EpsonCommands.BitImageMode mode, int maxWidth) => Task.CompletedTask;
public Task SetDefaultLineSpacing() => Task.CompletedTask;
public Task SetCustomLineSpacing(int spacing = 24) => Task.CompletedTask;
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
```
If the compiler reports any missing interface member, add it as a no-op in the same style. Do not change `IEpsonPrinter`.
- [ ] **Step 2: Write the failing tests**
Create `Inspectron.Epson.Tests/POS895Tests.cs`:
```csharp
using Inspectron.Epson.PrintServer.Printers;
using Inspectron.Epson.PrintServer.Printers.Utils;
namespace Inspectron.Epson.Tests;
public class POS895Tests
{
private static POS895 Make(out RecordingEpsonPrinter fake)
{
fake = new RecordingEpsonPrinter();
return new POS895(fake) { SkipDelays = true };
}
[Fact]
public async Task PrintAsync_never_uses_ESC_d_to_feed()
{
// This firmware ignores ESC d. Feeding with it would cut the last line.
var printer = Make(out var fake);
await printer.PrintAsync(new List<PrintCommand>
{
new PrintCommand("hello")
});
Assert.Empty(fake.FeedLineCalls);
}
[Fact]
public async Task PrintAsync_feeds_with_literal_line_feeds_before_finishing()
{
var printer = Make(out var fake);
await printer.PrintAsync(new List<PrintCommand>
{
new PrintCommand("hello")
});
Assert.Equal(10, fake.LineFeedBytes);
}
[Fact]
public async Task PrintAsync_writes_the_command_text()
{
var printer = Make(out var fake);
await printer.PrintAsync(new List<PrintCommand>
{
new PrintCommand("hello")
});
Assert.Contains(fake.Text, t => t.Contains("hello"));
}
[Fact]
public async Task PrintAsync_handles_an_IsCut_command_with_line_feeds_then_a_cut()
{
var printer = Make(out var fake);
await printer.PrintAsync(new List<PrintCommand>
{
new PrintCommand("a"),
new PrintCommand("") { IsCut = true }
});
Assert.Empty(fake.FeedLineCalls);
Assert.Equal(1, fake.CutCount);
}
[Fact]
public async Task PrintImageAsync_uses_bit_image_mode_at_576_dots()
{
// GS ( L prints nothing on this firmware, so LoadImageAsync must not be used.
var printer = Make(out var fake);
await printer.PrintImageAsync("logo.png");
Assert.Empty(fake.RasterImages);
var image = Assert.Single(fake.BitModeImages);
Assert.Equal("logo.png", image.Path);
Assert.Equal(EpsonCommands.BitImageMode.DoubleDensity24Dot, image.Mode);
Assert.Equal(576, image.MaxWidth);
}
[Fact]
public async Task PrintImageAsync_does_not_use_absolute_positioning()
{
// ESC $ is unverified on this firmware; the logo spans the full width.
var printer = Make(out var fake);
await printer.PrintImageAsync("logo.png");
Assert.Empty(fake.AbsolutePositions);
}
[Fact]
public async Task Cut_cuts_once()
{
var printer = Make(out var fake);
await printer.Cut();
Assert.Equal(1, fake.CutCount);
}
}
```
- [ ] **Step 3: Run the tests to verify they fail**
Run: `dotnet test Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj --filter POS895Tests`
Expected: FAIL to compile — `POS895` does not exist.
- [ ] **Step 4: Write the implementation**
Create `Inspectron.Epson/PrintServer/Printers/POS895.cs`:
```csharp
using Inspectron.Epson.PrintServer.Printers.Utils;
using Inspectron.Epson.PrintServer.PrintServices;
namespace Inspectron.Epson.PrintServer.Printers;
/// <summary>
/// Adapter for the POS895 / POS896, SPRT-family ESC/POS clones on an 80 mm,
/// 576-dot line (Font A = 48 columns).
///
/// Cloned from TM_T30IIITranslated with three differences, each confirmed on
/// hardware and recorded in POS895_POS896_integration_notes.md:
///
/// 1. ESC d n is ignored by this firmware, so feeding uses literal LF bytes.
/// Using ESC d would cut the receipt through its last line.
/// 2. GS ( L prints nothing, so the logo goes out as ESC * bit-image.
/// 3. The line is 576 dots, not 384, and the logo spans it from position 0,
/// which avoids ESC $ (still unverified here).
/// </summary>
public class POS895 : IPrinter
{
/// <summary>Dots per line. Font A is 12 dots wide, giving 48 columns.</summary>
public const int PaperWidthDots = 576;
private const byte LineFeed = 0x0A;
private const int FeedLinesBeforeCut = 10;
private readonly IEpsonPrinter _printer;
public bool SkipDelays { get; set; }
public POS895(IEpsonPrinter printer)
{
_printer = printer;
}
public Task InitAsync()
{
return _printer.InitAsync();
}
public async Task PrintImageAsync(string path)
{
await FeedAsync(1);
await _printer.PrintImageBitModeAsync(
path, EpsonCommands.BitImageMode.DoubleDensity24Dot, PaperWidthDots);
if (!SkipDelays) await Task.Delay(100);
await FeedAsync(1);
}
public async Task PrintAsync(List<PrintCommand> commands)
{
await _printer.SetCustomLineSpacing(22);
foreach (var command in commands)
{
if (command.IsCut)
{
await FeedAsync(FeedLinesBeforeCut);
await _printer.CutAsync(false);
continue;
}
if (command.SetLineSpacing.HasValue)
{
await _printer.SetCustomLineSpacing(command.SetLineSpacing.Value);
continue;
}
await _printer.SetFontSizeAsync(command.IsBig ? 2 : 1, command.IsTall ? 2 : 1);
await _printer.SetEmphasized(command.IsBold);
await _printer.PrintTextAsync(command.Text + "\n");
}
await _printer.SetDefaultLineSpacing();
await FeedAsync(FeedLinesBeforeCut);
if (!SkipDelays) await Task.Delay(200);
}
public async Task Cut()
{
await _printer.CutAsync();
if (!SkipDelays) await Task.Delay(200);
}
/// <summary>
/// Feeds with literal LF bytes. IEpsonPrinter.FeedLinesAsync emits ESC d,
/// which this firmware ignores, and the Epson models depend on that method
/// staying as it is.
/// </summary>
private Task FeedAsync(int lines)
{
var bytes = new byte[lines];
Array.Fill(bytes, LineFeed);
return _printer.SendRawCommandAsync(bytes);
}
}
```
Note `PrintAsync` feeds 10 lines at the end **and** 10 on an `IsCut` command, matching `TM_T30IIITranslated`. The `PrintAsync_feeds_with_literal_line_feeds_before_finishing` test uses a command list with no `IsCut`, so it sees exactly 10.
- [ ] **Step 5: Run the tests to verify they pass**
Run: `dotnet test Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj`
Expected: PASS, 19 tests.
- [ ] **Step 6: Commit**
```bash
git add Inspectron.Epson/PrintServer/Printers/POS895.cs Inspectron.Epson.Tests/RecordingEpsonPrinter.cs Inspectron.Epson.Tests/POS895Tests.cs
git commit -m "add POS895 printer adapter with LF feed and ESC * logo"
```
---
### Task 4: Register printer ID `0x20` in the three switches
Three one-line additions in three files. The third is the one that fails silently if missed.
**Files:**
- Modify: `Inspectron.Epson/PrintServer/Printers/PrinterWrapperFactory.cs`
- Modify: `Inspectron.Epson/DiscoveryService.cs` (`GetModelNameFromId`)
- Modify: `Inspectron.Epson/PrintServer/Printers/Utils/ReceiptConverterFactory.cs`
- Create: `Inspectron.Epson.Tests/PrinterRegistrationTests.cs`
**Interfaces:**
- Consumes: `POS895` from Task 3.
- Produces: nothing new; wires `0x20` into existing dispatch.
- [ ] **Step 1: Write the failing tests**
Create `Inspectron.Epson.Tests/PrinterRegistrationTests.cs`:
```csharp
using Inspectron.Epson.PrintServer.Printers;
using Inspectron.Epson.PrintServer.Printers.Utils;
namespace Inspectron.Epson.Tests;
public class PrinterRegistrationTests
{
[Fact]
public void WrapperFactory_returns_a_POS895_for_id_0x20()
{
var factory = new PrinterWrapperFactory();
var printer = factory.CreatePrinterFromId(0x20, new RecordingEpsonPrinter());
Assert.IsType<POS895>(printer);
}
[Fact]
public void WrapperFactory_still_returns_a_TM_T30III_for_id_0x01()
{
var factory = new PrinterWrapperFactory();
var printer = factory.CreatePrinterFromId(0x01, new RecordingEpsonPrinter());
Assert.IsType<TM_T30IIITranslated>(printer);
}
[Fact]
public void WorkareaTicket_uses_48_columns_for_the_POS895()
{
// The switch defaults to the 33-column TM-U220 layout. Without an
// explicit 0x20 case a POS895 prints kitchen tickets far too narrow -
// wrong, but not obviously broken.
//
// Both branches return the same converter type and only the constructor
// argument differs, so comparing types would pass even unfixed. Reading
// the private field is the only way to observe it.
var factory = new ReceiptConverterFactory();
var pos895 = factory.Create((int)ReceiptConverterFactory.EReceiptType.WorkareaTicket, 0x20);
Assert.Equal(48, LineWidthOf(pos895));
}
[Fact]
public void WorkareaTicket_still_uses_33_columns_for_the_narrow_printer()
{
var factory = new ReceiptConverterFactory();
var u220 = factory.Create((int)ReceiptConverterFactory.EReceiptType.WorkareaTicket, 0x13);
Assert.Equal(33, LineWidthOf(u220));
}
/// <summary>
/// Reads KitchenReceiptConverterAdapter._lineWidth. Brittle by design: if the
/// field is renamed this fails loudly rather than silently passing.
/// </summary>
private static int LineWidthOf(IReceiptConverter converter)
{
var field = converter.GetType().GetField("_lineWidth",
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
Assert.NotNull(field);
return (int)field!.GetValue(converter)!;
}
[Fact]
public void Receipt_types_resolve_for_the_POS895()
{
var factory = new ReceiptConverterFactory();
foreach (var type in new[]
{
ReceiptConverterFactory.EReceiptType.Receipt,
ReceiptConverterFactory.EReceiptType.WorkareaTicket,
ReceiptConverterFactory.EReceiptType.OrdersOverview,
ReceiptConverterFactory.EReceiptType.Invoice,
ReceiptConverterFactory.EReceiptType.MoneyReturn
})
{
Assert.NotNull(factory.Create((int)type, 0x20));
}
}
}
```
- [ ] **Step 2: Run the tests to verify they fail**
Run: `dotnet test Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj --filter PrinterRegistrationTests`
Expected: FAIL — `WrapperFactory_returns_a_POS895_for_id_0x20` throws `NotSupportedException: Printer with ID 20 is not supported.`
- [ ] **Step 3: Register in `PrinterWrapperFactory`**
In `Inspectron.Epson/PrintServer/Printers/PrinterWrapperFactory.cs`, add before the `default:` case:
```csharp
case 0x20:
return new POS895(epsonPrinter);
```
- [ ] **Step 4: Register in `GetModelNameFromId`**
In `Inspectron.Epson/DiscoveryService.cs`, in the `GetModelNameFromId` switch expression, add before the `_ =>` arm:
```csharp
0x20 => "POS895",
```
- [ ] **Step 5: Register in `ReceiptConverterFactory`**
In `Inspectron.Epson/PrintServer/Printers/Utils/ReceiptConverterFactory.cs`, change the `WorkareaTicket` arm so `0x20` gets the wide layout:
```csharp
EReceiptType.WorkareaTicket => printerId switch
{
//0x01=>new BarReceiptConverterAdapter(48, 24),
0x01 => new KitchenReceipt.KitchenReceiptConverterAdapter(48, 24),
0x20 => new KitchenReceipt.KitchenReceiptConverterAdapter(48, 24),
_ => new KitchenReceipt.KitchenReceiptConverterAdapter(33, 20),
},
```
Leave `EReceiptType.NextCourse` at `33, 20`. It is hardcoded for every printer and changing it would alter TM-T30III behaviour.
- [ ] **Step 6: Run the tests to verify they pass**
Run: `dotnet test Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj`
Expected: PASS, 24 tests.
- [ ] **Step 7: Commit**
```bash
git add Inspectron.Epson/PrintServer/Printers/PrinterWrapperFactory.cs Inspectron.Epson/DiscoveryService.cs Inspectron.Epson/PrintServer/Printers/Utils/ReceiptConverterFactory.cs Inspectron.Epson.Tests/PrinterRegistrationTests.cs
git commit -m "register printer id 0x20 as POS895 across the three dispatch points"
```
---
### Task 5: Guard the nullable printer ID
`printerId` is a `byte?` dereferenced with `.Value` twice. A printer that does not answer `GS I 1` throws `NullReferenceException` into the generic catch, reporting `PrintErrorType.Other` and burning three `PrinterQueue` retries with no usable log line.
**Files:**
- Modify: `Inspectron.Epson/PrintServer/PrintServices/EpsonPrintService.cs:38-48`
**Interfaces:**
- Consumes: nothing new.
- Produces: nothing new.
- [ ] **Step 1: Add the guard**
In `Inspectron.Epson/PrintServer/PrintServices/EpsonPrintService.cs`, directly after the existing line 40 log call and before `var status = await epsonPrinter.GetPrinterStatusAsync();`, insert:
```csharp
if (printerId is null)
{
_logger.LogWarning(
"Printer {PrinterIp} did not answer GS I 1, so its model is unknown; " +
"cannot choose a printer adapter", printerIp);
return PrintResult.Fail(PrintErrorType.Other);
}
```
This one check covers both `printerId.Value` dereferences, at `:48` and `:91`.
- [ ] **Step 2: Verify the build**
Run: `dotnet build Inspectron.Epson/Inspectron.Epson.csproj -v minimal`
Expected: `0 Error(s)`, and no new warnings attributable to `EpsonPrintService.cs`.
- [ ] **Step 3: Run the full test suite**
Run: `dotnet test Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj`
Expected: PASS, 24 tests.
- [ ] **Step 4: Commit**
```bash
git add Inspectron.Epson/PrintServer/PrintServices/EpsonPrintService.cs
git commit -m "fail cleanly when a printer does not answer GS I 1"
```
---
### Task 6: DI wiring and emulation entry
Binds the composite discovery in production and adds a 576px emulated POS895 so receipt layouts can be checked without hardware.
**Files:**
- Modify: `EpsonPrintService/PrintServerBootstrapper.cs:52-78`
**Interfaces:**
- Consumes: `SprtDiscoveryService` (Task 1), `CompositeDiscoveryService` (Task 2).
- Produces: nothing new.
- [ ] **Step 1: Add the emulated POS895**
In `EpsonPrintService/PrintServerBootstrapper.cs`, extend the `emulationConfigs` dictionary:
```csharp
var emulationConfigs = new Dictionary<string, (string OutputPath, byte PrinterId, int PaperWidth)>
{
["127.0.0.2"] = ("emulation/u220.html", 0x13, 258),
["127.0.0.3"] = ("emulation/t30.html", 0x01, 384),
["127.0.0.4"] = ("emulation/pos895.html", 0x20, 576),
};
```
Register it with the preconfigured discovery, next to the existing two:
```csharp
discovery.AddPrinter("127.0.0.4", "POS895");
```
And update the console line so the mode still reports what it started:
```csharp
Console.WriteLine("Emulation mode enabled: 127.0.0.2 (TM-U220II), 127.0.0.3 (TM-T30III), 127.0.0.4 (POS895)");
```
- [ ] **Step 2: Bind the composite discovery in production**
In the `else` branch, replace this line:
```csharp
_kernel.Bind<IDiscoveryService>().To<DiscoveryService>().InSingletonScope();
```
with:
```csharp
// Epson printers answer ENPC; POS895/POS896 answer SPRT. Both broadcast
// on UDP 3289, but only the composite sees them all.
_kernel.Bind<IDiscoveryService>().ToMethod(ctx =>
{
var logger = ctx.Kernel.Get<ILogger>();
return new CompositeDiscoveryService(
new IDiscoveryService[]
{
new DiscoveryService(logger),
new SprtDiscoveryService(logger)
},
logger);
}).InSingletonScope();
```
Leave the commented-out `PreconfiguredDiscoveryService` block above it alone — it is marked `DO NOT REMOVE !!!`.
- [ ] **Step 3: Verify the whole solution builds**
Run: `dotnet build Inspectron.Epson.slnx -v minimal`
Expected: `0 Error(s)`.
- [ ] **Step 4: Run the full test suite**
Run: `dotnet test Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj`
Expected: PASS, 24 tests.
- [ ] **Step 5: Verify discovery against the real printer**
The `Pos895Test` probe must still find the printer, proving the extracted `SprtDiscoveryService` behaves like the code it came from. Run on a machine on the printer's segment:
```bash
dotnet run --project Pos895Test/Pos895Test.csproj
```
Expected: `FOUND 10.1.252.219 mac 28-0e-8b-3d-ca-f1 serial 2608120478`
- [ ] **Step 6: Commit**
```bash
git add EpsonPrintService/PrintServerBootstrapper.cs
git commit -m "bind composite discovery and add emulated POS895"
```
---
## Manual verification after all tasks
These need the printer and cannot be automated here.
- [ ] Start the service in emulation mode and confirm `emulation/pos895.html` renders each receipt type at 48 columns.
- [ ] With a POS895 on the network, confirm the discovery background task registers it and the backend receives it as model `POS895`.
- [ ] Print one of each receipt type to the real printer with a logo. Check: the logo renders at full width, no line is cut through, and kitchen tickets use the full 48 columns rather than 33.
- [ ] Confirm an Epson printer on the same network is still discovered — the composite must not have broken ENPC.