feat(setup): print a real test slip from the printer "Test connection" modal

healthCheck only opens the transport (TCP connect / USB open) — it proves the
printer is REACHABLE, not that paper feeds and the head fires. Add a "Print test
slip" action so the admin can physically confirm a printer is live (the new
host-net USB /dev/usb/lpN path, or a network printer).

- server: POST /api/setup/test-print — printer-only, re-merges stored secrets like
  /test (so an edited network printer authenticates), creates the device, and pushes
  a short slip via the device-agnostic printReport(). Fail-soft: a print error
  (paper out, head fault, transport drop) is reported, never a 500. Mirrors the
  test-anpr pattern.
- web: testPrint() client + PrintTestResult; a button in the device modal shown for
  category=printer, with ok/fail rendering. i18n keys in sq + en (parity holds).

Server 168 tests pass; web + server typecheck clean.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-27 14:36:39 +02:00
parent 045892bc94
commit 3a60367232
5 changed files with 145 additions and 0 deletions
+65
View File
@@ -7,6 +7,7 @@ import {
isCamera, isCamera,
isDiscoverable, isDiscoverable,
isHardenable, isHardenable,
isPrinter,
registerBuiltinDrivers, registerBuiltinDrivers,
registry, registry,
setDeviceLogSink, 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<string, string | number | boolean | undefined> = { ...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<string, string | number | boolean>);
} 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 // 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 // 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. // on multi-NIC hosts). See net.ts / wiki/concepts/device-input-flow.md.
+48
View File
@@ -9,8 +9,10 @@ import {
fetchState, fetchState,
testAnpr, testAnpr,
testDevice, testDevice,
testPrint,
unassignDevice, unassignDevice,
type AnprTestResult, type AnprTestResult,
type PrintTestResult,
type Assignment, type Assignment,
type BackendIpCandidate, type BackendIpCandidate,
type ButtonLightSpec, type ButtonLightSpec,
@@ -339,6 +341,7 @@ function DeviceForm({
const pushesToBackend = selected != null && pushCapableIds.includes(selected.id); const pushesToBackend = selected != null && pushCapableIds.includes(selected.id);
const isController = category === "access"; const isController = category === "access";
const isCamera = category === "camera"; const isCamera = category === "camera";
const isPrinter = category === "printer";
// ANPR opt-in for a camera: when true, the VisionReader polls this camera for plates // 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. // (config.anpr). Off by default. See wiki/entities/opencv-anpr-service.md.
const [anpr, setAnpr] = useState<boolean>(editCfg?.anpr === true); const [anpr, setAnpr] = useState<boolean>(editCfg?.anpr === true);
@@ -380,6 +383,10 @@ function DeviceForm({
const [anprResult, setAnprResult] = useState<AnprTestResult | null>(null); const [anprResult, setAnprResult] = useState<AnprTestResult | null>(null);
const [anprTesting, setAnprTesting] = useState(false); const [anprTesting, setAnprTesting] = useState(false);
const [anprError, setAnprError] = useState<string | null>(null); const [anprError, setAnprError] = useState<string | null>(null);
const [printResult, setPrintResult] = useState<PrintTestResult | null>(null);
const [printTesting, setPrintTesting] = useState(false);
const [printError, setPrintError] = useState<string | null>(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null); const [saveError, setSaveError] = useState<string | null>(null);
const [found, setFound] = useState<DiscoveredDevice[] | null>(null); const [found, setFound] = useState<DiscoveredDevice[] | null>(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() { async function save() {
if (!selected) return; if (!selected) return;
// Bound devices must point at a controller relay (binding is optional in the // Bound devices must point at a controller relay (binding is optional in the
@@ -867,6 +891,30 @@ function DeviceForm({
</div> </div>
)} )}
{/* PRINTER: push a real test slip so the admin can confirm it physically
prints (healthCheck only opens the transport / USB node). */}
{isPrinter && (
<div className="mt-3 rounded-term border border-term-border bg-term-bg p-2">
<button type="button" className="btn btn-sm" onClick={testPrintNow} disabled={printTesting}>
{printTesting ? t("setup.printTesting") : t("setup.testPrint")}
</button>
<p className="hint mt-1">{t("setup.testPrintHint")}</p>
{printError && <p className="mt-2 text-[12px] text-term-red">{t("setup.testFailed", { error: printError })}</p>}
{printResult &&
(printResult.ok ? (
<div className="mt-2 text-[12px] text-term-green">
{t("setup.printOk", { ms: printResult.tookMs })}
</div>
) : (
<div className="mt-2 text-[12px] text-term-amber">
⚠ {t(`setup.printFail.${printResult.reason}`, { defaultValue: printResult.reason })}
{printResult.detail && <span className="text-term-muted"> — {printResult.detail}</span>}
</div>
))}
</div>
)}
{backendIps && backendIps.length > 0 && ( {backendIps && backendIps.length > 0 && (
<div className="mt-3"> <div className="mt-3">
<div className="field max-w-md"> <div className="field max-w-md">
+18
View File
@@ -350,6 +350,24 @@ export function testAnpr(driverId: string, config: DeviceConfig): Promise<AnprTe
}); });
} }
/** Result of a physical print test: a real test slip is pushed to the printer. */
export type PrintTestResult =
| { ok: true; tookMs: number }
| { ok: false; reason: string; detail?: string; tookMs?: number };
/** Print a real test slip on the printer — without saving. Confirms the printer
* actually feeds paper + fires the head (healthCheck only opens the transport). */
export function testPrint(
driverId: string,
config: DeviceConfig,
id?: string,
): Promise<PrintTestResult> {
return apiFetch<PrintTestResult>("/api/setup/test-print", {
method: "POST",
body: JSON.stringify({ driverId, config, id }),
});
}
// --- Admin reports ------------------------------------------------------- // --- Admin reports -------------------------------------------------------
export type ReportBucket = "hour" | "day" | "month"; export type ReportBucket = "hour" | "day" | "month";
+7
View File
@@ -406,6 +406,13 @@ export const en: Catalog = {
"anprFail.vision-disabled": "Vision service is disabled — enable it (VISION_ENABLED) to test ANPR.", "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.snapshot-failed": "Couldn't take a snapshot from the camera (offline or unreachable).",
"anprFail.no-plate": "No plate found in the snapshot.", "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)", alarmUrlTitle: "Alarm Server settings (enter these in the camera)",
alarmUrlHint: alarmUrlHint:
"Enter these in the camera at Configuration → Event → … → Alarm Settings (or Notify Surveillance Center). The camera POSTs every event here — no polling.", "Enter these in the camera at Configuration → Event → … → Alarm Settings (or Notify Surveillance Center). The camera POSTs every event here — no polling.",
+7
View File
@@ -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.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.snapshot-failed": "Nuk u mor dot pamje nga kamera (jashtë linje ose e paarritshme).",
"anprFail.no-plate": "Nuk u gjet asnjë targë në pamje.", "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. // Alarm Server push settings — generated for the camera's Event → Alarm Server form.
alarmUrlTitle: "Cilësimet e Alarm Server (vendosi te kamera)", alarmUrlTitle: "Cilësimet e Alarm Server (vendosi te kamera)",
alarmUrlHint: alarmUrlHint: