Printing Debug

This commit is contained in:
2026-09-07 11:18:13 +10:00
parent 8bc71792e3
commit eb986581e4
5 changed files with 123 additions and 25 deletions
+8
View File
@@ -212,6 +212,14 @@ it is laid out along the length and turned before printing, which is what you wa
hangs from its short edge. Set it per site and check the bitmap preview — the two look very
different and only one will suit how you hang them.
**Label stock** at the top of the badge settings is a shortcut that fills in the size, the roll
type and the colour option. It is not itself a saved setting — the three fields it fills are what
get stored, which is why it can appear to "revert" when the same dimensions describe two rolls.
**Diagnostics.** The site card has a **Diagnostics** button showing exactly what would be sent,
including the `brother_ql` command line, so it can be run by hand on the host. A failed test print
shows the printer's own words rather than a summary.
**If the printer cannot be reached**, sign in still completes. The kiosk falls back to its own
browser print dialog, and the failure is shown against the site in **Admin → Sites** with the
reason. Admins can reprint any badge from the **On site** list.
+15
View File
@@ -400,3 +400,18 @@ tr.row-bad td { background: #fdf0f2; }
vertical-align: -2px;
margin-right: 2px;
}
pre.raw {
margin: 0 0 14px;
padding: 12px;
background: var(--paper);
border: 1px solid var(--rule);
border-radius: 3px;
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 12.5px;
line-height: 1.45;
white-space: pre-wrap;
word-break: break-word;
max-height: 320px;
overflow: auto;
}
+55 -12
View File
@@ -689,6 +689,7 @@ async function loadSites() {
<button class="ghost" data-preview-badge="${s.id}">Preview badge</button>
<button class="ghost" data-bitmap="${s.id}">Bitmap preview</button>
${s.printer.enabled ? `<button class="ghost" data-test-print="${s.id}">Test print</button>` : ''}
${s.printer.enabled ? `<button class="ghost" data-print-diag="${s.id}">Diagnostics</button>` : ''}
</div>
</div>
<dl class="site-meta">
@@ -740,6 +741,17 @@ async function loadSites() {
window.open(`/admin/api/sites/${btn.dataset.bitmap}/badge-bitmap`, '_blank')
)
);
$$('[data-print-diag]').forEach((btn) =>
btn.addEventListener('click', async () => {
const d = await api(`/sites/${btn.dataset.printDiag}/printer-diagnostics`);
openModal(
'Printer diagnostics',
`<pre class="raw">${esc(JSON.stringify(d, null, 2))}</pre>`,
null,
{ saveLabel: 'Close', hideCancel: true }
);
})
);
$$('[data-test-print]').forEach((btn) =>
btn.addEventListener('click', async () => {
btn.disabled = true;
@@ -748,7 +760,17 @@ async function loadSites() {
await api(`/sites/${btn.dataset.testPrint}/test-print`, { method: 'POST' });
toast('Sent to the printer.');
} catch (err) {
toast(err.message, true);
// Show the printer's own words as well as the summary, because a refusal
// usually names the reason and the summary cannot cover every case.
const raw = err.payload?.raw;
openModal(
'The printer refused the job',
`<p>${esc(err.message)}</p>
${err.payload?.command ? `<p class="hint">Command:</p><pre class="raw">${esc(err.payload.command)}</pre>` : ''}
${raw ? `<p class="hint">What the printer and brother_ql said:</p><pre class="raw">${esc(raw)}</pre>` : ''}`,
null,
{ saveLabel: 'Close', hideCancel: true }
);
} finally {
btn.disabled = false;
btn.textContent = 'Test print';
@@ -764,16 +786,29 @@ async function loadSites() {
* 62 mm is for a different printer.
*/
const LABEL_PRESETS = [
{ id: 'dk22205-90', label: 'Brother DK-22205 continuous, cut at 90 mm', w: 62, h: 90, photo: true },
{ id: 'dk11202', label: 'Brother DK-11202 die-cut 62 × 100 mm', w: 62, h: 100, photo: true },
{ id: 'dk22251-90', label: 'Brother DK-22251 black/red continuous, cut at 90 mm', w: 62, h: 90, photo: true, accent: true },
{ id: 'dk11208', label: 'Brother DK-11208 die-cut 38 × 90 mm', w: 38, h: 90, photo: false },
{ id: 'dk11209', label: 'Brother DK-11209 die-cut 29 × 62 mm', w: 29, h: 62, photo: false },
{ id: 'dk11201', label: 'Brother DK-11201 die-cut 29 × 90 mm', w: 29, h: 90, photo: false },
{ id: 'card', label: 'Card size 86 × 54 mm (not a QL-820NWB size)', w: 86, h: 54, photo: true },
{ id: 'dymo99014', label: 'Dymo 99014 101 × 54 mm', w: 101, h: 54, photo: true },
{ id: 'dk22205-90', label: 'Brother DK-22205 continuous, cut at 90 mm', w: 62, h: 90, photo: true, roll: '62' },
{ id: 'dk11202', label: 'Brother DK-11202 die-cut 62 × 100 mm', w: 62, h: 100, photo: true, roll: '62' },
{ id: 'dk22251-90', label: 'Brother DK-22251 black/red continuous, cut at 90 mm', w: 62, h: 90, photo: true, roll: '62red', accent: true },
{ id: 'dk11208', label: 'Brother DK-11208 die-cut 38 × 90 mm', w: 38, h: 90, photo: false, roll: '62' },
{ id: 'dk11209', label: 'Brother DK-11209 die-cut 29 × 62 mm', w: 29, h: 62, photo: false, roll: '62' },
{ id: 'dk11201', label: 'Brother DK-11201 die-cut 29 × 90 mm', w: 29, h: 90, photo: false, roll: '62' },
{ id: 'card', label: 'Card size 86 × 54 mm (not a QL-820NWB size)', w: 86, h: 54, photo: true, roll: '62' },
{ id: 'dymo99014', label: 'Dymo 99014 101 × 54 mm', w: 101, h: 54, photo: true, roll: '62' },
];
/**
* Which preset the saved settings correspond to. Dimensions alone are ambiguous —
* DK-22205 and DK-22251 are both 62 x 90 — so the roll type decides between them.
*/
function matchingPreset(site) {
return LABEL_PRESETS.find(
(p) =>
Number(site.badge.widthMm) === p.w &&
Number(site.badge.heightMm) === p.h &&
p.roll === (site.printer.label === '62red' ? '62red' : '62')
);
}
function openSiteModal(site) {
openModal(
`Edit ${site.name}`,
@@ -787,11 +822,11 @@ function openSiteModal(site) {
<option value="">Custom size</option>
${LABEL_PRESETS.map(
(p) =>
`<option value="${p.id}" ${
Number(site.badge.widthMm) === p.w && Number(site.badge.heightMm) === p.h ? 'selected' : ''
}>${esc(p.label)}</option>`
`<option value="${p.id}" ${matchingPreset(site)?.id === p.id ? 'selected' : ''}>${esc(p.label)}</option>`
).join('')}
</select></label>
<p class="hint">A shortcut that fills in the boxes below. The size, the roll type and the
colour option are what actually get saved.</p>
<div class="modal-row">
${field('Width (mm)', 'widthMm', site.badge.widthMm, 'number')}
${field('Height (mm)', 'heightMm', site.badge.heightMm, 'number')}
@@ -1083,6 +1118,14 @@ function wireBadgePreset() {
height.value = preset.h;
$('#badge-photo').checked = preset.photo;
$('#badge-accent').checked = Boolean(preset.accent);
// The preset knows which roll it describes, so keep the two in step. Picking
// the DK-22251 preset while the roll stayed on "black only" is what makes the
// printer refuse the job.
const roll = $('#modal-form [name="printerLabel"]');
if (roll && preset.roll) {
roll.value = preset.roll;
roll.dispatchEvent(new Event('change'));
}
check();
});
+9 -7
View File
@@ -336,24 +336,26 @@ export async function printBadge(visit, site) {
const port = Number(site.printer_port) || 9100;
const target = `tcp://${site.printer_host}:${port}`;
try {
await runBrotherQl(
[
const args = [
'--backend', 'network',
'--model', site.printer_model || 'QL-820NWB',
'--printer', target,
'print',
'--label', labelFor(site),
file,
],
config.printing.timeoutMs
);
];
try {
await runBrotherQl(args, config.printing.timeoutMs);
note(site.id, true, `Printed to ${site.printer_host}`);
return { ok: true, target };
} catch (err) {
const friendly = explainPrintError(err.message, site.printer_host);
note(site.id, false, friendly);
throw new Error(friendly);
const wrapped = new Error(friendly);
wrapped.raw = err.message;
wrapped.command = `${config.printing.command} ${args.join(' ')}`;
throw wrapped;
} finally {
fs.rm(file, { force: true }, () => {});
}
+31 -1
View File
@@ -510,10 +510,40 @@ router.post('/sites/:id/test-print', async (req, res) => {
const result = await printer.printBadge(printer.sampleVisit(site), site);
res.json({ ok: true, ...result });
} catch (err) {
res.status(400).json({ error: err.message });
// The raw output and the exact command matter when the friendly message is
// not enough — a printer refusing a job says why in its own words.
res.status(400).json({ error: err.message, raw: err.raw || null, command: err.command || null });
}
});
/** Everything about how this site would print, for working out why it will not. */
router.get('/sites/:id/printer-diagnostics', (req, res) => {
const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id);
if (!site) return res.status(404).json({ error: 'Not found.' });
res.json({
site: site.name,
printing: {
enabled: Boolean(site.printer_enabled),
host: site.printer_host,
port: site.printer_port,
model: site.printer_model,
rotate: site.printer_rotate,
},
rollSetting: site.printer_label,
labelSentToPrinter: printer.labelFor(site),
redRequested: Boolean(site.badge_accent),
redWillPrint: printer.accentWillPrintRed(site),
badgeMm: { width: site.badge_width_mm, height: site.badge_height_mm },
lastResult: printer.printerStatus(site.id),
command: [
'brother_ql --backend network',
`--model ${site.printer_model || 'QL-820NWB'}`,
`--printer tcp://${site.printer_host}:${site.printer_port || 9100}`,
`print --label ${printer.labelFor(site)} badge.png`,
].join(' '),
});
});
/** Reprints a real visitor's badge on the server's printer. */
router.post('/visits/:id/print', async (req, res) => {
const visit = db.prepare('SELECT * FROM visits WHERE id = ?').get(req.params.id);