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:
@@ -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<boolean>(editCfg?.anpr === true);
|
||||
@@ -380,6 +383,10 @@ function DeviceForm({
|
||||
const [anprResult, setAnprResult] = useState<AnprTestResult | null>(null);
|
||||
const [anprTesting, setAnprTesting] = useState(false);
|
||||
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 [saveError, setSaveError] = useState<string | 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() {
|
||||
if (!selected) return;
|
||||
// Bound devices must point at a controller relay (binding is optional in the
|
||||
@@ -867,6 +891,30 @@ function DeviceForm({
|
||||
</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 && (
|
||||
<div className="mt-3">
|
||||
<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 -------------------------------------------------------
|
||||
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.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.",
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user