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">
|
||||
|
||||
Reference in New Issue
Block a user