// 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, ): Promise { 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); }