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:
@@ -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.
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user