From 772982deb2ed46ded54723d4400f58d3c6270676 Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Thu, 3 Sep 2026 09:23:49 +0200 Subject: [PATCH 01/19] add POS895/POS896 feasibility notes and CLAUDE.md pointer Study-only writeup of what supporting the SPRT-derived POS895/POS896 ESC/POS clones would take: no vendor SDK linking, a 576-dot IPrinter adapter, non-ENPC discovery, a sturdier printer-ID probe, and the hardware checks still open. Claude-Session: https://claude.ai/code/session_01WAG8xogHxq9hDJScu386Jf --- CLAUDE.md | 4 + POS895_POS896_integration_notes.md | 135 +++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 POS895_POS896_integration_notes.md diff --git a/CLAUDE.md b/CLAUDE.md index 57cac85..86072a5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -180,6 +180,10 @@ Printers are registered via `PreconfiguredDiscoveryService` with hardcoded IPs i 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) + +A feasibility study for the POS895 / POS896 ESC/POS clones lives in `POS895_POS896_integration_notes.md`. Read it before touching discovery, printer identification (`GetPrinterIdAsync`), or `PrinterWrapperFactory` for a non-Epson model. Short version: do not link the vendor SDK; add an `IPrinter` adapter for a 576-dot line, a discovery strategy that is not Epson ENPC, and a more robust printer-ID probe. The document lists the ESC/POS commands the vendor firmware is known to accept and the hardware checks still open. + ## Adding New Receipt Types 1. Create a new folder under `Inspectron.Epson/PrintServer/Printers/Utils/` diff --git a/POS895_POS896_integration_notes.md b/POS895_POS896_integration_notes.md new file mode 100644 index 0000000..8865370 --- /dev/null +++ b/POS895_POS896_integration_notes.md @@ -0,0 +1,135 @@ +# POS895 / POS896 printer support: feasibility notes + +Date: 2026-09-02. Status: study only, no code changes made. + +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 @, ESC d, GS V, ESC 3, ESC 2 | init, feed, cut, line spacing | Fine, SDK emits them. | +| DLE EOT 1..4 | status | Fine, identical layout. | +| GS ! n (`SetFontSizeAsync`) | magnification | Likely fine, Windows SDK exposes a width/height font-size call. | +| ESC E (`SetEmphasized`) | bold | Standard, likely fine. SDK uses ESC ! bit 3 instead. | +| ESC t 18 + CP852 | umlauts | Likely fine, see code pages above. | +| ESC $ (`SetAbsolutePrintPosition`) | logo offset 100 | Unverified. SDK uses GS L margins instead. | +| GS ( L fn 112 / fn 50 (`LoadImageAsync` + `PrintLoadedImage`) | logo | Probably unsupported. SDK never uses it. Fallbacks: ESC * 24-dot double density (`PrintImageBitModeAsync`, already implemented) or GS v 0 (not implemented). | +| GS I 1 (`GetPrinterIdAsync`) | model byte | **Unknown.** SDK uses GS I 66 for a maker string instead. | +| ESC r (`SetRedColor`) | two-colour | Not applicable, monochrome printer. The T30III adapter ignores `IsRed` anyway. | + +## 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. `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.** Preferred: sweep the local /24 for open TCP 9100 and run the identification probe on responders. Alternatives: the vendor's UDP discovery protocol used by `EasySet Tool.exe` (unknown, needs a packet capture; the exe yields no readable strings), or manual static registration through `SimpleHttpServer`. Then a composite `IDiscoveryService` that merges ENPC results with the new strategy. `PrinterDiscoveryBackgroundTask` needs no change. This is the largest item, 1 to 2 days. +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`, only if the probe shows GS ( L is rejected. +7. **CLAUDE.md updates** once the adapter exists: printer ID byte, image width, discovery method. + +## Open questions that only hardware can answer + +- What does `GS I 1` return, if anything? +- Does the firmware accept `GS ( L` raster graphics? +- Is code page 18 really CP852 in ESC/POS mode? +- Does DLE EOT 1 bit 3 go high on paper-out? +- Does the printer answer any UDP or mDNS discovery at all? From 8ca8370426598bbfc5ecd2affda97ad38baeda80 Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Thu, 3 Sep 2026 10:09:47 +0200 Subject: [PATCH 02/19] add POS895 hardware smoke test project Pos895Test is a console app that drives a POS895 over TCP through the existing EpsonPrinter class, to answer the first question in the feasibility notes: does the printer accept our ESC/POS at all. It builds the whole job as one byte array and sends that exact array, so the hex listing it prints is the wire content by construction rather than a reconstruction that can drift. Current job prints a "|---" column ruler at 48/width characters per row for width magnifications 1x, 2x and 3x at double height, so every row should end at the same right margin. Confirmed against the hardware so far: - DLE EOT 1..4 are answered and follow the Epson bit layout exactly, with the 0x12 fixed bits set on every reply, so EpsonPrinterStatus parses this printer unmodified. - Font A is 48 columns on the 576-dot line, as the manuals claim. - GS ! magnification works, verified at width 1x/2x/3x and height 2x. - ESC ! 0x00 selects Font A. Not yet confirmed: Font B and its claimed 64 columns, the GS I 1 model byte, ESC d n feed (this firmware appears to ignore it, hence the literal LF bytes before the cut), code page 18 being CP852, and raster graphics. Deliberately does not call GS I 1: the model byte is still an open unknown and a smoke test must not be able to fail on one. All settings are constants at the top of Program.cs; there are no arguments. Claude-Session: https://claude.ai/code/session_01E6N3P4nSZLtp4BC2LXVref --- Inspectron.Epson.slnx | 1 + Pos895Test/Pos895Test.csproj | 14 +++ Pos895Test/Program.cs | 160 +++++++++++++++++++++++++++++++++++ 3 files changed, 175 insertions(+) create mode 100644 Pos895Test/Pos895Test.csproj create mode 100644 Pos895Test/Program.cs diff --git a/Inspectron.Epson.slnx b/Inspectron.Epson.slnx index 6cb719b..875148b 100644 --- a/Inspectron.Epson.slnx +++ b/Inspectron.Epson.slnx @@ -2,6 +2,7 @@ + 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..87e73d8 --- /dev/null +++ b/Pos895Test/Program.cs @@ -0,0 +1,160 @@ +using System.Text; +using Inspectron.Epson; + +// POS895 smoke test: Font A column count vs. GS ! width magnification. +// +// Prints one ruler row per width magnification, each at 48/width characters, so +// every row should end at the same right margin with progressively larger type. +// A row that stops short means that magnification was ignored. +// +// All settings are the constants below. Edit them here; there are no arguments. + +// An AnyDesk TCP tunnel forwards this local endpoint to the remote printer's +// port 9100, so the printer is reachable as though it sat on this machine. +// A refused connection here usually means the AnyDesk session or its tunnel is +// down, not that the printer is off. +const string PrinterIp = "127.0.0.1"; +const int PrinterPort = 8888; +const int ConnectTimeoutSeconds = 5; + +// Font A is 12 dots wide on a 576-dot line. Width magnification divides this. +const int FontAColumns = 48; +const int HeightMagnification = 2; +int[] widths = { 1, 2, 3 }; + +// Blank lines between the last row and the cut. Literal LF bytes rather than +// ESC d n: LF is the most universally implemented feed in ESC/POS, and this +// firmware appears to ignore ESC d. +const int BlankLinesAfterText = 10; + +// Set true to drop the ESC t 18 (code page 18 / CP852) selection. The ruler is +// pure ASCII either way; this only isolates whether ESC t itself is honoured. +bool useRawAscii = false; + +byte[] job = BuildJob(FontAColumns, widths, HeightMagnification, BlankLinesAfterText, useRawAscii); + +Console.WriteLine($"POS895 smoke test -> {PrinterIp}:{PrinterPort}"); +Console.WriteLine($"Font A, height {HeightMagnification}x, widths {string.Join("x / ", widths)}x"); +Console.WriteLine(); +foreach (int w in widths) +{ + int cols = FontAColumns / w; + Console.WriteLine($" GS ! 0x{SizeByte(w, HeightMagnification):X2} w={w} h={HeightMagnification} {cols,2} cols {Ruler(cols)}"); +} +Console.WriteLine(); +Console.WriteLine("Byte sequence:"); +Console.WriteLine(Describe(job)); +Console.WriteLine(); + +try +{ + await using var printer = new EpsonPrinter(); + + Console.Write("Connecting... "); + await printer.ConnectAsync(PrinterIp, PrinterPort, ConnectTimeoutSeconds); + Console.WriteLine("connected."); + + Console.Write($"Sending {job.Length} bytes... "); + await printer.SendRawCommandAsync(job); + Console.WriteLine("sent."); + + Console.WriteLine(); + Console.WriteLine("[OK] Bytes sent. All three rows should end at the same right"); + Console.WriteLine(" margin, each in larger type than the one above it."); + return 0; +} +catch (EpsonConnectionException ex) +{ + Console.Error.WriteLine(); + Console.Error.WriteLine($"[FAIL] Could not reach {PrinterIp}:{PrinterPort}."); + Console.Error.WriteLine(" Is the port forward to the printer's 9100 up?"); + Console.Error.WriteLine($" {ex.Message}"); + if (ex.InnerException is not null) + { + Console.Error.WriteLine($" {ex.InnerException.Message}"); + } + return 1; +} +catch (Exception ex) +{ + Console.Error.WriteLine(); + Console.Error.WriteLine($"[FAIL] {ex.GetType().Name}: {ex.Message}"); + return 1; +} + +// The whole job as one array, so what gets listed above is exactly what goes on +// the wire. +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, no attributes + + 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); // blank line + } + + job.AddRange(new byte[] { 0x1D, 0x56, 0x00 }); // GS V 0 full cut + return job.ToArray(); +} + +// GS ! n: bits 4-6 are width magnification - 1, bits 0-2 height - 1. Same +// formula as EpsonPrinter.SetFontSizeAsync. +static byte SizeByte(int width, int height) => + (byte)(((height - 1) & 0x07) | (((width - 1) & 0x07) << 4)); + +// "|---" repeated and cut to an exact column count, so the pipes fall every +// 4 characters and the row ends precisely at the claimed right margin. +static string Ruler(int columns) +{ + var sb = new StringBuilder(); + while (sb.Length < columns) + { + sb.Append("|---"); + } + + return sb.ToString(0, columns); +} + +// Hex plus a printable-ASCII gutter, so the output can be compared against the +// printer's hex dump mode (open cover, FEED twice within 3 s, close cover). +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(); +} From 17a6eb6b39f81336915613f82f9c3dd644cb35cc Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Thu, 3 Sep 2026 10:45:35 +0200 Subject: [PATCH 03/19] reverse engineer POS895 discovery; add SPRT discovery probe The POS895 does not answer Epson's ENPC broadcast, so PrintServer never registers it and every job for it fails with "Printer not found". The feasibility notes listed the vendor's own discovery protocol as unknown and expected a packet capture to recover it, which is what this does. Captured pktmon traffic while running the vendor EasySet tool on the printer's segment. The protocol is: 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 :3289 back to :9100, 39 ASCII bytes "28-0e-8b-3d-ca-f1 2608120478" MAC, 17 chars 12 spaces serial, 10 chars The printer's address is the reply's source IP. Pos895Test now sends this probe and parses the replies; run against the real printer it returns the same MAC and serial the vendor tool got, so the reimplementation matches. Two useful side effects. Port 3289 is the same UDP port ENPC uses, so a composite IDiscoveryService can share one socket. And the querying machine and the printer sat in different /24s of one flat segment, so the /24 TCP sweep the notes originally preferred would have missed this printer. The print test is commented out at the bottom of Program.cs rather than deleted; it confirmed Font A is 48 columns and that GS ! magnification works at width 1x/2x/3x with height 2x. Notes updated: the discovery work item is no longer the largest one, the compatibility table records DLE EOT and GS ! as confirmed and ESC d as apparently ignored, and the answered questions are separated from the ones still open. Claude-Session: https://claude.ai/code/session_01E6N3P4nSZLtp4BC2LXVref --- .gitattributes | 4 + POS895_POS896_integration_notes.md | 70 ++++++- Pos895Test/Program.cs | 281 ++++++++++++++++++----------- docs/captures/README.md | 25 +++ docs/captures/easyset.etl | Bin 0 -> 44858 bytes docs/captures/easyset.pcapng | Bin 0 -> 83532 bytes 6 files changed, 269 insertions(+), 111 deletions(-) create mode 100644 docs/captures/README.md create mode 100644 docs/captures/easyset.etl create mode 100644 docs/captures/easyset.pcapng 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/POS895_POS896_integration_notes.md b/POS895_POS896_integration_notes.md index 8865370..4343fbf 100644 --- a/POS895_POS896_integration_notes.md +++ b/POS895_POS896_integration_notes.md @@ -1,6 +1,8 @@ # POS895 / POS896 printer support: feasibility notes -Date: 2026-09-02. Status: study only, no code changes made. +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). @@ -93,9 +95,10 @@ Code pages: the SDK manual's code page table (TSPL section, but the same family) | Command | Our use | Assessment | |---|---|---| -| ESC @, ESC d, GS V, ESC 3, ESC 2 | init, feed, cut, line spacing | Fine, SDK emits them. | -| DLE EOT 1..4 | status | Fine, identical layout. | -| GS ! n (`SetFontSizeAsync`) | magnification | Likely fine, Windows SDK exposes a width/height font-size call. | +| 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 | Likely fine, see code pages above. | | ESC $ (`SetAbsolutePrintPosition`) | logo offset 100 | Unverified. SDK uses GS L margins instead. | @@ -103,9 +106,42 @@ Code pages: the SDK manual's code page table (TSPL section, but the same family) | GS I 1 (`GetPrinterIdAsync`) | model byte | **Unknown.** SDK uses GS I 66 for a maker string instead. | | 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. `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. +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. @@ -121,15 +157,33 @@ Code pages: the SDK manual's code page table (TSPL section, but the same family) - `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.** Preferred: sweep the local /24 for open TCP 9100 and run the identification probe on responders. Alternatives: the vendor's UDP discovery protocol used by `EasySet Tool.exe` (unknown, needs a packet capture; the exe yields no readable strings), or manual static registration through `SimpleHttpServer`. Then a composite `IDiscoveryService` that merges ENPC results with the new strategy. `PrinterDiscoveryBackgroundTask` needs no change. This is the largest item, 1 to 2 days. +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`, only if the probe shows GS ( L is rejected. 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. + ## Open questions that only hardware can answer - What does `GS I 1` return, if anything? - Does the firmware accept `GS ( L` raster graphics? - Is code page 18 really CP852 in ESC/POS mode? -- Does DLE EOT 1 bit 3 go high on paper-out? -- Does the printer answer any UDP or mDNS discovery at all? +- 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? diff --git a/Pos895Test/Program.cs b/Pos895Test/Program.cs index 87e73d8..471f851 100644 --- a/Pos895Test/Program.cs +++ b/Pos895Test/Program.cs @@ -1,138 +1,138 @@ +using System.Net; +using System.Net.Sockets; using System.Text; using Inspectron.Epson; -// POS895 smoke test: Font A column count vs. GS ! width magnification. +// POS895 discovery probe. // -// Prints one ruler row per width magnification, each at 48/width characters, so -// every row should end at the same right margin with progressively larger type. -// A row that stops short means that magnification was ignored. +// 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. -// An AnyDesk TCP tunnel forwards this local endpoint to the remote printer's -// port 9100, so the printer is reachable as though it sat on this machine. -// A refused connection here usually means the AnyDesk session or its tunnel is -// down, not that the printer is off. -const string PrinterIp = "127.0.0.1"; -const int PrinterPort = 8888; -const int ConnectTimeoutSeconds = 5; +const string ProbeMagic = "SPRT-Infor"; +const int DiscoveryPort = 3289; +const int ReplyPort = 9100; +const int ListenSeconds = 3; -// Font A is 12 dots wide on a 576-dot line. Width magnification divides this. -const int FontAColumns = 48; -const int HeightMagnification = 2; -int[] widths = { 1, 2, 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; -// Blank lines between the last row and the cut. Literal LF bytes rather than -// ESC d n: LF is the most universally implemented feed in ESC/POS, and this -// firmware appears to ignore ESC d. -const int BlankLinesAfterText = 10; +byte[] probe = BuildProbe(ProbeMagic); -// Set true to drop the ESC t 18 (code page 18 / CP852) selection. The ruler is -// pure ASCII either way; this only isolates whether ESC t itself is honoured. -bool useRawAscii = false; - -byte[] job = BuildJob(FontAColumns, widths, HeightMagnification, BlankLinesAfterText, useRawAscii); - -Console.WriteLine($"POS895 smoke test -> {PrinterIp}:{PrinterPort}"); -Console.WriteLine($"Font A, height {HeightMagnification}x, widths {string.Join("x / ", widths)}x"); -Console.WriteLine(); -foreach (int w in widths) -{ - int cols = FontAColumns / w; - Console.WriteLine($" GS ! 0x{SizeByte(w, HeightMagnification):X2} w={w} h={HeightMagnification} {cols,2} cols {Ruler(cols)}"); -} -Console.WriteLine(); -Console.WriteLine("Byte sequence:"); -Console.WriteLine(Describe(job)); +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 { - await using var printer = new EpsonPrinter(); - - Console.Write("Connecting... "); - await printer.ConnectAsync(PrinterIp, PrinterPort, ConnectTimeoutSeconds); - Console.WriteLine("connected."); - - Console.Write($"Sending {job.Length} bytes... "); - await printer.SendRawCommandAsync(job); - Console.WriteLine("sent."); - - Console.WriteLine(); - Console.WriteLine("[OK] Bytes sent. All three rows should end at the same right"); - Console.WriteLine(" margin, each in larger type than the one above it."); - return 0; + udp.Client.Bind(new IPEndPoint(IPAddress.Any, bindSourcePort9100 ? ReplyPort : 0)); } -catch (EpsonConnectionException ex) +catch (SocketException ex) { - Console.Error.WriteLine(); - Console.Error.WriteLine($"[FAIL] Could not reach {PrinterIp}:{PrinterPort}."); - Console.Error.WriteLine(" Is the port forward to the printer's 9100 up?"); - Console.Error.WriteLine($" {ex.Message}"); - if (ex.InnerException is not null) - { - Console.Error.WriteLine($" {ex.InnerException.Message}"); - } - return 1; -} -catch (Exception ex) -{ - Console.Error.WriteLine(); - Console.Error.WriteLine($"[FAIL] {ex.GetType().Name}: {ex.Message}"); + 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; } -// The whole job as one array, so what gets listed above is exactly what goes on -// the wire. -static byte[] BuildJob(int fontACols, int[] widths, int heightMag, int blankLines, bool rawAscii) +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) { - var job = new List { 0x1B, 0x40 }; // ESC @ initialize - - if (!rawAscii) + using var cts = new CancellationTokenSource(deadline - DateTime.UtcNow); + UdpReceiveResult res; + try { - job.AddRange(new byte[] { 0x1B, 0x74, 0x12 }); // ESC t 18 code page 852 + res = await udp.ReceiveAsync(cts.Token); + } + catch (OperationCanceledException) + { + break; } - job.AddRange(new byte[] { 0x1B, 0x21, 0x00 }); // ESC ! 0 Font A, no attributes + string ip = res.RemoteEndPoint.Address.ToString(); + var parsed = ParseReply(res.Buffer); - foreach (int w in widths) + if (parsed is null) { - job.AddRange(new byte[] { 0x1D, 0x21, SizeByte(w, heightMag) }); - job.AddRange(Encoding.ASCII.GetBytes(Ruler(fontACols / w))); - job.Add(0x0A); + Console.WriteLine($" {ip}:{res.RemoteEndPoint.Port} unrecognised, {res.Buffer.Length} bytes"); + Console.WriteLine(Describe(res.Buffer)); + continue; } - job.AddRange(new byte[] { 0x1D, 0x21, 0x00 }); // GS ! 0 back to 1x1 - - for (int i = 0; i < blankLines; i++) - { - job.Add(0x0A); // blank line - } - - job.AddRange(new byte[] { 0x1D, 0x56, 0x00 }); // GS V 0 full cut - return job.ToArray(); + (string mac, string serial) = parsed.Value; + found[ip] = mac; + Console.WriteLine($" FOUND {ip,-15} mac {mac} serial {serial}"); } -// GS ! n: bits 4-6 are width magnification - 1, bits 0-2 height - 1. Same -// formula as EpsonPrinter.SetFontSizeAsync. -static byte SizeByte(int width, int height) => - (byte)(((height - 1) & 0x07) | (((width - 1) & 0x07) << 4)); - -// "|---" repeated and cut to an exact column count, so the pipes fall every -// 4 characters and the row ends precisely at the claimed right margin. -static string Ruler(int columns) +Console.WriteLine(); +if (found.Count == 0) { - var sb = new StringBuilder(); - while (sb.Length < columns) - { - sb.Append("|---"); - } - - return sb.ToString(0, columns); + 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; } -// Hex plus a printable-ASCII gutter, so the output can be compared against the -// printer's hex dump mode (open cover, FEED twice within 3 s, close cover). +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(); @@ -158,3 +158,78 @@ static string Describe(byte[] bytes) 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 0000000000000000000000000000000000000000..54ebd2c96a380850cfe6bb2e8f3988ec380a2d47 GIT binary patch literal 44858 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 IkQaae0M}KuDF6Tf literal 0 HcmV?d00001 diff --git a/docs/captures/easyset.pcapng b/docs/captures/easyset.pcapng new file mode 100644 index 0000000000000000000000000000000000000000..234e49cb0e1bdfdf6f8bce1946ebd86d0a4c09f9 GIT binary patch literal 83532 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 Date: Thu, 3 Sep 2026 11:19:59 +0200 Subject: [PATCH 04/19] probe remaining POS895 unknowns; add integration design spec Closes every hardware question that was blocking the integration design, so the design now rests on measurements rather than assumptions. Probe results, all against the real printer: - GS I 1 returns 0x20, unique against 0x01/0x0D/0x13. This is what keeps the integration small: model identity stays a byte and the existing per-job identification flow works. 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 come from discovery instead. - GS I 66 and 67 are blank, so the model-name fallback the work items proposed is useless here. GS I 68 returns the serial, and it matches the SPRT discovery serial exactly, which makes it a correlation key between a discovered printer and a connected one. - GS ( L prints nothing. ESC * 33 and GS v 0 both render correctly across the full 576 dots, so the adapter uses PrintImageBitModeAsync and the GS v 0 work item is dropped as redundant. - ESC t is honoured and index 18 really is CP852; 2 and 0 also render. That last one exposed a fleet-wide bug that is deliberately left out of scope: PrintTextAsync hardcodes CP852 for every printer, and CP852 cannot encode e-grave, a-grave, u-grave, e-circumflex, i-grave or o-grave, so French and Italian accents are dropped on the Epsons too. Now known to be fixable via ESC t 2, but it needs its own spec. The design spec covers discovery composition, the adapter, the three switch registrations, testing and risks. It calls out that the ReceiptConverterFactory WorkareaTicket switch defaults to 33 columns, so missing it yields a POS895 printing kitchen tickets at the narrow TM-U220 layout: wrong, but not obviously broken. Claude-Session: https://claude.ai/code/session_01E6N3P4nSZLtp4BC2LXVref --- POS895_POS896_integration_notes.md | 31 +++- .../2026-09-03-pos895-integration-design.md | 168 ++++++++++++++++++ 2 files changed, 192 insertions(+), 7 deletions(-) create mode 100644 docs/superpowers/specs/2026-09-03-pos895-integration-design.md diff --git a/POS895_POS896_integration_notes.md b/POS895_POS896_integration_notes.md index 4343fbf..f1ea350 100644 --- a/POS895_POS896_integration_notes.md +++ b/POS895_POS896_integration_notes.md @@ -100,10 +100,12 @@ Code pages: the SDK manual's code page table (TSPL section, but the same family) | 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 | Likely fine, see code pages above. | +| 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 | Probably unsupported. SDK never uses it. Fallbacks: ESC * 24-dot double density (`PrintImageBitModeAsync`, already implemented) or GS v 0 (not implemented). | -| GS I 1 (`GetPrinterIdAsync`) | model byte | **Unknown.** SDK uses GS I 66 for a maker string 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 @@ -159,7 +161,7 @@ step and cannot be folded into discovery. 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`, only if the probe shows GS ( L is rejected. +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 @@ -177,13 +179,28 @@ discovery probe run on the printer's own segment. Tooling in `Pos895Test/`. 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 -- What does `GS I 1` return, if anything? -- Does the firmware accept `GS ( L` raster graphics? -- Is code page 18 really CP852 in ESC/POS mode? - 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/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..fe33964 --- /dev/null +++ b/docs/superpowers/specs/2026-09-03-pos895-integration-design.md @@ -0,0 +1,168 @@ +# 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. On `SocketException` +the service logs a warning and returns an empty list, so ENPC discovery still +runs. Discovery degrades; it does not crash. + +**`CompositeDiscoveryService`** - takes `IEnumerable`, runs +them concurrently on the shared timeout, merges results by `IPAddress` with +first-wins, and forwards each child's `onPrinterDiscovered` callback as it fires. + +**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`. From 84b94e8c1ab3203411e09d3ed8fefb1519c526f8 Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Thu, 3 Sep 2026 11:32:19 +0200 Subject: [PATCH 05/19] add POS895 integration implementation plan Seven TDD tasks derived from the design spec, each ending in a commit: test project and SPRT protocol parsing, the discovery network path, CompositeDiscoveryService, the POS895 adapter, the three switch registrations, the nullable printer-id guard, and DI wiring plus an emulated POS895. Claude-Session: https://claude.ai/code/session_01E6N3P4nSZLtp4BC2LXVref --- .../plans/2026-09-03-pos895-integration.md | 1344 +++++++++++++++++ 1 file changed, 1344 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-03-pos895-integration.md 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..6e11508 --- /dev/null +++ b/docs/superpowers/plans/2026-09-03-pos895-integration.md @@ -0,0 +1,1344 @@ +# 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: Test project, `DiscoveredPrinter.Serial`, and SPRT protocol parsing + +Creates the test project the later tasks need, adds the one new DTO field, and implements the pure functions of the SPRT protocol so they can be tested without a network. + +**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` (existing class, `Inspectron.Epson`). +- Produces: `SprtDiscoveryService.BuildProbe()` returning `byte[]`; `SprtDiscoveryService.TryParseReply(byte[] payload, string ipAddress, out DiscoveredPrinter? printer)` returning `bool`. Task 2 uses both. `DiscoveredPrinter.Serial` (`string?`) used by Tasks 1-2. + +- [ ] **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); + } +} +``` + +- [ ] **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.Text; + +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; + + /// 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"; + + /// 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 Task> DiscoverPrintersAsync( + TimeSpan? timeout = null, + Action? onPrinterDiscovered = null) + { + throw new NotImplementedException("Task 2"); + } +} +``` + +- [ ] **Step 6: Run the tests to verify they pass** + +Run: `dotnet test Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj` +Expected: PASS, 6 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 protocol parsing and test project" +``` + +--- + +### Task 2: `SprtDiscoveryService` network path + +Broadcasts the probe and collects replies. Binding UDP 9100 can fail when another process holds it; discovery must degrade to empty rather than crash, so ENPC discovery still runs. + +**Files:** +- Modify: `Inspectron.Epson/SprtDiscoveryService.cs` +- Modify: `Inspectron.Epson.Tests/SprtDiscoveryServiceTests.cs` + +**Interfaces:** +- Consumes: `BuildProbe()`, `TryParseReply(...)` from Task 1. +- Produces: `SprtDiscoveryService(ILogger? logger = null)` constructor; a working `DiscoverPrintersAsync`. Task 6 constructs it for DI. + +- [ ] **Step 1: Write the failing tests** + +Append to `Inspectron.Epson.Tests/SprtDiscoveryServiceTests.cs`: + +```csharp + [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); + } + + [Fact] + public async Task DiscoverPrintersAsync_returns_empty_when_nothing_answers() + { + var service = new SprtDiscoveryService(); + var result = await service.DiscoverPrintersAsync(TimeSpan.FromMilliseconds(300)); + + Assert.Empty(result); + } +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `dotnet test Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj --filter DiscoverPrintersAsync` +Expected: FAIL with `NotImplementedException: Task 2`. + +- [ ] **Step 3: Write the implementation** + +In `Inspectron.Epson/SprtDiscoveryService.cs`, add these usings at the top: + +```csharp +using System.Net; +using System.Net.Sockets; +using Microsoft.Extensions.Logging; +``` + +Add a logger field and constructor above `BuildProbe`: + +```csharp + private const int DiscoveryPort = 3289; + private const int ReplyPort = 9100; + + private readonly ILogger? _logger; + + public SprtDiscoveryService(ILogger? logger = null) + { + _logger = logger; + } +``` + +Replace the `NotImplementedException` body of `DiscoverPrintersAsync` with: + +```csharp + 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 4: Run the tests to verify they pass** + +Run: `dotnet test Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj` +Expected: PASS, 8 tests. + +- [ ] **Step 5: Commit** + +```bash +git add Inspectron.Epson/SprtDiscoveryService.cs Inspectron.Epson.Tests/SprtDiscoveryServiceTests.cs +git commit -m "implement SPRT discovery broadcast and reply collection" +``` + +--- + +### Task 3: `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, 13 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 4: `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 5 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, 20 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 5: 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 4. +- 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, 25 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 6: 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, 25 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 7: 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 2), `CompositeDiscoveryService` (Task 3). +- 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, 25 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. From 2f26a933cb4d1becf92c6d93fbbb897481c6a33d Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Thu, 3 Sep 2026 11:46:25 +0200 Subject: [PATCH 06/19] amend POS895 plan: merge discovery tasks, drop network-dependent test Pre-flight scan found two things the plan mandated that a reviewer would rightly flag: Task 1 committed a DiscoverPrintersAsync that threw NotImplementedException until Task 2 replaced it, and a unit test asserted an empty result from a real UDP broadcast, which passes on a laptop and fails on any network with a POS895 on it. Tasks 1 and 2 are now one task delivering the complete service, and the network-dependent test is gone. The deterministic bind-failure test still covers the degradation path. Six tasks now. Claude-Session: https://claude.ai/code/session_01E6N3P4nSZLtp4BC2LXVref --- .../plans/2026-09-03-pos895-integration.md | 171 ++++++------------ 1 file changed, 53 insertions(+), 118 deletions(-) diff --git a/docs/superpowers/plans/2026-09-03-pos895-integration.md b/docs/superpowers/plans/2026-09-03-pos895-integration.md index 6e11508..39a39f8 100644 --- a/docs/superpowers/plans/2026-09-03-pos895-integration.md +++ b/docs/superpowers/plans/2026-09-03-pos895-integration.md @@ -19,9 +19,9 @@ --- -### Task 1: Test project, `DiscoveredPrinter.Serial`, and SPRT protocol parsing +### Task 1: `SprtDiscoveryService`, test project, and `DiscoveredPrinter.Serial` -Creates the test project the later tasks need, adds the one new DTO field, and implements the pure functions of the SPRT protocol so they can be tested without a network. +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` @@ -32,8 +32,8 @@ Creates the test project the later tasks need, adds the one new DTO field, and i - Modify: `Inspectron.Epson.slnx` **Interfaces:** -- Consumes: `DiscoveredPrinter` (existing class, `Inspectron.Epson`). -- Produces: `SprtDiscoveryService.BuildProbe()` returning `byte[]`; `SprtDiscoveryService.TryParseReply(byte[] payload, string ipAddress, out DiscoveredPrinter? printer)` returning `bool`. Task 2 uses both. `DiscoveredPrinter.Serial` (`string?`) used by Tasks 1-2. +- 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** @@ -160,9 +160,27 @@ public class SprtDiscoveryServiceTests 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` @@ -192,7 +210,10 @@ In `Inspectron.Epson/PreconfiguredDiscoveryService.cs`, inside the object initia Create `Inspectron.Epson/SprtDiscoveryService.cs`: ```csharp +using System.Net; +using System.Net.Sockets; using System.Text; +using Microsoft.Extensions.Logging; namespace Inspectron.Epson; @@ -214,11 +235,20 @@ 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() { @@ -265,102 +295,6 @@ public class SprtDiscoveryService : IDiscoveryService return true; } - public Task> DiscoverPrintersAsync( - TimeSpan? timeout = null, - Action? onPrinterDiscovered = null) - { - throw new NotImplementedException("Task 2"); - } -} -``` - -- [ ] **Step 6: Run the tests to verify they pass** - -Run: `dotnet test Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj` -Expected: PASS, 6 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 protocol parsing and test project" -``` - ---- - -### Task 2: `SprtDiscoveryService` network path - -Broadcasts the probe and collects replies. Binding UDP 9100 can fail when another process holds it; discovery must degrade to empty rather than crash, so ENPC discovery still runs. - -**Files:** -- Modify: `Inspectron.Epson/SprtDiscoveryService.cs` -- Modify: `Inspectron.Epson.Tests/SprtDiscoveryServiceTests.cs` - -**Interfaces:** -- Consumes: `BuildProbe()`, `TryParseReply(...)` from Task 1. -- Produces: `SprtDiscoveryService(ILogger? logger = null)` constructor; a working `DiscoverPrintersAsync`. Task 6 constructs it for DI. - -- [ ] **Step 1: Write the failing tests** - -Append to `Inspectron.Epson.Tests/SprtDiscoveryServiceTests.cs`: - -```csharp - [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); - } - - [Fact] - public async Task DiscoverPrintersAsync_returns_empty_when_nothing_answers() - { - var service = new SprtDiscoveryService(); - var result = await service.DiscoverPrintersAsync(TimeSpan.FromMilliseconds(300)); - - Assert.Empty(result); - } -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `dotnet test Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj --filter DiscoverPrintersAsync` -Expected: FAIL with `NotImplementedException: Task 2`. - -- [ ] **Step 3: Write the implementation** - -In `Inspectron.Epson/SprtDiscoveryService.cs`, add these usings at the top: - -```csharp -using System.Net; -using System.Net.Sockets; -using Microsoft.Extensions.Logging; -``` - -Add a logger field and constructor above `BuildProbe`: - -```csharp - private const int DiscoveryPort = 3289; - private const int ReplyPort = 9100; - - private readonly ILogger? _logger; - - public SprtDiscoveryService(ILogger? logger = null) - { - _logger = logger; - } -``` - -Replace the `NotImplementedException` body of `DiscoverPrintersAsync` with: - -```csharp public async Task> DiscoverPrintersAsync( TimeSpan? timeout = null, Action? onPrinterDiscovered = null) @@ -442,23 +376,24 @@ Replace the `NotImplementedException` body of `DiscoverPrintersAsync` with: return discovered; } +} ``` -- [ ] **Step 4: Run the tests to verify they pass** +- [ ] **Step 6: Run the tests to verify they pass** Run: `dotnet test Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj` -Expected: PASS, 8 tests. +Expected: PASS, 7 tests. -- [ ] **Step 5: Commit** +- [ ] **Step 7: Commit** ```bash -git add Inspectron.Epson/SprtDiscoveryService.cs Inspectron.Epson.Tests/SprtDiscoveryServiceTests.cs -git commit -m "implement SPRT discovery broadcast and reply collection" +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 3: `CompositeDiscoveryService` +### Task 2: `CompositeDiscoveryService` Runs several discovery services concurrently and merges their results, so ENPC and SPRT printers appear in one list. @@ -674,7 +609,7 @@ Note the children are called **without** the `onPrinterDiscovered` callback. The - [ ] **Step 4: Run the tests to verify they pass** Run: `dotnet test Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj` -Expected: PASS, 13 tests. +Expected: PASS, 12 tests. - [ ] **Step 5: Commit** @@ -685,7 +620,7 @@ git commit -m "add CompositeDiscoveryService merging ENPC and SPRT results" --- -### Task 4: `POS895` printer adapter +### 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. @@ -696,7 +631,7 @@ The adapter differs from `TM_T30IIITranslated` in exactly three ways, each confi **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 5 constructs it. +- Produces: `POS895(IEpsonPrinter printer)` with a `bool SkipDelays { get; set; }`. Task 4 constructs it. - [ ] **Step 1: Write the recording test double** @@ -1026,7 +961,7 @@ Note `PrintAsync` feeds 10 lines at the end **and** 10 on an `IsCut` command, ma - [ ] **Step 5: Run the tests to verify they pass** Run: `dotnet test Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj` -Expected: PASS, 20 tests. +Expected: PASS, 19 tests. - [ ] **Step 6: Commit** @@ -1037,7 +972,7 @@ git commit -m "add POS895 printer adapter with LF feed and ESC * logo" --- -### Task 5: Register printer ID `0x20` in the three switches +### 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. @@ -1048,7 +983,7 @@ Three one-line additions in three files. The third is the one that fails silentl - Create: `Inspectron.Epson.Tests/PrinterRegistrationTests.cs` **Interfaces:** -- Consumes: `POS895` from Task 4. +- Consumes: `POS895` from Task 3. - Produces: nothing new; wires `0x20` into existing dispatch. - [ ] **Step 1: Write the failing tests** @@ -1184,7 +1119,7 @@ Leave `EReceiptType.NextCourse` at `33, 20`. It is hardcoded for every printer a - [ ] **Step 6: Run the tests to verify they pass** Run: `dotnet test Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj` -Expected: PASS, 25 tests. +Expected: PASS, 24 tests. - [ ] **Step 7: Commit** @@ -1195,7 +1130,7 @@ git commit -m "register printer id 0x20 as POS895 across the three dispatch poin --- -### Task 6: Guard the nullable printer ID +### 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. @@ -1230,7 +1165,7 @@ Expected: `0 Error(s)`, and no new warnings attributable to `EpsonPrintService.c - [ ] **Step 3: Run the full test suite** Run: `dotnet test Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj` -Expected: PASS, 25 tests. +Expected: PASS, 24 tests. - [ ] **Step 4: Commit** @@ -1241,7 +1176,7 @@ git commit -m "fail cleanly when a printer does not answer GS I 1" --- -### Task 7: DI wiring and emulation entry +### 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. @@ -1249,7 +1184,7 @@ Binds the composite discovery in production and adds a 576px emulated POS895 so - Modify: `EpsonPrintService/PrintServerBootstrapper.cs:52-78` **Interfaces:** -- Consumes: `SprtDiscoveryService` (Task 2), `CompositeDiscoveryService` (Task 3). +- Consumes: `SprtDiscoveryService` (Task 1), `CompositeDiscoveryService` (Task 2). - Produces: nothing new. - [ ] **Step 1: Add the emulated POS895** @@ -1313,7 +1248,7 @@ Expected: `0 Error(s)`. - [ ] **Step 4: Run the full test suite** Run: `dotnet test Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj` -Expected: PASS, 25 tests. +Expected: PASS, 24 tests. - [ ] **Step 5: Verify discovery against the real printer** From f219129ce4726b85ecda05ea06f3572c741ed08c Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Thu, 3 Sep 2026 11:51:05 +0200 Subject: [PATCH 07/19] add SPRT discovery service for POS895 printers Claude-Session: https://claude.ai/code/session_01E6N3P4nSZLtp4BC2LXVref --- Inspectron.Epson.Tests/GlobalUsings.cs | 1 + .../Inspectron.Epson.Tests.csproj | 25 +++ .../SprtDiscoveryServiceTests.cs | 97 ++++++++++ Inspectron.Epson.slnx | 1 + Inspectron.Epson/DiscoveredPrinter.cs | 8 + .../PreconfiguredDiscoveryService.cs | 1 + Inspectron.Epson/SprtDiscoveryService.cs | 167 ++++++++++++++++++ 7 files changed, 300 insertions(+) create mode 100644 Inspectron.Epson.Tests/GlobalUsings.cs create mode 100644 Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj create mode 100644 Inspectron.Epson.Tests/SprtDiscoveryServiceTests.cs create mode 100644 Inspectron.Epson/SprtDiscoveryService.cs diff --git a/Inspectron.Epson.Tests/GlobalUsings.cs b/Inspectron.Epson.Tests/GlobalUsings.cs new file mode 100644 index 0000000..c802f44 --- /dev/null +++ b/Inspectron.Epson.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj b/Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj new file mode 100644 index 0000000..2371a47 --- /dev/null +++ b/Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj @@ -0,0 +1,25 @@ + + + + net8.0 + enable + enable + false + true + Major + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/Inspectron.Epson.Tests/SprtDiscoveryServiceTests.cs b/Inspectron.Epson.Tests/SprtDiscoveryServiceTests.cs new file mode 100644 index 0000000..cc747e9 --- /dev/null +++ b/Inspectron.Epson.Tests/SprtDiscoveryServiceTests.cs @@ -0,0 +1,97 @@ +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); + } +} diff --git a/Inspectron.Epson.slnx b/Inspectron.Epson.slnx index 875148b..d271a31 100644 --- a/Inspectron.Epson.slnx +++ b/Inspectron.Epson.slnx @@ -7,6 +7,7 @@ + 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/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/SprtDiscoveryService.cs b/Inspectron.Epson/SprtDiscoveryService.cs new file mode 100644 index 0000000..f4383d9 --- /dev/null +++ b/Inspectron.Epson/SprtDiscoveryService.cs @@ -0,0 +1,167 @@ +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; + } +} From e93dfdc1843c22d38f35ebc4e2e4b2b9546d497f Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Thu, 3 Sep 2026 11:57:03 +0200 Subject: [PATCH 08/19] add CompositeDiscoveryService merging ENPC and SPRT results Claude-Session: https://claude.ai/code/session_01E6N3P4nSZLtp4BC2LXVref --- .../CompositeDiscoveryServiceTests.cs | 118 ++++++++++++++++++ Inspectron.Epson/CompositeDiscoveryService.cs | 62 +++++++++ 2 files changed, 180 insertions(+) create mode 100644 Inspectron.Epson.Tests/CompositeDiscoveryServiceTests.cs create mode 100644 Inspectron.Epson/CompositeDiscoveryService.cs diff --git a/Inspectron.Epson.Tests/CompositeDiscoveryServiceTests.cs b/Inspectron.Epson.Tests/CompositeDiscoveryServiceTests.cs new file mode 100644 index 0000000..dd4a96b --- /dev/null +++ b/Inspectron.Epson.Tests/CompositeDiscoveryServiceTests.cs @@ -0,0 +1,118 @@ +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); + } +} diff --git a/Inspectron.Epson/CompositeDiscoveryService.cs b/Inspectron.Epson/CompositeDiscoveryService.cs new file mode 100644 index 0000000..164406b --- /dev/null +++ b/Inspectron.Epson/CompositeDiscoveryService.cs @@ -0,0 +1,62 @@ +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; + } +} From 40a72195d3ee2cca2b270adcbb00014e9fcee7ff Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Thu, 3 Sep 2026 12:03:24 +0200 Subject: [PATCH 09/19] strengthen CompositeDiscoveryService tests for deduplication and concurrency - Replace weak deduplication test with one using distinguishable printers (TM-T30III/ENPC vs POS895/SPRT on same IP) to prove first-wins semantics - Add concurrency test using SlowStubDiscovery to catch sequential regressions (3 * 300ms tasks must complete in < 700ms, not >= 900ms) - Add StubDiscovery constructor accepting List for fully-populated test data Test suite now 13/13 passing with proven concurrent execution. Claude-Session: https://claude.ai/code/session_01E6N3P4nSZLtp4BC2LXVref --- .../CompositeDiscoveryServiceTests.cs | 70 +++++++++++++++++-- 1 file changed, 64 insertions(+), 6 deletions(-) diff --git a/Inspectron.Epson.Tests/CompositeDiscoveryServiceTests.cs b/Inspectron.Epson.Tests/CompositeDiscoveryServiceTests.cs index dd4a96b..3075977 100644 --- a/Inspectron.Epson.Tests/CompositeDiscoveryServiceTests.cs +++ b/Inspectron.Epson.Tests/CompositeDiscoveryServiceTests.cs @@ -20,6 +20,11 @@ public class CompositeDiscoveryServiceTests _throws = throws; } + public StubDiscovery(List printers) + { + _printers = printers; + } + public Task> DiscoverPrintersAsync( TimeSpan? timeout = null, Action? onPrinterDiscovered = null) @@ -38,6 +43,26 @@ public class CompositeDiscoveryServiceTests } } + 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() { @@ -56,18 +81,51 @@ public class CompositeDiscoveryServiceTests } [Fact] - public async Task Deduplicates_by_ip_first_wins() + public async Task Deduplicates_by_ip_keeping_the_first_child_that_reported_it() { - // A printer answering both ENPC and SPRT must appear once. - var composite = new CompositeDiscoveryService(new IDiscoveryService[] + // 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 StubDiscovery("10.0.0.1"), - new StubDiscovery("10.0.0.1") + 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)); - Assert.Single(result); + 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] From cd916c276189405d69c918b23828e8b0cd154991 Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Thu, 3 Sep 2026 12:07:32 +0200 Subject: [PATCH 10/19] add POS895 printer adapter with LF feed and ESC * logo Claude-Session: https://claude.ai/code/session_01E6N3P4nSZLtp4BC2LXVref --- Inspectron.Epson.Tests/POS895Tests.cs | 104 ++++++++++++++++++ .../RecordingEpsonPrinter.cs | 96 ++++++++++++++++ .../PrintServer/Printers/POS895.cs | 96 ++++++++++++++++ 3 files changed, 296 insertions(+) create mode 100644 Inspectron.Epson.Tests/POS895Tests.cs create mode 100644 Inspectron.Epson.Tests/RecordingEpsonPrinter.cs create mode 100644 Inspectron.Epson/PrintServer/Printers/POS895.cs diff --git a/Inspectron.Epson.Tests/POS895Tests.cs b/Inspectron.Epson.Tests/POS895Tests.cs new file mode 100644 index 0000000..adeb774 --- /dev/null +++ b/Inspectron.Epson.Tests/POS895Tests.cs @@ -0,0 +1,104 @@ +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); + } +} diff --git a/Inspectron.Epson.Tests/RecordingEpsonPrinter.cs b/Inspectron.Epson.Tests/RecordingEpsonPrinter.cs new file mode 100644 index 0000000..259165c --- /dev/null +++ b/Inspectron.Epson.Tests/RecordingEpsonPrinter.cs @@ -0,0 +1,96 @@ +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; + 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/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); + } +} From 020c17896aa8169dd3b42b6d2582f107cb4334c1 Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Thu, 3 Sep 2026 12:13:41 +0200 Subject: [PATCH 11/19] register printer id 0x20 as POS895 across the three dispatch points Claude-Session: https://claude.ai/code/session_01E6N3P4nSZLtp4BC2LXVref --- .../PrinterRegistrationTests.cs | 85 +++++++++++++++++++ Inspectron.Epson/DiscoveryService.cs | 1 + .../Printers/PrinterWrapperFactory.cs | 2 + .../Printers/Utils/ReceiptConverterFactory.cs | 1 + 4 files changed, 89 insertions(+) create mode 100644 Inspectron.Epson.Tests/PrinterRegistrationTests.cs diff --git a/Inspectron.Epson.Tests/PrinterRegistrationTests.cs b/Inspectron.Epson.Tests/PrinterRegistrationTests.cs new file mode 100644 index 0000000..6561dd3 --- /dev/null +++ b/Inspectron.Epson.Tests/PrinterRegistrationTests.cs @@ -0,0 +1,85 @@ +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)); + } + } +} 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/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), From 51792fcd20deb153c1108d540b17d74661d540a4 Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Thu, 3 Sep 2026 12:19:34 +0200 Subject: [PATCH 12/19] add reflection-based test coverage for DiscoveryService.GetModelNameFromId Ensures the 0x20 => "POS895" registration is actually tested. Without this, the line could be deleted and the whole suite would still pass. Claude-Session: https://claude.ai/code/session_01E6N3P4nSZLtp4BC2LXVref --- .../PrinterRegistrationTests.cs | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/Inspectron.Epson.Tests/PrinterRegistrationTests.cs b/Inspectron.Epson.Tests/PrinterRegistrationTests.cs index 6561dd3..af6ea0e 100644 --- a/Inspectron.Epson.Tests/PrinterRegistrationTests.cs +++ b/Inspectron.Epson.Tests/PrinterRegistrationTests.cs @@ -1,3 +1,4 @@ +using Inspectron.Epson; using Inspectron.Epson.PrintServer.Printers; using Inspectron.Epson.PrintServer.Printers.Utils; @@ -82,4 +83,40 @@ public class PrinterRegistrationTests 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 })!; + } } From ca773610ad0341ee0815bd37af6e49bf118e224d Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Thu, 3 Sep 2026 12:22:14 +0200 Subject: [PATCH 13/19] fail cleanly when a printer does not answer GS I 1 Claude-Session: https://claude.ai/code/session_01E6N3P4nSZLtp4BC2LXVref --- .../PrintServer/PrintServices/EpsonPrintService.cs | 8 ++++++++ 1 file changed, 8 insertions(+) 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) { From 9680ab0ad309442310b7318b065bdc81f01a6830 Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Thu, 3 Sep 2026 12:26:56 +0200 Subject: [PATCH 14/19] bind composite discovery and add emulated POS895 Epson printers answer ENPC and POS895/POS896 answer SPRT; only the composite discovery service sees both. Wire CompositeDiscoveryService (wrapping DiscoveryService + SprtDiscoveryService) into production DI, and add a 576px emulated POS895 (127.0.0.4) alongside the existing TM-U220II/TM-T30III emulation entries so receipt layouts can be checked without hardware. Claude-Session: https://claude.ai/code/session_01E6N3P4nSZLtp4BC2LXVref --- EpsonPrintService/PrintServerBootstrapper.cs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/EpsonPrintService/PrintServerBootstrapper.cs b/EpsonPrintService/PrintServerBootstrapper.cs index 40f5906..81a319f 100644 --- a/EpsonPrintService/PrintServerBootstrapper.cs +++ b/EpsonPrintService/PrintServerBootstrapper.cs @@ -53,17 +53,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 { @@ -74,7 +76,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(); From a4e96f30414bfd193b4451e7fcd0fef0630a9b7c Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Thu, 3 Sep 2026 12:46:14 +0200 Subject: [PATCH 15/19] Fix three pre-merge review findings on POS895 branch 1. Strengthen POS895 IsCut test: add RecordingEpsonPrinter.CallOrder so the test can assert feeds happen BEFORE the cut, plus a LineFeedBytes count assertion. The prior test could not detect the FeedAsync call being deleted from the IsCut branch (verified: test now fails when that line is removed, passes when restored). 2. CompositeDiscoveryService: distinguish "some children failed" from "every child failed". Per-child failures are still absorbed, but if every discovery service throws, the composite now rethrows instead of silently returning an empty list. Previously a total discovery failure (e.g. a routeless interface) looked like "discovery succeeded, found nothing" to PrinterDiscoveryBackgroundTask, which would expire and unregister every known printer after the retention window, discarding their queued jobs. Also corrected a doc comment that overstated Task.WhenAll's timeout guarantee. 3. Update CLAUDE.md to describe the POS895/POS896 work as shipped rather than future work: added the Inspectron.Epson.Tests and Pos895Test projects, a dotnet test command, SprtDiscoveryService/ CompositeDiscoveryService/POS895 in the architecture sections, the 0x20 printer-ID note, and the established SPRT hardware facts. Claude-Session: https://claude.ai/code/session_01E6N3P4nSZLtp4BC2LXVref --- CLAUDE.md | 19 ++++++++++-- .../CompositeDiscoveryServiceTests.cs | 17 +++++++++++ Inspectron.Epson.Tests/POS895Tests.cs | 9 ++++++ .../RecordingEpsonPrinter.cs | 8 +++++ Inspectron.Epson/CompositeDiscoveryService.cs | 30 +++++++++++++++---- 5 files changed, 75 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 86072a5..d5d21b3 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,7 +181,7 @@ 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 @@ -182,7 +190,14 @@ Printers are registered via `PreconfiguredDiscoveryService` with hardcoded IPs i ### Non-Epson printers (POS895 / POS896) -A feasibility study for the POS895 / POS896 ESC/POS clones lives in `POS895_POS896_integration_notes.md`. Read it before touching discovery, printer identification (`GetPrinterIdAsync`), or `PrinterWrapperFactory` for a non-Epson model. Short version: do not link the vendor SDK; add an `IPrinter` adapter for a 576-dot line, a discovery strategy that is not Epson ENPC, and a more robust printer-ID probe. The document lists the ESC/POS commands the vendor firmware is known to accept and the hardware checks still open. +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 diff --git a/Inspectron.Epson.Tests/CompositeDiscoveryServiceTests.cs b/Inspectron.Epson.Tests/CompositeDiscoveryServiceTests.cs index 3075977..775f546 100644 --- a/Inspectron.Epson.Tests/CompositeDiscoveryServiceTests.cs +++ b/Inspectron.Epson.Tests/CompositeDiscoveryServiceTests.cs @@ -143,6 +143,23 @@ public class CompositeDiscoveryServiceTests 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() { diff --git a/Inspectron.Epson.Tests/POS895Tests.cs b/Inspectron.Epson.Tests/POS895Tests.cs index adeb774..9efbf30 100644 --- a/Inspectron.Epson.Tests/POS895Tests.cs +++ b/Inspectron.Epson.Tests/POS895Tests.cs @@ -64,6 +64,15 @@ public class POS895Tests 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] diff --git a/Inspectron.Epson.Tests/RecordingEpsonPrinter.cs b/Inspectron.Epson.Tests/RecordingEpsonPrinter.cs index 259165c..b6ecc33 100644 --- a/Inspectron.Epson.Tests/RecordingEpsonPrinter.cs +++ b/Inspectron.Epson.Tests/RecordingEpsonPrinter.cs @@ -18,6 +18,12 @@ public sealed class RecordingEpsonPrinter : IEpsonPrinter 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)); @@ -26,6 +32,7 @@ public sealed class RecordingEpsonPrinter : IEpsonPrinter public Task SendRawCommandAsync(byte[] command) { RawCommands.Add(command); + CallOrder.Add($"raw:{command.Length}"); return Task.CompletedTask; } @@ -62,6 +69,7 @@ public sealed class RecordingEpsonPrinter : IEpsonPrinter public Task CutAsync(bool fullCut = true) { CutCount++; + CallOrder.Add("cut"); return Task.CompletedTask; } diff --git a/Inspectron.Epson/CompositeDiscoveryService.cs b/Inspectron.Epson/CompositeDiscoveryService.cs index 164406b..d6c4a72 100644 --- a/Inspectron.Epson/CompositeDiscoveryService.cs +++ b/Inspectron.Epson/CompositeDiscoveryService.cs @@ -7,7 +7,11 @@ namespace Inspectron.Epson; /// /// 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. +/// 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 { @@ -27,8 +31,14 @@ public class CompositeDiscoveryService : IDiscoveryService 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. + // 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 @@ -39,14 +49,22 @@ public class CompositeDiscoveryService : IDiscoveryService { _logger?.LogWarning(ex, "Discovery service {Service} failed", service.GetType().Name); - return new List(); + childExceptions.Add(ex); + return null; } }); - var results = await Task.WhenAll(tasks); + 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.SelectMany(r => r)) + foreach (var printer in results.Where(r => r is not null).SelectMany(r => r!)) { if (printer.IPAddress is null || !seenIps.Add(printer.IPAddress)) { From fd19cd935862170d1c1fb1c880c46fbfbc2a114f Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Thu, 3 Sep 2026 13:01:58 +0200 Subject: [PATCH 16/19] fix: propagate SPRT discovery bind/broadcast failures instead of swallowing them SprtDiscoveryService caught SocketException on the UDP 9100 bind and the broadcast send and returned an empty list either way. That defeated the CompositeDiscoveryService fix that rethrows only when every child discovery service fails: on a real network fault the composite would see one child throw (ENPC) and one child silently succeed empty (SPRT), conclude discovery worked and found nothing, and let PrinterDiscoveryBackgroundTask expire and deregister live Epson printers after the retention window - discarding their queued jobs. Move the policy decision to the composite, which already has it right: the child now reports bind/broadcast failure honestly by rethrowing, and CompositeDiscoveryService absorbs a single child's failure while still rethrowing when every child fails. The per-datagram receive-loop catch is unchanged - a single bad receive still shouldn't abort the pass. Rewrote SprtDiscoveryServiceTests' bind-failure test to assert the throw, and added an end-to-end CompositeDiscoveryServiceTests case with a real SprtDiscoveryService failing to bind alongside a stub that succeeds, proving the composite still degrades gracefully rather than losing the pass. Updated the POS895 design doc to match the new contract. Claude-Session: https://claude.ai/code/session_01E6N3P4nSZLtp4BC2LXVref --- .../CompositeDiscoveryServiceTests.cs | 24 +++++++++++++++++++ .../SprtDiscoveryServiceTests.cs | 14 +++++++---- Inspectron.Epson/SprtDiscoveryService.cs | 13 ++++++++-- .../2026-09-03-pos895-integration-design.md | 14 ++++++++--- 4 files changed, 55 insertions(+), 10 deletions(-) diff --git a/Inspectron.Epson.Tests/CompositeDiscoveryServiceTests.cs b/Inspectron.Epson.Tests/CompositeDiscoveryServiceTests.cs index 775f546..4e3276d 100644 --- a/Inspectron.Epson.Tests/CompositeDiscoveryServiceTests.cs +++ b/Inspectron.Epson.Tests/CompositeDiscoveryServiceTests.cs @@ -190,4 +190,28 @@ public class CompositeDiscoveryServiceTests 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/SprtDiscoveryServiceTests.cs b/Inspectron.Epson.Tests/SprtDiscoveryServiceTests.cs index cc747e9..4ea5050 100644 --- a/Inspectron.Epson.Tests/SprtDiscoveryServiceTests.cs +++ b/Inspectron.Epson.Tests/SprtDiscoveryServiceTests.cs @@ -82,16 +82,20 @@ public class SprtDiscoveryServiceTests } [Fact] - public async Task DiscoverPrintersAsync_returns_empty_when_port_9100_is_taken() + public async Task DiscoverPrintersAsync_throws_when_it_cannot_bind_port_9100() { - // 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. + // 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(); - var result = await service.DiscoverPrintersAsync(TimeSpan.FromMilliseconds(200)); - Assert.Empty(result); + await Assert.ThrowsAsync( + () => service.DiscoverPrintersAsync(TimeSpan.FromMilliseconds(200))); } } diff --git a/Inspectron.Epson/SprtDiscoveryService.cs b/Inspectron.Epson/SprtDiscoveryService.cs index f4383d9..c76d593 100644 --- a/Inspectron.Epson/SprtDiscoveryService.cs +++ b/Inspectron.Epson/SprtDiscoveryService.cs @@ -18,6 +18,15 @@ namespace Inspectron.Epson; /// 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 { @@ -104,7 +113,7 @@ public class SprtDiscoveryService : IDiscoveryService { _logger?.LogWarning(ex, "SPRT discovery could not bind UDP {Port}; skipping this pass", ReplyPort); - return discovered; + throw; } var probe = BuildProbe(); @@ -117,7 +126,7 @@ public class SprtDiscoveryService : IDiscoveryService catch (SocketException ex) { _logger?.LogWarning(ex, "SPRT discovery broadcast failed"); - return discovered; + throw; } DateTime deadline = DateTime.UtcNow.Add(timeout.Value); diff --git a/docs/superpowers/specs/2026-09-03-pos895-integration-design.md b/docs/superpowers/specs/2026-09-03-pos895-integration-design.md index fe33964..80247cf 100644 --- a/docs/superpowers/specs/2026-09-03-pos895-integration-design.md +++ b/docs/superpowers/specs/2026-09-03-pos895-integration-design.md @@ -58,13 +58,21 @@ listens for the timeout, and maps each 39-byte reply to a `DiscoveredPrinter`: 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. On `SocketException` -the service logs a warning and returns an empty list, so ENPC discovery still -runs. Discovery degrades; it does not crash. +**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, and forwards each child's `onPrinterDiscovered` callback as it fires. +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 From b42d9808777952570594ebee727a2b6a910626c6 Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Thu, 3 Sep 2026 13:08:39 +0200 Subject: [PATCH 17/19] Fix UDP port 9100 test race; correct CompositeDiscoveryService design doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FIX 1 — UDP 9100 binding race: SprtDiscoveryServiceTests and CompositeDiscoveryServiceTests both use exclusive UDP 9100 binds. xunit runs test classes concurrently by default, causing the tests to fail intermittently with AddressAlreadyInUse. Add [Collection("udp-9100")] attribute to both classes so they run serially instead. FIX 2 — Design doc: CompositeDiscoveryService does NOT forward the callback down to children; it raises the caller's callback itself after all children finish, once per unique IP. Rewrite the relevant sentence to describe the real behavior. Verify: 30 tests pass (x2), build clean, no production code touched. Claude-Session: https://claude.ai/code/session_01E6N3P4nSZLtp4BC2LXVref --- Inspectron.Epson.Tests/CompositeDiscoveryServiceTests.cs | 1 + Inspectron.Epson.Tests/SprtDiscoveryServiceTests.cs | 1 + .../superpowers/specs/2026-09-03-pos895-integration-design.md | 4 +++- 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/Inspectron.Epson.Tests/CompositeDiscoveryServiceTests.cs b/Inspectron.Epson.Tests/CompositeDiscoveryServiceTests.cs index 4e3276d..548ae13 100644 --- a/Inspectron.Epson.Tests/CompositeDiscoveryServiceTests.cs +++ b/Inspectron.Epson.Tests/CompositeDiscoveryServiceTests.cs @@ -2,6 +2,7 @@ using Inspectron.Epson; namespace Inspectron.Epson.Tests; +[Collection("udp-9100")] public class CompositeDiscoveryServiceTests { private sealed class StubDiscovery : IDiscoveryService diff --git a/Inspectron.Epson.Tests/SprtDiscoveryServiceTests.cs b/Inspectron.Epson.Tests/SprtDiscoveryServiceTests.cs index 4ea5050..817aec2 100644 --- a/Inspectron.Epson.Tests/SprtDiscoveryServiceTests.cs +++ b/Inspectron.Epson.Tests/SprtDiscoveryServiceTests.cs @@ -3,6 +3,7 @@ using Inspectron.Epson; namespace Inspectron.Epson.Tests; +[Collection("udp-9100")] public class SprtDiscoveryServiceTests { // The exact 39 bytes the printer returned on the bench: diff --git a/docs/superpowers/specs/2026-09-03-pos895-integration-design.md b/docs/superpowers/specs/2026-09-03-pos895-integration-design.md index 80247cf..54e0853 100644 --- a/docs/superpowers/specs/2026-09-03-pos895-integration-design.md +++ b/docs/superpowers/specs/2026-09-03-pos895-integration-design.md @@ -67,7 +67,9 @@ this service. **`CompositeDiscoveryService`** - takes `IEnumerable`, runs them concurrently on the shared timeout, merges results by `IPAddress` with -first-wins, and forwards each child's `onPrinterDiscovered` callback as it fires. +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. From ac72e9ac4e156be52ebb2ec746771de75003f576 Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Thu, 3 Sep 2026 13:34:02 +0200 Subject: [PATCH 18/19] add a POS895 target to EpsonTest that prints via the IPrinter adapter EpsonTest predates the adapter layer: PrintCommandsToEpsonPrinterAsync takes an EpsonPrinter and drives it directly, so PrinterWrapperFactory, the model adapters and the 0x20 dispatch were never exercised. Against a POS895 that path feeds with ESC d, which the firmware ignores, so every cut slices through the last printed line, and it never sends a logo at all - the only logo branch is the HTML one, which uses GS ( L, the command that prints nothing on this hardware. PrintCommandsViaAdapterAsync instead does what EpsonPrintService does for a real job: query GS I 1, let PrinterWrapperFactory choose the adapter from the reported byte, then print the logo and commands through it and cut. It logs the reported id and the chosen adapter, so a wrong or missing model byte is visible rather than silent. Menu gains a third target and the non-interactive form accepts "pos895", so `dotnet run -- final pos895` prints a Final receipt with its logo through the production path. That covers the one part of the POS895 work nothing had executed end to end: the ESC * bit-image logo. Note EpsonTest must be run from its own directory; the logo paths are relative and `dotnet run --project` from the repo root cannot resolve them. That predates this change. Claude-Session: https://claude.ai/code/session_01E6N3P4nSZLtp4BC2LXVref --- EpsonTest/Program.cs | 87 ++++++++++++-- EpsonTest/TestHelpers.cs | 44 +++++++ EpsonTest/test.html | 173 +++++++++++++++++++++++++++ EpsonTest/test_images/image_0001.png | Bin 0 -> 2874 bytes 4 files changed, 294 insertions(+), 10 deletions(-) create mode 100644 EpsonTest/test.html create mode 100644 EpsonTest/test_images/image_0001.png 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 0000000000000000000000000000000000000000..ec4894788ab09a7a16f1420bc825c78f3ab30800 GIT binary patch literal 2874 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 Date: Thu, 3 Sep 2026 14:11:17 +0200 Subject: [PATCH 19/19] fix ESC * bit-image banding: match line spacing to the slice height The POS895 logo printed with a white line under every band. Root cause is in PrintImageBitModeAsync, not in the adapter: between slices it advances with a bare LF, which moves the paper by the CURRENT line spacing rather than by the slice height. ESC @ leaves that spacing at 1/6 inch, about 33.8 dots at 203 dpi, so each pass painted 24 dot rows and then advanced ~34, leaving a ~10 dot gap. The code assumed LF advances exactly one slice; nothing ever made that true. Confirmed on hardware before changing anything: printed two identical solid 576x96 blocks, one at default spacing and one after ESC 3 24. The first came out striped, the second solid. Fixed by setting the spacing to the slice height for the duration of the image and restoring the default in a finally. The restore matters as much as the fix - leaving it at 24 would compress every following line of text, turning a logo defect into a whole-receipt one. Fixed here rather than in POS895 because the assumption is wrong for any caller of this method. Blast radius is nil: PrintImageBitModeAsync has no other production consumer, since TM_T30IIITranslated uses the raster path and TM_U220IITranslated.PrintImageAsync is an empty stub. Three regression tests drive a real EpsonPrinter against a loopback TCP listener and assert on the wire bytes: ESC 3 before the first ESC *, ESC 2 after the last, and the spacing tracking the mode (ESC 3 8 for 8-dot) rather than a hardcoded 24. All three failed before the fix. Verified on the printer: the logo now prints solid. Claude-Session: https://claude.ai/code/session_01E6N3P4nSZLtp4BC2LXVref --- .../BitImagePrintingTests.cs | 137 ++++++++++++++++++ Inspectron.Epson/EpsonPrinter.cs | 47 ++++-- 2 files changed, 168 insertions(+), 16 deletions(-) create mode 100644 Inspectron.Epson.Tests/BitImagePrintingTests.cs 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/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"); }