diff --git a/apps/server/src/routes/setup.ts b/apps/server/src/routes/setup.ts index f132554..07a7f62 100644 --- a/apps/server/src/routes/setup.ts +++ b/apps/server/src/routes/setup.ts @@ -7,6 +7,7 @@ import { isCamera, isDiscoverable, isHardenable, + isPrinter, registerBuiltinDrivers, registry, setDeviceLogSink, @@ -391,6 +392,70 @@ export async function setupRoutes( }, ); + // Print a TEST SLIP on a printer config WITHOUT saving. healthCheck only opens the + // transport (TCP connect / USB open) — it proves reachability, NOT that paper feeds + // and the head fires. This pushes a real short slip through the device-agnostic + // printReport(), so the admin can physically confirm the printer is live (the USB + // /dev/usb/lpN path or the network printer). Fail-soft like test-anpr: a print error + // is reported, never a 500. Mirrors /test's stored-secret re-merge so an edited + // network printer still authenticates. + app.post<{ Body: TestBody }>( + "/api/setup/test-print", + { preHandler: adminGuard }, + async (req, reply) => { + const { driverId, config, id } = req.body; + const driver = registry.get(driverId); + if (!driver) return reply.code(400).send({ error: `unknown driver: ${driverId}` }); + if (driver.category !== "printer") { + return reply.code(400).send({ error: `driver ${driverId} is not a printer` }); + } + + const merged: Record = { ...config }; + if (id) { + for (const [k, v] of Object.entries(storedSecrets(db, id, driverId, config))) { + const sent = merged[k]; + if (sent === undefined || sent === "" || sent === 0) merged[k] = v as string | number; + } + } + + let device; + try { + device = registry.create(driverId, merged as Record); + } catch (err) { + return reply.code(400).send({ error: (err as Error).message }); + } + if (!isPrinter(device)) { + return reply.code(400).send({ error: `driver ${driverId} cannot print` }); + } + + const startedAt = Date.now(); + try { + await device.printReport({ + title: "TEST PRINT", + lines: [ + "Parking System", + "Printer test slip", + new Date().toLocaleString("sv"), // YYYY-MM-DD HH:MM:SS, locale-stable + "", + "If you can read this, the", + "printer is connected and", + "printing correctly.", + ], + }); + } catch (err) { + // The failure we're testing for (paper out, head fault, transport drop) — + // report it, don't 500. + return reply.send({ + ok: false, + reason: "print-failed", + detail: (err as Error).message, + tookMs: Date.now() - startedAt, + }); + } + return reply.send({ ok: true, tookMs: Date.now() - startedAt }); + }, + ); + // Candidate backend IPs the device can push to, for a given device host. The // wizard pre-fills with the on-subnet one and lets the admin override (matters // on multi-NIC hosts). See net.ts / wiki/concepts/device-input-flow.md. diff --git a/apps/web/src/SetupWizard.tsx b/apps/web/src/SetupWizard.tsx index f7ddd53..4249a57 100644 --- a/apps/web/src/SetupWizard.tsx +++ b/apps/web/src/SetupWizard.tsx @@ -9,8 +9,10 @@ import { fetchState, testAnpr, testDevice, + testPrint, unassignDevice, type AnprTestResult, + type PrintTestResult, type Assignment, type BackendIpCandidate, type ButtonLightSpec, @@ -339,6 +341,7 @@ function DeviceForm({ const pushesToBackend = selected != null && pushCapableIds.includes(selected.id); const isController = category === "access"; const isCamera = category === "camera"; + const isPrinter = category === "printer"; // ANPR opt-in for a camera: when true, the VisionReader polls this camera for plates // (config.anpr). Off by default. See wiki/entities/opencv-anpr-service.md. const [anpr, setAnpr] = useState(editCfg?.anpr === true); @@ -380,6 +383,10 @@ function DeviceForm({ const [anprResult, setAnprResult] = useState(null); const [anprTesting, setAnprTesting] = useState(false); const [anprError, setAnprError] = useState(null); + + const [printResult, setPrintResult] = useState(null); + const [printTesting, setPrintTesting] = useState(false); + const [printError, setPrintError] = useState(null); const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(null); const [found, setFound] = useState(null); @@ -531,6 +538,23 @@ function DeviceForm({ } } + // Push a real test slip to the printer — proves it physically prints (healthCheck + // only opens the transport). Passes editing?.id so an edited network printer's + // stored secrets re-merge. Never blocks save. + async function testPrintNow() { + if (!selected) return; + setPrintTesting(true); + setPrintError(null); + setPrintResult(null); + try { + setPrintResult(await testPrint(selected.id, mergedScalarConfig(), editing?.id)); + } catch (e) { + setPrintError((e as Error).message); + } finally { + setPrintTesting(false); + } + } + async function save() { if (!selected) return; // Bound devices must point at a controller relay (binding is optional in the @@ -867,6 +891,30 @@ function DeviceForm({ )} + {/* PRINTER: push a real test slip so the admin can confirm it physically + prints (healthCheck only opens the transport / USB node). */} + {isPrinter && ( +
+ +

