diff --git a/.gitattributes b/.gitattributes index 1ff0c42..13d8e37 100644 --- a/.gitattributes +++ b/.gitattributes @@ -61,3 +61,7 @@ #*.PDF diff=astextplain #*.rtf diff=astextplain #*.RTF diff=astextplain + +# packet captures +*.pcapng binary +*.etl binary diff --git a/CLAUDE.md b/CLAUDE.md index 598e933..345b577 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,6 +13,8 @@ This is a C# .NET 8.0 solution for Epson thermal receipt printer integration. Th - **Inspectron.Epson** - Core SDK library providing printer communication, status monitoring, ESC/POS commands, print queue management, and receipt conversion - **EpsonPrintService** - Production console application with SignalR-based remote job source, printer discovery, and systemd service support - **EpsonTest** - Test/example project for SDK functionality and receipt conversion +- **Inspectron.Epson.Tests** - xunit unit test project (POS895, CompositeDiscoveryService, SprtDiscoveryService, receipt converters, etc.) +- **Pos895Test** - Scratch console app for probing POS895/POS896 hardware directly; not production code ## Common Build Commands @@ -30,6 +32,9 @@ dotnet run --project EpsonPrintService/EpsonPrintService.csproj # Run tests/examples dotnet run --project EpsonTest/EpsonTest.csproj +# Run the unit test suite +dotnet test Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj + # Clean build artifacts dotnet clean ``` @@ -45,6 +50,8 @@ dotnet clean - `IDiscoveryService` / `DiscoveryService`: Network printer discovery (SLP, ENPC) - `PreconfiguredDiscoveryService`: Preconfigured printer list (no network scan) - `BonjourDiscovery`: mDNS-based printer discovery + - `SprtDiscoveryService`: UDP broadcast discovery for SPRT-family printers (POS895 / POS896), which do not answer Epson ENPC + - `CompositeDiscoveryService`: Runs several `IDiscoveryService` implementations concurrently and merges results by IP; absorbs individual child failures but rethrows if every child fails, so a genuinely broken discovery pass is distinguishable from an empty network - `HtmlPrinter`: HTML output implementation for testing without hardware - Status classes: PrinterStatus, OfflineStatus, ErrorStatus, PaperSensorStatus, OverallStatus - Exception hierarchy: EpsonPrinterException -> EpsonConnectionException, EpsonCommandException @@ -86,6 +93,7 @@ Receipt content is converted from JSON to `PrintCommand` lists via `IReceiptConv `IPrinter` implementations wrap `IEpsonPrinter` with model-specific behavior: - `TM-T30III` / `TM-T30IIITranslated` - Thermal receipt printer (384px image width) - `TM-U220II` / `TM-U220IITranslated` - Dot matrix receipt printer (narrower image width) +- `POS895` - SPRT-family ESC/POS clone (POS895 / POS896), 576-dot line, Font A at 48 columns. See "Non-Epson printers" below for the firmware quirks it works around. Created via `IWrapperPrinterFactory.CreatePrinterFromId(modelIdByte, epsonPrinter)`. @@ -173,13 +181,24 @@ Printers are registered via `PreconfiguredDiscoveryService` with hardcoded IPs i ## Adding New Printer Models -1. Get printer ID byte via `EpsonPrinter.GetPrinterIdAsync()` (GS I 1 command) +1. Get printer ID byte via `EpsonPrinter.GetPrinterIdAsync()` (GS I 1 command). Note: ID `0x20` is already taken by the POS895 - pick a different byte. 2. Create new class implementing `IPrinter` in `Inspectron.Epson/PrintServer/Printers/` 3. Implement required methods: InitAsync, PrintImageAsync, SetFontSizeAsync, PrintAsync, Cut 4. Add case to `PrinterWrapperFactory.CreatePrinterFromId()` with the printer ID byte 5. Add receipt converter support in `ReceiptConverterFactory` if needed 6. Test with actual hardware - different models have different image widths and positioning +### Non-Epson printers (POS895 / POS896) + +POS895 / POS896 support has shipped: `POS895` (`Inspectron.Epson/PrintServer/Printers/POS895.cs`) is the `IPrinter` adapter, `SprtDiscoveryService` is the discovery strategy, and `PrinterWrapperFactory` maps printer ID `0x20` to `POS895`. The full write-up, including the hardware checks that are still open, lives in `POS895_POS896_integration_notes.md` - read it before touching discovery, printer identification (`GetPrinterIdAsync`), or `PrinterWrapperFactory` for a non-Epson model. + +Established hardware facts (confirmed on hardware, so treat as given rather than re-deriving them): +- 576 dots wide; Font A is 48 columns. +- `GS I 1` (`GetPrinterIdAsync`) answers with `0x20`. +- The firmware ignores `ESC d`, so `POS895` feeds with literal LF bytes via `SendRawCommandAsync` instead of `IEpsonPrinter.FeedLinesAsync`. Using `ESC d` here would cut the receipt through its last printed line. +- `GS ( L` raster mode prints nothing, so logos go out via `ESC *` bit-image mode (`PrintImageBitModeAsync`, `DoubleDensity24Dot`) instead of `LoadImageAsync`. +- Discovery is a UDP broadcast of `"SPRT-Infor\0\0\r"` to port 3289, sent from source port 9100 (`SprtDiscoveryService`); this does not go through Epson's ENPC protocol. + ## Adding New Receipt Types 1. Create a new folder under `Inspectron.Epson/PrintServer/Printers/Utils/` diff --git a/EpsonPrintService/PrintServerBootstrapper.cs b/EpsonPrintService/PrintServerBootstrapper.cs index 8b1fa54..670ac4b 100644 --- a/EpsonPrintService/PrintServerBootstrapper.cs +++ b/EpsonPrintService/PrintServerBootstrapper.cs @@ -59,17 +59,19 @@ public class PrintServerBootstrapper { ["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), }; var discovery = new PreconfiguredDiscoveryService(); discovery.AddPrinter("127.0.0.2", "TM-U220II"); discovery.AddPrinter("127.0.0.3", "TM-T30III"); + discovery.AddPrinter("127.0.0.4", "POS895"); _kernel.Bind().ToConstant(discovery).InSingletonScope(); _kernel.Bind().ToMethod(ctx => new EmulationPrinterFactory(emulationConfigs, ctx.Kernel.Get())); - Console.WriteLine("Emulation mode enabled: 127.0.0.2 (TM-U220II), 127.0.0.3 (TM-T30III)"); + Console.WriteLine("Emulation mode enabled: 127.0.0.2 (TM-U220II), 127.0.0.3 (TM-T30III), 127.0.0.4 (POS895)"); } else { @@ -80,7 +82,19 @@ public class PrintServerBootstrapper //discovery.AddPrinter("192.168.1.124", "TM-T30III"); //_kernel.Bind().ToConstant(discovery).InSingletonScope();\ - _kernel.Bind().To().InSingletonScope(); + // 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(); _kernel.Bind().To(); } _kernel.Bind().To().InSingletonScope(); diff --git a/EpsonTest/Program.cs b/EpsonTest/Program.cs index d58c249..4db06c5 100644 --- a/EpsonTest/Program.cs +++ b/EpsonTest/Program.cs @@ -18,8 +18,8 @@ if (args.Length > 0) return; } - int pt = args.Length > 1 && args[1].Equals("epson", StringComparison.OrdinalIgnoreCase) ? 1 : 0; - Console.WriteLine($"Running {GetReceiptTypeName(rt)} on {(pt == 0 ? "HTML" : "Epson")} printer...\n"); + int pt = ParsePrinterTargetArg(args.Length > 1 ? args[1] : "html"); + Console.WriteLine($"Running {GetReceiptTypeName(rt)} on {GetPrinterTargetName(pt)} printer...\n"); await RunTest(rt, pt); Console.WriteLine("\nDone."); return; @@ -46,11 +46,12 @@ while (true) var printerTarget = ShowMenu("Select printer target:", new[] { "HTML Printer (local file)", - "Epson Printer (real hardware)" + "Epson Printer (real hardware)", + "POS895 (real hardware, via IPrinter adapter)" }); Console.Clear(); - Console.WriteLine($"Running {GetReceiptTypeName(receiptType)} on {(printerTarget == 0 ? "HTML" : "Epson")} printer...\n"); + Console.WriteLine($"Running {GetReceiptTypeName(receiptType)} on {GetPrinterTargetName(printerTarget)} printer...\n"); try { @@ -109,6 +110,21 @@ static int ShowMenu(string prompt, string[] options) } } +static string GetPrinterTargetName(int index) => index switch +{ + 0 => "HTML", + 1 => "Epson", + 2 => "POS895", + _ => "Unknown" +}; + +static int ParsePrinterTargetArg(string arg) => arg.ToLowerInvariant() switch +{ + "1" or "epson" => 1, + "2" or "pos895" or "pos" => 2, + _ => 0 +}; + static string GetReceiptTypeName(int index) => index switch { 0 => "KitchenReceipt", @@ -163,11 +179,19 @@ static async Task RunKitchenReceiptTest(int printerTarget) await TestHelpers.PrintKitchenCommandsToHtmlPrinterAsync(printer, printCommands); Console.WriteLine("Output written to: kitchen_test.html"); } - else + else if (printerTarget == 1) { var printer = await TestHelpers.CreateEpsonPrinterAsync(paperWidth: TestHelpers.KitchenPaperWidth); await TestHelpers.PrintCommandsToEpsonPrinterAsync(printer, printCommands, useDelay: true); } + else + { + // Routes through PrinterWrapperFactory and the model adapter, the way + // EpsonPrintService does for a real job. The Epson branch above drives + // EpsonPrinter directly and feeds with ESC d, which the POS895 ignores. + var printer = await TestHelpers.CreateEpsonPrinterAsync(); + await TestHelpers.PrintCommandsViaAdapterAsync(printer, printCommands); + } } static async Task RunFinalReceiptTest(int printerTarget) @@ -189,11 +213,20 @@ static async Task RunFinalReceiptTest(int printerTarget) logoPath: "ristorante-klinglers.ch-logo_white_bg.png"); Console.WriteLine("Output written to: test.html"); } - else + else if (printerTarget == 1) { var printer = await TestHelpers.CreateEpsonPrinterAsync(); await TestHelpers.PrintCommandsToEpsonPrinterAsync(printer, printCommands); } + else + { + // Routes through PrinterWrapperFactory and the model adapter, the way + // EpsonPrintService does for a real job. The Epson branch above drives + // EpsonPrinter directly and feeds with ESC d, which the POS895 ignores. + var printer = await TestHelpers.CreateEpsonPrinterAsync(); + await TestHelpers.PrintCommandsViaAdapterAsync(printer, printCommands, + logoPath: "ristorante-klinglers.ch-logo_white_bg.png"); + } } static async Task RunInvoiceReceiptTest(int printerTarget) @@ -215,11 +248,20 @@ static async Task RunInvoiceReceiptTest(int printerTarget) logoPath: "ristorante-klinglers.ch-logo_white_bg.png"); Console.WriteLine("Output written to: invoice_test.html"); } - else + else if (printerTarget == 1) { var printer = await TestHelpers.CreateEpsonPrinterAsync(); await TestHelpers.PrintCommandsToEpsonPrinterAsync(printer, printCommands); } + else + { + // Routes through PrinterWrapperFactory and the model adapter, the way + // EpsonPrintService does for a real job. The Epson branch above drives + // EpsonPrinter directly and feeds with ESC d, which the POS895 ignores. + var printer = await TestHelpers.CreateEpsonPrinterAsync(); + await TestHelpers.PrintCommandsViaAdapterAsync(printer, printCommands, + logoPath: "ristorante-klinglers.ch-logo_white_bg.png"); + } } static async Task RunBarReceiptTest(int printerTarget) @@ -239,11 +281,19 @@ static async Task RunBarReceiptTest(int printerTarget) await TestHelpers.PrintKitchenCommandsToHtmlPrinterAsync(printer, printCommands); Console.WriteLine("Output written to: bar_test.html"); } - else + else if (printerTarget == 1) { var printer = await TestHelpers.CreateEpsonPrinterAsync(paperWidth: TestHelpers.KitchenPaperWidth); await TestHelpers.PrintCommandsToEpsonPrinterAsync(printer, printCommands, useDelay: true); } + else + { + // Routes through PrinterWrapperFactory and the model adapter, the way + // EpsonPrintService does for a real job. The Epson branch above drives + // EpsonPrinter directly and feeds with ESC d, which the POS895 ignores. + var printer = await TestHelpers.CreateEpsonPrinterAsync(); + await TestHelpers.PrintCommandsViaAdapterAsync(printer, printCommands); + } } static async Task RunOrderItemsTest(int printerTarget) @@ -262,11 +312,19 @@ static async Task RunOrderItemsTest(int printerTarget) await TestHelpers.PrintCommandsToHtmlPrinterAsync(printer, printCommands); Console.WriteLine("Output written to: order_items.html"); } - else + else if (printerTarget == 1) { var printer = await TestHelpers.CreateEpsonPrinterAsync(); await TestHelpers.PrintCommandsToEpsonPrinterAsync(printer, printCommands); } + else + { + // Routes through PrinterWrapperFactory and the model adapter, the way + // EpsonPrintService does for a real job. The Epson branch above drives + // EpsonPrinter directly and feeds with ESC d, which the POS895 ignores. + var printer = await TestHelpers.CreateEpsonPrinterAsync(); + await TestHelpers.PrintCommandsViaAdapterAsync(printer, printCommands); + } } static async Task RunMoneyReturnReceiptTest(int printerTarget) @@ -288,9 +346,18 @@ static async Task RunMoneyReturnReceiptTest(int printerTarget) logoPath: "ristorante-klinglers.ch-logo_white_bg.png"); Console.WriteLine("Output written to: money_return_test.html"); } - else + else if (printerTarget == 1) { var printer = await TestHelpers.CreateEpsonPrinterAsync(); await TestHelpers.PrintCommandsToEpsonPrinterAsync(printer, printCommands); } + else + { + // Routes through PrinterWrapperFactory and the model adapter, the way + // EpsonPrintService does for a real job. The Epson branch above drives + // EpsonPrinter directly and feeds with ESC d, which the POS895 ignores. + var printer = await TestHelpers.CreateEpsonPrinterAsync(); + await TestHelpers.PrintCommandsViaAdapterAsync(printer, printCommands, + logoPath: "ristorante-klinglers.ch-logo_white_bg.png"); + } } diff --git a/EpsonTest/TestHelpers.cs b/EpsonTest/TestHelpers.cs index 0209d9d..c8010b0 100644 --- a/EpsonTest/TestHelpers.cs +++ b/EpsonTest/TestHelpers.cs @@ -1,5 +1,7 @@ using System.Diagnostics; using Inspectron.Epson; +using Inspectron.Epson.PrintServer.Printers; +using Inspectron.Epson.PrintServer.PrintServices; using Inspectron.Epson.PrintServer.Printers.Utils; using Inspectron.Epson.PrintServer.Printers.Utils.BarReceipt; using Inspectron.Epson.PrintServer.Printers.Utils.FinalRecipe; @@ -577,6 +579,48 @@ public static class TestHelpers await printer.CutAsync(); } + /// + /// Prints through the production IPrinter adapter chain instead of driving + /// EpsonPrinter directly: query the model byte, let PrinterWrapperFactory pick + /// the adapter from it, then print the logo and commands through that adapter. + /// This is what EpsonPrintService does for a real job, so it exercises the + /// model dispatch and the adapter's own ESC/POS dialect. + /// + /// Use this for the POS895. PrintCommandsToEpsonPrinterAsync above predates the + /// adapter layer and feeds with ESC d, which that firmware ignores - every cut + /// would slice through the last printed line. + /// + public static async Task PrintCommandsViaAdapterAsync( + EpsonPrinter printer, + IEnumerable commands, + string? logoPath = null) + { + byte? printerId = await printer.GetPrinterIdAsync(); + if (printerId is null) + { + throw new InvalidOperationException( + "Printer did not answer GS I 1, so no adapter can be chosen for it."); + } + + Console.WriteLine($"Printer reported model id 0x{printerId.Value:X2}"); + + IPrinter adapter = new PrinterWrapperFactory() + .CreatePrinterFromId(printerId.Value, printer); + + Console.WriteLine($"Using adapter {adapter.GetType().Name}"); + + await adapter.InitAsync(); + + if (logoPath is not null) + { + Console.WriteLine($"Printing logo {logoPath}"); + await adapter.PrintImageAsync(logoPath); + } + + await adapter.PrintAsync(commands.ToList()); + await adapter.Cut(); + } + public static async Task PrintKitchenCommandsToHtmlPrinterAsync( HtmlPrinter printer, IEnumerable commands) diff --git a/EpsonTest/test.html b/EpsonTest/test.html new file mode 100644 index 0000000..4b2f947 --- /dev/null +++ b/EpsonTest/test.html @@ -0,0 +1,173 @@ + + + + + + Epson Printer Output + + + +
+
Printed image
+ Klingler Gastro AG + Münzplatz 3 + CH-8001 Zürich + 043 321 22 22 + + +Debitorenrechnung + 14:58 16.12.2025 +Guests: 2 + +1x San Pellegrino 50cl 6.50 6.50 A +1x Panna 50cl 6.50 6.50 A +1x An item with really long 5.50 5.50 A + description that should wrap +2x Business Lunch Menu Seco 44.00 88.00 A + - Tomato Soup + - Grilled Salmon + - Tiramisu +2x Brunello di Montalcino 1 16.00 32.00 A +2x Espresso 5.50 11.00 A +1x Tip 10.50 10.50 B + + --------- + + Summe: 160.00 CHF + + 172.80 EUR + +Fine Dine -50.00 CHF + + Cash: 50.00 CHF + Card: 110.00 CHF + +MASTER 160.00 CHF + +MwSt % Brutto Netto MwSt +A: 8.1% 149.50 CHF 138.30 CHF 11.20 CHF +B: 0% 10.50 CHF 10.50 CHF 0.00 CHF + + Bedient von: Yves + Terminal: Hauptkasse ZH + Tisch: 12 + + + CHE-449.635.880 MWST + + *** Kundenbeleg *** + Buchung + TWINT +XXXXXXXXXXXXXXX1494 +11.11.2025 12:34:49 +Trm-Id: 31108834 +AID: A0000015749E +Trx. Seq-Cnt: 6127 +Trx. Ref-No: 99036644599 +Auth. Code: 0d3396 +Acq-Id: 2 +EFT CHF: 67.00 +Trinkgeld CHF: 6.70 +Total-EFT CHF: 73.70 +------------------------------------------------ + + *** Kundenbeleg *** + Buchung + MASTER +XXXXXXXXXXXXXXX2587 +11.11.2025 12:36:12 +Trm-Id: 31108834 +AID: A0000000041010 +Trx. Seq-Cnt: 6128 +Trx. Ref-No: 99036644600 +Auth. Code: 1a4b2c +Acq-Id: 1 +EFT CHF: 93.00 +Trinkgeld CHF: 0.00 +Total-EFT CHF: 93.00 +------------------------------------------------ + + Das Team bedankt sich herzlich für Ihren + Besuch. + Auf Wiedersehen. + + + +------------------------------------------------ +Unterschrift + +
+ + \ No newline at end of file diff --git a/EpsonTest/test_images/image_0001.png b/EpsonTest/test_images/image_0001.png new file mode 100644 index 0000000..ec48947 Binary files /dev/null and b/EpsonTest/test_images/image_0001.png differ diff --git a/Inspectron.Epson.Tests/BitImagePrintingTests.cs b/Inspectron.Epson.Tests/BitImagePrintingTests.cs new file mode 100644 index 0000000..492d577 --- /dev/null +++ b/Inspectron.Epson.Tests/BitImagePrintingTests.cs @@ -0,0 +1,137 @@ +using System.Net; +using System.Net.Sockets; +using Inspectron.Epson; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; + +namespace Inspectron.Epson.Tests; + +/// +/// Byte-level tests for ESC * bit-image printing. EpsonPrinter writes to a TCP +/// socket, so these point it at a loopback listener and assert on what actually +/// goes on the wire. +/// +public class BitImagePrintingTests +{ + private const byte ESC = 0x1B; + + /// + /// Runs an EpsonPrinter against a loopback listener and returns every byte it sent. + /// + private static async Task CaptureAsync(Func work) + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + int port = ((IPEndPoint)listener.LocalEndpoint).Port; + + var captured = new List(); + var pump = Task.Run(async () => + { + using var client = await listener.AcceptTcpClientAsync(); + using var stream = client.GetStream(); + + var buffer = new byte[16384]; + int read; + while ((read = await stream.ReadAsync(buffer)) > 0) + { + captured.AddRange(buffer.AsSpan(0, read).ToArray()); + } + }); + + await using (var printer = new EpsonPrinter()) + { + await printer.ConnectAsync("127.0.0.1", port); + await work(printer); + } + + await pump; + listener.Stop(); + + return captured.ToArray(); + } + + private static int IndexOf(byte[] haystack, byte[] needle, int start = 0) + { + for (int i = start; i <= haystack.Length - needle.Length; i++) + { + bool match = true; + for (int j = 0; j < needle.Length; j++) + { + if (haystack[i + j] != needle[j]) { match = false; break; } + } + + if (match) return i; + } + + return -1; + } + + private static int LastIndexOf(byte[] haystack, byte[] needle) + { + int found = -1, at = 0; + while ((at = IndexOf(haystack, needle, at)) >= 0) + { + found = at; + at++; + } + + return found; + } + + [Fact] + public async Task Sets_line_spacing_to_the_slice_height_before_the_first_slice() + { + // Each ESC * 33 pass paints 24 dot rows, but the LF between passes advances + // by the current line spacing - 1/6 inch, about 34 dots at 203 dpi - unless + // told otherwise. Left at the default, every band is followed by a ~10 dot + // white gap and the image comes out striped. Confirmed on real hardware. + byte[] wire = await CaptureAsync(async printer => + { + using var image = new Image(64, 48); // two 24-dot slices + await printer.PrintImageBitModeAsync( + image, EpsonCommands.BitImageMode.DoubleDensity24Dot, 64); + }); + + int spacing = IndexOf(wire, new byte[] { ESC, 0x33, 24 }); // ESC 3 24 + int firstSlice = IndexOf(wire, new byte[] { ESC, 0x2A }); // ESC * + + Assert.True(spacing >= 0, "expected ESC 3 24 to set line spacing to the slice height"); + Assert.True(firstSlice >= 0, "expected at least one ESC * bit-image slice"); + Assert.True(spacing < firstSlice, + "line spacing must be set BEFORE the first slice, or the first gap still appears"); + } + + [Fact] + public async Task Restores_default_line_spacing_after_the_last_slice() + { + // Leaving the image's tight spacing set would compress every subsequent + // line of text on the receipt. + byte[] wire = await CaptureAsync(async printer => + { + using var image = new Image(64, 48); + await printer.PrintImageBitModeAsync( + image, EpsonCommands.BitImageMode.DoubleDensity24Dot, 64); + }); + + int lastSlice = LastIndexOf(wire, new byte[] { ESC, 0x2A }); + int restore = LastIndexOf(wire, new byte[] { ESC, 0x32 }); // ESC 2 + + Assert.True(restore >= 0, "expected ESC 2 to restore the default line spacing"); + Assert.True(restore > lastSlice, "default spacing must be restored after the last slice"); + } + + [Fact] + public async Task Uses_the_eight_dot_slice_height_for_eight_dot_modes() + { + // The spacing has to track the mode, not be hardcoded to 24. + byte[] wire = await CaptureAsync(async printer => + { + using var image = new Image(64, 16); // two 8-dot slices + await printer.PrintImageBitModeAsync( + image, EpsonCommands.BitImageMode.SingleDensity8Dot, 64); + }); + + Assert.True(IndexOf(wire, new byte[] { ESC, 0x33, 8 }) >= 0, + "expected ESC 3 8 for an 8-dot bit-image mode"); + } +} diff --git a/Inspectron.Epson.Tests/CompositeDiscoveryServiceTests.cs b/Inspectron.Epson.Tests/CompositeDiscoveryServiceTests.cs new file mode 100644 index 0000000..548ae13 --- /dev/null +++ b/Inspectron.Epson.Tests/CompositeDiscoveryServiceTests.cs @@ -0,0 +1,218 @@ +using Inspectron.Epson; + +namespace Inspectron.Epson.Tests; + +[Collection("udp-9100")] +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 StubDiscovery(List printers) + { + _printers = printers; + } + + 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); + } + } + + private sealed class SlowStubDiscovery : IDiscoveryService + { + private readonly TimeSpan _delay; + private readonly string _ip; + + public SlowStubDiscovery(TimeSpan delay, string ip) + { + _delay = delay; + _ip = ip; + } + + public async Task> DiscoverPrintersAsync( + TimeSpan? timeout = null, + Action? onPrinterDiscovered = null) + { + await Task.Delay(_delay); + return new List { new DiscoveredPrinter { IPAddress = _ip } }; + } + } + + [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_keeping_the_first_child_that_reported_it() + { + // The two stubs report the same IP but are distinguishable, so this + // proves first-wins rather than merely proving that one of them survived. + var first = new StubDiscovery(new List + { + new DiscoveredPrinter { IPAddress = "10.0.0.1", ModelName = "TM-T30III", ServiceType = "ENPC" } + }); + var second = new StubDiscovery(new List + { + new DiscoveredPrinter { IPAddress = "10.0.0.1", ModelName = "POS895", ServiceType = "SPRT" } + }); + + var composite = new CompositeDiscoveryService(new IDiscoveryService[] { first, second }); + + var result = await composite.DiscoverPrintersAsync(TimeSpan.FromMilliseconds(50)); + + var only = Assert.Single(result); + Assert.Equal("TM-T30III", only.ModelName); + Assert.Equal("ENPC", only.ServiceType); + } + + [Fact] + public async Task Runs_children_concurrently_not_sequentially() + { + // Three children that each take ~300ms. Sequential execution needs >=900ms; + // concurrent should land near 300ms. The 700ms bound leaves generous headroom + // for a loaded machine while still failing a sequential regression widely. + var children = new IDiscoveryService[] + { + new SlowStubDiscovery(TimeSpan.FromMilliseconds(300), "10.0.0.1"), + new SlowStubDiscovery(TimeSpan.FromMilliseconds(300), "10.0.0.2"), + new SlowStubDiscovery(TimeSpan.FromMilliseconds(300), "10.0.0.3") + }; + + var composite = new CompositeDiscoveryService(children); + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + var result = await composite.DiscoverPrintersAsync(TimeSpan.FromSeconds(5)); + stopwatch.Stop(); + + Assert.Equal(3, result.Count); + Assert.True(stopwatch.ElapsedMilliseconds < 700, + $"expected concurrent execution near 300ms but took {stopwatch.ElapsedMilliseconds}ms, " + + "which indicates the children ran sequentially"); + } + + [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 Every_child_throwing_propagates_instead_of_reporting_no_printers() + { + // If every discovery protocol failed (e.g. a routeless interface), that is + // "discovery is broken", not "no printers on the network". The caller + // (PrinterDiscoveryBackgroundTask) relies on this to skip the pass instead + // of expiring and unregistering every printer it already knows about. + var composite = new CompositeDiscoveryService(new IDiscoveryService[] + { + new StubDiscovery(new InvalidOperationException("boom 1")), + new StubDiscovery(new InvalidOperationException("boom 2")) + }); + + await Assert.ThrowsAsync( + () => composite.DiscoverPrintersAsync(TimeSpan.FromMilliseconds(50))); + } + + [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); + } + + [Fact] + public async Task A_real_sprt_bind_failure_degrades_instead_of_losing_the_other_child() + { + // End-to-end proof of the fix: SprtDiscoveryService now throws when it + // cannot bind UDP 9100 (instead of swallowing the fault and returning + // empty), but the composite still absorbs that single child's failure and + // returns whatever the other child found - it does not propagate. Total + // failure only happens when every child fails, which is asserted + // separately by Every_child_throwing_propagates_instead_of_reporting_no_printers. + using var blocker = new System.Net.Sockets.UdpClient(); + blocker.Client.Bind(new System.Net.IPEndPoint(System.Net.IPAddress.Any, 9100)); + + var composite = new CompositeDiscoveryService(new IDiscoveryService[] + { + new SprtDiscoveryService(), + new StubDiscovery("10.0.0.9") + }); + + var result = await composite.DiscoverPrintersAsync(TimeSpan.FromMilliseconds(200)); + + var only = Assert.Single(result); + Assert.Equal("10.0.0.9", only.IPAddress); + } +} diff --git a/Inspectron.Epson.Tests/Configuration/ConfigurationPathsTests.cs b/Inspectron.Epson.Tests/Configuration/ConfigurationPathsTests.cs deleted file mode 100644 index e9eb198..0000000 --- a/Inspectron.Epson.Tests/Configuration/ConfigurationPathsTests.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System.Runtime.InteropServices; -using EpsonPrintService; - -namespace Inspectron.Epson.Tests.Configuration; - -public class ConfigurationPathsTests -{ - [Fact] - public void GetConfigPath_uses_opt_dir_on_linux_when_env_unset_and_no_local_file() - { - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) return; - var previous = Environment.GetEnvironmentVariable("EPSON_CONFIG_PATH"); - Environment.SetEnvironmentVariable("EPSON_CONFIG_PATH", null); - try - { - var path = ConfigurationPaths.GetConfigPath(); - Assert.Equal("/opt/epson-print-service/config.txt", path); - } - finally - { - Environment.SetEnvironmentVariable("EPSON_CONFIG_PATH", previous); - } - } - - [Fact] - public void EPSON_CONFIG_PATH_override_still_wins() - { - var previous = Environment.GetEnvironmentVariable("EPSON_CONFIG_PATH"); - Environment.SetEnvironmentVariable("EPSON_CONFIG_PATH", "/tmp/override.txt"); - try - { - var path = ConfigurationPaths.GetConfigPath(); - Assert.Equal("/tmp/override.txt", path); - } - finally - { - Environment.SetEnvironmentVariable("EPSON_CONFIG_PATH", previous); - } - } -} diff --git a/Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj b/Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj index fc64d8b..2371a47 100644 --- a/Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj +++ b/Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj @@ -20,7 +20,6 @@ - diff --git a/Inspectron.Epson.Tests/POS895Tests.cs b/Inspectron.Epson.Tests/POS895Tests.cs new file mode 100644 index 0000000..9efbf30 --- /dev/null +++ b/Inspectron.Epson.Tests/POS895Tests.cs @@ -0,0 +1,113 @@ +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); + + // 10 LF for the IsCut branch plus 10 for the trailing feed. + Assert.Equal(20, fake.LineFeedBytes); + + // The cut must be preceded by a feed, or the receipt is cut through its + // last printed line - the exact failure ESC d would have caused. + int cutIndex = fake.CallOrder.IndexOf("cut"); + Assert.True(cutIndex > 0, "expected a feed before the cut"); + Assert.StartsWith("raw:", fake.CallOrder[cutIndex - 1]); + } + + [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); + } +} diff --git a/Inspectron.Epson.Tests/PrinterRegistrationTests.cs b/Inspectron.Epson.Tests/PrinterRegistrationTests.cs new file mode 100644 index 0000000..af6ea0e --- /dev/null +++ b/Inspectron.Epson.Tests/PrinterRegistrationTests.cs @@ -0,0 +1,122 @@ +using Inspectron.Epson; +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)); + } + } + + [Fact] + public void GetModelNameFromId_maps_0x20_to_POS895() + { + Assert.Equal("POS895", ModelNameFor(0x20)); + } + + [Fact] + public void GetModelNameFromId_still_maps_the_existing_epson_ids() + { + // Guards against a change that makes every id report as POS895. + Assert.Equal("TM-T30III", ModelNameFor(0x01)); + Assert.Equal("TM-U220II", ModelNameFor(0x0D)); + Assert.Equal("TM-U220II", ModelNameFor(0x13)); + } + + [Fact] + public void GetModelNameFromId_falls_back_for_an_unknown_id() + { + Assert.Equal("Unknown (0x99)", ModelNameFor(0x99)); + } + + /// + /// Invokes DiscoveryService.GetModelNameFromId, which is private static and + /// otherwise reachable only through a live TCP connection. Reflection keeps the + /// production code untouched; if the method is renamed this fails loudly rather + /// than silently passing. + /// + private static string ModelNameFor(byte printerId) + { + var method = typeof(DiscoveryService).GetMethod("GetModelNameFromId", + System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic); + + Assert.NotNull(method); + return (string)method!.Invoke(null, new object[] { printerId })!; + } +} diff --git a/Inspectron.Epson.Tests/RecordingEpsonPrinter.cs b/Inspectron.Epson.Tests/RecordingEpsonPrinter.cs new file mode 100644 index 0000000..b6ecc33 --- /dev/null +++ b/Inspectron.Epson.Tests/RecordingEpsonPrinter.cs @@ -0,0 +1,104 @@ +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; } + + /// + /// Ordered log of the calls that affect receipt layout, so a test can assert + /// that feeds happen BEFORE a cut rather than merely that both occurred. + /// + public List CallOrder { get; } = new(); + + /// 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); + CallOrder.Add($"raw:{command.Length}"); + 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++; + CallOrder.Add("cut"); + return Task.CompletedTask; + } + + // Remaining interface members: no-ops. + public Task ConnectAsync(string ip, int port = 9100, int timeoutSeconds = 10) => Task.CompletedTask; + public void Disconnect() { } + // 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; +} diff --git a/Inspectron.Epson.Tests/Sanity/SanityTests.cs b/Inspectron.Epson.Tests/Sanity/SanityTests.cs deleted file mode 100644 index abb1753..0000000 --- a/Inspectron.Epson.Tests/Sanity/SanityTests.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Inspectron.Epson.Tests.Sanity; - -public class SanityTests -{ - [Fact] - public void Truth_is_true() => Assert.True(true); -} diff --git a/Inspectron.Epson.Tests/SprtDiscoveryServiceTests.cs b/Inspectron.Epson.Tests/SprtDiscoveryServiceTests.cs new file mode 100644 index 0000000..817aec2 --- /dev/null +++ b/Inspectron.Epson.Tests/SprtDiscoveryServiceTests.cs @@ -0,0 +1,102 @@ +using System.Text; +using Inspectron.Epson; + +namespace Inspectron.Epson.Tests; + +[Collection("udp-9100")] +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_throws_when_it_cannot_bind_port_9100() + { + // Hold UDP 9100 so the service cannot bind it. It must throw rather than + // swallow the failure and return empty: CompositeDiscoveryService needs to + // tell "this pass is broken" apart from "the network genuinely has no SPRT + // printers on it". Swallowing it here would let a network fault look like + // a clean empty result, which is exactly what let live Epson printers get + // silently deregistered when discovery quietly "succeeded" with nothing. + using var blocker = new System.Net.Sockets.UdpClient(); + blocker.Client.Bind(new System.Net.IPEndPoint(System.Net.IPAddress.Any, 9100)); + + var service = new SprtDiscoveryService(); + + await Assert.ThrowsAsync( + () => service.DiscoverPrintersAsync(TimeSpan.FromMilliseconds(200))); + } +} diff --git a/Inspectron.Epson.Tests/Telemetry/InsinEventKindsTests.cs b/Inspectron.Epson.Tests/Telemetry/InsinEventKindsTests.cs deleted file mode 100644 index ca56f1d..0000000 --- a/Inspectron.Epson.Tests/Telemetry/InsinEventKindsTests.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Inspectron.Epson.PrintServer.Telemetry; -using Inspectron.Epson.Queue; - -namespace Inspectron.Epson.Tests.Telemetry; - -public class InsinEventKindsTests -{ - [Theory] - [InlineData(PrintErrorType.OutOfPaper, "job.failed.paper_out")] - [InlineData(PrintErrorType.Offline, "job.failed.connection")] - [InlineData(PrintErrorType.ConversionError, "job.failed.other")] - [InlineData(PrintErrorType.Other, "job.failed.other")] - [InlineData(PrintErrorType.None, "job.failed.other")] - public void MapErrorTypeToKind(PrintErrorType input, string expected) - { - Assert.Equal(expected, InsinEventKinds.MapErrorTypeToKind(input)); - } - - [Fact] - public void Constants_have_expected_values() - { - Assert.Equal("service.started", InsinEventKinds.ServiceStarted); - Assert.Equal("job.printed", InsinEventKinds.JobPrinted); - Assert.Equal("printer.discovered", InsinEventKinds.PrinterDiscovered); - Assert.Equal("printer.online", InsinEventKinds.PrinterOnline); - Assert.Equal("printer.offline", InsinEventKinds.PrinterOffline); - } -} diff --git a/Inspectron.Epson.Tests/Telemetry/InsinMessageFormatterTests.cs b/Inspectron.Epson.Tests/Telemetry/InsinMessageFormatterTests.cs deleted file mode 100644 index 2645ca4..0000000 --- a/Inspectron.Epson.Tests/Telemetry/InsinMessageFormatterTests.cs +++ /dev/null @@ -1,52 +0,0 @@ -using Inspectron.Epson.PrintServer.Telemetry; - -namespace Inspectron.Epson.Tests.Telemetry; - -public class InsinMessageFormatterTests -{ - [Fact] - public void Format_single_pair_no_spaces() - { - var result = InsinMessageFormatter.Format(("printer", "192.168.1.10")); - Assert.Equal("printer=192.168.1.10", result); - } - - [Fact] - public void Format_multiple_pairs_joined_by_space() - { - var result = InsinMessageFormatter.Format( - ("printer", "192.168.1.10"), - ("jobId", "42")); - Assert.Equal("printer=192.168.1.10 jobId=42", result); - } - - [Fact] - public void Format_quotes_value_containing_space() - { - var result = InsinMessageFormatter.Format(("error", "paper end detected")); - Assert.Equal("error=\"paper end detected\"", result); - } - - [Fact] - public void Format_escapes_embedded_double_quote() - { - var result = InsinMessageFormatter.Format(("error", "he said \"nope\"")); - Assert.Equal("error=\"he said \\\"nope\\\"\"", result); - } - - [Fact] - public void Format_treats_null_or_empty_value_as_empty_string() - { - var result = InsinMessageFormatter.Format(("model", (string?)null), ("name", "")); - Assert.Equal("model= name=", result); - } - - [Fact] - public void Format_omits_pairs_with_null_or_empty_key() - { - var result = InsinMessageFormatter.Format( - ("", "ignored"), - ("printer", "192.168.1.10")); - Assert.Equal("printer=192.168.1.10", result); - } -} diff --git a/Inspectron.Epson.Tests/Telemetry/InsinTelemetryDiscoveredPrintersReceiverTests.cs b/Inspectron.Epson.Tests/Telemetry/InsinTelemetryDiscoveredPrintersReceiverTests.cs deleted file mode 100644 index 526eab9..0000000 --- a/Inspectron.Epson.Tests/Telemetry/InsinTelemetryDiscoveredPrintersReceiverTests.cs +++ /dev/null @@ -1,104 +0,0 @@ -using EpsonPrintService; -using Inspectron.Epson; -using Inspectron.Epson.PrintServer.Telemetry; - -namespace Inspectron.Epson.Tests.Telemetry; - -public class InsinTelemetryDiscoveredPrintersReceiverTests -{ - private sealed class RecordingTelemetry : IInsinTelemetry - { - public List<(string Kind, string Message)> Events { get; } = new(); - public void Emit(string kind, string message) => Events.Add((kind, message)); - } - - private sealed class CountingInner : IDiscoveredPrintersReceiver - { - public int Calls; - public Task OnPrintersDiscoveredAsync(IReadOnlyList printers) - { - Calls++; - return Task.CompletedTask; - } - } - - private static DiscoveredPrinter Printer(string ip, string model = "TM-T30III") => - new() { IPAddress = ip, ModelName = model }; - - [Fact] - public async Task First_sighting_emits_discovered_and_online() - { - var telemetry = new RecordingTelemetry(); - var inner = new CountingInner(); - var sut = new InsinTelemetryDiscoveredPrintersReceiver(inner, telemetry); - - await sut.OnPrintersDiscoveredAsync(new[] { Printer("192.168.1.10") }); - - Assert.Equal(1, inner.Calls); - Assert.Collection(telemetry.Events, - e => Assert.Equal("printer.discovered", e.Kind), - e => Assert.Equal("printer.online", e.Kind)); - Assert.Contains("printer=192.168.1.10", telemetry.Events[0].Message); - Assert.Contains("model=TM-T30III", telemetry.Events[0].Message); - } - - [Fact] - public async Task Repeat_sighting_emits_nothing() - { - var telemetry = new RecordingTelemetry(); - var sut = new InsinTelemetryDiscoveredPrintersReceiver(new CountingInner(), telemetry); - await sut.OnPrintersDiscoveredAsync(new[] { Printer("192.168.1.10") }); - telemetry.Events.Clear(); - - await sut.OnPrintersDiscoveredAsync(new[] { Printer("192.168.1.10") }); - - Assert.Empty(telemetry.Events); - } - - [Fact] - public async Task Disappearance_emits_offline() - { - var telemetry = new RecordingTelemetry(); - var sut = new InsinTelemetryDiscoveredPrintersReceiver(new CountingInner(), telemetry); - await sut.OnPrintersDiscoveredAsync(new[] { Printer("192.168.1.10") }); - telemetry.Events.Clear(); - - await sut.OnPrintersDiscoveredAsync(Array.Empty()); - - Assert.Single(telemetry.Events); - Assert.Equal("printer.offline", telemetry.Events[0].Kind); - Assert.Equal("printer=192.168.1.10", telemetry.Events[0].Message); - } - - [Fact] - public async Task Reappearance_emits_online_only_not_discovered() - { - var telemetry = new RecordingTelemetry(); - var sut = new InsinTelemetryDiscoveredPrintersReceiver(new CountingInner(), telemetry); - await sut.OnPrintersDiscoveredAsync(new[] { Printer("192.168.1.10") }); - await sut.OnPrintersDiscoveredAsync(Array.Empty()); - telemetry.Events.Clear(); - - await sut.OnPrintersDiscoveredAsync(new[] { Printer("192.168.1.10") }); - - Assert.Single(telemetry.Events); - Assert.Equal("printer.online", telemetry.Events[0].Kind); - } - - [Fact] - public async Task Telemetry_failures_do_not_break_inner() - { - var telemetry = new ThrowingTelemetry(); - var inner = new CountingInner(); - var sut = new InsinTelemetryDiscoveredPrintersReceiver(inner, telemetry); - - await sut.OnPrintersDiscoveredAsync(new[] { Printer("192.168.1.10") }); - - Assert.Equal(1, inner.Calls); - } - - private sealed class ThrowingTelemetry : IInsinTelemetry - { - public void Emit(string kind, string message) => throw new InvalidOperationException("boom"); - } -} diff --git a/Inspectron.Epson.Tests/Telemetry/LoopbackInsinTelemetryTests.cs b/Inspectron.Epson.Tests/Telemetry/LoopbackInsinTelemetryTests.cs deleted file mode 100644 index d6d2ddb..0000000 --- a/Inspectron.Epson.Tests/Telemetry/LoopbackInsinTelemetryTests.cs +++ /dev/null @@ -1,74 +0,0 @@ -using System.Net; -using System.Text; -using System.Text.Json; -using Inspectron.Epson.PrintServer.Telemetry; -using Microsoft.Extensions.Logging.Abstractions; - -namespace Inspectron.Epson.Tests.Telemetry; - -public class LoopbackInsinTelemetryTests -{ - private const int TestPort = 47823; - - [Fact] - public async Task Emit_posts_json_payload_to_loopback() - { - using var listener = new HttpListener(); - listener.Prefixes.Add($"http://127.0.0.1:{TestPort}/"); - listener.Start(); - - using var http = new HttpClient(); - var telemetry = new LoopbackInsinTelemetry(http, NullLogger.Instance); - telemetry.Emit("service.started", "version=1.0.14"); - - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3)); - var ctxTask = listener.GetContextAsync(); - var completed = await Task.WhenAny(ctxTask, Task.Delay(Timeout.Infinite, cts.Token)); - Assert.Same(ctxTask, completed); - var ctx = ctxTask.Result; - - Assert.Equal("POST", ctx.Request.HttpMethod); - Assert.Equal("/events", ctx.Request.Url!.AbsolutePath); - using var sr = new StreamReader(ctx.Request.InputStream, Encoding.UTF8); - var body = await sr.ReadToEndAsync(); - using var doc = JsonDocument.Parse(body); - Assert.Equal("service.started", doc.RootElement.GetProperty("kind").GetString()); - Assert.Equal("version=1.0.14", doc.RootElement.GetProperty("message").GetString()); - var at = doc.RootElement.GetProperty("at").GetString(); - Assert.False(string.IsNullOrEmpty(at)); - Assert.EndsWith("Z", at); - - ctx.Response.StatusCode = 200; - ctx.Response.Close(); - listener.Stop(); - } - - [Fact] - public async Task Emit_swallows_connection_refused_and_does_not_throw() - { - using var http = new HttpClient(); - var telemetry = new LoopbackInsinTelemetry(http, NullLogger.Instance); - var ex = Record.Exception(() => telemetry.Emit("service.started", "version=1.0.14")); - Assert.Null(ex); - await Task.Delay(200); - } - - [Fact] - public async Task Emit_swallows_500_response_and_does_not_throw() - { - using var listener = new HttpListener(); - listener.Prefixes.Add($"http://127.0.0.1:{TestPort}/"); - listener.Start(); - - using var http = new HttpClient(); - var telemetry = new LoopbackInsinTelemetry(http, NullLogger.Instance); - telemetry.Emit("service.started", "version=1.0.14"); - - var ctx = await listener.GetContextAsync(); - ctx.Response.StatusCode = 500; - ctx.Response.Close(); - - await Task.Delay(200); - listener.Stop(); - } -} diff --git a/Inspectron.Epson.Tests/Telemetry/NullInsinTelemetryTests.cs b/Inspectron.Epson.Tests/Telemetry/NullInsinTelemetryTests.cs deleted file mode 100644 index 973776b..0000000 --- a/Inspectron.Epson.Tests/Telemetry/NullInsinTelemetryTests.cs +++ /dev/null @@ -1,14 +0,0 @@ -using Inspectron.Epson.PrintServer.Telemetry; - -namespace Inspectron.Epson.Tests.Telemetry; - -public class NullInsinTelemetryTests -{ - [Fact] - public void Emit_does_nothing_and_does_not_throw() - { - var telemetry = new NullInsinTelemetry(); - var ex = Record.Exception(() => telemetry.Emit("service.started", "version=1.0.0")); - Assert.Null(ex); - } -} diff --git a/Inspectron.Epson.slnx b/Inspectron.Epson.slnx index 4839700..d271a31 100644 --- a/Inspectron.Epson.slnx +++ b/Inspectron.Epson.slnx @@ -2,6 +2,7 @@ + diff --git a/Inspectron.Epson/CompositeDiscoveryService.cs b/Inspectron.Epson/CompositeDiscoveryService.cs new file mode 100644 index 0000000..d6c4a72 --- /dev/null +++ b/Inspectron.Epson/CompositeDiscoveryService.cs @@ -0,0 +1,80 @@ +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 - but if +/// EVERY child fails, that is not "no printers found", it is "discovery is +/// broken", and this rethrows so callers (e.g. PrinterDiscoveryBackgroundTask) +/// can tell the difference and skip the pass instead of expiring every printer +/// they already know about. +/// +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); + + // Captures one exception per failed child so, if every child fails, the + // rethrow below can carry a real InnerException instead of a bare message. + var childExceptions = new System.Collections.Concurrent.ConcurrentBag(); + + // The children run concurrently, so the pass takes about as long as the + // slowest child rather than the sum of all of them. Task.WhenAll applies + // no timeout of its own - each child is responsible for honouring the + // timeout it is given. + 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); + childExceptions.Add(ex); + return null; + } + }); + + List?[] results = await Task.WhenAll(tasks); + + if (_services.Count > 0 && results.All(r => r is null)) + { + throw new InvalidOperationException( + "Every discovery service failed; cannot tell whether any printers are on the network.", + childExceptions.FirstOrDefault()); + } + + // Task.WhenAll has completed, so this merge is single-threaded. + foreach (var printer in results.Where(r => r is not null).SelectMany(r => r!)) + { + if (printer.IPAddress is null || !seenIps.Add(printer.IPAddress)) + { + continue; + } + + merged.Add(printer); + onPrinterDiscovered?.Invoke(printer); + } + + return merged; + } +} diff --git a/Inspectron.Epson/DiscoveredPrinter.cs b/Inspectron.Epson/DiscoveredPrinter.cs index 03e13e2..8ecdf36 100644 --- a/Inspectron.Epson/DiscoveredPrinter.cs +++ b/Inspectron.Epson/DiscoveredPrinter.cs @@ -8,6 +8,14 @@ public class DiscoveredPrinter public string ModelName { get; set; } public string IPAddress { get; set; } public string MACAddress { get; set; } + + /// + /// 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; } + public int Port { get; set; } public DateTime DiscoveredAt { get; set; } public byte[] RawResponse { get; set; } diff --git a/Inspectron.Epson/DiscoveryService.cs b/Inspectron.Epson/DiscoveryService.cs index cbbd9ee..c900e62 100644 --- a/Inspectron.Epson/DiscoveryService.cs +++ b/Inspectron.Epson/DiscoveryService.cs @@ -144,6 +144,7 @@ public class DiscoveryService : IDiscoveryService 0x01 => "TM-T30III", 0x0D => "TM-U220II", 0x13 => "TM-U220II", + 0x20 => "POS895", _ => $"Unknown (0x{printerId:X2})" }; } diff --git a/Inspectron.Epson/EpsonPrinter.cs b/Inspectron.Epson/EpsonPrinter.cs index d99319d..0d86085 100644 --- a/Inspectron.Epson/EpsonPrinter.cs +++ b/Inspectron.Epson/EpsonPrinter.cs @@ -608,26 +608,41 @@ public class EpsonPrinter : IEpsonPrinter _logger?.LogDebug("Printing in {SliceCount} slices of {BitsPerSlice} dots each", totalSlices, bitsPerSlice); - // Print each horizontal slice - for (int slice = 0; slice < totalSlices; slice++) + // Each ESC * pass paints bitsPerSlice dot rows, but the LF between passes + // advances by the CURRENT line spacing, which ESC @ leaves at 1/6 inch - + // about 34 dots at 203 dpi. Left alone, every 24-dot band is followed by a + // ~10 dot white gap and the image prints striped. Match the spacing to the + // slice height for the duration of the image, then put it back: leaving it + // tight would compress every following line of text on the receipt. + await SetCustomLineSpacing(bitsPerSlice); + + try { - int sliceOffset = slice * finalWidth * bytesPerColumn; - int sliceLength = finalWidth * bytesPerColumn; - - var sliceData = new byte[sliceLength]; - Array.Copy(columnData, sliceOffset, sliceData, 0, sliceLength); - - // Create and send ESC * command for this slice - var command = EpsonCommands.CreateBitImageCommand(mode, sliceData, finalWidth); - - await SendRawAsync(command); - - // Move to next line after each slice (except the last one) - if (slice < totalSlices - 1) + // Print each horizontal slice + for (int slice = 0; slice < totalSlices; slice++) { - await SendRawAsync(EpsonCommands.LineFeed); + int sliceOffset = slice * finalWidth * bytesPerColumn; + int sliceLength = finalWidth * bytesPerColumn; + + var sliceData = new byte[sliceLength]; + Array.Copy(columnData, sliceOffset, sliceData, 0, sliceLength); + + // Create and send ESC * command for this slice + var command = EpsonCommands.CreateBitImageCommand(mode, sliceData, finalWidth); + + await SendRawAsync(command); + + // Move to next line after each slice (except the last one) + if (slice < totalSlices - 1) + { + await SendRawAsync(EpsonCommands.LineFeed); + } } } + finally + { + await SetDefaultLineSpacing(); + } _logger?.LogInformation("Image printed successfully using bit-image mode"); } diff --git a/Inspectron.Epson/PreconfiguredDiscoveryService.cs b/Inspectron.Epson/PreconfiguredDiscoveryService.cs index c53283d..4dd81a1 100644 --- a/Inspectron.Epson/PreconfiguredDiscoveryService.cs +++ b/Inspectron.Epson/PreconfiguredDiscoveryService.cs @@ -38,6 +38,7 @@ public class PreconfiguredDiscoveryService : IDiscoveryService ModelName = p.ModelName, Port = p.Port, MACAddress = p.MACAddress, + Serial = p.Serial, ServiceUrl = p.ServiceUrl, ServiceType = p.ServiceType, Lifetime = p.Lifetime, diff --git a/Inspectron.Epson/PrintServer/PrintServices/EpsonPrintService.cs b/Inspectron.Epson/PrintServer/PrintServices/EpsonPrintService.cs index 7655651..c77e3a5 100644 --- a/Inspectron.Epson/PrintServer/PrintServices/EpsonPrintService.cs +++ b/Inspectron.Epson/PrintServer/PrintServices/EpsonPrintService.cs @@ -39,6 +39,14 @@ public class EpsonPrintService: IPrintService _logger.LogInformation("Printer ID({ip}): {PrinterId}", printerIp, printerId); + 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); + } + var status = await epsonPrinter.GetPrinterStatusAsync(); if (!status.IsOnline) { diff --git a/Inspectron.Epson/PrintServer/Printers/POS895.cs b/Inspectron.Epson/PrintServer/Printers/POS895.cs new file mode 100644 index 0000000..f3df8b6 --- /dev/null +++ b/Inspectron.Epson/PrintServer/Printers/POS895.cs @@ -0,0 +1,96 @@ +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); + } +} diff --git a/Inspectron.Epson/PrintServer/Printers/PrinterWrapperFactory.cs b/Inspectron.Epson/PrintServer/Printers/PrinterWrapperFactory.cs index 62aa7a4..5835be3 100644 --- a/Inspectron.Epson/PrintServer/Printers/PrinterWrapperFactory.cs +++ b/Inspectron.Epson/PrintServer/Printers/PrinterWrapperFactory.cs @@ -14,6 +14,8 @@ public class PrinterWrapperFactory:IWrapperPrinterFactory return new TM_U220IITranslated(epsonPrinter); case 0x01: return new TM_T30IIITranslated(epsonPrinter); + case 0x20: + return new POS895(epsonPrinter); default: throw new NotSupportedException($"Printer with ID {printerId:X2} is not supported."); } diff --git a/Inspectron.Epson/PrintServer/Printers/Utils/ReceiptConverterFactory.cs b/Inspectron.Epson/PrintServer/Printers/Utils/ReceiptConverterFactory.cs index 8c65f0d..8ec217f 100644 --- a/Inspectron.Epson/PrintServer/Printers/Utils/ReceiptConverterFactory.cs +++ b/Inspectron.Epson/PrintServer/Printers/Utils/ReceiptConverterFactory.cs @@ -15,6 +15,7 @@ public class ReceiptConverterFactory : IReceiptConverterFactory { //0x01=>new BarReceiptConverterAdapter(48, 24), 0x01 => new KitchenReceipt.KitchenReceiptConverterAdapter(48, 24), + 0x20 => new KitchenReceipt.KitchenReceiptConverterAdapter(48, 24), _ => new KitchenReceipt.KitchenReceiptConverterAdapter(33, 20), }, EReceiptType.NextCourse => new KitchenReceipt.KitchenReceiptConverterAdapter(33, 20), diff --git a/Inspectron.Epson/SprtDiscoveryService.cs b/Inspectron.Epson/SprtDiscoveryService.cs new file mode 100644 index 0000000..c76d593 --- /dev/null +++ b/Inspectron.Epson/SprtDiscoveryService.cs @@ -0,0 +1,176 @@ +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 ->| +/// +/// Error contract: a failure to bind UDP 9100 or to send the broadcast probe +/// propagates as a instead of being swallowed +/// here. That lets , which absorbs a +/// single child's failure but rethrows when every child fails, tell a broken +/// discovery pass (this service throwing) apart from a healthy pass on a +/// network with no SPRT printers on it (this service returning an empty +/// list). A single bad datagram in the receive loop is not treated as a pass +/// failure and does not throw. +/// +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); + throw; + } + + 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"); + throw; + } + + 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; + } +} diff --git a/POS895_POS896_integration_notes.md b/POS895_POS896_integration_notes.md new file mode 100644 index 0000000..f1ea350 --- /dev/null +++ b/POS895_POS896_integration_notes.md @@ -0,0 +1,206 @@ +# POS895 / POS896 printer support: feasibility notes + +Date: 2026-09-02, updated 2026-09-03 after a hardware session. Status: several +of the open questions below are now answered, and the vendor discovery protocol +is reverse engineered and reimplemented. See `Pos895Test/`. + +Source material reviewed: `D:\Inspectron\James\epson\POS895\POS895&896 SDK\` (Linux SDK, Windows POS_SDK, EasySet tool, POS895 and POS896 user manuals, POS_SDK Development Manual V1.4). + +## Verdict + +The POS895 and POS896 can be supported by this codebase, but **not by linking the vendor SDK**. The SDK is a thin C wrapper that builds ESC/POS byte strings and writes them to a raw TCP socket on port 9100. `Inspectron.Epson/EpsonPrinter.cs` already does the same thing in C#. The real work is: + +1. a new `IPrinter` adapter for a 576-dot, 80 mm ESC/POS clone, +2. a discovery strategy that does not rely on Epson's ENPC broadcast, +3. a more robust printer identification than the single `GS I 1` byte, +4. a hardware probe session to close the unknowns listed below. + +Rough effort: 3 to 5 developer days once a printer is on the bench. No new deployment dependencies, no native code, no libusb. Restrict to the Ethernet models (POS895 UEd, POS896 UE); USB-attached operation is a separate project because the whole pipeline keys on an IP address. + +## What the vendor SDK is + +Path: `Linux_SDK\Linux_SDK_20190110\Linux_SDK_64_group_ARM\Linux_SDK_64_group\` + +| File | What it is | +|---|---| +| `HWISPRTPrinter.c` / `.h` | ~5,500 lines of C. Transports: libusb, serial tty, TCP socket to port 9100. Every `POS_*` function fills a global 1 KB buffer with an ESC/POS sequence and writes it. | +| `sprtGraphic.c` / `.h` | BMP loader and a proprietary line-bitmap encoder (see below). | +| `libHWISPRTPrinter.so` | Prebuilt library. **32-bit Intel i386**, despite the folder name saying ARM. | +| `libusb/` | Bundled libusb, **x86-64**. | +| `testMain.c`, `CPCLtest.c`, `TSPLtest.c`, `CPCLDemo.c`, `GtestMain.c` | Demos. Only `testMain.c` is ESC/POS. | +| `makefile` | Cross-compiles with `arm-linux-gcc`. The shipped binaries were not built with it. | +| `README.txt` | Chinese, GBK encoded. Decode with `iconv -f GB18030 -t UTF-8`. The `.c` and `.h` files are UTF-8. | + +Reasons not to use it: + +- Global state only. One socket descriptor (`socketFd`), one command buffer (`gCmdBuf`), no printer handle. It cannot drive two printers concurrently; `PrintServer` does. +- The shipped `.so` would not load on the Raspberry Pi. It would need cross-compiling from source, then P/Invoke from .NET, for zero functional gain over what `EpsonPrinter` already sends. +- Roughly half the API is CPCL and TSPL label-printer commands. Irrelevant for receipts. +- `pos-sdk\POS-SDK\POS-SDK_V1219\` is a Windows `POS_SDK.dll` with a WinForms P/Invoke demo (`Testing\C#\TestSDKCSharp\Form1.cs`). Irrelevant for the Linux service. Its manual (`POS_SDK Development Manual V1.4_Neutral.pdf`) is in English and is the best command reference in the package. +- Development stopped around 2016 to 2019 for the Linux part. + +The firmware family is "SPRT" (the SDK is named `HWISPRTPrinter`, log prefix `qzf`). Treat the printer as a SPRT-derived ESC/POS clone. + +## Printer facts from the manuals + +| | POS895 | POS896 | +|---|---|---| +| Paper | 80 mm (79.5 ±0.5), 72 mm printable | same | +| Resolution | 203 dpi, 8 dots/mm, 576 dots/line | same | +| Fonts | Font A 12x24 (48 columns), Font B 9x17, CJK 24x24 | same | +| Interfaces | Ud: USB. UEd: USB + Ethernet | UBTDM/UBTMM: USB + Bluetooth + drawer. UE: USB + serial + Ethernet + drawer | +| Command set | ESC/POS | ESC/POS | +| Barcodes | UPC/EAN/CODE39/ITF/CODABAR/CODE93/CODE128, PDF417, QR, DataMatrix | same | +| Speed | 160 mm/s | 260 mm/s | +| DHCP | Off by default, auto-enables if the static IP cannot reach the router | same | +| Factory reset | `1B 09 1B 92 FF 01` | | +| Hex dump mode | Open cover, press FEED twice within 3 s, close cover. Exit: open cover, press FEED three times. | | +| Self-test | Press FEED twice within 3 s. Prints model, firmware, interface, default code page. | | + +The POS896 buffers up to 45 KB of unprinted data across a paper-out and prints it after reloading. Worth knowing when reasoning about retries. + +## ESC/POS commands the SDK emits (so the firmware certainly supports them) + +| Function | Bytes | +|---|---| +| Initialize | `1B 40` | +| Feed n lines | `1B 64 n` | +| Cut, full / partial | `1D 56 00` / `1D 56 01` (SDK always feeds 3 lines first) | +| Line spacing | `1B 33 n` | +| Alignment | `1B 61 n` | +| Left margin | `1D 4C nL nH` | +| Font mode (font B, bold, double width, double height, underline) | `1B 21 n` (bits 0, 3, 5, 4, 7) | +| Bit image | `1B 2A m nL nH data`, m in {0, 1, 32, 33} | +| Real-time status | `10 04 n`, n in 1..4, one-byte reply | +| Printer ID | `1D 49 42` (GS I 66, returns `"_" + factory name` as a string) | +| Cash drawer | `1B 70 m t1 t2` | +| Inverse print | `1D 42 n` | +| Rotate / upside-down | `1B 56 n` / `1B 7B n` | +| 1D barcode | `1D 6B ...` | +| 2D barcode | `1D 5A ...` (PDF417 / DataMatrix / QR selector) | +| Proprietary line bitmap | `1D 44 01`, then per non-blank row `15 16 `, then `1D 44 00` | + +Status byte interpretation in the SDK (matches Epson's DLE EOT layout and our parsers in `EpsonPrinterStatus.cs`): + +| Query | Fixed bits | Meaning of set bit | +|---|---|---| +| `10 04 01` printer status | `0x12` must be set | `0x04` drawer high | +| `10 04 02` offline status | `0x12` | `0x04` cover open | +| `10 04 03` error status | `0x12` | `0x08` cutter error, `0x20` unrecoverable, `0x40` auto-recoverable | +| `10 04 04` paper status | `0x12` | `0x40` paper end | + +Code pages: the SDK manual's code page table (TSPL section, but the same family) has index 18 = CP852, matching Epson numbering. Our `EpsonCommands.CP852Encoding` sends `1B 74 12`, so Latin-2 text is probably fine. Confirm on the self-test page. + +## Compatibility of what `TM_T30IIITranslated` sends today + +| Command | Our use | Assessment | +|---|---|---| +| ESC @, GS V, ESC 3, ESC 2 | init, cut, line spacing | Fine, SDK emits them. | +| ESC d n | feed | **Appears to be ignored.** Feed with literal LF bytes. | +| DLE EOT 1..4 | status | **Confirmed.** Identical layout, `0x12` fixed bits set. | +| GS ! n (`SetFontSizeAsync`) | magnification | **Confirmed** at width 1x/2x/3x, height 2x. | +| ESC E (`SetEmphasized`) | bold | Standard, likely fine. SDK uses ESC ! bit 3 instead. | +| ESC t 18 + CP852 | umlauts | **Confirmed.** ESC t honoured; 18=CP852, 2=CP850, 0=CP437 all render. | +| ESC $ (`SetAbsolutePrintPosition`) | logo offset 100 | Unverified. SDK uses GS L margins instead. | +| GS ( L fn 112 / fn 50 (`LoadImageAsync` + `PrintLoadedImage`) | logo | **Confirmed unsupported.** Prints nothing. | +| ESC * 33 (`PrintImageBitModeAsync`) | logo | **Confirmed.** Full 576-dot width. Use this. | +| GS v 0 | logo | **Confirmed** working too, but redundant. Do not implement. | +| GS I 1 (`GetPrinterIdAsync`) | model byte | **Confirmed `0x20`.** No collision with 0x01/0x0D/0x13. | +| ESC r (`SetRedColor`) | two-colour | Not applicable, monochrome printer. The T30III adapter ignores `IsRed` anyway. | + +## SPRT discovery protocol + +Captured with `pktmon` while running `EasySet Tool.exe` on the printer's segment, +then reimplemented in `Pos895Test` and confirmed to return byte-identical results +(same MAC, same serial) as the vendor tool. + +**Query** - UDP broadcast to `255.255.255.255:3289`, sent **from source port 9100**, 13 bytes: + +``` +53 50 52 54 2D 49 6E 66 6F 72 00 00 0D "SPRT-Infor" + 00 00 0D +``` + +**Reply** - from `:3289` back to `:9100`, 39 bytes of ASCII: + +``` +28-0e-8b-3d-ca-f1 2608120478 +|<--- MAC, 17 --->||<- 12 sp ->||<- serial, 10 ->| +``` + +The printer's address is the reply's source IP. The payload carries MAC and +serial only, no model name, so model identification remains a separate TCP-side +step and cannot be folded into discovery. + +- Port 3289 is the same UDP port Epson ENPC uses, so a composite + `IDiscoveryService` can reuse the socket it already has and dispatch on the + shape of the reply. +- The querying machine was `10.1.250.71` and the printer `10.1.252.219`: + different /24s on one flat segment. A /24 sweep would have missed this printer, + which is why the capture route beat the port-sweep route here. +- Unverified: whether the firmware replies to the sender's source port or + hard-codes 9100. `Pos895Test` binds 9100 to match EasySet. +- Unverified: reply framing when more than one printer answers. + +## What breaks today if a POS895 is plugged into the network + +1. **Discovery.** `PrintServerBootstrapper` binds `DiscoveryService`, which broadcasts Epson's proprietary `EPSONQ` packet on UDP 3289. The POS895 never answers it (confirmed: an `EPSONQ` broadcast went unanswered in the same capture that the printer did answer SPRT discovery in). `PrintServer.RegisterPrinter` is never called, so `SubmitJob` logs "Printer not found" and reports the job Failed. `JamesDiscoveredPrintersReceiver` never tells the backend about the printer, so it cannot be assigned to a work area. +2. **Identification.** `EpsonPrinter.GetPrinterIdAsync` sends `1D 49 01` and reads one byte. Three places dispatch on that byte: `PrinterWrapperFactory.CreatePrinterFromId`, `ReceiptConverterFactory.Create` (48 vs 33 columns), and `DiscoveryService.GetModelNameFromId`. If the printer answers nothing, the read waits the full connection timeout (10 s), returns null, `printerId.Value` throws, the job fails with `PrintErrorType.Other`, and `PrinterQueue` retries three times. If it answers `0x01` it is silently treated as a TM-T30III and mostly works by accident, with a 384 px logo at offset 100 on a 576-dot line. +3. **Paper-out logic.** `EpsonPrintService.PrintAsync` maps `!PrinterStatus.IsOnline` (DLE EOT 1 bit 3) to `OutOfPaper`. Whether this firmware sets that bit on paper-out is unverified. DLE EOT 4 bit 6 is what the SDK uses. + +## Work items + +1. **Hardware probe (do first, half a day).** With the printer in hex dump mode and then in normal mode, send and record replies for: + - `1D 49 01`, `1D 49 41`, `1D 49 42`, `1D 49 43` (what identifies the model, and how many bytes come back) + - `10 04 01..04` in normal, paper-out, cover-open states + - `1B 74 12` followed by `ä ö ü ß č` bytes in CP852 + - `1D 21 00 / 11 / 10 / 01` text sizes + - `1B 24 nL nH` then text (does absolute positioning work) + - a logo via GS ( L, via ESC * 33, and via `1D 76 30` + - `EpsonDiagnostics.RunFullDiagnosticsAsync` output for the record +2. **Adapter.** New `Inspectron.Epson/PrintServer/Printers/POS895.cs` implementing `IPrinter`, cloned from `TM_T30IIITranslated`: logo width 576, centred offset `(576 - width) / 2`, image via ESC * or GS v 0, delays tuned. One case each in `PrinterWrapperFactory`, `ReceiptConverterFactory` (48 columns), `DiscoveryService.GetModelNameFromId`. +3. **Identification hardening in `EpsonPrinter`.** Shorter read timeout for GS I, fall back to the GS I 67 model-name string, or to a per-IP configured model, when the byte is missing or collides with an Epson ID. +4. **Discovery for non-Epson printers.** Solved: the vendor SPRT protocol is documented above and reimplemented in `Pos895Test`, confirmed against the printer. Remaining work is a `SprtDiscoveryService` implementing `IDiscoveryService`, and a composite service that merges its results with ENPC. Both probes go to UDP 3289, so they can share a socket. `PrinterDiscoveryBackgroundTask` needs no change. Now well under the 1 to 2 days originally estimated. +5. **Emulation and tests.** Add a 576 px `HtmlPrinter` entry with the new ID to the emulation dictionary in `PrintServerBootstrapper`, and EpsonTest cases, so receipt layouts can be checked without hardware. +6. ~~**GS v 0 raster** in `EpsonCommands`~~ Not needed: `GS ( L` is rejected but `ESC *` works and is already implemented. +7. **CLAUDE.md updates** once the adapter exists: printer ID byte, image width, discovery method. + +## Answered on hardware, 2026-09-03 + +Probed through an AnyDesk TCP tunnel to the printer's port 9100, plus the SPRT +discovery probe run on the printer's own segment. Tooling in `Pos895Test/`. + +- **Does the printer answer any UDP discovery?** Yes, SPRT on UDP 3289, above. +- **Does DLE EOT work?** Yes. All four queries answered, exact Epson bit layout, + `0x12` fixed bits set on every reply. `EpsonPrinterStatus.cs` parses this + printer unmodified, so no custom status parser is needed. +- **Font A column count?** 48 on the 576-dot line, as the manuals claim. The + existing 48-column converters apply unchanged. +- **Does `GS !` magnification work?** Yes, verified at width 1x/2x/3x, height 2x, + so `SetFontSizeAsync` can pass magnification through without clamping. +- **`ESC !`?** Selects Font A with `0x00`. +- **`ESC d n`?** Appears to be ignored. Feed with literal LF bytes. +- **`GS I` family?** `GS I 1` = `0x20`, unique. `GS I 2` = `0x02`, + `GS I 3` = `0x64`, `GS I 65` = `"N-656 1.+0"`, `GS I 69` = `"_English"`. + `GS I 66` (maker) and `GS I 67` (model name) are **blank**, so the model-name + fallback the work items suggested is useless here. `GS I 68` returns the + serial and it matches the SPRT discovery serial exactly, which makes it a + usable correlation key between a discovered and a connected printer. +- **Raster graphics?** `GS ( L` prints nothing. `ESC * 33` and `GS v 0` both + render correctly across the full 576 dots. Use `PrintImageBitModeAsync`. +- **Code pages?** `ESC t` is honoured and index 18 really is CP852. Index 2 + (CP850) and 0 (CP437) also render correctly. + + Note CP852 has no `e`-grave, `a`-grave, `u`-grave, `e`-circumflex, `i`-grave + or `o`-grave. `EpsonPrinter.PrintTextAsync` hardcodes CP852 for every printer, + so French and Italian accents are already dropped fleet-wide, Epsons included. + Now known to be fixable by selecting CP850, but that is a product-wide change + with its own blast radius, not part of POS895 support. + +## Open questions that only hardware can answer + +- Does DLE EOT 1 bit 3 go high on paper-out? The status queries answer, but + paper-out was never induced. +- Font B, and its claimed 64 columns. +- Does the SPRT reply go to the sender's port, or is 9100 hard-coded? +- Does the POS896 report the same `GS I 1` = `0x20`? Assumed yes, same spec + sheet and same command set, but untested. diff --git a/Pos895Test/Pos895Test.csproj b/Pos895Test/Pos895Test.csproj new file mode 100644 index 0000000..fef97ee --- /dev/null +++ b/Pos895Test/Pos895Test.csproj @@ -0,0 +1,14 @@ + + + + Exe + net8.0 + enable + enable + + + + + + + diff --git a/Pos895Test/Program.cs b/Pos895Test/Program.cs new file mode 100644 index 0000000..471f851 --- /dev/null +++ b/Pos895Test/Program.cs @@ -0,0 +1,235 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; +using Inspectron.Epson; + +// POS895 discovery probe. +// +// The POS895 does not answer Epson's ENPC broadcast, but it does answer the +// vendor's own SPRT discovery, which the EasySet tool uses. Protocol, taken from +// a packet capture of EasySet on the printer's segment: +// +// 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\0\0\r" +// +// reply from :3289 back to :9100, 39 bytes of ASCII +// "28-0e-8b-3d-ca-f1 2608120478" +// |<-- MAC, 17 -->||<- 12 sp ->||<- serial, 10 ->| +// +// The printer's address is the reply's source IP; the payload carries only MAC +// and serial, no model name. Note 3289 is the same UDP port Epson ENPC uses, so +// a composite IDiscoveryService can reuse the socket it already has. +// +// All settings are the constants below. Edit them here; there are no arguments. + +const string ProbeMagic = "SPRT-Infor"; +const int DiscoveryPort = 3289; +const int ReplyPort = 9100; +const int ListenSeconds = 3; + +// EasySet sends from source port 9100 and the reply comes back to 9100. Whether +// the firmware honours the sender's port or hard-codes 9100 is unknown from a +// single capture. Set this false to bind an ephemeral port instead: if replies +// still arrive, the firmware replies to the sender; if they stop, 9100 is +// hard-coded and any implementation must bind it. +bool bindSourcePort9100 = true; + +byte[] probe = BuildProbe(ProbeMagic); + +Console.WriteLine("POS895 / SPRT discovery probe"); +Console.WriteLine($" probe -> 255.255.255.255:{DiscoveryPort} ({probe.Length} bytes)"); +Console.WriteLine(Describe(probe)); +Console.WriteLine(); + +using var udp = new UdpClient(AddressFamily.InterNetwork); +udp.EnableBroadcast = true; + +try +{ + udp.Client.Bind(new IPEndPoint(IPAddress.Any, bindSourcePort9100 ? ReplyPort : 0)); +} +catch (SocketException ex) +{ + Console.Error.WriteLine($"[FAIL] Could not bind UDP {ReplyPort}: {ex.Message}"); + Console.Error.WriteLine(" Something else holds it, or this needs elevation."); + Console.Error.WriteLine(" Set bindSourcePort9100 = false to try an ephemeral port."); + return 1; +} + +int localPort = ((IPEndPoint)udp.Client.LocalEndPoint!).Port; +Console.WriteLine($" listening on UDP {localPort} for {ListenSeconds}s"); +Console.WriteLine(); + +await udp.SendAsync(probe, probe.Length, new IPEndPoint(IPAddress.Broadcast, DiscoveryPort)); + +var found = new Dictionary(); +DateTime deadline = DateTime.UtcNow.AddSeconds(ListenSeconds); + +while (DateTime.UtcNow < deadline) +{ + using var cts = new CancellationTokenSource(deadline - DateTime.UtcNow); + UdpReceiveResult res; + try + { + res = await udp.ReceiveAsync(cts.Token); + } + catch (OperationCanceledException) + { + break; + } + + string ip = res.RemoteEndPoint.Address.ToString(); + var parsed = ParseReply(res.Buffer); + + if (parsed is null) + { + Console.WriteLine($" {ip}:{res.RemoteEndPoint.Port} unrecognised, {res.Buffer.Length} bytes"); + Console.WriteLine(Describe(res.Buffer)); + continue; + } + + (string mac, string serial) = parsed.Value; + found[ip] = mac; + Console.WriteLine($" FOUND {ip,-15} mac {mac} serial {serial}"); +} + +Console.WriteLine(); +if (found.Count == 0) +{ + Console.WriteLine("[--] No replies."); + Console.WriteLine(" - is this machine on the printer's broadcast domain?"); + Console.WriteLine(" - does the firewall allow inbound UDP on this port?"); + Console.WriteLine($" - try flipping bindSourcePort9100 (currently {bindSourcePort9100})."); + return 1; +} + +Console.WriteLine($"[OK] {found.Count} printer(s) answered SPRT discovery."); +return 0; + +// "SPRT-Infor" followed by 00 00 0D. The trailing 0x0D is also the total length +// of the datagram, which may or may not be a coincidence. +static byte[] BuildProbe(string magic) +{ + var p = new List(Encoding.ASCII.GetBytes(magic)); + p.AddRange(new byte[] { 0x00, 0x00, 0x0D }); + return p.ToArray(); +} + +// 17 ASCII bytes of dashed lowercase MAC, space padding, then the serial. +static (string Mac, string Serial)? ParseReply(byte[] payload) +{ + if (payload.Length < 17) + { + return null; + } + + string text = Encoding.ASCII.GetString(payload); + string mac = text[..17]; + + bool looksLikeMac = mac.Split('-') is { Length: 6 } parts + && parts.All(p => p.Length == 2 && p.All(Uri.IsHexDigit)); + + return looksLikeMac ? (mac, text[17..].Trim()) : null; +} + +// Hex plus a printable-ASCII gutter. +static string Describe(byte[] bytes) +{ + var sb = new StringBuilder(); + for (int offset = 0; offset < bytes.Length; offset += 16) + { + int count = Math.Min(16, bytes.Length - offset); + sb.Append($" {offset:X4} "); + + for (int i = 0; i < 16; i++) + { + sb.Append(i < count ? $"{bytes[offset + i]:X2} " : " "); + } + + sb.Append(' '); + for (int i = 0; i < count; i++) + { + byte b = bytes[offset + i]; + sb.Append(b >= 0x20 && b < 0x7F ? (char)b : '.'); + } + + sb.AppendLine(); + } + + return sb.ToString().TrimEnd(); +} + +/* ===================================================================== + PRINT TEST -- disabled. + + Confirmed working against the printer through the AnyDesk tunnel on + 127.0.0.1:8888. Font A is 48 columns on the 576-dot line, and GS ! + magnification works at width 1x/2x/3x with height 2x. ESC d n appears + to be ignored, hence the literal LF bytes before the cut. + + To re-enable, move this code above the discovery section. + +const string PrinterIp = "127.0.0.1"; +const int PrinterPort = 8888; +const int ConnectTimeoutSeconds = 5; + +const int FontAColumns = 48; +const int HeightMagnification = 2; +int[] widths = { 1, 2, 3 }; +const int BlankLinesAfterText = 10; +bool useRawAscii = false; + +byte[] job = BuildJob(FontAColumns, widths, HeightMagnification, BlankLinesAfterText, useRawAscii); + +Console.WriteLine("Byte sequence:"); +Console.WriteLine(Describe(job)); + +await using var printer = new EpsonPrinter(); +await printer.ConnectAsync(PrinterIp, PrinterPort, ConnectTimeoutSeconds); +await printer.SendRawCommandAsync(job); + +static byte[] BuildJob(int fontACols, int[] widths, int heightMag, int blankLines, bool rawAscii) +{ + var job = new List { 0x1B, 0x40 }; // ESC @ initialize + + if (!rawAscii) + { + job.AddRange(new byte[] { 0x1B, 0x74, 0x12 }); // ESC t 18 code page 852 + } + + job.AddRange(new byte[] { 0x1B, 0x21, 0x00 }); // ESC ! 0 Font A + + foreach (int w in widths) + { + job.AddRange(new byte[] { 0x1D, 0x21, SizeByte(w, heightMag) }); + job.AddRange(Encoding.ASCII.GetBytes(Ruler(fontACols / w))); + job.Add(0x0A); + } + + job.AddRange(new byte[] { 0x1D, 0x21, 0x00 }); // GS ! 0 back to 1x1 + + for (int i = 0; i < blankLines; i++) + { + job.Add(0x0A); + } + + job.AddRange(new byte[] { 0x1D, 0x56, 0x00 }); // GS V 0 full cut + return job.ToArray(); +} + +// GS ! n: bits 4-6 width magnification - 1, bits 0-2 height - 1. +static byte SizeByte(int width, int height) => + (byte)(((height - 1) & 0x07) | (((width - 1) & 0x07) << 4)); + +static string Ruler(int columns) +{ + var sb = new StringBuilder(); + while (sb.Length < columns) + { + sb.Append("|---"); + } + + return sb.ToString(0, columns); +} + + ===================================================================== */ diff --git a/docs/captures/README.md b/docs/captures/README.md new file mode 100644 index 0000000..17c6219 --- /dev/null +++ b/docs/captures/README.md @@ -0,0 +1,25 @@ +# Packet captures + +## easyset.pcapng / easyset.etl + +`pktmon` capture taken on 2026-09-03 on a machine on the POS895's network +segment, while running the vendor's `EasySet Tool.exe` and clicking its scan. + +Taken to reverse engineer the vendor discovery protocol, since the POS895 does +not answer Epson's ENPC broadcast and the EasySet binary yields no readable +strings. The resulting wire format is written up under "SPRT discovery protocol" +in `POS895_POS896_integration_notes.md` and implemented in `Pos895Test`. + +`easyset.etl` is the raw pktmon output; `easyset.pcapng` is the converted form +and the one worth opening. + +What is in it: + +- `10.1.250.71:9100 -> 255.255.255.255:3289` the `SPRT-Infor` discovery query +- `10.1.252.219:3289 -> 10.1.250.71:9100` the printer's MAC + serial reply +- `10.1.252.148:58165 -> 255.255.255.255:3289` an Epson `EPSONQ` broadcast that + the POS895 does not answer, which is the point +- unrelated noise: Ubiquiti AP beacons on UDP 10001, SSDP, mDNS, NetBIOS + +Note pktmon records each frame several times, once per stack layer, so every +packet appears about six times. diff --git a/docs/captures/easyset.etl b/docs/captures/easyset.etl new file mode 100644 index 0000000..54ebd2c Binary files /dev/null and b/docs/captures/easyset.etl differ diff --git a/docs/captures/easyset.pcapng b/docs/captures/easyset.pcapng new file mode 100644 index 0000000..234e49c Binary files /dev/null and b/docs/captures/easyset.pcapng differ diff --git a/docs/superpowers/plans/2026-09-03-pos895-integration.md b/docs/superpowers/plans/2026-09-03-pos895-integration.md new file mode 100644 index 0000000..39a39f8 --- /dev/null +++ b/docs/superpowers/plans/2026-09-03-pos895-integration.md @@ -0,0 +1,1279 @@ +# 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. diff --git a/docs/superpowers/specs/2026-09-03-pos895-integration-design.md b/docs/superpowers/specs/2026-09-03-pos895-integration-design.md new file mode 100644 index 0000000..54e0853 --- /dev/null +++ b/docs/superpowers/specs/2026-09-03-pos895-integration-design.md @@ -0,0 +1,178 @@ +# POS895 integration design + +Date: 2026-09-03. Status: design complete, awaiting review. + +Adds POS895 / POS896 support to the print service. Every decision here rests on +hardware probes run against a real POS895 through an AnyDesk TCP tunnel; the raw +findings live in `POS895_POS896_integration_notes.md` and the tooling in +`Pos895Test/`. + +## Goal + +A POS895 on the network is discovered automatically, reported to the backend so +it can be assigned to a work area, and prints every existing receipt type +correctly at 48 columns with a logo. + +## What the hardware told us + +These four results shape the whole design. + +| Probe | Result | Consequence | +|---|---|---| +| `GS I 1` | `0x20`, no collision with `0x01`/`0x0D`/`0x13` | Model identity keeps working as a `byte`. No resolver, no discovery-seeded cache. | +| SPRT discovery | `SPRT-Infor` on UDP 3289 answers with MAC + serial | Discovery is a new `IDiscoveryService`, not a TCP port sweep. | +| `GS ( L` | prints nothing; `ESC *` and `GS v 0` both work | The adapter must not use `LoadImageAsync`. | +| `ESC d n` | ignored | The adapter must feed with `LF`. | + +The `GS I 1` result is what keeps this small. Had it returned nothing or `0x01`, +the POS895 would have been indistinguishable from a TM-T30III by probe, and +model identity would have had to flow from discovery instead - a much larger +change to `EpsonPrintService`. + +## Components + +### 1. Discovery + +``` +IDiscoveryService +├── DiscoveryService ENPC / EPSONQ unchanged +├── SprtDiscoveryService SPRT-Infor new +└── CompositeDiscoveryService runs both, merges new +``` + +**`SprtDiscoveryService`** - `Inspectron.Epson/SprtDiscoveryService.cs`. +Promotes the probe in `Pos895Test` into the SDK. Broadcasts +`"SPRT-Infor" 00 00 0D` to `255.255.255.255:3289` from UDP source port 9100, +listens for the timeout, and maps each 39-byte reply to a `DiscoveredPrinter`: + +| Field | Value | +|---|---| +| `IPAddress` | reply's source IP | +| `MACAddress` | payload bytes 0..16, dashed lowercase | +| `Serial` | payload bytes 29..38, trimmed | +| `ModelName` | `"POS895"` | +| `Port` | 9100 | +| `ServiceType` | `"SPRT"` | +| `RawResponse` | the 39 bytes | + +Replies that do not parse as a 17-character dashed MAC are logged and skipped, +never thrown. + +**Binding UDP 9100 can fail** when another process holds it, and the broadcast +send can fail too. On `SocketException` the service logs a warning and +**rethrows** - it does not swallow the fault into an empty list. A silent empty +list is indistinguishable from "no SPRT printers on this network", and +`CompositeDiscoveryService` is the layer that needs to tell those apart, not +this service. + +**`CompositeDiscoveryService`** - takes `IEnumerable`, runs +them concurrently on the shared timeout, merges results by `IPAddress` with +first-wins. It does not pass the `onPrinterDiscovered` callback to its children; +instead, it raises the caller's callback itself after all children finish, +once per unique IP, so a printer answering two discovery protocols notifies only once. +It absorbs a single child's failure (logs it, keeps the other children's +results) but rethrows when every child fails, since that means discovery +itself is broken rather than that the network simply has no printers on it. +Discovery still degrades gracefully overall - it just does so at the composite +layer, which is the only layer that can see all children and make that call. + +**Wiring** - `PrintServerBootstrapper.cs:77` changes from +`Bind().To()` to the composite over both +children. `PrinterDiscoveryBackgroundTask` is untouched: its 3s timeout and 30s +interval still hold because the children run concurrently. + +### 2. `DiscoveredPrinter.Serial` + +A new nullable `string Serial` property. SPRT discovery returns a serial, and +`GS I 68` returns the same value over TCP, making it the one field that can +correlate a discovered printer with a connected one. ENPC discovery leaves it +null. `JamesDiscoveredPrintersReceiver` is unchanged - it does not send the +field upstream, and adding it there is out of scope. + +### 3. Identification + +No change to the flow in `EpsonPrintService.PrintAsync`; `GS I 1` returns `0x20` +and the existing code path works. + +One defensive fix: `printerId` is a `byte?` dereferenced with `.Value` twice, +at `EpsonPrintService.cs:48` and `:91`. A printer that does not answer `GS I 1` +throws a `NullReferenceException` into the generic catch, which reports +`PrintErrorType.Other` and burns three `PrinterQueue` retries with no usable log +line. Add one null check after the call at `:38` that logs the IP and returns +`PrintResult.Fail(PrintErrorType.Other)`, which covers both dereferences. This +printer came within one probe of triggering it. + +### 4. `POS895` printer adapter + +`Inspectron.Epson/PrintServer/Printers/POS895.cs`, implementing `IPrinter`, +cloned from `TM-T30IIITranslated.cs`. Three differences, each backed by a probe: + +| Concern | TM-T30III | POS895 | Why | +|---|---|---|---| +| Logo | `LoadImageAsync(path, 384)` (`GS ( L`) | `PrintImageBitModeAsync(path, DoubleDensity24Dot, 576)` (`ESC *`) | `GS ( L` prints nothing on this firmware | +| Logo position | `SetAbsolutePrintPosition(100)` | none, print at 0 across the full width | avoids `ESC $`, which is still unverified | +| Feed before cut | `FeedLinesAsync(10)` (`ESC d`) | 10 × `LF` | `ESC d` is ignored, so the receipt would be cut through its last line | + +Everything else - line spacing, `GS !` sizing, `ESC E` bold - carries over +unchanged. `GS !` is confirmed working at width 1x/2x/3x and height 2x, so +`SetFontSizeAsync` needs no clamping. + +The adapter needs a small `FeedLinesAsync`-equivalent that emits `LF` bytes, +since `IEpsonPrinter.FeedLinesAsync` is hardcoded to `ESC d`. Implement it in +the adapter using `SendRawCommandAsync`, not by changing `EpsonPrinter` - the +Epson models rely on `ESC d` working. + +### 5. Three registrations + +| File | Change | +|---|---| +| `PrinterWrapperFactory.CreatePrinterFromId` | `case 0x20: return new POS895(epsonPrinter);` | +| `DiscoveryService.GetModelNameFromId` | `0x20 => "POS895"` | +| `ReceiptConverterFactory.Create` | add `0x20` to the **WorkareaTicket** switch, 48/24 | + +The third is the easy one to miss. That switch defaults to `33/20`, the narrow +TM-U220 layout, so without it a POS895 prints kitchen tickets at 33 columns on a +48-column head - wrong, but not obviously broken. Every other receipt type +already hardcodes 48/24 and needs no change. + +`NextCourse` stays at `33/20` for all printers. It looks like a pre-existing bug, +but changing it alters TM-T30III behaviour and is deliberately out of scope. + +### 6. Emulation + +Add a 576px `HtmlPrinter` entry under ID `0x20` to the emulation dictionary in +`PrintServerBootstrapper`, so receipt layouts can be checked without hardware. + +## Testing + +**Unit-testable, no hardware:** + +- SPRT reply parsing: valid 39-byte reply, short reply, wrong-shape MAC, + trailing whitespace in the serial. +- `CompositeDiscoveryService`: merge by IP, first-wins on duplicates, one child + throwing does not suppress the other, callbacks forwarded. + +**Hardware, via `Pos895Test`:** discovery against the real printer, and a receipt +of each type through the full `PrintAsync` path with a logo. + +**Emulation:** existing `EpsonTest` receipt-layout cases extended to ID `0x20`. + +## Risks + +| Risk | Mitigation | +|---|---| +| POS896 may not report `0x20` | Untestable without the hardware. If it differs, it is one more `case` in the same three switches. | +| SPRT may hard-code reply port 9100 | We bind 9100, which is proven to work. Only matters if the port is ever contended. | +| Logo rendering quality at 576 via `ESC *` | The command is confirmed working at full width; only dithering quality is unverified. | + +## Explicitly out of scope + +- **The CP852 code page problem.** `EpsonPrinter.PrintTextAsync` hardcodes CP852 + fleet-wide, and CP852 cannot encode `e`-grave, `a`-grave, `u`-grave, + `e`-circumflex, `i`-grave or `o`-grave. French and Italian accents are dropped + on every printer today, Epsons included. Now known fixable via `ESC t 2` + (CP850, confirmed working), but it is a product-wide change with its own blast + radius and deserves its own spec. +- `NextCourse` hardcoded to 33 columns. +- USB-attached POS895 operation - the pipeline keys on an IP address throughout. +- Sending `Serial` upstream from `JamesDiscoveredPrintersReceiver`.