Print Bridge — Bidirectional Printer Status
Overview
Print Bridge now reports real printer telemetry (online/offline, paper, cover, cash drawer, identity) from TCP printers over ESC/POS.
USB printers: status polling is disabled in this release (Phase 2, pending libusb integration). The bridge reports statusSource: "disabled" for USB printers and continues printing normally.
Architecture
PrinterStatusManager StatusBus
DLE EOT poll (per 5s) ──► DiagnosticsStore ──► SSE (browser)
GS I identity (once) ──► Socket.IO ──► backend printer:status event
ESC p drawer kick
PrinterStatusManagerpolls each TCP printer every 5 s with DLE EOT commands. Identity is queried once per connection lifecycle viaGS I.StatusBusfans out the results to three sinks: DiagnosticsStore (triggers SSE), Socket.IOprinter:statusevent to the FlowPOS backend.- Fresh-socket-per-poll: each poll opens its own TCP connection to avoid conflicting with print job sockets. Most receipt printers only allow one connection at a time on port 9100; the poll naturally yields to in-progress print jobs (which fail the poll gracefully, keeping the last known status).
Status sources
| Value | Meaning |
|---|---|
telemetry | Real data from hardware (DLE EOT response) |
inferred | No telemetry — status inferred from write-path success/failure |
disabled | Polling disabled (USB printer or statusPollingEnabled: false) |
unknown | Agent just started, no data yet |
Socket.IO event: printer:status
Emitted on the /restaurant namespace by each PrinterAgent to the FlowPOS backend.
Event name
printer:status
Payload schema
{
printerId: string; // UUID — bridge-side printer instance
deviceId: string | null; // Bridge device UUID (from pairing)
businessId: string;
locationId: string;
stationIds: string[]; // Kitchen station IDs served by this printer
statusSource: "telemetry" | "inferred" | "disabled" | "unknown";
// Present when statusSource === "telemetry"
online?: boolean;
paperLoaded?: boolean; // false → out of paper
paperLow?: boolean; // true → near-end sensor triggered
coverOpened?: boolean;
// Present once after first successful connection (null until fetched)
identity?: {
manufacturer: string | null;
model: string | null;
serialNumber: string | null; // Redacted: only last 4 chars
firmwareVersion: string | null;
} | null;
}
Security notes
deviceTokenis never included.serialNumberis redacted to its last 4 characters before emission (e.g."****1234").- Strings from the printer are sanitized (non-printable chars stripped, max 128 chars) before storage or emission.
REST endpoints
GET /api/status
Returns the full DiagnosticsSnapshot including new telemetry fields per printer:
{
statusSource: "telemetry" | "inferred" | "disabled" | "unknown";
paperLow: boolean | null;
coverOpened: boolean | null;
printerIdentity: {
manufacturer: string | null;
model: string | null;
serialNumber: string | null;
firmwareVersion: string | null;
} | null;
// ... existing fields unchanged
}
POST /api/printers/:id/drawer/open
Opens the cash drawer for a TCP printer.
- Auth: Bearer token required (same as all other authenticated endpoints).
- Rate limit: 1 kickout per second per printer (HTTP 429 on excess).
- Conditions: Returns 409 if the printer is not a TCP printer or has no URL.
POST /api/printers/<printerId>/drawer/open
Authorization: Bearer <token>
→ 200 { "ok": true }
→ 404 { "error": "Printer not found" }
→ 409 { "error": "Cash drawer control not available for this printer" }
→ 429 { "error": "Too many requests" }
GET /api/devices/usb
Now includes usbDeviceClass on each device entry:
[
{
path: string; // e.g. "/dev/ttyUSB0"
stable: boolean;
usbDeviceClass: "printer" | "serial" | "unknown";
}
]
SSE events
The /events stream sends type: "status" on every DiagnosticsStore change. The payload now includes all new telemetry fields. No new event type was added — existing SSE consumers will receive the extended state transparently.
Per-printer configuration
| Config field | Type | Default | Description |
|---|---|---|---|
statusPollingEnabled | boolean | true | Set false to disable status polling for a misbehaving printer |
statusPollIntervalMs | number | 5000 | Poll interval in ms (range: 1000–60000) |
usbDeviceClass | "printer" | "serial" | "unknown" | auto-detected | USB device class; set at pair time |
Graceful degradation
| Condition | Outcome |
|---|---|
| 5 consecutive poll failures | statusSource: "disabled", polling stops |
statusPollingEnabled: false | statusSource: "disabled", no polling started |
connectionType: "usb" | statusSource: "disabled" (Phase 2 pending) |
| Single poll failure | printerStatus: "offline", retries next interval |
| Identity query fails | printerIdentity: null, printing unaffected |
| Drawer kickout on USB | HTTP 409 (not supported) |
Backend integration (out of scope for this release)
The backend does not yet consume printer:status. This page documents the payload contract so the backend listener can be implemented separately. A listener on the /restaurant Socket.IO namespace should:
- Validate
businessId+locationIdfrom the payload against the authenticated device session. - Persist or forward status to any interested subscribers (e.g. FlowPOS dashboard).
- Optionally emit a
printer:status:ackback to the bridge (currently ignored).