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 0000000..54ebd2c Binary files /dev/null and b/docs/captures/easyset.etl differ diff --git a/docs/captures/easyset.pcapng b/docs/captures/easyset.pcapng new file mode 100644 index 0000000..234e49c Binary files /dev/null and b/docs/captures/easyset.pcapng differ