Files
Print_server/docs/superpowers/specs/2026-09-03-pos895-integration-design.md
EugeneTes b42d980877 Fix UDP port 9100 test race; correct CompositeDiscoveryService design doc
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
2026-09-03 13:08:39 +02:00

179 lines
8.3 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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. It does not pass the `onPrinterDiscovered` callback to its children;
instead, it raises the caller's callback itself after all children finish,
once per unique IP, so a printer answering two discovery protocols notifies only once.
It absorbs a single child's failure (logs it, keeps the other children's
results) but rethrows when every child fails, since that means discovery
itself is broken rather than that the network simply has no printers on it.
Discovery still degrades gracefully overall - it just does so at the composite
layer, which is the only layer that can see all children and make that call.
**Wiring** - `PrintServerBootstrapper.cs:77` changes from
`Bind<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`.