e14e31a840
Closes the three known follow-ups of the Tills decision (venue-modules.md): - Activity log per till: `tillOfEvent(type, payload)` in @parking/shared (money events by payload till, other events by their owning module's till, everything else booth), applied by `/api/events?till=` in SQL and passed by the hub log, the Drawer "today" panel and the booth feed (history + live pushes). The events route admits a role that holds a module feed permission without event:read and returns only that module's event types — the live-socket rule. - Booth Z-report: `chargesByModuleMinor` sums the chargeLines on the till's payments by module; the ticket bucket excludes them (Bileta = parking only); printed "Lavazh (në biletë)" only when any was taken. The wash till's slip prints "Lavazh:". - Printer role `wash-desk`: the wash till's Z-report and vouchers print there, falling back to the booth printer; nothing falls back to the desk. `printerRoleOf()` is the one reading of the role field (the entry/booth loaders treated any non-booth role as an entry dispenser). Footer label "at wash desk". Also: `GET /api/carwash/settings` opens to carwash:read OR site:read (new requireAnyPermission) — the Wash operator job could not load the desk's category and service pickers. Tests for all four; wiki (shift, printer-roles-failover, venue-modules, log) updated. Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
337 lines
12 KiB
TypeScript
337 lines
12 KiB
TypeScript
import { request as httpRequest } from "node:http";
|
|
import type {
|
|
Device,
|
|
DeviceHealth,
|
|
MonitorableDevice,
|
|
PrinterDevice,
|
|
PrinterStatus,
|
|
PrintReport,
|
|
SubscriptionCardData,
|
|
TicketData,
|
|
WindowChargeNoticeData,
|
|
} from "../interfaces.js";
|
|
import type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.js";
|
|
import { hostField, portField, stubLog } from "./common.js";
|
|
import {
|
|
devicePathField,
|
|
probeTo,
|
|
renderReceipt,
|
|
renderReport,
|
|
renderSubscriptionCard,
|
|
renderTicket,
|
|
renderWindowChargeNotice,
|
|
sendTo,
|
|
transportField,
|
|
transportFromConfig,
|
|
type Transport,
|
|
} from "./printer-escpos.js";
|
|
|
|
// Rongta 80mm thermal printer driver (network OR USB). Rongta RP-series printers
|
|
// (and the many OEM clones that share their firmware) speak ESC/POS over a raw TCP
|
|
// socket on port 9100 — the JetDirect/RAW convention — or over a local USB usblp
|
|
// char device. The ESC/POS rendering + transport are shared with the other ESC/POS
|
|
// clones in ./printer-escpos.ts (config.transport picks the wire); what is unique to
|
|
// Rongta — and lives here — is LIVE STATUS via the board's own status web page. That
|
|
// page is a NETWORK feature: a USB Rongta degrades to reachability-only monitoring
|
|
// (see readStatus). There is no auth on the print socket; like the other field
|
|
// devices a networked unit lives on the isolated device VLAN.
|
|
// See wiki/entities/rongta-printer.md and wiki/concepts/network-isolation.md.
|
|
//
|
|
// ROLES + FAILOVER: a lane has more than one printer. Each instance declares a
|
|
// `role` (entry-dispenser at the lane / booth-receipt in the booth) and a
|
|
// `failoverRank`. The entry flow prints on the highest-rank healthy printer for
|
|
// the wanted role and falls back to the next — so if the outside dispenser is
|
|
// offline, the booth printer prints the entry ticket as a backup. The driver
|
|
// itself is role-agnostic; the role/rank live in config and the caller (server)
|
|
// owns the failover selection. See wiki/concepts/printer-roles-failover.md.
|
|
|
|
// --- live status via the device's own status web page -------------------------
|
|
// The Rongta board serves /prn_stat.htm, a small HTML table where the DEVICE has
|
|
// already decoded the ESC/POS status bits into labelled Yes/No rows. We scrape
|
|
// that rather than send raw `DLE EOT` ourselves: on this clone the DLE EOT reply
|
|
// bytes don't follow the canonical bit layout (verified on hardware), so trusting
|
|
// the device's own decode is the safe choice. See printer-status-monitoring.md.
|
|
// A clone that does NOT serve this page (e.g. the Cashino) uses its own driver
|
|
// with a plain reachability probe — it must not pretend to report paper/cover.
|
|
|
|
/** The fault flags the status page reports (a subset of PrinterStatus). */
|
|
type StatusFlag =
|
|
| "coverOpen"
|
|
| "cutterError"
|
|
| "paperEnd"
|
|
| "paperNearEnd"
|
|
| "offline";
|
|
type StatusFlags = Partial<Record<StatusFlag, boolean>>;
|
|
|
|
/** Label text on the status page (NBSP/space-normalised, lowercased) → our key. */
|
|
const STATUS_FIELDS: Record<string, StatusFlag> = {
|
|
"cover is open": "coverOpen",
|
|
"cutter error": "cutterError",
|
|
"paper end": "paperEnd",
|
|
"paper near end": "paperNearEnd",
|
|
"printer off-line": "offline",
|
|
};
|
|
|
|
/** GET the status page over HTTP and return the raw HTML. */
|
|
function fetchStatusPage(
|
|
host: string,
|
|
httpPort: number,
|
|
timeoutMs: number,
|
|
): Promise<string> {
|
|
return new Promise((resolve, reject) => {
|
|
const req = httpRequest(
|
|
{
|
|
host,
|
|
port: httpPort,
|
|
path: "/prn_stat.htm",
|
|
method: "GET",
|
|
timeout: timeoutMs,
|
|
},
|
|
(res) => {
|
|
let data = "";
|
|
res.on("data", (c) => (data += c));
|
|
res.on("end", () =>
|
|
res.statusCode === 200
|
|
? resolve(data)
|
|
: reject(new Error(`status page HTTP ${res.statusCode}`)),
|
|
);
|
|
},
|
|
);
|
|
req.on("error", reject);
|
|
req.on("timeout", () => req.destroy(new Error("status page timeout")));
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Parse /prn_stat.htm into boolean flags. Each fault is a `<TD>label</TD>
|
|
* <TD>Yes|No</TD>` pair. Returns only the recognised fields; a missing field is
|
|
* left undefined so the caller can detect an unexpected page (fail safe, not a
|
|
* false "ok").
|
|
*/
|
|
function parseStatusPage(html: string): StatusFlags {
|
|
const out: StatusFlags = {};
|
|
const rowRe = /<TD[^>]*>([^<]*?)<\/TD>\s*<TD[^>]*>([^<]*?)<\/TD>/gi;
|
|
let m: RegExpExecArray | null;
|
|
while ((m = rowRe.exec(html))) {
|
|
if (m[1] === undefined || m[2] === undefined) continue;
|
|
const label = m[1]
|
|
.replace(/ /gi, " ")
|
|
.replace(/\s+/g, " ")
|
|
.trim()
|
|
.toLowerCase();
|
|
const value = m[2]
|
|
.replace(/ /gi, " ")
|
|
.trim()
|
|
.toLowerCase();
|
|
const key = STATUS_FIELDS[label];
|
|
if (key && (value === "yes" || value === "no")) {
|
|
out[key] = value === "yes";
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
class RongtaPrinter implements PrinterDevice, MonitorableDevice {
|
|
readonly driverId = "rongta";
|
|
readonly #transport: Transport;
|
|
readonly #host: string;
|
|
readonly #httpPort: number;
|
|
readonly #timeout: number;
|
|
|
|
constructor(config: DeviceConfig) {
|
|
this.#transport = transportFromConfig(config);
|
|
// Kept for the HTTP status page (TCP only); empty on a USB printer.
|
|
this.#host = config.host ? String(config.host) : "";
|
|
this.#httpPort = config.httpPort ? Number(config.httpPort) : 80;
|
|
this.#timeout = config.timeoutMs ? Number(config.timeoutMs) : 3000;
|
|
}
|
|
|
|
async connect(): Promise<void> {
|
|
await this.healthCheck();
|
|
}
|
|
|
|
async disconnect(): Promise<void> {
|
|
stubLog(this.driverId, "disconnect");
|
|
}
|
|
|
|
async healthCheck(): Promise<DeviceHealth> {
|
|
try {
|
|
await probeTo(this.#transport, this.#timeout);
|
|
return { status: "ready" };
|
|
} catch (err) {
|
|
return { status: "offline", detail: (err as Error).message };
|
|
}
|
|
}
|
|
|
|
async printTicket(data: TicketData): Promise<void> {
|
|
await sendTo(this.#transport, renderTicket(data), this.#timeout);
|
|
stubLog(this.driverId, `printed ticket ${data.ticketId}`);
|
|
}
|
|
|
|
async printReport(report: PrintReport): Promise<void> {
|
|
await sendTo(this.#transport, renderReport(report), this.#timeout);
|
|
stubLog(
|
|
this.driverId,
|
|
`printed report "${report.title}" (${report.lines.length} lines)`,
|
|
);
|
|
}
|
|
|
|
async printSubscriptionCard(data: SubscriptionCardData): Promise<void> {
|
|
await sendTo(this.#transport, renderSubscriptionCard(data), this.#timeout);
|
|
stubLog(this.driverId, `printed subscription card ${data.code}`);
|
|
}
|
|
|
|
async printReceipt(data: import("../interfaces.js").ReceiptData): Promise<void> {
|
|
await sendTo(this.#transport, renderReceipt(data), this.#timeout);
|
|
stubLog(
|
|
this.driverId,
|
|
`printed ${data.voucher ? "voucher" : "receipt"} ${data.ticketId}`,
|
|
);
|
|
}
|
|
|
|
async printWindowChargeNotice(data: WindowChargeNoticeData): Promise<void> {
|
|
await sendTo(this.#transport, renderWindowChargeNotice(data), this.#timeout);
|
|
stubLog(this.driverId, `printed out-of-window slip ${data.occurrenceId}`);
|
|
}
|
|
|
|
/**
|
|
* Live operator-actionable status, scraped from the device's own status page.
|
|
* The board decodes the ESC/POS status bits itself, so we trust its Yes/No
|
|
* over hand-decoding this clone's non-standard DLE EOT reply.
|
|
*
|
|
* - status page unreachable → offline (the same signal as a dead printer),
|
|
* - page reachable but a recognised field missing → degraded (don't claim
|
|
* "ready" off a page we didn't fully understand — fail safe),
|
|
* - any fault flag true → degraded,
|
|
* - otherwise → ready.
|
|
*/
|
|
async readStatus(): Promise<PrinterStatus> {
|
|
const checkedAt = new Date().toISOString();
|
|
// The status page is an HTTP feature of the network board; a USB printer has no
|
|
// such page. Degrade to the reachability floor (open the char device) and report
|
|
// ready/offline only — never a guessed paper/cover state, same honesty rule as
|
|
// the Cashino. (A USB Rongta is effectively a Cashino for monitoring purposes.)
|
|
if (this.#transport.kind === "usb") {
|
|
try {
|
|
await probeTo(this.#transport, this.#timeout);
|
|
return { status: "ready", checkedAt };
|
|
} catch (err) {
|
|
return { status: "offline", detail: (err as Error).message, checkedAt };
|
|
}
|
|
}
|
|
let html: string;
|
|
try {
|
|
html = await fetchStatusPage(this.#host, this.#httpPort, this.#timeout);
|
|
} catch (err) {
|
|
return { status: "offline", detail: (err as Error).message, checkedAt };
|
|
}
|
|
|
|
const flags = parseStatusPage(html);
|
|
const expected: StatusFlag[] = [
|
|
"coverOpen",
|
|
"cutterError",
|
|
"paperEnd",
|
|
"paperNearEnd",
|
|
"offline",
|
|
];
|
|
const missing = expected.filter((k) => flags[k] === undefined);
|
|
if (missing.length > 0) {
|
|
return {
|
|
status: "degraded",
|
|
detail: `unexpected status page (missing: ${missing.join(", ")})`,
|
|
checkedAt,
|
|
};
|
|
}
|
|
|
|
const faults = expected.filter((k) => flags[k] === true);
|
|
const labels: Record<StatusFlag, string> = {
|
|
paperEnd: "paper out",
|
|
coverOpen: "cover open",
|
|
cutterError: "cutter error",
|
|
offline: "printer off-line",
|
|
paperNearEnd: "paper low",
|
|
};
|
|
return {
|
|
status: faults.length > 0 ? "degraded" : "ready",
|
|
...flags,
|
|
detail:
|
|
faults.length > 0 ? faults.map((f) => labels[f]).join(", ") : undefined,
|
|
checkedAt,
|
|
};
|
|
}
|
|
}
|
|
|
|
/** Where a printer sits: at the lane (entry tickets), in the booth (receipts, reports,
|
|
* the backup for entry tickets) or at the wash desk (the Car Wash till's slips). */
|
|
export type PrinterRole = "entry-dispenser" | "booth-receipt" | "wash-desk";
|
|
|
|
const roleField: ConfigField = {
|
|
key: "role",
|
|
label: "Role",
|
|
type: "select",
|
|
required: true,
|
|
default: "entry-dispenser",
|
|
options: [
|
|
{
|
|
value: "entry-dispenser",
|
|
label: "Entry dispenser (outside / at the lane)",
|
|
},
|
|
{ value: "booth-receipt", label: "Booth printer (receipts + backup)" },
|
|
{ value: "wash-desk", label: "Wash desk printer (Car Wash till slips)" },
|
|
],
|
|
help: "Entry tickets print on the entry dispenser, falling back to the booth printer if it is offline. The wash desk's Z-reports and vouchers print on the wash desk printer, falling back to the booth printer.",
|
|
};
|
|
|
|
const rankField: ConfigField = {
|
|
key: "failoverRank",
|
|
label: "Failover rank",
|
|
type: "number",
|
|
required: false,
|
|
default: 0,
|
|
help: "Higher = tried first within the same role. The booth printer also backs up the entry dispenser.",
|
|
};
|
|
|
|
export const rongtaDriver: PrinterDriver = {
|
|
id: "rongta",
|
|
category: "printer",
|
|
label: "Rongta 80mm thermal printer",
|
|
description:
|
|
"Rongta RP-series 80mm thermal printer (and ESC/POS-compatible clones that serve the /prn_stat.htm status page) over raw TCP (port 9100), OR local USB /dev/usb/lp0. The decoded status page is a network feature — a USB Rongta is monitored by reachability only. No auth on the print socket — isolate the VLAN.",
|
|
transports: ["tcp-ip", "usb"],
|
|
configFields: [
|
|
transportField,
|
|
devicePathField,
|
|
// host/port/status-page are TCP-only; not required for a USB printer.
|
|
{ ...hostField, required: false, help: `${hostField.help} Leave blank for a USB printer.` },
|
|
{
|
|
...portField(9100),
|
|
required: false,
|
|
help: "Raw print socket (ESC/POS over JetDirect/RAW, default 9100). TCP only.",
|
|
},
|
|
{
|
|
key: "httpPort",
|
|
label: "Status web port",
|
|
type: "port",
|
|
required: false,
|
|
default: 80,
|
|
help: "Device status page (/prn_stat.htm) port for live monitoring (default 80). TCP only.",
|
|
},
|
|
roleField,
|
|
rankField,
|
|
{
|
|
key: "timeoutMs",
|
|
label: "Timeout (ms)",
|
|
type: "number",
|
|
required: false,
|
|
default: 3000,
|
|
},
|
|
],
|
|
create: (c) => new RongtaPrinter(c),
|
|
};
|
|
|
|
/** Type guard exposed for callers that need to read a device's printer role. */
|
|
export function isPrinter(device: Device): device is PrinterDevice {
|
|
return typeof (device as Partial<PrinterDevice>).printTicket === "function";
|
|
}
|