b2a0471b08
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.
92 lines
3.5 KiB
TypeScript
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);
|
|
}
|