Files
Print_server/docs/superpowers/specs/2026-09-03-pos895-integration-design.md
EugeneTes fd19cd9358 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
2026-09-03 13:01:58 +02:00

8.1 KiB
Raw Blame History

POS895 integration design

Date: 2026-09-03. Status: design complete, awaiting review.

Adds POS895 / POS896 support to the print service. Every decision here rests on hardware probes run against a real POS895 through an AnyDesk TCP tunnel; the raw findings live in POS895_POS896_integration_notes.md and the tooling in Pos895Test/.

Goal

A POS895 on the network is discovered automatically, reported to the backend so it can be assigned to a work area, and prints every existing receipt type correctly at 48 columns with a logo.

What the hardware told us

These four results shape the whole design.

Probe Result Consequence
GS I 1 0x20, no collision with 0x01/0x0D/0x13 Model identity keeps working as a byte. No resolver, no discovery-seeded cache.
SPRT discovery SPRT-Infor on UDP 3289 answers with MAC + serial Discovery is a new IDiscoveryService, not a TCP port sweep.
GS ( L prints nothing; ESC * and GS v 0 both work The adapter must not use LoadImageAsync.
ESC d n ignored The adapter must feed with LF.

The GS I 1 result is what keeps this small. Had it returned nothing or 0x01, the POS895 would have been indistinguishable from a TM-T30III by probe, and model identity would have had to flow from discovery instead - a much larger change to EpsonPrintService.

Components

1. Discovery

IDiscoveryService
├── DiscoveryService           ENPC / EPSONQ       unchanged
├── SprtDiscoveryService       SPRT-Infor          new
└── CompositeDiscoveryService  runs both, merges   new

SprtDiscoveryService - Inspectron.Epson/SprtDiscoveryService.cs. Promotes the probe in Pos895Test into the SDK. Broadcasts "SPRT-Infor" 00 00 0D to 255.255.255.255:3289 from UDP source port 9100, listens for the timeout, and maps each 39-byte reply to a DiscoveredPrinter:

Field Value
IPAddress reply's source IP
MACAddress payload bytes 0..16, dashed lowercase
Serial payload bytes 29..38, trimmed
ModelName "POS895"
Port 9100
ServiceType "SPRT"
RawResponse the 39 bytes

Replies that do not parse as a 17-character dashed MAC are logged and skipped, never thrown.

Binding UDP 9100 can fail when another process holds it, and the broadcast send can fail too. On SocketException the service logs a warning and rethrows - it does not swallow the fault into an empty list. A silent empty list is indistinguishable from "no SPRT printers on this network", and CompositeDiscoveryService is the layer that needs to tell those apart, not this service.

CompositeDiscoveryService - takes IEnumerable<IDiscoveryService>, 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<IDiscoveryService>().To<DiscoveryService>() 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.