{t("setup.testPrintHint")}

+ + {printError &&

{t("setup.testFailed", { error: printError })}

} + {printResult && + (printResult.ok ? ( +
+ {t("setup.printOk", { ms: printResult.tookMs })} +
+ ) : ( +
+ ⚠ {t(`setup.printFail.${printResult.reason}`, { defaultValue: printResult.reason })} + {printResult.detail && — {printResult.detail}} +
+ ))} +
+ )} + {backendIps && backendIps.length > 0 && (
diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 85af531..09f6acf 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -350,6 +350,24 @@ export function testAnpr(driverId: string, config: DeviceConfig): Promise { + return apiFetch("/api/setup/test-print", { + method: "POST", + body: JSON.stringify({ driverId, config, id }), + }); +} + // --- Admin reports ------------------------------------------------------- export type ReportBucket = "hour" | "day" | "month"; diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts index f2a626a..e8bbf29 100644 --- a/apps/web/src/lib/i18n/en.ts +++ b/apps/web/src/lib/i18n/en.ts @@ -406,6 +406,13 @@ export const en: Catalog = { "anprFail.vision-disabled": "Vision service is disabled — enable it (VISION_ENABLED) to test ANPR.", "anprFail.snapshot-failed": "Couldn't take a snapshot from the camera (offline or unreachable).", "anprFail.no-plate": "No plate found in the snapshot.", + // Printer test slip — pushes a real slip so the admin can confirm it physically prints. + testPrint: "Print test slip", + printTesting: "Printing…", + testPrintHint: + "Sends a test slip to the printer now. ‘Connected’ only opens the link — this confirms the printer actually feeds paper.", + printOk: "✓ Test slip sent ({{ms}} ms). Check the printer.", + "printFail.print-failed": "The printer rejected the job (out of paper, cover open, or the link dropped).", alarmUrlTitle: "Alarm Server settings (enter these in the camera)", alarmUrlHint: "Enter these in the camera at Configuration → Event → … → Alarm Settings (or Notify Surveillance Center). The camera POSTs every event here — no polling.", diff --git a/apps/web/src/lib/i18n/sq.ts b/apps/web/src/lib/i18n/sq.ts index 09fe6f7..36fd6e2 100644 --- a/apps/web/src/lib/i18n/sq.ts +++ b/apps/web/src/lib/i18n/sq.ts @@ -416,6 +416,13 @@ export const sq = { "anprFail.vision-disabled": "Shërbimi i vizionit është çaktivizuar — aktivizoje (VISION_ENABLED) për ta testuar ANPR.", "anprFail.snapshot-failed": "Nuk u mor dot pamje nga kamera (jashtë linje ose e paarritshme).", "anprFail.no-plate": "Nuk u gjet asnjë targë në pamje.", + // Printer test slip — pushes a real slip so the admin can confirm it physically prints. + testPrint: "Printo provë", + printTesting: "Duke printuar…", + testPrintHint: + "Dërgon një fletë prove te printeri tani. ‘I lidhur’ vetëm hap lidhjen — kjo konfirmon se printeri vërtet nxjerr letër.", + printOk: "✓ Fleta e provës u dërgua ({{ms}} ms). Kontrollo printerin.", + "printFail.print-failed": "Printeri nuk pranoi punën (pa letër, kapaku hapur, ose lidhja ra).", // Alarm Server push settings — generated for the camera's Event → Alarm Server form. alarmUrlTitle: "Cilësimet e Alarm Server (vendosi te kamera)", alarmUrlHint: