Files
parking_solution/packages/devices/src/printer-routing.ts
T
julian b2a0471b08 Rongta 80mm printer: driver, role-based failover, live status monitoring
Add the rongta PrinterDevice driver (ESC/POS over raw TCP 9100) and the
device-agnostic pieces around it:

- Roles + failover: each printer declares a role (entry-dispenser/booth-
  receipt) and failoverRank; printer-routing.ts picks the best healthy printer
  and falls back outside->booth for entry tickets (never the reverse).
- Live status: MonitorableDevice.readStatus()/PrinterStatus capability. The
  Rongta driver scrapes the device's own /prn_stat.htm (Cover/Cutter/Paper
  End/Near End/Off-Line) rather than hand-decoding DLE EOT, whose reply bytes
  on this clone don't match the canonical ESC/POS bit layout (verified on
  hardware) -- avoids a false-healthy. Maps to ready/degraded/offline, fail
  safe on an unreachable or unexpected page.
- Server PrinterMonitor polls enabled printers (PRINTER_POLL_MS, default 5s),
  caches latest, emits "printer-status" on change. Exposed via
  GET /api/printers/status and an SSE stream for the booth UI.

Verified against 10.0.10.6: ready when healthy, offline when unreachable
(no throw), bus emits on change and suppresses unchanged reads.

Wiki: new rongta-printer entity, printer-roles-failover and
printer-status-monitoring concepts; BOM/index/log updated.
2026-06-14 20:26:45 +02:00

92 lines
3.5 KiB
TypeScript

// Printer routing: pick which printer prints a given job across a lane's
// printers, with automatic failover. A lane has more than one printer — an
// entry dispenser outside (where the driver takes the ticket) and a booth
// printer inside (receipts, and a BACKUP for entry tickets if the dispenser is
// offline). See wiki/concepts/printer-roles-failover.md.
//
// This is pure selection logic over (config, health) — no device I/O — so the
// entry/exit flow can decide where to print without coupling to a transport.
import type { PrinterDevice } from "./interfaces.js";
import type { PrinterRole } from "./drivers/printer-rongta.js";
/** A configured printer instance + its live adapter, as the caller holds them. */
export interface PrinterInstance {
readonly id: string;
readonly role: PrinterRole;
/** Higher = preferred within a role. Ties broken by id for determinism. */
readonly failoverRank: number;
readonly device: PrinterDevice;
}
/**
* Order the candidate printers for a job targeting `wantRole`, best-first.
*
* Rule: printers of the wanted role come first (highest rank first); the booth
* printer is also a fallback for entry tickets, so when an entry ticket is
* routed, booth-receipt printers follow the entry dispensers. The reverse is
* deliberately NOT done — a receipt never prints on the outside dispenser.
*/
export function orderForRole(
printers: readonly PrinterInstance[],
wantRole: PrinterRole,
): PrinterInstance[] {
const fallbackRole: PrinterRole | null =
wantRole === "entry-dispenser" ? "booth-receipt" : null;
const rank = (p: PrinterInstance): number => {
if (p.role === wantRole) return 2;
if (p.role === fallbackRole) return 1;
return 0;
};
return printers
.filter((p) => rank(p) > 0)
.sort((a, b) => {
if (rank(a) !== rank(b)) return rank(b) - rank(a); // wanted role first
if (a.failoverRank !== b.failoverRank) return b.failoverRank - a.failoverRank;
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; // stable tiebreak
});
}
export class NoPrinterAvailableError extends Error {
constructor(public readonly attempts: { id: string; error: string }[]) {
super(
attempts.length === 0
? "no printer configured for this job"
: `all ${attempts.length} candidate printer(s) failed: ${attempts
.map((a) => `${a.id} (${a.error})`)
.join(", ")}`,
);
this.name = "NoPrinterAvailableError";
}
}
/**
* Print `job` on the best healthy printer for `wantRole`, failing over down the
* ordered list. Tries each candidate's print directly: a healthCheck race is
* pointless when the print itself is the real reachability test, so we just
* attempt the print and move on if it throws. Returns the id that succeeded.
*
* Throws {@link NoPrinterAvailableError} if every candidate fails — the caller
* (entry flow) decides what that means (e.g. raise the barrier without a paper
* ticket vs. hold). That policy is the flow's, not the printer's.
*/
export async function printWithFailover(
printers: readonly PrinterInstance[],
wantRole: PrinterRole,
job: (device: PrinterDevice) => Promise<void>,
): Promise<string> {
const ordered = orderForRole(printers, wantRole);
const attempts: { id: string; error: string }[] = [];
for (const p of ordered) {
try {
await job(p.device);
return p.id;
} catch (err) {
attempts.push({ id: p.id, error: (err as Error).message });
}
}
throw new NoPrinterAvailableError(attempts);
}