# 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 net8.0 enable enable false true Major runtime; build; native; contentfiles; analyzers; buildtransitive all ``` Then register it in `Inspectron.Epson.slnx` by adding this line after the `Inspectron.Epson.TemplateEngine.Tests` entry: ```xml ``` - [ ] **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 /// /// 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. /// 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; /// /// 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 <printer>:3289 back to <querier>:9100, 39 ASCII bytes /// "28-0e-8b-3d-ca-f1 2608120478" /// |<-- MAC, 17 -->||<- 12 sp ->||<- serial ->| /// 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; /// Model reported for anything answering SPRT. The reply carries no /// model name, and a POS896 is indistinguishable from a POS895 here. public const string SprtModelName = "POS895"; private readonly ILogger? _logger; public SprtDiscoveryService(ILogger? logger = null) { _logger = logger; } /// The 13-byte discovery query, byte-identical to EasySet's. public static byte[] BuildProbe() { var probe = new List(Encoding.ASCII.GetBytes(ProbeMagic)); probe.AddRange(new byte[] { 0x00, 0x00, 0x0D }); return probe.ToArray(); } /// /// 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. /// 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> DiscoverPrintersAsync( TimeSpan? timeout = null, Action? onPrinterDiscovered = null) { timeout ??= TimeSpan.FromSeconds(5); var discovered = new List(); 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 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 _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(); _throws = throws; } public Task> DiscoverPrintersAsync( TimeSpan? timeout = null, Action? 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(); 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(); 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; /// /// 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. /// public class CompositeDiscoveryService : IDiscoveryService { private readonly IReadOnlyList _services; private readonly ILogger? _logger; public CompositeDiscoveryService(IEnumerable services, ILogger? logger = null) { _services = services.ToList(); _logger = logger; } public async Task> DiscoverPrintersAsync( TimeSpan? timeout = null, Action? onPrinterDiscovered = null) { var merged = new List(); var seenIps = new HashSet(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(); } }); 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; /// /// Records what an IPrinter adapter sends. Only the calls the POS895 adapter /// makes are recorded; the rest satisfy the interface and do nothing. /// public sealed class RecordingEpsonPrinter : IEpsonPrinter { public List RawCommands { get; } = new(); public List FeedLineCalls { get; } = new(); public List 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 AbsolutePositions { get; } = new(); public int CutCount { get; private set; } /// Total LF bytes across every raw command sent. 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 GetPrinterStatusAsync() => Task.FromResult(PrinterStatus.Parse(0x16)); public Task GetOfflineStatusAsync() => Task.FromResult(OfflineStatus.Parse(0x12)); public Task GetErrorStatusAsync() => Task.FromResult(ErrorStatus.Parse(0x12)); public Task GetPaperSensorStatusAsync() => Task.FromResult(PaperSensorStatus.Parse(0x12)); public Task GetTM220StatusAsync() => Task.FromResult((byte)0); public Task GetPrinterIdAsync() => Task.FromResult(0x20); public Task 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 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 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 { 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 { 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 { 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 { 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; /// /// 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). /// public class POS895 : IPrinter { /// Dots per line. Font A is 12 dots wide, giving 48 columns. 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 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); } /// /// 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. /// 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(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(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)); } /// /// Reads KitchenReceiptConverterAdapter._lineWidth. Brittle by design: if the /// field is renamed this fails loudly rather than silently passing. /// 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 { ["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().To().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().ToMethod(ctx => { var logger = ctx.Kernel.Get(); 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.