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
179 lines
8.3 KiB
Markdown
179 lines
8.3 KiB
Markdown
# 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`.
|