Author SHA1 Message Date
jessikitty c3ce46913d Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA 2026-09-03 14:47:59 +10:00
jessikitty e6731dbdfa Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA 2026-09-03 14:43:03 +10:00
jessikitty 8c97c314e0 Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA 2026-09-02 16:45:48 +10:00
jessikitty ff9f51eaf9 Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA 2026-09-02 16:21:42 +10:00
jessikitty 74792b2764 Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA 2026-09-02 16:07:52 +10:00
jessikitty c21123885d Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA 2026-09-01 16:08:27 +10:00
jessikitty cccba98d97 Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA 2026-09-01 15:45:47 +10:00
jessikitty a7564b9033 Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA 2026-09-01 15:24:03 +10:00
jessikitty 9da7e88eb3 Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA 2026-09-01 15:14:46 +10:00
jessikitty a011587d66 Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA 2026-08-31 15:16:09 +10:00
jessikitty 14678e13e8 Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA 2026-08-31 14:54:17 +10:00
jessikitty b23ad422d0 Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA 2026-08-31 14:33:30 +10:00
jessikitty ed77493817 Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA 2026-08-31 09:47:36 +10:00
25 changed files with 55 additions and 2576 deletions
-8
View File
@@ -76,13 +76,5 @@ GOOGLE_CREDENTIALS_PATH=/secrets/google-service-account.json
GOOGLE_CREDENTIALS_B64= GOOGLE_CREDENTIALS_B64=
SHEETS_RETRY_INTERVAL_MS=60000 SHEETS_RETRY_INTERVAL_MS=60000
# ------------------------------------------------------------- printing
# Badges are rendered and printed by the server, so kiosk tablets need no driver.
# The printer's address is set per site in Admin -> Sites, not here.
# PRINT_COMMAND=brother_ql
PRINT_TIMEOUT_MS=15000
# How long a sign in waits for the badge before falling back to the kiosk browser.
PRINT_SIGNIN_WAIT_MS=9000
# -------------------------------------------------------------- storage # -------------------------------------------------------------- storage
DATA_DIR=/data DATA_DIR=/data
+1 -5
View File
@@ -11,11 +11,7 @@ RUN npm install --omit=dev
FROM node:22-bookworm-slim FROM node:22-bookworm-slim
ENV NODE_ENV=production ENV NODE_ENV=production
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends \ && apt-get install -y --no-install-recommends openssl ca-certificates tini util-linux \
openssl ca-certificates tini util-linux \
# Fonts for the server-rendered badge, and brother_ql to drive the label printer.
fonts-liberation python3 python3-pip \
&& pip3 install --break-system-packages --no-cache-dir "brother_ql==0.9.4" \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
+11 -53
View File
@@ -179,45 +179,19 @@ the widest roll it will accept — the console warns you if you enter anything w
| DK-11208 | 38 × 90 mm die-cut | Narrower; turn the photo off | | DK-11208 | 38 × 90 mm die-cut | Narrower; turn the photo off |
| DK-11209 | 29 × 62 mm die-cut | Name and host only | | DK-11209 | 29 × 62 mm die-cut | Name and host only |
**Tell it which roll is loaded.** *Roll loaded in the printer* under **Sites → Edit → Printer**
must match what is physically in the machine. A two-colour job sent to a plain roll is refused
outright: the printer shows **Wrong Roll Type** and nothing comes out. The setting is separate
from the red styling option on purpose — a design choice should not silently change the media
type the printer is told to expect.
**The red option.** Tick *Print the heading and the no-check warning in red* and the site name **The red option.** Tick *Print the heading and the no-check warning in red* and the site name
and the **No WWCC / VIT** box print red instead of black, which makes a visitor without a check and the **No WWCC / VIT** box print red instead of black, which makes a visitor without a check
obvious across a room. It needs the roll setting above to be the DK-22251; with a plain roll obvious across a room. It only works on a DK-22251 roll — on any other roll the printer renders
selected the badge is drawn in black instead and the console says so. Two-colour printing is also far slower than black alone (Brother rate it at roughly it as grey. Two-colour printing is also far slower than black alone (Brother rate it at roughly
15 labels a minute against 110), which is irrelevant for one badge at a time but worth knowing. 15 labels a minute against 110), which is irrelevant for one badge at a time but worth knowing.
### Printing from the server **Driver setup on the kiosk.** Set the QL-820NWB as the default printer for the browser running
the kiosk, choose the matching paper size in the driver, and set margins to none and scaling to
100%. Then use **Preview badge** in the console and print one before committing a roll — the
preview renders at the exact millimetre size the printer will receive.
Set the printer's IP address under **Sites → Edit → Printer** and tick *Print from the server*. The QL-820NWB has Ethernet and Wi-Fi, so it does not need to hang off the kiosk tablet. Install
The server then renders the badge itself and pushes it to the printer over the network, so: it as a network printer on whichever device drives the kiosk browser.
- a kiosk tablet needs no printer driver, no default printer and no print dialog
- adding a second kiosk means plugging in a tablet, nothing else
- the badge prints automatically the moment someone completes their sign in
The QL-820NWB has Ethernet and Wi-Fi, so it lives on the network rather than tethered to a
tablet. Give it a **fixed IP** — a DHCP lease change would silently stop badges printing.
Rendering happens at 300 dpi and 696 dots across, which is the printer's fixed head width on a
62 mm roll. **Bitmap preview** on the site card shows the exact image that will be sent, and
**Test print** sends a sample badge. Use both before committing a roll.
**Rotation.** At 0° the badge is laid out across the 62 mm width and runs down the label. At 90°
it is laid out along the length and turned before printing, which is what you want when the label
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.
**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.
Leave *Print from the server* off and the kiosk prints through the browser as before: set the
QL-820NWB as the browser's default printer, margins to none, scaling 100%.
## WWCC and VIT expiry warnings ## WWCC and VIT expiry warnings
@@ -277,25 +251,9 @@ HOST_PORT=8443
HTTPS_PUBLIC_PORT=8443 HTTPS_PUBLIC_PORT=8443
``` ```
An address that isn't listed produces a browser warning. An address that isn't listed produces a browser warning. Change the list and restart; the
certificate reissues itself automatically, and devices that already trust the authority accept
After changing anything in `.env`, bring the container back with **`docker compose up -d`**, not it without any further work.
`docker compose restart`. Restart reuses the running container along with the environment it
started with, so the edit appears to do nothing; `up -d` recreates it and picks the new values
up. Confirm with:
```bash
docker compose exec visitor-signin printenv HTTPS_HOSTNAMES
docker compose logs --tail=20 visitor-signin | grep tls
```
The log should say `renewing the server certificate: HTTPS_HOSTNAMES changed` and then list every
name it now covers. If it lists only `localhost`, `visitors.local` and a `172.x` address, the
variable never reached the container — those are the defaults plus the container's own docker
bridge address.
Reissuing does **not** touch the certificate authority, so devices that already trust it keep
working and no MDM profile needs redeploying.
The kiosk is then at `https://visitors.local:8443`, admin at `https://visitors.local:8443/admin`. The kiosk is then at `https://visitors.local:8443`, admin at `https://visitors.local:8443/admin`.
-140
View File
@@ -1,140 +0,0 @@
# Badge printing
The app renders the badge and sends it to the Brother QL-820NWB over TCP port
9100, speaking Brother's raster command language directly. No CUPS, no driver,
no print dialog, and no Python.
## Why not AirPrint
AirPrint from the kiosk browser means iPadOS renders the badge from print CSS
and picks the page geometry from what the printer advertises. That costs exact
control of the 60.96 mm print width, makes two-colour DK-22251 output
impossible, and cannot suppress the print dialog — so a staff member has to tap
"Print" for every visitor. Fine for a staffed desk, useless for unattended
sign-in.
Doing it server-side also means the kiosk device stops mattering. An iPad, an
old Android tablet, a browser on a NUC: they all just POST.
## Layout
```
src/printing/
ql-raster.js command language: media, cut, expanded mode, bit packing
ql-status.js decoder for the 32-byte status frames
ql-transport.js TCP client for port 9100
ql-print.js printPng / printerHealth, plus the serial queue
src/printer.js badge layout and rendering (unchanged by this work)
```
Protocol details follow Brother's *Raster Command Reference, QL-800/810W/820NWB
v1.01*. Where that manual and pklaus/brother_ql disagree, the manual wins;
brother_ql targets older models and several of its defaults are wrong for this
hardware (200-byte invalidate instead of 400, the print-quality bit set during
two-colour jobs, the media-length valid bit asserted for continuous tape).
## The roll setting is not cosmetic
`sites.printer_label` must match the roll physically in the machine:
| Roll | Setting |
|---|---|
| DK-22205, 62 mm white | `62` |
| DK-22251, 62 mm black/red | `62red` |
This is enforced by the printer, not by us. A monochrome job sent to a
black/red roll is refused outright, and vice versa. It applies even when the
badge has no red on it — with `62red` set, every job is built as two-colour and
the red plane is simply empty.
The printer's own error message is misleading here. It says "change it to
Monochrome media", which points at a driver setting that doesn't exist in this
setup. The fix is the roll dropdown in the admin console, or swapping the roll.
## Status, and why there isn't much
Manual section 5.9: over a network connection the print data is sent as-is and
nothing comes back. Status frames only arrive over USB. brother_ql documents the
same limitation for its network backend.
So `printerHealth` returns a tri-state `ready`:
- `true` — the printer answered and everything is fine
- `false` — it answered and something is wrong
- `null` — reachable, but it won't say
`null` is the normal answer here. Show it as amber/unknown in the admin console;
colouring it green claims something we don't know. Likewise `printPng` returns
`confirmed: false` when no status came back: the bytes were delivered, but
whether paper moved is unknown.
Consequence worth accepting: a jammed or empty printer cannot be detected. The
practical signal is a visitor saying no badge appeared. That is tolerable
because the evacuation record is written before printing is attempted. If real
roll monitoring is ever needed, the lead to follow is SNMP — the Brother Status
Monitor on Windows gets roll state over the network somehow, and it is not
using port 9100.
## Concurrency
The QL accepts one TCP connection at a time and has no job spooler worth the
name. Two visitors signing in together would otherwise produce a refused
connection, half a label, or both. Every job goes through a serial queue keyed
on printer host, in `ql-print.js`.
That queue is in-process. Running more than one app instance against one printer
would need a lock in SQLite instead.
## Geometry
| | |
|---|---|
| Print head | 720 pins, 60.96 mm at 300 dpi |
| 62 mm roll printable | 696 pins, 58.9 mm |
| Margins | 12 pins each side |
| Feed margin | 35 dots (3 mm), the documented minimum |
| Length | 150 to 11811 raster lines (12.7 mm to 1 m) |
Every raster line sent is the full 90 bytes regardless of the roll. Narrower
media just means more pins miss the paper.
## Diagnostics
Reachability and roll state:
```bash
docker compose exec visitor-signin node -e "
import('./src/printing/ql-print.js').then(async m =>
console.log(await m.printerHealth({ host: '10.142.177.169', label: '62red' })));
"
```
If a label never appears, work down this list:
**Nothing at all, `Cannot reach printer`.** Routing or firewall between the
container and the printer. Try `nc -vz IP 9100` from the Ubuntu host; if that
works but the container doesn't, it is Docker networking.
**Connects but silent.** Normal. See above.
**Media mismatch error.** The roll setting and the physical roll disagree.
**Labels come out blank.** Roll in backwards, or a non-Brother roll with no end
sensor. The printer reports no error for this.
**Everything mirrored.** The bit packing is wrong. The head clocks each raster
line out right-to-left; see `packRow`.
**First label after a power cycle is misaligned.** Feed one label from the front
panel before the first sign-in of the day.
## Known rough edge
Photos are not dithered. `drawBadge` draws them straight and the plane
conversion thresholds at luminance 180. A thermal head has no grey, so faces
come out blotchy. The fix is FloydSteinberg on the photo region before it goes
on the canvas, inside `drawBadge`.
---
Created by: Jess Rogerson (yelling commands at Claude.AI)
-1
View File
@@ -13,7 +13,6 @@
"node": ">=20" "node": ">=20"
}, },
"dependencies": { "dependencies": {
"@napi-rs/canvas": "^1.0.8",
"better-sqlite3": "^11.5.0", "better-sqlite3": "^11.5.0",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"express": "^4.21.1", "express": "^4.21.1",
+3 -122
View File
@@ -184,7 +184,6 @@ async function loadOnsite() {
<td class="mono">${stamp(v.signedInAt)}</td> <td class="mono">${stamp(v.signedInAt)}</td>
<td class="actions"> <td class="actions">
<button class="ghost" data-badge="${v.id}">Badge</button> <button class="ghost" data-badge="${v.id}">Badge</button>
<button class="ghost" data-print="${v.id}">Print</button>
<button class="ghost" data-signout="${v.id}">Sign out</button> <button class="ghost" data-signout="${v.id}">Sign out</button>
</td> </td>
</tr>` </tr>`
@@ -202,19 +201,6 @@ async function loadOnsite() {
$$('[data-badge]').forEach((btn) => $$('[data-badge]').forEach((btn) =>
btn.addEventListener('click', () => window.open(`/admin/api/badge/${btn.dataset.badge}`, '_blank')) btn.addEventListener('click', () => window.open(`/admin/api/badge/${btn.dataset.badge}`, '_blank'))
); );
$$('[data-print]').forEach((btn) =>
btn.addEventListener('click', async () => {
btn.disabled = true;
try {
await api(`/visits/${btn.dataset.print}/print`, { method: 'POST' });
toast('Sent to the printer.');
} catch (err) {
toast(err.message, true);
} finally {
btn.disabled = false;
}
})
);
} }
$('#refresh-onsite').addEventListener('click', () => loadOnsite()); $('#refresh-onsite').addEventListener('click', () => loadOnsite());
@@ -687,8 +673,6 @@ async function loadSites() {
<div class="actions"> <div class="actions">
<button class="ghost" data-edit-site="${s.id}">Edit</button> <button class="ghost" data-edit-site="${s.id}">Edit</button>
<button class="ghost" data-preview-badge="${s.id}">Preview badge</button> <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>` : ''}
</div> </div>
</div> </div>
<dl class="site-meta"> <dl class="site-meta">
@@ -699,20 +683,6 @@ async function loadSites() {
: 'Off' : 'Off'
}</dd> }</dd>
${s.badge.note ? `<dt>Badge note</dt><dd>${esc(s.badge.note)}</dd>` : ''} ${s.badge.note ? `<dt>Badge note</dt><dd>${esc(s.badge.note)}</dd>` : ''}
<dt>Printer</dt>
<dd>${
s.printer.enabled && s.printer.host
? `${esc(s.printer.model)} at ${esc(s.printer.host)}:${s.printer.port}, ${
s.printer.label === '62red' ? 'black and red roll' : 'black roll'
}${s.printer.rotate ? `, rotated ${s.printer.rotate}°` : ''}${
s.printerStatus
? s.printerStatus.ok
? ` <span class="pill">last print ok, ${stamp(s.printerStatus.at)}</span>`
: ` <span class="pill bad">${esc(s.printerStatus.message)}</span>`
: ''
}`
: 'Printed by the kiosk browser'
}</dd>
<dt>Kiosk branding</dt> <dt>Kiosk branding</dt>
<dd> <dd>
${s.branding.hasBanner ? `Banner set, ${s.branding.bannerAlign === 'center' ? 'centred' : 'left'}` : 'No banner'} · ${s.branding.hasBanner ? `Banner set, ${s.branding.bannerAlign === 'center' ? 'centred' : 'left'}` : 'No banner'} ·
@@ -735,27 +705,6 @@ async function loadSites() {
window.open(`/admin/api/sites/${btn.dataset.previewBadge}/badge-preview`, '_blank') window.open(`/admin/api/sites/${btn.dataset.previewBadge}/badge-preview`, '_blank')
) )
); );
$$('[data-bitmap]').forEach((btn) =>
btn.addEventListener('click', () =>
window.open(`/admin/api/sites/${btn.dataset.bitmap}/badge-bitmap`, '_blank')
)
);
$$('[data-test-print]').forEach((btn) =>
btn.addEventListener('click', async () => {
btn.disabled = true;
btn.textContent = 'Printing…';
try {
await api(`/sites/${btn.dataset.testPrint}/test-print`, { method: 'POST' });
toast('Sent to the printer.');
} catch (err) {
toast(err.message, true);
} finally {
btn.disabled = false;
btn.textContent = 'Test print';
loadSites();
}
})
);
} }
/** /**
@@ -799,38 +748,8 @@ function openSiteModal(site) {
<p class="hint" id="badge-warning" hidden></p> <p class="hint" id="badge-warning" hidden></p>
<label class="inline"><input type="checkbox" name="showPhoto" id="badge-photo" ${site.badge.showPhoto ? 'checked' : ''}> Include the visitor's photo</label> <label class="inline"><input type="checkbox" name="showPhoto" id="badge-photo" ${site.badge.showPhoto ? 'checked' : ''}> Include the visitor's photo</label>
<label class="inline"><input type="checkbox" name="accent" id="badge-accent" ${site.badge.accent ? 'checked' : ''}> Print the heading and the no-check warning in red</label> <label class="inline"><input type="checkbox" name="accent" id="badge-accent" ${site.badge.accent ? 'checked' : ''}> Print the heading and the no-check warning in red</label>
<p class="hint" id="accent-note"></p> <p class="hint">Red needs a two-colour roll such as the Brother DK-22251. On any other
<p class="hint">Two-colour printing is much slower than black alone.</p> roll it prints as grey. Two-colour printing is also much slower than black alone.</p>
<h4 class="modal-section">Printer</h4>
<label class="inline"><input type="checkbox" name="printerEnabled" ${site.printer.enabled ? 'checked' : ''}> Print from the server, straight to a network printer</label>
<div class="modal-row">
${field('Printer IP address', 'printerHost', site.printer.host, 'text')}
${field('Port', 'printerPort', site.printer.port, 'number')}
</div>
<label class="modal-field"><span>Roll loaded in the printer</span>
<select name="printerLabel">
<option value="62" ${site.printer.label !== '62red' ? 'selected' : ''}>62 mm continuous, black only</option>
<option value="62red" ${site.printer.label === '62red' ? 'selected' : ''}>62 mm continuous, black and red (DK-22251)</option>
</select></label>
<p class="hint">This must match the roll actually in the machine. Send a two-colour job to a
plain roll and the printer answers <em>Wrong Roll Type</em> and prints nothing.</p>
<div class="modal-row">
${field('Model', 'printerModel', site.printer.model)}
<label class="modal-field"><span>Rotation</span>
<select name="printerRotate">
${[0, 90, 180, 270]
.map(
(deg) =>
`<option value="${deg}" ${Number(site.printer.rotate) === deg ? 'selected' : ''}>${deg}°</option>`
)
.join('')}
</select></label>
</div>
<p class="hint">With this on, the kiosk does not print at all — the server sends the badge
to the printer over the network, so a tablet needs no driver and no default printer. At 90°
the badge is laid out along the length of the label and turned, which reads correctly when
the label hangs from its short edge. Check it with <strong>Bitmap preview</strong> before
using a roll.</p>
${field('Line printed at the bottom', 'note', site.badge.note)} ${field('Line printed at the bottom', 'note', site.badge.note)}
<h4 class="modal-section">Kiosk branding</h4> <h4 class="modal-section">Kiosk branding</h4>
<div class="banner-editor"> <div class="banner-editor">
@@ -884,14 +803,6 @@ function openSiteModal(site) {
accent: form.has('accent'), accent: form.has('accent'),
note: data.note, note: data.note,
}, },
printer: {
enabled: form.has('printerEnabled'),
host: data.printerHost,
port: Number(data.printerPort) || 9100,
model: data.printerModel,
rotate: Number(data.printerRotate) || 0,
label: data.printerLabel,
},
branding: { branding: {
brand: data.brand || null, brand: data.brand || null,
signout: data.signout || null, signout: data.signout || null,
@@ -1000,35 +911,6 @@ function wireBannerEditor() {
[pageInput, textInput].forEach((el) => el.addEventListener('input', showContrast)); [pageInput, textInput].forEach((el) => el.addEventListener('input', showContrast));
showContrast(); showContrast();
// Red is only possible on the two-colour roll, so say so as the two settings change.
const accentBox = $('#badge-accent');
const rollSelect = $('#modal-form [name="printerLabel"]');
const accentNote = $('#accent-note');
const showAccentNote = () => {
if (!accentNote) return;
if (!accentBox?.checked) {
accentNote.hidden = false;
accentNote.className = 'hint';
accentNote.textContent = 'Everything prints black.';
return;
}
if (rollSelect?.value === '62red') {
accentNote.hidden = false;
accentNote.className = 'hint';
accentNote.textContent = 'The heading and the no-check warning will print red.';
} else {
accentNote.hidden = false;
accentNote.className = 'hint warn';
accentNote.textContent =
'The roll selected below cannot print red, so these will come out black. Load a DK-22251 and change the roll setting to use colour.';
}
};
accentBox?.addEventListener('change', showAccentNote);
rollSelect?.addEventListener('change', showAccentNote);
showAccentNote();
$('#banner-file').addEventListener('change', (event) => { $('#banner-file').addEventListener('change', (event) => {
const file = event.target.files[0]; const file = event.target.files[0];
if (!file) return; if (!file) return;
@@ -1391,8 +1273,7 @@ function renderTls(tls) {
itself before it lapses, and devices that trust the authority keep working without being itself before it lapses, and devices that trust the authority keep working without being
touched again.</p> touched again.</p>
<div class="sys-actions"> <div class="sys-actions">
<a class="ghost" href="/admin/api/tls/ca.crt" download>CA certificate (.crt)</a> <a class="ghost" href="/admin/api/tls/ca.crt" download>Download the CA certificate</a>
<a class="ghost" href="/admin/api/tls/ca.cer" download>CA certificate (.cer, for Jamf and Apple)</a>
<button class="ghost owner-only" id="renew-cert">Renew the server certificate</button> <button class="ghost owner-only" id="renew-cert">Renew the server certificate</button>
<button class="ghost danger owner-only" id="new-ca">Start a new authority</button> <button class="ghost danger owner-only" id="new-ca">Start a new authority</button>
</div>`; </div>`;
+1 -4
View File
@@ -382,12 +382,9 @@ async function submitSignIn() {
}); });
stopCamera(); stopCamera();
$('#done-in-message').textContent = `You're all set, ${result.firstName}.`; $('#done-in-message').textContent = `You're all set, ${result.firstName}.`;
const printing = result.serverPrinted || result.badgeUrl; $('#done-in-detail').textContent = result.badgeUrl
$('#done-in-detail').textContent = printing
? `${result.hostName} has been recorded as your host. Your badge is printing — please wear it, and sign out when you leave.` ? `${result.hostName} has been recorded as your host. Your badge is printing — please wear it, and sign out when you leave.`
: `${result.hostName} has been recorded as your host. Please sign out when you leave.`; : `${result.hostName} has been recorded as your host. Please sign out when you leave.`;
// With server printing the badge is already coming out of the label printer,
// so the kiosk neither prints nor offers to.
lastBadgeUrl = result.badgeUrl; lastBadgeUrl = result.badgeUrl;
$('#reprint-badge').hidden = !result.badgeUrl; $('#reprint-badge').hidden = !result.badgeUrl;
if (result.badgeUrl) printBadge(result.badgeUrl); if (result.badgeUrl) printBadge(result.badgeUrl);
+20 -90
View File
@@ -1,112 +1,42 @@
# Pushes this folder to the Gitea repo. # Pushes this folder into the Gitea repo created for it.
# # Run from PowerShell, inside the visitor-signin folder:
# Run from PowerShell inside the visitor-signin folder:
# .\push-to-gitea.ps1 # .\push-to-gitea.ps1
# If Windows blocks it: # If Windows blocks it: powershell -ExecutionPolicy Bypass -File .\push-to-gitea.ps1
# powershell -ExecutionPolicy Bypass -File .\push-to-gitea.ps1
$ErrorActionPreference = 'Stop'
$Remote = 'https://gitea.hideawaygaming.com.au/jessikitty/visitor-signin.git' $Remote = 'https://gitea.hideawaygaming.com.au/jessikitty/visitor-signin.git'
# git reports failure through its exit code, not as a PowerShell error, so every if (-not (Test-Path 'package.json')) {
# call has to be checked. Without this the script reports success after a Write-Error 'Run this from inside the visitor-signin folder.'
# rejected push, which is exactly what it used to do.
function Invoke-Git {
param([Parameter(ValueFromRemainingArguments = $true)][string[]]$Arguments)
& git @Arguments
return $LASTEXITCODE
}
function Fail($message) {
Write-Host ''
Write-Host $message -ForegroundColor Red
exit 1 exit 1
} }
if (-not (Test-Path 'package.json')) { Fail 'Run this from inside the visitor-signin folder.' }
if (-not (Get-Command git -ErrorAction SilentlyContinue)) { if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
Fail 'Git is not installed or not on PATH. Get it from https://git-scm.com/download/win' Write-Error 'Git is not installed or not on PATH. Install it from https://git-scm.com/download/win'
exit 1
} }
# Keeps line endings sane between Windows and the Ubuntu docker host. # Keep line endings sane between Windows and the Ubuntu docker host.
git config --global core.autocrlf input | Out-Null git config --global core.autocrlf input | Out-Null
if (-not (Test-Path '.git')) { if (-not (Test-Path '.git')) {
Write-Host 'Setting up a new local repository...' git init -b main
if ((Invoke-Git init -b main) -ne 0) { Fail 'git init failed.' } } else {
Write-Host 'This folder is already a git repo, adding a commit to it.'
} }
if ((git remote) -match '^origin$') { git add .
$message = 'Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA'
git commit -m $message
if (git remote | Select-String -Quiet '^origin$') {
git remote set-url origin $Remote git remote set-url origin $Remote
} else { } else {
git remote add origin $Remote git remote add origin $Remote
} }
# ------------------------------------------------- finish what was started git push -u origin main
# A rebase or merge left half-done blocks everything that follows, and the error
# git gives is easy to mistake for a push problem. Catch it here and say plainly
# what to do.
$gitDir = (git rev-parse --git-dir 2>$null)
if ($gitDir) {
$stuck = @('rebase-merge', 'rebase-apply', 'MERGE_HEAD', 'CHERRY_PICK_HEAD') |
Where-Object { Test-Path (Join-Path $gitDir $_) }
if ($stuck) {
Write-Host ''
Write-Host 'There is an unfinished rebase or merge in this folder.' -ForegroundColor Red
Write-Host 'Nothing else can happen until it is settled. Your options:' -ForegroundColor Yellow
Write-Host ''
Write-Host ' git rebase --abort throw the attempt away and go back to how things were'
Write-Host ' git status see which files still need attention'
Write-Host ' git rebase --continue after fixing the files git listed'
Write-Host ''
Write-Host 'If you are unsure, "git rebase --abort" is the safe one. It puts the' -ForegroundColor Yellow
Write-Host 'folder back exactly as it was before the rebase started.' -ForegroundColor Yellow
exit 1
}
}
# ---------------------------------------------------------------- commit
git add -A
$pending = git status --porcelain
if ($pending) {
$message = Read-Host 'Describe this change (press enter for a dated default)'
if (-not $message) { $message = "Update $(Get-Date -Format 'yyyy-MM-dd HH:mm')" }
if ((Invoke-Git commit -m $message) -ne 0) { Fail 'git commit failed.' }
Write-Host 'Committed.' -ForegroundColor Green
} else {
Write-Host 'No file changes to commit. Checking for anything unpushed...' -ForegroundColor Yellow
}
# ------------------------------------------------------- catch up, then push
Write-Host 'Checking what is on the server...'
if ((Invoke-Git fetch origin) -ne 0) {
Fail 'Could not reach Gitea. Check the network and your sign in details.'
}
if (git ls-remote --heads origin main) {
$behind = (git rev-list --count HEAD..origin/main 2>$null)
if ($behind -and [int]$behind -gt 0) {
Write-Host "The server has $behind commit(s) this folder does not. Replaying your work on top..."
if ((Invoke-Git pull --rebase origin main) -ne 0) {
Write-Host ''
Write-Host 'The two histories could not be joined automatically.' -ForegroundColor Red
Write-Host ''
Write-Host 'See what is on the server that you do not have:' -ForegroundColor Yellow
Write-Host ' git log --oneline HEAD..origin/main'
Write-Host ''
Write-Host 'If that is nothing you need, and this folder is the good copy:' -ForegroundColor Yellow
Write-Host ' git push --force-with-lease origin main'
exit 1
}
}
}
if ((Invoke-Git push -u origin main) -ne 0) {
Fail 'The push was rejected. Read the message above. Nothing was sent.'
}
Write-Host '' Write-Host ''
Write-Host 'Pushed successfully.' -ForegroundColor Green Write-Host 'Pushed. Repo: https://gitea.hideawaygaming.com.au/jessikitty/visitor-signin' -ForegroundColor Green
Write-Host 'https://gitea.hideawaygaming.com.au/jessikitty/visitor-signin' Write-Host 'Sign in with your Gitea username and password, or a token as the password.'
+11 -69
View File
@@ -1,78 +1,20 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Pushes this folder to the Gitea repo. Run from inside the visitor-signin folder. # Pushes this folder into the (empty) Gitea repo created for it.
set -uo pipefail # Run once from inside the extracted visitor-signin folder: ./push-to-gitea.sh
set -euo pipefail
REMOTE="https://gitea.hideawaygaming.com.au/jessikitty/visitor-signin.git" REMOTE="https://gitea.hideawaygaming.com.au/jessikitty/visitor-signin.git"
fail() { printf '\n%s\n' "$1" >&2; exit 1; } if [ ! -f package.json ]; then
echo "Run this from inside the visitor-signin folder." >&2
[ -f package.json ] || fail "Run this from inside the visitor-signin folder."
command -v git >/dev/null || fail "Git is not installed."
git config --global core.autocrlf input >/dev/null 2>&1 || true
[ -d .git ] || git init -b main || fail "git init failed."
if git remote | grep -qx origin; then
git remote set-url origin "$REMOTE"
else
git remote add origin "$REMOTE"
fi
# An unfinished rebase or merge blocks everything below, and git's own error is
# easy to mistake for a push problem.
GIT_DIR_PATH=$(git rev-parse --git-dir 2>/dev/null || echo .git)
for marker in rebase-merge rebase-apply MERGE_HEAD CHERRY_PICK_HEAD; do
if [ -e "$GIT_DIR_PATH/$marker" ]; then
cat >&2 <<'MSG'
There is an unfinished rebase or merge in this folder.
Nothing else can happen until it is settled:
git rebase --abort throw the attempt away, back to how things were
git status see which files still need attention
git rebase --continue after fixing the files git listed
If unsure, "git rebase --abort" is the safe one.
MSG
exit 1 exit 1
fi fi
done
git add -A git init -b main
if [ -n "$(git status --porcelain)" ]; then git add .
read -r -p "Describe this change (enter for a dated default): " MSG git commit -m "Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA"
[ -n "$MSG" ] || MSG="Update $(date '+%Y-%m-%d %H:%M')" git remote add origin "$REMOTE" 2>/dev/null || git remote set-url origin "$REMOTE"
git commit -m "$MSG" || fail "git commit failed." git push -u origin main
echo "Committed."
else
echo "No file changes to commit. Checking for anything unpushed..."
fi
git fetch origin || fail "Could not reach Gitea."
if git ls-remote --heads origin main | grep -q main; then
BEHIND=$(git rev-list --count HEAD..origin/main 2>/dev/null || echo 0)
if [ "$BEHIND" -gt 0 ]; then
echo "The server has $BEHIND commit(s) this folder does not. Replaying your work on top..."
if ! git pull --rebase origin main; then
cat >&2 <<'MSG'
The two histories could not be joined automatically.
See what is on the server that you do not have:
git log --oneline HEAD..origin/main
If that is nothing you need, and this folder is the good copy:
git push --force-with-lease origin main
MSG
exit 1
fi
fi
fi
git push -u origin main || fail "The push was rejected. Read the message above. Nothing was sent."
echo echo
echo "Pushed successfully." echo "Pushed. Repo: https://gitea.hideawaygaming.com.au/jessikitty/visitor-signin"
echo "https://gitea.hideawaygaming.com.au/jessikitty/visitor-signin"
-98
View File
@@ -1,98 +0,0 @@
#!/usr/bin/env node
/**
* One-shot patch: dither visitor photos before they go on the badge canvas.
*
* node scripts/dither-badge-photos.mjs # patch
* node scripts/dither-badge-photos.mjs --check # report only
*
* A thermal head has no grey. Drawn straight, a photo gets thresholded by the
* plane conversion and most of a face lands on one side of that threshold, so
* it prints as a solid black mass. This inserts a Floyd-Steinberg pass at
* exactly the size the photo will occupy.
*
* Idempotent, writes printer.js.bak, refuses to run if the anchor is missing.
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
const target = path.resolve(here, '..', 'src', 'printer.js');
const checkOnly = process.argv.includes('--check');
function fail(message) {
console.error(`\n FAILED: ${message}\n`);
console.error(' Nothing was changed.\n');
process.exit(1);
}
if (!fs.existsSync(target)) fail(`${target} does not exist. Run this from the repo root.`);
let source = fs.readFileSync(target, 'utf8');
const original = source;
if (source.includes('ditherPhoto(')) {
console.log('\n Already patched — printer.js dithers photos. Nothing to do.\n');
process.exit(0);
}
const changes = [];
/* -- 1. Import ---------------------------------------------------- */
const importAnchor = "import { photoAbsolutePath } from './photos.js';";
if (!source.includes(importAnchor)) fail('could not find the photos.js import');
source = source.replace(
importAnchor,
`${importAnchor}\nimport { ditherPhoto } from './printing/dither.js';`
);
changes.push('added the ditherPhoto import');
/* -- 2. Dither once, at final size, before either draw site ------- */
// Matches the existing line regardless of the exact multipliers, so tweaking
// the photo size later doesn't break this patch.
const sizeAnchor = /const photoSize = photo \? Math\.round\([^;]+\) : 0;/;
const match = source.match(sizeAnchor);
if (!match) fail('could not find the photoSize calculation in drawBadge');
const insertion = `${match[0]}
// Thermal heads print pure black or nothing, so a photo has to be reduced to
// 1-bit before it lands on the canvas — otherwise the plane conversion
// thresholds it into a solid blob. Done once here rather than at each draw
// site, and at exactly photoSize: rescaling a dithered image resamples the
// dot pattern back into greys and undoes the whole thing.
//
// Skipped on the measuring pass, which never paints.
if (photo && photoSize > 0 && startY !== null) {
photo = ditherPhoto(photo, photoSize);
}`;
source = source.replace(sizeAnchor, insertion);
changes.push('drawBadge dithers the photo at its final size');
/* -- 3. Sanity check ---------------------------------------------- */
if (!/let photo = null;/.test(source)) {
fail('photo is not declared with let — it cannot be reassigned. Patch by hand.');
}
console.log('\n Changes to src/printer.js:');
for (const c of changes) console.log(` - ${c}`);
if (checkOnly) {
console.log('\n --check given, nothing written.\n');
process.exit(0);
}
if (source === original) fail('nothing actually changed');
fs.writeFileSync(`${target}.bak`, original);
fs.writeFileSync(target, source);
console.log(`\n Wrote ${target}`);
console.log(` Backup at ${target}.bak`);
console.log('\n Next: docker compose build && docker compose up -d\n');
+2 -4
View File
@@ -25,10 +25,8 @@ fi
if docker compose ps --status running 2>/dev/null | grep -q visitor-signin; then if docker compose ps --status running 2>/dev/null | grep -q visitor-signin; then
docker compose exec -T visitor-signin node scripts/make-cert.mjs $FORCE docker compose exec -T visitor-signin node scripts/make-cert.mjs $FORCE
echo "Recreating the container so the new certificate is served..." echo "Restarting so the new certificate is served..."
# up -d rather than restart: restart keeps the environment the container was docker compose restart visitor-signin
# started with, so an edited .env would be ignored.
docker compose up -d visitor-signin
else else
node scripts/make-cert.mjs $FORCE node scripts/make-cert.mjs $FORCE
fi fi
-171
View File
@@ -1,171 +0,0 @@
#!/usr/bin/env node
/**
* One-shot patch: point printer.js at the native QL protocol module instead of
* shelling out to the Python brother_ql CLI.
*
* node scripts/wire-native-printing.mjs # patch
* node scripts/wire-native-printing.mjs --check # report only, change nothing
*
* Safe to run twice — it detects an already-patched file and stops.
* Writes printer.js.bak next to the original before touching anything.
*
* Only the imports and the two functions at the bottom change. The badge
* layout (drawBadge / renderBadgePng) is left exactly as it is.
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
const target = path.resolve(here, '..', 'src', 'printer.js');
const checkOnly = process.argv.includes('--check');
function fail(message) {
console.error(`\n FAILED: ${message}\n`);
console.error(' Nothing was changed. Patch printer.js by hand instead.\n');
process.exit(1);
}
/**
* Find a top-level function and return its full text by matching braces.
* Formatting-independent, unlike matching the whole body literally.
*/
function extractFunction(source, signature) {
const start = source.indexOf(signature);
if (start === -1) return null;
let depth = 0;
let i = source.indexOf('{', start);
if (i === -1) return null;
for (; i < source.length; i++) {
if (source[i] === '{') depth++;
else if (source[i] === '}') {
depth--;
if (depth === 0) {
return { start, end: i + 1, text: source.slice(start, i + 1) };
}
}
}
return null;
}
if (!fs.existsSync(target)) fail(`${target} does not exist. Run this from the repo root.`);
let source = fs.readFileSync(target, 'utf8');
const original = source;
if (source.includes("from './printing/ql-print.js'")) {
console.log('\n Already patched — printer.js imports the native module. Nothing to do.\n');
process.exit(0);
}
const changes = [];
/* -- 1. Import the native module ---------------------------------- */
const importAnchor = "import { photoAbsolutePath } from './photos.js';";
if (!source.includes(importAnchor)) fail('could not find the photos.js import');
source = source.replace(
importAnchor,
`${importAnchor}\nimport { printPng, printerHealth } from './printing/ql-print.js';`
);
changes.push('added the ql-print import');
/* -- 2. Replace printBadge ---------------------------------------- */
const printBadge = extractFunction(source, 'export async function printBadge(visit, site)');
if (!printBadge) fail('could not locate printBadge');
if (!printBadge.text.includes('runBrotherQl')) {
fail('printBadge does not call runBrotherQl — it may already have been changed');
}
const newPrintBadge = `export async function printBadge(visit, site) {
if (!isConfigured(site)) throw new Error('Server printing is not turned on for this site.');
const png = await renderBadgePng(visit, site);
const port = Number(site.printer_port) || 9100;
const target = \`tcp://\${site.printer_host}:\${port}\`;
// The roll decides whether the job is two-colour, not the accent setting.
// A DK-22251 roll refuses a monochrome job even when the badge has no red
// on it, so labelFor() is passed straight through and ql-print maps it.
const result = await printPng(png, {
host: site.printer_host,
port,
label: labelFor(site),
});
note(site.id, result.ok, result.message);
if (!result.ok) throw new Error(result.message);
// confirmed is false when the printer accepted the bytes but never said the
// label came out. Over the network that is the normal case, not a fault.
return { ok: true, confirmed: Boolean(result.confirmed), target };
}`;
source =
source.slice(0, printBadge.start) + newPrintBadge + source.slice(printBadge.end);
changes.push('printBadge now uses printPng');
/* -- 3. Replace available ----------------------------------------- */
const available = extractFunction(source, 'export function available()');
if (available) {
const newAvailable = `export function available() {
// Kept for callers that only ask "can this server print at all?". The
// external brother_ql binary is no longer involved, so the answer is always
// yes; use health(site) to ask about a specific printer.
return Promise.resolve(true);
}
/**
* Reachability and, where the printer will say, roll state.
*
* ready is tri-state: true, false, or null meaning "reachable but it won't
* tell us". Null is the normal answer over the network — show it as unknown
* in the admin console rather than green or red.
*/
export function health(site) {
return printerHealth({
host: site?.printer_host,
port: Number(site?.printer_port) || 9100,
label: labelFor(site),
});
}`;
source =
source.slice(0, available.start) + newAvailable + source.slice(available.end);
changes.push('available() no longer probes for a binary; added health(site)');
} else {
console.warn(' note: available() not found, skipping that step');
}
/* -- 4. Flag the now-dead subprocess code ------------------------- */
for (const name of ['function explainPrintError', 'function runBrotherQl']) {
if (source.includes(name)) {
changes.push(`${name.split(' ')[1]}() is now unused and can be deleted`);
}
}
/* -- Report / write ----------------------------------------------- */
console.log('\n Changes to src/printer.js:');
for (const c of changes) console.log(` - ${c}`);
if (checkOnly) {
console.log('\n --check given, nothing written.\n');
process.exit(0);
}
if (source === original) fail('nothing actually changed');
fs.writeFileSync(`${target}.bak`, original);
fs.writeFileSync(target, source);
console.log(`\n Wrote ${target}`);
console.log(` Backup at ${target}.bak`);
console.log('\n Next: docker compose build && docker compose up -d\n');
-9
View File
@@ -46,15 +46,6 @@ export const config = {
require2fa: bool(process.env.ADMIN_REQUIRE_2FA, true), require2fa: bool(process.env.ADMIN_REQUIRE_2FA, true),
}, },
printing: {
// brother_ql drives the label printer over the network. Overridable so a
// wrapper or a different binary can be swapped in.
command: process.env.PRINT_COMMAND || 'brother_ql',
timeoutMs: int(process.env.PRINT_TIMEOUT_MS, 15000),
// How long a sign in waits for the badge before falling back to the browser.
signInWaitMs: int(process.env.PRINT_SIGNIN_WAIT_MS, 9000),
},
// Admins are warned this many days before a WWCC or VIT expires. // Admins are warned this many days before a WWCC or VIT expires.
expiryWarningDays: int(process.env.EXPIRY_WARNING_DAYS, 28), expiryWarningDays: int(process.env.EXPIRY_WARNING_DAYS, 28),
-16
View File
@@ -22,12 +22,6 @@ CREATE TABLE IF NOT EXISTS sites (
badge_show_photo INTEGER NOT NULL DEFAULT 1, badge_show_photo INTEGER NOT NULL DEFAULT 1,
badge_accent INTEGER NOT NULL DEFAULT 0, badge_accent INTEGER NOT NULL DEFAULT 0,
badge_note TEXT, badge_note TEXT,
printer_enabled INTEGER NOT NULL DEFAULT 0,
printer_host TEXT,
printer_port INTEGER NOT NULL DEFAULT 9100,
printer_model TEXT NOT NULL DEFAULT 'QL-820NWB',
printer_rotate INTEGER NOT NULL DEFAULT 0,
printer_label TEXT NOT NULL DEFAULT '62',
banner_path TEXT, banner_path TEXT,
banner_height INTEGER NOT NULL DEFAULT 64, banner_height INTEGER NOT NULL DEFAULT 64,
banner_align TEXT NOT NULL DEFAULT 'left', banner_align TEXT NOT NULL DEFAULT 'left',
@@ -178,16 +172,6 @@ addColumn('sites', 'colour_page', 'TEXT');
addColumn('sites', 'colour_text', 'TEXT'); addColumn('sites', 'colour_text', 'TEXT');
// Optional "who are you from", handy for contractors and visiting staff. // Optional "who are you from", handy for contractors and visiting staff.
addColumn('visits', 'company', 'TEXT'); addColumn('visits', 'company', 'TEXT');
// Server-side printing, so a kiosk needs no printer driver of its own.
addColumn('sites', 'printer_enabled', 'INTEGER NOT NULL DEFAULT 0');
addColumn('sites', 'printer_host', 'TEXT');
addColumn('sites', 'printer_port', 'INTEGER NOT NULL DEFAULT 9100');
addColumn('sites', 'printer_model', "TEXT NOT NULL DEFAULT 'QL-820NWB'");
addColumn('sites', 'printer_rotate', 'INTEGER NOT NULL DEFAULT 0');
// Which roll is physically loaded. Kept separate from the red styling option:
// the printer refuses a two-colour job on a plain roll, so guessing the media
// from a design setting means a wrong-roll error nobody can explain.
addColumn('sites', 'printer_label', "TEXT NOT NULL DEFAULT '62'");
addColumn('frequent_visitors', 'company', 'TEXT'); addColumn('frequent_visitors', 'company', 'TEXT');
db.exec('CREATE INDEX IF NOT EXISTS idx_visits_site ON visits(site_id, signed_out_at)'); db.exec('CREATE INDEX IF NOT EXISTS idx_visits_site ON visits(site_id, signed_out_at)');
-401
View File
@@ -1,401 +0,0 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import crypto from 'node:crypto';
import { execFile } from 'node:child_process';
import { createCanvas, loadImage, GlobalFonts } from '@napi-rs/canvas';
import config from './config.js';
import { photoAbsolutePath } from './photos.js';
import { ditherPhoto } from './printing/dither.js';
import { printPng, printerHealth } from './printing/ql-print.js';
/**
* Printing happens on the server, not in the kiosk browser.
*
* The badge is drawn to a bitmap here and pushed straight to the printer over the
* network, so the tablet at the door needs no printer driver, no default printer
* and no print dialog — and a second kiosk can be added without configuring
* anything on it.
*
* The QL-820NWB prints 696 dots across a 62 mm roll at 300 dpi. That figure is
* fixed by the printer, so the bitmap is always 696 wide however the badge is
* laid out; rotation is applied to the finished image, not to the layout.
*/
const DPI = 300;
const DOTS_ACROSS_62MM = 696;
const FONT = 'Liberation Sans, DejaVu Sans, Arial, sans-serif';
const mm = (value) => Math.round((value / 25.4) * DPI);
/** Per-site outcome of the last print, surfaced in the admin console. */
const lastResult = new Map();
export function printerStatus(siteId) {
return lastResult.get(Number(siteId)) || null;
}
function note(siteId, ok, message) {
lastResult.set(Number(siteId), { ok, message, at: new Date().toISOString() });
}
export function isConfigured(site) {
return Boolean(site?.printer_enabled && site?.printer_host);
}
/**
* brother_ql's media id for the roll that is actually loaded.
*
* This is set explicitly rather than inferred from the red styling option. A
* two-colour job sent to a plain roll is rejected by the printer with "Wrong
* Roll Type", which gives no clue that a colour checkbox caused it.
*/
export const ROLL_TYPES = {
'62': '62 mm continuous, black only',
'62red': '62 mm continuous, black and red (DK-22251)',
};
export function labelFor(site) {
const label = site?.printer_label;
return Object.hasOwn(ROLL_TYPES, label) ? label : '62';
}
/** Red ink only exists on the two-colour roll; anywhere else it prints dark grey. */
export function accentWillPrintRed(site) {
return Boolean(site?.badge_accent) && labelFor(site) === '62red';
}
/* ------------------------------------------------------------ rendering */
function wrapText(ctx, text, maxWidth, maxLines) {
const words = String(text || '').split(/\s+/).filter(Boolean);
const lines = [];
let line = '';
for (const word of words) {
const candidate = line ? `${line} ${word}` : word;
if (ctx.measureText(candidate).width <= maxWidth || !line) {
line = candidate;
} else {
lines.push(line);
line = word;
if (lines.length === maxLines - 1) break;
}
}
if (line) lines.push(line);
return lines.slice(0, maxLines);
}
/**
* Draws the badge at its designed size in dots. Mirrors the browser badge so the
* preview and the printed label agree.
*/
async function drawBadge(ctx, widthDots, heightDots, visit, site, accent, startY = null) {
const unit = Math.min(widthDots, heightDots);
const pad = Math.round(unit * 0.07);
const black = '#000000';
const red = accent ? '#ff0000' : '#000000';
if (startY !== null) {
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, widthDots, heightDots);
}
const portrait = heightDots >= widthDots * 1.2;
const nameSize = Math.max(mm(3.2), Math.round(unit * (portrait ? 0.105 : 0.115)));
const bodySize = Math.max(mm(2.0), Math.round(unit * (portrait ? 0.055 : 0.062)));
let photo = null;
const abs = site.badge_show_photo ? photoAbsolutePath(visit.photo_path) : null;
if (abs) {
try {
photo = await loadImage(abs);
} catch {
photo = null;
}
}
const photoSize = photo ? Math.round(unit * (portrait ? 0.52 : 0.5)) : 0;
// Thermal heads print pure black or nothing, so a photo has to be reduced to
// 1-bit before it lands on the canvas — otherwise the plane conversion
// thresholds it into a solid blob. Done once here rather than at each draw
// site, and at exactly photoSize: rescaling a dithered image resamples the
// dot pattern back into greys and undoes the whole thing.
//
// Skipped on the measuring pass, which never paints.
if (photo && photoSize > 0 && startY !== null) {
photo = ditherPhoto(photo, photoSize);
}
let cursorY = startY === null ? pad : startY;
let textLeft = pad;
let textWidth = widthDots - pad * 2;
const paint = startY !== null;
if (photo) {
if (portrait) {
const x = Math.round((widthDots - photoSize) / 2);
if (paint) ctx.drawImage(photo, x, cursorY, photoSize, photoSize);
if (paint) {
ctx.strokeStyle = black;
ctx.lineWidth = Math.max(2, Math.round(mm(0.3)));
ctx.strokeRect(x, cursorY, photoSize, photoSize);
}
cursorY += photoSize + Math.round(unit * 0.05);
} else {
const y = Math.round((heightDots - photoSize) / 2);
if (paint) {
ctx.drawImage(photo, pad, y, photoSize, photoSize);
ctx.strokeStyle = black;
ctx.lineWidth = Math.max(2, Math.round(mm(0.3)));
ctx.strokeRect(pad, y, photoSize, photoSize);
}
textLeft = pad + photoSize + Math.round(unit * 0.05);
textWidth = widthDots - textLeft - pad;
}
}
ctx.textBaseline = 'top';
ctx.textAlign = portrait ? 'center' : 'left';
const centreX = portrait ? widthDots / 2 : textLeft;
// Site name, with a rule under it.
ctx.fillStyle = red;
ctx.font = `${Math.round(bodySize * 0.8)}px ${FONT}`;
if (paint) ctx.fillText(`${site.name.toUpperCase()} · VISITOR`, centreX, cursorY, textWidth);
cursorY += Math.round(bodySize * 0.8 * 1.3);
if (paint) ctx.fillRect(textLeft, cursorY, textWidth, Math.max(2, Math.round(mm(0.35))));
cursorY += Math.round(unit * 0.04);
// Name, wrapped to at most two lines.
ctx.fillStyle = black;
ctx.font = `bold ${nameSize}px ${FONT}`;
const nameLines = wrapText(ctx, `${visit.first_name} ${visit.last_name}`, textWidth, 2);
for (const line of nameLines) {
if (paint) ctx.fillText(line, centreX, cursorY, textWidth);
cursorY += Math.round(nameSize * 1.05);
}
cursorY += Math.round(unit * 0.04);
// Detail rows.
const timeIn = new Date(visit.signed_in_at);
const rows = [
`Visiting ${visit.host_name}`,
`In at ${timeIn.toLocaleTimeString('en-AU', { hour: '2-digit', minute: '2-digit', hour12: false })} on ${timeIn.toLocaleDateString('en-AU', { day: '2-digit', month: 'short', year: '2-digit' })}`,
];
ctx.font = `${bodySize}px ${FONT}`;
ctx.fillStyle = black;
for (const row of rows) {
for (const line of wrapText(ctx, row, textWidth, 2)) {
if (paint) ctx.fillText(line, centreX, cursorY, textWidth);
cursorY += Math.round(bodySize * 1.3);
}
}
// Check status: boxed and in the accent colour when they hold nothing.
if (visit.check_type === 'NONE') {
const label = 'No WWCC / VIT';
ctx.font = `bold ${Math.round(bodySize * 0.95)}px ${FONT}`;
const w = ctx.measureText(label).width + bodySize;
const x = portrait ? Math.round((widthDots - w) / 2) : textLeft;
const h = Math.round(bodySize * 1.5);
if (paint) {
ctx.strokeStyle = red;
ctx.lineWidth = Math.max(2, Math.round(mm(0.35)));
ctx.strokeRect(x, cursorY, w, h);
ctx.fillStyle = red;
ctx.textAlign = 'center';
ctx.fillText(label, x + w / 2, cursorY + Math.round(bodySize * 0.25));
ctx.textAlign = portrait ? 'center' : 'left';
}
cursorY += h + Math.round(bodySize * 0.3);
} else {
ctx.fillStyle = black;
ctx.font = `${bodySize}px ${FONT}`;
if (paint) {
ctx.fillText(`${visit.check_type} ${visit.check_number || ''}`.trim(), centreX, cursorY, textWidth);
}
cursorY += Math.round(bodySize * 1.3);
}
if (site.badge_note) {
ctx.fillStyle = black;
ctx.font = `${Math.round(bodySize * 0.85)}px ${FONT}`;
for (const line of wrapText(ctx, site.badge_note, textWidth, 2)) {
if (paint) ctx.fillText(line, centreX, cursorY, textWidth);
cursorY += Math.round(bodySize * 1.1);
}
}
return cursorY - (startY === null ? pad : startY);
}
/**
* Produces the PNG that gets sent to the printer.
*
* The bitmap is always 696 dots across, because that is the printer's fixed head
* width on a 62 mm roll. With rotation the badge is laid out along the length of
* the label instead and the finished image is turned, so the content still lands
* within those 696 dots.
*/
export async function renderBadgePng(visit, site) {
const rotate = Number(site.printer_rotate) || 0;
const lengthMm = Number(site.badge_height_mm) || 90;
const turned = rotate === 90 || rotate === 270;
const acrossDots = DOTS_ACROSS_62MM;
const alongDots = mm(lengthMm);
// Design canvas: swapped when the badge is laid out along the label.
const designW = turned ? alongDots : acrossDots;
const designH = turned ? acrossDots : alongDots;
const design = createCanvas(designW, designH);
const ctx = design.getContext('2d');
// Only paint red when the loaded roll can actually print it.
const accent = accentWillPrintRed(site);
// Measure first, then draw the block centred down the label. Without this the
// content hugs the top and leaves a wide blank strip at the bottom of every badge.
const used = await drawBadge(ctx, designW, designH, visit, site, accent, null);
const pad = Math.round(Math.min(designW, designH) * 0.07);
const startY = Math.max(pad, Math.round((designH - used) / 2));
await drawBadge(ctx, designW, designH, visit, site, accent, startY);
if (!rotate) return design.toBuffer('image/png');
const out = createCanvas(turned ? acrossDots : designW, turned ? alongDots : designH);
const outCtx = out.getContext('2d');
outCtx.fillStyle = '#ffffff';
outCtx.fillRect(0, 0, out.width, out.height);
outCtx.translate(out.width / 2, out.height / 2);
outCtx.rotate((rotate * Math.PI) / 180);
outCtx.drawImage(design, -designW / 2, -designH / 2);
return out.toBuffer('image/png');
}
/* -------------------------------------------------------------- sending */
/**
* brother_ql reports failures as a Python traceback. Nobody at a front desk can
* act on that, so the useful last line is pulled out and the common network
* failures are rewritten as something with a next step.
*/
function explainPrintError(output, host) {
const lines = String(output || '')
.split('\n')
.map((l) => l.trim())
.filter((l) => l && !/^deprecation warning/i.test(l));
const last = lines[lines.length - 1] || '';
if (/Connection refused/i.test(last)) {
return `${host} refused the connection. Check the printer is switched on and that port 9100 is the right one.`;
}
if (/timed out|timeout/i.test(last)) {
return `${host} did not answer. Check the IP address and that the printer is on the same network as the server.`;
}
if (/No route to host|Network is unreachable/i.test(last)) {
return `${host} cannot be reached from the server. Check the address and any firewall between them.`;
}
if (/Name or service not known|getaddrinfo/i.test(last)) {
return `${host} could not be resolved. Use the printer's IP address rather than a name.`;
}
if (/Unknown label|label/i.test(last) && /identifier/i.test(last)) {
return 'The printer rejected the label size. Check the roll loaded matches the badge settings.';
}
if (/wrong roll|WrongMedia|media/i.test(last)) {
return 'The printer says the roll is wrong. Check "Roll loaded in the printer" matches what is actually in the machine — a black and red job is refused on a plain roll.';
}
return last || 'The printer did not accept the job.';
}
function runBrotherQl(args, timeoutMs) {
return new Promise((resolve, reject) => {
execFile(
config.printing.command,
args,
{ timeout: timeoutMs, env: { ...process.env, BROTHER_QL_PRINTER: '', BROTHER_QL_MODEL: '' } },
(err, stdout, stderr) => {
const output = `${stdout || ''}${stderr || ''}`.trim();
if (err) {
if (err.code === 'ENOENT') {
return reject(
new Error(
`${config.printing.command} is not installed in the container. Rebuild the image, or set PRINT_COMMAND.`
)
);
}
if (err.killed) return reject(new Error('The printer did not respond in time.'));
return reject(new Error(output || err.message));
}
resolve(output);
}
);
});
}
/**
* Renders and prints one badge. Resolves with a short description on success and
* rejects with something an admin can act on.
*/
export async function printBadge(visit, site) {
if (!isConfigured(site)) throw new Error('Server printing is not turned on for this site.');
const png = await renderBadgePng(visit, site);
const port = Number(site.printer_port) || 9100;
const target = `tcp://${site.printer_host}:${port}`;
// The roll decides whether the job is two-colour, not the accent setting.
// A DK-22251 roll refuses a monochrome job even when the badge has no red
// on it, so labelFor() is passed straight through and ql-print maps it.
const result = await printPng(png, {
host: site.printer_host,
port,
label: labelFor(site),
});
note(site.id, result.ok, result.message);
if (!result.ok) throw new Error(result.message);
// confirmed is false when the printer accepted the bytes but never said the
// label came out. Over the network that is the normal case, not a fault.
return { ok: true, confirmed: Boolean(result.confirmed), target };
}
/** A sample badge, for checking the printer and the layout without a real visit. */
export function sampleVisit(site) {
return {
id: 0,
first_name: 'Sample',
last_name: 'Visitor',
host_name: 'Jess Rogerson',
check_type: 'NONE',
check_number: null,
photo_path: null,
signed_in_at: new Date().toISOString(),
site_name: site.name,
};
}
export function available() {
// Kept for callers that only ask "can this server print at all?". The
// external brother_ql binary is no longer involved, so the answer is always
// yes; use health(site) to ask about a specific printer.
return Promise.resolve(true);
}
/**
* Reachability and, where the printer will say, roll state.
*
* ready is tri-state: true, false, or null meaning "reachable but it won't
* tell us". Null is the normal answer over the network — show it as unknown
* in the admin console rather than green or red.
*/
export function health(site) {
return printerHealth({
host: site?.printer_host,
port: Number(site?.printer_port) || 9100,
label: labelFor(site),
});
}
-234
View File
@@ -1,234 +0,0 @@
import { createCanvas } from '@napi-rs/canvas';
/**
* Reduce a photo to pure black and white for a thermal print head.
*
* The head has no grey: every dot is burnt or not. Sending a photo straight
* through means the plane conversion thresholds it, and since most of a face
* sits on one side of any threshold you get a solid blob. Floyd-Steinberg
* error diffusion trades spatial resolution for apparent tone instead, which
* is what makes a photo readable at 300 dpi.
*
* Three things matter for this to look like a face rather than noise:
*
* 1. Dither at the exact pixel size the photo will occupy. Rescaling a
* dithered image resamples the dot pattern back into greys, and the later
* threshold turns those into the same blob we were avoiding.
*
* 2. Stretch the levels first. Webcam captures under office lighting are
* usually squeezed into the middle of the range; dithering that directly
* produces flat mush. Normalising to the full range first gives the error
* diffusion something to work with.
*
* 3. Aim for a fixed ink coverage. Thermal dots spread as the paper heats, so
* they come out fatter on the label than they look on screen, and a
* digitally "correct" image prints muddy. Targeting coverage also makes
* every badge print at the same density regardless of how the visitor
* happened to be lit.
*
* Text is deliberately NOT dithered anywhere — dithered glyph edges look furry
* at this resolution. Only photographs go through here.
*/
/** Rec. 601 luma, which matches how the eye weights these channels. */
function luminance(r, g, b) {
return 0.299 * r + 0.587 * g + 0.114 * b;
}
/**
* Stretch contrast so the darkest few percent land near black and the
* lightest few near white.
*
* Percentiles rather than absolute min/max, so one bright window or one dark
* shadow doesn't waste the whole range.
*/
function autoLevels(grey, { lowPercentile = 0.02, highPercentile = 0.98 } = {}) {
const histogram = new Uint32Array(256);
for (let i = 0; i < grey.length; i++) histogram[grey[i] | 0]++;
const lowTarget = grey.length * lowPercentile;
const highTarget = grey.length * highPercentile;
let low = 0;
let high = 255;
let seen = 0;
for (let v = 0; v < 256; v++) {
seen += histogram[v];
if (seen >= lowTarget) { low = v; break; }
}
seen = 0;
for (let v = 255; v >= 0; v--) {
seen += histogram[v];
if (seen >= grey.length - highTarget) { high = v; break; }
}
// A nearly flat image would blow up into noise; leave it alone.
if (high - low < 24) return;
const scale = 255 / (high - low);
for (let i = 0; i < grey.length; i++) {
grey[i] = Math.min(255, Math.max(0, (grey[i] - low) * scale));
}
}
/**
* Floyd-Steinberg error diffusion over a luminance buffer, in place.
*
* Serpentine scanning: alternating direction per row avoids the diagonal
* banding a plain left-to-right pass leaves in smooth gradients like skin.
*/
function floydSteinberg(grey, width, height) {
const at = (x, y) => y * width + x;
for (let y = 0; y < height; y++) {
const leftToRight = y % 2 === 0;
const from = leftToRight ? 0 : width - 1;
const to = leftToRight ? width : -1;
const step = leftToRight ? 1 : -1;
for (let x = from; x !== to; x += step) {
const p = at(x, y);
const old = grey[p];
const next = old < 128 ? 0 : 255;
grey[p] = next;
const error = old - next;
const ahead = x + step;
const behind = x - step;
if (ahead >= 0 && ahead < width) grey[at(ahead, y)] += error * (7 / 16);
if (y + 1 < height) {
if (behind >= 0 && behind < width) grey[at(behind, y + 1)] += error * (3 / 16);
grey[at(x, y + 1)] += error * (5 / 16);
if (ahead >= 0 && ahead < width) grey[at(ahead, y + 1)] += error * (1 / 16);
}
}
}
}
/** Add a constant to every sample, clamped to the printable range. */
function applyShift(grey, shift) {
if (!shift) return;
for (let p = 0; p < grey.length; p++) {
grey[p] = Math.min(255, Math.max(0, grey[p] + shift));
}
}
/** Fraction of dots that would burn, for a given luminance buffer. */
function coverageOf(grey, width, height) {
const trial = Float32Array.from(grey);
floydSteinberg(trial, width, height);
let black = 0;
for (let p = 0; p < trial.length; p++) if (trial[p] < 128) black++;
return black / trial.length;
}
/**
* Find the brightness offset that lands the dithered result on a given ink
* coverage.
*
* Error diffusion preserves mean tone, so coverage is roughly 1 - mean/255 and
* the first guess can be computed directly rather than searched for. Clipping
* at the ends of the range spoils that slightly, so up to three cheap
* correction passes follow. Each pass is one dither over a few tens of
* thousands of samples — the whole thing runs in about 6 ms for a 24 mm photo.
*
* Capped at +/-120 so a very dark or very bright capture degrades into
* something faint rather than a blank square.
*/
function solveShiftForCoverage(grey, width, height, target, maxPasses = 3) {
const clamp = (v) => Math.min(120, Math.max(-120, v));
let mean = 0;
for (let p = 0; p < grey.length; p++) mean += grey[p];
mean /= grey.length;
let shift = clamp(255 * (1 - target) - mean);
for (let pass = 0; pass < maxPasses; pass++) {
const trial = Float32Array.from(grey);
applyShift(trial, shift);
const actual = coverageOf(trial, width, height);
const error = actual - target;
if (Math.abs(error) < 0.005) break;
// Coverage moves roughly linearly with the offset over this range.
shift = clamp(shift + error * 255);
}
return shift;
}
/**
* Dither a photo to 1-bit at a given square size.
*
* @param {import('@napi-rs/canvas').Image} image Loaded photo
* @param {number} size Final size in printer dots — must match the size it
* will be drawn at, or the dither is destroyed
* @param {Object} [options]
* @param {boolean} [options.levels=true] Stretch contrast first
* @param {number} [options.brightness=0] -100..100, nudge before dithering.
* Negative darkens.
* @param {number} [options.targetCoverage=0.333]
* Fraction of dots to burn, 0..1. The brightness needed to hit it is
* solved for, so every photo prints at the same density however it was
* lit. Raise it for a heavier print, lower it for a lighter one. Pass
* null to leave density alone and print whatever the photo gives.
* @returns {import('@napi-rs/canvas').Canvas} ready to pass to drawImage
*/
export function ditherPhoto(image, size, options = {}) {
const { levels = true, brightness = 0, targetCoverage = 0.333 } = options;
const canvas = createCanvas(size, size);
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, size, size);
// Cover-fit rather than stretch, so a non-square capture isn't distorted.
const scale = Math.max(size / image.width, size / image.height);
const drawWidth = image.width * scale;
const drawHeight = image.height * scale;
ctx.drawImage(
image,
(size - drawWidth) / 2,
(size - drawHeight) / 2,
drawWidth,
drawHeight
);
const pixels = ctx.getImageData(0, 0, size, size);
const { data } = pixels;
const grey = new Float32Array(size * size);
for (let p = 0, i = 0; p < grey.length; p++, i += 4) {
const a = data[i + 3] / 255;
grey[p] = luminance(
data[i] * a + 255 * (1 - a),
data[i + 1] * a + 255 * (1 - a),
data[i + 2] * a + 255 * (1 - a)
);
}
if (levels) autoLevels(grey);
if (brightness !== 0) applyShift(grey, (brightness / 100) * 255);
if (targetCoverage != null) {
applyShift(grey, solveShiftForCoverage(grey, size, size, targetCoverage));
}
floydSteinberg(grey, size, size);
for (let p = 0, i = 0; p < grey.length; p++, i += 4) {
const v = grey[p] < 128 ? 0 : 255;
data[i] = v;
data[i + 1] = v;
data[i + 2] = v;
data[i + 3] = 255;
}
ctx.putImageData(pixels, 0, 0);
return canvas;
}
export default ditherPhoto;
-218
View File
@@ -1,218 +0,0 @@
import { createCanvas, loadImage } from '@napi-rs/canvas';
import { getMedia, buildJob, PIXEL_WIDTH } from './ql-raster.js';
import { probeStatus, sendJob, DEFAULT_PORT } from './ql-transport.js';
import { describeProblem, checkMediaMatches } from './ql-status.js';
/**
* Sends a rendered badge to a Brother QL over the network, speaking the
* printer's raster protocol directly.
*
* This is the drop-in replacement for shelling out to the Python brother_ql
* CLI in printer.js. Same job, minus a Python runtime in the image and minus
* writing every badge to a temp file.
*
* What it does NOT buy us is reliable printer state. The QL-820NWB accepts
* jobs on port 9100 but stays silent over TCP; status frames only come back
* over USB. So roll type, roll level and cover state are unknowable from here,
* exactly as they were with brother_ql. Status handling below is best effort:
* used when offered, never required.
*
* Input is the PNG renderBadgePng() already produces, so the badge layout,
* rotation and vertical centring are untouched.
*/
/** Maps the site's printer_label value onto a media definition. */
const LABEL_TO_MEDIA = {
62: { media: '62', red: false },
'62red': { media: '62', red: true },
};
/**
* The QL accepts one TCP connection at a time and has no job spooler worth the
* name. Two visitors signing in together would otherwise collide: a refused
* connection, a half-printed label, or both. One queue per printer host.
*
* Note this is in-process. If the app is ever scaled past one instance against
* a single printer, this needs to become a lock in SQLite instead.
*/
const queues = new Map();
function enqueue(host, task) {
const previous = queues.get(host) || Promise.resolve();
// Swallow the previous failure so one bad job doesn't poison the queue.
const next = previous.catch(() => {}).then(task);
queues.set(
host,
next.catch(() => {})
);
return next;
}
/**
* Decode a PNG into the black and red ink maps the printer wants.
*
* The renderer draws accent elements as pure #ff0000, so red separation is
* just a colour test. A dot never lands in both planes; the printer would burn
* it twice and the result is muddy brown.
*/
async function pngToPlanes(png, media, { red, threshold = 180 }) {
const image = await loadImage(png);
const width = image.width;
const height = image.height;
if (width > media.printableDots) {
throw new Error(
`Badge is ${width} dots wide but ${media.label} only prints ${media.printableDots}.`
);
}
const canvas = createCanvas(width, height);
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, width, height);
ctx.drawImage(image, 0, 0);
const { data } = ctx.getImageData(0, 0, width, height);
// Inset so the image lands on the paper rather than off the head's edge.
const xOffset = PIXEL_WIDTH - width - media.offsetR;
const size = PIXEL_WIDTH * height;
const black = new Uint8Array(size);
const redPlane = red ? new Uint8Array(size) : null;
for (let y = 0; y < height; y++) {
const srcRow = y * width * 4;
const dstRow = y * PIXEL_WIDTH + xOffset;
for (let x = 0; x < width; x++) {
const i = srcRow + x * 4;
const a = data[i + 3] / 255;
const r = data[i] * a + 255 * (1 - a);
const g = data[i + 1] * a + 255 * (1 - a);
const b = data[i + 2] * a + 255 * (1 - a);
if (red && r >= 90 && r - Math.max(g, b) >= 60) {
redPlane[dstRow + x] = 1;
} else if (0.299 * r + 0.587 * g + 0.114 * b <= threshold) {
black[dstRow + x] = 1;
}
}
}
return { width: PIXEL_WIDTH, height, black, red: redPlane };
}
/**
* Print one badge.
*
* @param {Buffer} png Output of renderBadgePng()
* @param {Object} options
* @param {string} options.host
* @param {number} [options.port=9100]
* @param {string} [options.label='62'] The site's printer_label value
* @param {boolean} [options.cut=true]
* @param {number} [options.threshold] Luminance cutover, 0-255; higher is bolder
* @param {boolean} [options.checkMedia=true] Best effort; skipped if the printer is mute
* @returns {Promise<{ok: boolean, confirmed?: boolean, message: string}>}
*/
export async function printPng(png, options = {}) {
const {
host,
port = DEFAULT_PORT,
label = '62',
cut = true,
threshold,
checkMedia = true,
compress = false,
} = options;
if (!host) return { ok: false, message: 'No printer host is configured for this site.' };
const mapping = LABEL_TO_MEDIA[label] || LABEL_TO_MEDIA['62'];
const media = getMedia(mapping.media);
return enqueue(host, async () => {
try {
if (checkMedia) {
const probe = await probeStatus(host, { port });
if (!probe.reachable) {
return {
ok: false,
message: probe.error || `Cannot reach printer at ${host}:${port}`,
};
}
// A silent printer is the normal case over TCP, so absent status must
// never block the job — the visitor is already signed in and waiting.
if (probe.status) {
const problem =
describeProblem(probe.status) || checkMediaMatches(probe.status, media);
if (problem) return { ok: false, message: problem };
}
}
const page = await pngToPlanes(png, media, { red: mapping.red, threshold });
const job = buildJob([page], { media: media.id, cut, compress });
const result = await sendJob(host, job, { port });
const last = result.frames[result.frames.length - 1];
const problem = last ? describeProblem(last) : null;
if (problem) return { ok: false, message: problem };
// Without status frames, "delivered" is as strong a claim as we can make.
// Don't dress that up as confirmation the label physically came out.
return {
ok: true,
confirmed: result.statusAvailable,
message: result.statusAvailable
? `Printed to ${host}`
: `Sent to ${host} (this printer does not confirm over the network)`,
};
} catch (err) {
return { ok: false, message: err.message };
}
});
}
/**
* Ask the printer how it is, without printing.
*
* `ready` is tri-state: true, false, or null for "reachable but it won't say",
* which is the normal answer over the network. Treat null as unknown in the
* admin console rather than colouring it green or red.
*/
export async function printerHealth({ host, port = DEFAULT_PORT, label = '62' }) {
if (!host) return { online: false, ready: false, message: 'No printer configured' };
const mapping = LABEL_TO_MEDIA[label] || LABEL_TO_MEDIA['62'];
const media = getMedia(mapping.media);
const probe = await probeStatus(host, { port });
if (!probe.reachable) {
return { online: false, ready: false, message: probe.error || 'Printer did not answer' };
}
// The usual case on this hardware: reachable, but mute. Report that honestly
// rather than dressing silence up as either health or failure.
if (!probe.status) {
return {
online: true,
ready: null,
message:
'Reachable. This printer does not report status over the network, so ' +
'roll type, roll level and cover state cannot be checked from here.',
};
}
const problem = describeProblem(probe.status) || checkMediaMatches(probe.status, media);
return {
online: true,
ready: !problem,
message: problem || 'Ready',
mediaWidthMm: probe.status.mediaWidthMm,
mediaType: probe.status.mediaType,
};
}
export { LABEL_TO_MEDIA };
-378
View File
@@ -1,378 +0,0 @@
/**
* Brother QL-8xx raster command builder.
*
* Byte-level command sequence follows Brother's "Raster Command Reference,
* QL-800/810W/820NWB, Version 1.01". Where that manual and the reference
* implementation in pklaus/brother_ql disagree, the manual wins — brother_ql
* targets older models and its defaults are not correct for this hardware.
*
* The QL-820NWB print head is 720 dots wide (60.96 mm at 300 dpi). Every
* raster line transmitted is therefore exactly 90 bytes before compression,
* regardless of the media loaded. Narrower media just means more of those
* dots fall outside the paper.
*/
const PIXEL_WIDTH = 720;
const BYTES_PER_ROW = PIXEL_WIDTH / 8; // 90
// Manual section 2.1: "Sends a 400-byte invalidate command". brother_ql sends
// 200, which works on older models but is not what this hardware documents.
const INVALIDATE_BYTES = 400;
// Manual section 2.3.4. Continuous media outside this range is rejected.
const MIN_LENGTH_DOTS = 150; // 12.7 mm
const MAX_LENGTH_DOTS = 11811; // 1000 mm
/**
* Media definitions, from manual sections 2.3.2 and 2.3.5.
*
* `printableDots` is how many of the 720 head pins land on the label;
* `offsetR` is the number of right-margin pins. For 62 mm continuous the
* manual gives 12 left / 696 print area / 12 right.
*/
const MEDIA = {
// DK-22205 (62 mm white) and DK-22251 (62 mm black/red) — continuous.
'62': {
id: '62',
label: '62 mm continuous',
dieCut: false,
widthMm: 62,
lengthMm: 0,
printableDots: 696,
offsetR: 12,
feedMargin: 35, // 3 mm, the documented minimum for continuous tape
},
// Die-cut sizes, kept for completeness.
'62x29': {
id: '62x29',
label: '62 mm x 29 mm die-cut',
dieCut: true,
widthMm: 62,
lengthMm: 29,
printableDots: 696,
lengthDots: 271,
offsetR: 12,
feedMargin: 0, // must be 0 for die-cut
},
'62x100': {
id: '62x100',
label: '62 mm x 100 mm die-cut',
dieCut: true,
widthMm: 62,
lengthMm: 100,
printableDots: 696,
lengthDots: 1109,
offsetR: 12,
feedMargin: 0,
},
};
function getMedia(id) {
const media = MEDIA[id];
if (!media) {
throw new Error(
`Unknown media "${id}". Known media: ${Object.keys(MEDIA).join(', ')}`
);
}
return media;
}
/* ------------------------------------------------------------------ *
* Bit packing
* ------------------------------------------------------------------ */
/**
* Pack one row of the ink map into the 90 bytes the printer expects.
*
* The printer clocks each raster line out starting from the far side of the
* head, so the first transmitted bit is the RIGHTMOST dot. brother_ql achieves
* this by mirroring the image before packing; we do the same thing directly
* with an index flip, which avoids materialising a mirrored copy.
*
* Get this wrong and every label prints mirrored. See the byte-order tests.
*
* @param {Uint8Array} ink Full-image ink map, 1 byte per dot, 1 = burn.
* @param {number} rowStart Offset of this row within `ink`.
* @returns {Buffer} 90 bytes.
*/
function packRow(ink, rowStart) {
const out = Buffer.alloc(BYTES_PER_ROW);
for (let x = 0; x < PIXEL_WIDTH; x++) {
if (ink[rowStart + x] === 0) continue;
const i = PIXEL_WIDTH - 1 - x;
out[i >> 3] |= 0x80 >> (i & 7);
}
return out;
}
/**
* TIFF PackBits encoder, as used by the QL compression mode.
* Worst case for 90 bytes of input is 91 bytes out, so the result always
* fits the single-byte length field.
*/
function packBits(input) {
const out = [];
let i = 0;
const n = input.length;
while (i < n) {
// Look for a run of 3+ identical bytes.
let runEnd = i + 1;
while (runEnd < n && input[runEnd] === input[i] && runEnd - i < 128) runEnd++;
const runLength = runEnd - i;
if (runLength >= 3) {
out.push(257 - runLength, input[i]);
i = runEnd;
continue;
}
// Otherwise accumulate a literal run, stopping before any 3-byte run.
let litStart = i;
let litEnd = i;
while (litEnd < n && litEnd - litStart < 128) {
if (
litEnd + 2 < n &&
input[litEnd] === input[litEnd + 1] &&
input[litEnd] === input[litEnd + 2]
) {
break;
}
litEnd++;
}
const litLength = litEnd - litStart;
out.push(litLength - 1);
for (let k = litStart; k < litEnd; k++) out.push(input[k]);
i = litEnd;
}
return Buffer.from(out);
}
/* ------------------------------------------------------------------ *
* Command primitives
* ------------------------------------------------------------------ */
const CMD = {
invalidate: () => Buffer.alloc(INVALIDATE_BYTES, 0x00),
initialize: () => Buffer.from([0x1b, 0x40]), // ESC @
statusRequest: () => Buffer.from([0x1b, 0x69, 0x53]), // ESC i S
switchToRaster: () => Buffer.from([0x1b, 0x69, 0x61, 0x01]), // ESC i a 1
/** ESC i ! — automatic status notification. 0 = notify, 1 = stay quiet. */
statusNotification: (notify) =>
Buffer.from([0x1b, 0x69, 0x21, notify ? 0x00 : 0x01]),
/** ESC i z — media type, width, length, raster count, page flag. */
mediaAndQuality({ media, rasterLines, firstPage, highQuality, twoColour }) {
let flags = 0x80; // printer recovery always on
flags |= 0x02; // media type valid
flags |= 0x04; // media width valid
// Only meaningful for die-cut stock. Brother's own driver sends 0x86 for
// continuous tape — no length bit — and the manual warns that asserting a
// valid flag the loaded media doesn't match returns a "replace media"
// error (error information 2, bit 0).
if (media.dieCut) flags |= 0x08;
// The manual marks the print-quality bit as invalid for two-colour work.
if (highQuality && !twoColour) flags |= 0x40;
const buf = Buffer.alloc(13);
buf[0] = 0x1b;
buf[1] = 0x69;
buf[2] = 0x7a;
buf[3] = flags;
buf[4] = media.dieCut ? 0x0b : 0x0a;
buf[5] = media.widthMm & 0xff;
buf[6] = media.dieCut ? media.lengthMm & 0xff : 0x00;
// For two-colour, one raster line is the whole 186-byte packet
// ('w' 01 90 <90> 'w' 02 90 <90>), so this stays a line count either way.
buf.writeUInt32LE(rasterLines >>> 0, 7);
buf[11] = firstPage ? 0x00 : 0x01;
buf[12] = 0x00;
return buf;
},
/** ESC i M — auto cut on/off (bit 6). */
autoCut: (enabled) => Buffer.from([0x1b, 0x69, 0x4d, enabled ? 0x40 : 0x00]),
/** ESC i A — cut every n labels. */
cutEvery: (n) => Buffer.from([0x1b, 0x69, 0x41, n & 0xff]),
/** ESC i K — bit 0 two-colour, bit 3 cut at end, bit 6 600 dpi. */
expandedMode({ twoColour, cutAtEnd, dpi600 }) {
let flags = 0x00;
if (twoColour) flags |= 1 << 0;
if (cutAtEnd) flags |= 1 << 3;
if (dpi600) flags |= 1 << 6;
return Buffer.from([0x1b, 0x69, 0x4b, flags]);
},
/** ESC i d — feed / margin amount in dots. */
margins(dots) {
const buf = Buffer.alloc(5);
buf[0] = 0x1b;
buf[1] = 0x69;
buf[2] = 0x64;
buf.writeUInt16LE(dots & 0xffff, 3);
return buf;
},
/** M — compression mode (2 = PackBits). Not supported on the QL-800. */
compression: (enabled) => Buffer.from([0x4d, enabled ? 0x02 : 0x00]),
/** 0x1A ends the last page, 0x0C ends an intermediate page. */
print: (lastPage) => Buffer.from([lastPage ? 0x1a : 0x0c]),
};
/* ------------------------------------------------------------------ *
* Job assembly
* ------------------------------------------------------------------ */
/**
* @typedef {Object} Plane
* @property {number} width Must be 720.
* @property {number} height Raster lines.
* @property {Uint8Array} black 1 byte per dot, non-zero = burn black.
* @property {Uint8Array} [red] Same shape; omit for monochrome.
*/
/**
* Build the complete byte stream for one or more pages.
*
* @param {Plane[]} pages
* @param {Object} [options]
* @param {string} [options.media='62']
* @param {boolean} [options.cut=true] Cut after the last label.
* @param {number} [options.cutEvery=1]
* @param {boolean} [options.highQuality=true]
* @param {boolean} [options.compress=false]
* @returns {Buffer}
*/
function buildJob(pages, options = {}) {
const {
media: mediaId = '62',
cut = true,
cutEvery = 1,
highQuality = true,
compress = false,
} = options;
const media = getMedia(mediaId);
if (!Array.isArray(pages) || pages.length === 0) {
throw new Error('buildJob requires at least one page');
}
// Initialisation commands, once per job (manual section 2.1).
const chunks = [CMD.invalidate(), CMD.initialize()];
pages.forEach((page, index) => {
validatePage(page, media);
const twoColour = Boolean(page.red);
const isFirst = index === 0;
const isLast = index === pages.length - 1;
// Control codes, repeated for every page, in the manual's documented order.
chunks.push(CMD.switchToRaster());
chunks.push(CMD.statusNotification(true));
chunks.push(
CMD.mediaAndQuality({
media,
rasterLines: page.height,
firstPage: isFirst,
highQuality,
twoColour,
})
);
chunks.push(CMD.autoCut(cut));
chunks.push(CMD.cutEvery(cutEvery));
chunks.push(CMD.expandedMode({ twoColour, cutAtEnd: cut, dpi600: false }));
chunks.push(CMD.margins(media.feedMargin));
if (compress) chunks.push(CMD.compression(true));
chunks.push(encodeRasterData(page, compress));
chunks.push(CMD.print(isLast));
});
return Buffer.concat(chunks);
}
function validatePage(page, media) {
if (page.width !== PIXEL_WIDTH) {
throw new Error(
`Page width must be ${PIXEL_WIDTH} dots, got ${page.width}. ` +
`Pad the ${media.printableDots}-dot printable area out to the full head width.`
);
}
if (!media.dieCut) {
if (page.height < MIN_LENGTH_DOTS) {
throw new Error(
`Page is ${page.height} dots long; continuous media needs at least ${MIN_LENGTH_DOTS}.`
);
}
if (page.height > MAX_LENGTH_DOTS) {
throw new Error(
`Page is ${page.height} dots long; the maximum is ${MAX_LENGTH_DOTS}.`
);
}
} else if (page.height !== media.lengthDots) {
throw new Error(
`Die-cut media ${media.id} needs exactly ${media.lengthDots} raster lines, got ${page.height}.`
);
}
const expected = page.width * page.height;
if (page.black.length !== expected) {
throw new Error(
`Black plane is ${page.black.length} bytes, expected ${expected}.`
);
}
if (page.red && page.red.length !== expected) {
throw new Error(`Red plane is ${page.red.length} bytes, expected ${expected}.`);
}
}
function encodeRasterData(page, compress) {
const chunks = [];
const twoColour = Boolean(page.red);
for (let y = 0; y < page.height; y++) {
const rowStart = y * page.width;
const planes = twoColour
? [packRow(page.black, rowStart), packRow(page.red, rowStart)]
: [packRow(page.black, rowStart)];
planes.forEach((row, planeIndex) => {
const payload = compress ? packBits(row) : row;
// 'w' 0x01 = first colour (high energy, black), 'w' 0x02 = second colour
// (low energy, red). 'g' 0x00 is the monochrome transfer.
const header = twoColour
? Buffer.from([0x77, planeIndex === 0 ? 0x01 : 0x02, payload.length])
: Buffer.from([0x67, 0x00, payload.length]);
chunks.push(header, payload);
});
}
return Buffer.concat(chunks);
}
/** Bytes that ask the printer for a status frame and nothing else. */
function buildStatusRequest() {
return Buffer.concat([CMD.invalidate(), CMD.initialize(), CMD.statusRequest()]);
}
export {
PIXEL_WIDTH,
BYTES_PER_ROW,
INVALIDATE_BYTES,
MIN_LENGTH_DOTS,
MAX_LENGTH_DOTS,
MEDIA,
getMedia,
buildJob,
buildStatusRequest,
packBits,
packRow,
CMD,
};
-195
View File
@@ -1,195 +0,0 @@
/**
* Decoder for the 32-byte status frame the QL series sends back.
*
* Field layout and every code below is from Brother's "Raster Command
* Reference, QL-800/810W/820NWB v1.01", section 4, "Status information
* request".
*
* Note this is only useful over USB. Manual section 5.9 shows the network
* flow: over a TCP/IP port the print data is simply sent as-is and no status
* comes back. See probeStatus in ql-transport.js.
*
* 0 print head mark (0x80)
* 1 size (0x20)
* 2 fixed 'B' (0x42)
* 3 series code, fixed '4' (0x34)
* 4 model code
* 5-7 fixed
* 8 error information 1
* 9 error information 2
* 10 media width in mm
* 11 media type
* 12-13 fixed
* 14 fixed 0x3F
* 15 mode
* 16 fixed
* 17 media length in mm
* 18 status type
* 19 phase type
* 20-21 phase number (big endian)
* 22 notification number
* 23-31 reserved
*/
const FRAME_LENGTH = 32;
const ERROR_BITS_1 = [
[0x01, 'No media loaded'],
[0x02, 'End of media reached'],
[0x04, 'Cutter jam'],
[0x10, 'Printer busy'],
[0x20, 'Printer turned off'],
[0x40, 'High-voltage adapter fault'],
[0x80, 'Fan motor fault'],
];
const ERROR_BITS_2 = [
[
0x01,
'Media mismatch — the loaded roll does not match the job. A black/red ' +
'roll rejects a monochrome job, and vice versa.',
],
[0x02, 'Expansion buffer full'],
[0x04, 'Communication error'],
[0x08, 'Communication buffer full'],
[0x10, 'Cover is open'],
[0x20, 'Cancelled at the printer'],
[0x40, 'Media cannot be fed, or the end of the media was detected'],
[0x80, 'System error'],
];
const STATUS_TYPES = {
0x00: 'reply',
0x01: 'printing_completed',
0x02: 'error',
0x04: 'turned_off',
0x05: 'notification',
0x06: 'phase_change',
};
const PHASE_TYPES = {
0x00: 'waiting_to_receive',
0x01: 'printing',
};
/** Notification numbers. Cooling pauses printing but is not a failure. */
const NOTIFICATIONS = {
0x00: null,
0x03: 'Print head cooling (started)',
0x04: 'Print head cooling (finished)',
};
/** Byte 4 of the status frame. */
const MODEL_CODES = {
0x38: 'QL-800',
0x39: 'QL-810W',
0x41: 'QL-820NWB',
};
const MEDIA_TYPES = {
0x00: 'none',
0x0a: 'continuous',
0x0b: 'die_cut',
0x4a: 'continuous',
0x4b: 'die_cut',
0xff: 'incompatible',
};
/**
* Decode a status frame.
* @param {Buffer} buf
* @returns {Object|null} null if the buffer isn't a recognisable frame.
*/
function decodeStatus(buf) {
if (!buf || buf.length < FRAME_LENGTH) return null;
if (buf[0] !== 0x80 || buf[1] !== 0x20) return null;
const errors = [];
for (const [bit, message] of ERROR_BITS_1) {
if (buf[8] & bit) errors.push(message);
}
for (const [bit, message] of ERROR_BITS_2) {
if (buf[9] & bit) errors.push(message);
}
const statusType = STATUS_TYPES[buf[18]] || `unknown_0x${buf[18].toString(16)}`;
return {
model: MODEL_CODES[buf[4]] || `unknown_0x${buf[4].toString(16)}`,
errors,
hasError: errors.length > 0 || buf[18] === 0x02,
mediaWidthMm: buf[10],
mediaLengthMm: buf[17],
mediaType: MEDIA_TYPES[buf[11]] || `unknown_0x${buf[11].toString(16)}`,
mediaLoaded: buf[11] !== 0x00,
statusType,
phaseType: PHASE_TYPES[buf[19]] || `unknown_0x${buf[19].toString(16)}`,
phaseNumber: buf.readUInt16BE(20),
notification: NOTIFICATIONS[buf[22]] ?? `unknown_0x${buf[22].toString(16)}`,
cooling: buf[22] === 0x03,
raw: Buffer.from(buf.subarray(0, FRAME_LENGTH)),
};
}
/**
* Split a stream of concatenated frames. The printer often sends several
* (phase change, then printing completed) in one go.
* @param {Buffer} buf
* @returns {Object[]}
*/
function decodeAll(buf) {
const out = [];
for (let offset = 0; offset + FRAME_LENGTH <= buf.length; offset += FRAME_LENGTH) {
const frame = decodeStatus(buf.subarray(offset, offset + FRAME_LENGTH));
if (frame) out.push(frame);
}
return out;
}
/**
* Turn a decoded frame into something worth showing a receptionist.
* Returns null when nothing is wrong.
*/
function describeProblem(status) {
if (!status) return null;
if (status.errors.length > 0) return status.errors.join('; ');
if (status.statusType === 'error') return 'The printer reported an unspecified error';
if (status.statusType === 'turned_off') return 'The printer is turning off';
if (!status.mediaLoaded) return 'No label roll detected';
return null;
}
/**
* Check the loaded roll matches what the job expects.
*
* Worth doing before sending where status is available: a monochrome job on a
* black/red roll is refused outright, and the printer's own message ("change
* it to Monochrome media") points at the driver rather than at the roll
* setting, which sends people the wrong way.
*
* @returns {string|null} a problem description, or null if it matches.
*/
function checkMediaMatches(status, media) {
if (!status) return null;
if (!status.mediaLoaded) return 'No label roll is loaded';
if (status.mediaWidthMm !== media.widthMm) {
return `Wrong roll loaded: printer reports ${status.mediaWidthMm} mm, job needs ${media.widthMm} mm`;
}
const wantDieCut = Boolean(media.dieCut);
const isDieCut = status.mediaType === 'die_cut';
if (wantDieCut !== isDieCut) {
return wantDieCut
? 'Job needs die-cut labels but continuous tape is loaded'
: 'Job needs continuous tape but die-cut labels are loaded';
}
return null;
}
export {
FRAME_LENGTH,
MODEL_CODES,
decodeStatus,
decodeAll,
describeProblem,
checkMediaMatches,
};
-185
View File
@@ -1,185 +0,0 @@
import net from 'node:net';
import { decodeAll, FRAME_LENGTH } from './ql-status.js';
import { buildStatusRequest } from './ql-raster.js';
const DEFAULT_PORT = 9100;
const DEFAULT_CONNECT_TIMEOUT = 5000;
const DEFAULT_JOB_TIMEOUT = 30000;
/**
* Open a socket, run `handler`, and always clean up afterwards.
* The QL only accepts one connection at a time, so every helper here
* connects, does its work, and disconnects.
*/
function withSocket(host, port, connectTimeout, handler) {
return new Promise((resolve, reject) => {
const socket = new net.Socket();
let settled = false;
const finish = (err, value) => {
if (settled) return;
settled = true;
socket.removeAllListeners();
socket.destroy();
if (err) reject(err);
else resolve(value);
};
socket.setTimeout(connectTimeout);
socket.once('timeout', () =>
finish(new Error(`Timed out connecting to ${host}:${port}`))
);
socket.once('error', (err) =>
finish(new Error(`Cannot reach printer at ${host}:${port}${err.message}`))
);
socket.connect(port, host, () => {
socket.setTimeout(0);
handler(socket, finish);
});
});
}
/**
* Probe the printer.
*
* Never throws for the "connected but silent" case, because that is the normal
* result on this hardware: the QL-820NWB accepts jobs on port 9100 but does not
* report state back over TCP — status frames only come back over USB. brother_ql
* documents the same limitation for its network backend. A silent printer is
* therefore reachable-and-probably-fine, not broken, and callers must treat a
* null status as "unknown" rather than "bad".
*
* @returns {Promise<{reachable: boolean, status: Object|null, error: string|null}>}
*/
async function probeStatus(host, options = {}) {
const {
port = DEFAULT_PORT,
connectTimeout = DEFAULT_CONNECT_TIMEOUT,
replyTimeout = 2000,
} = options;
try {
return await withSocket(host, port, connectTimeout, (socket, finish) => {
let received = Buffer.alloc(0);
// No reply is the expected outcome over the network, so this timer is the
// normal path rather than an error path. Keep it short.
const timer = setTimeout(
() => finish(null, { reachable: true, status: null, error: null }),
replyTimeout
);
socket.on('data', (chunk) => {
received = Buffer.concat([received, chunk]);
if (received.length >= FRAME_LENGTH) {
clearTimeout(timer);
const frames = decodeAll(received);
finish(null, {
reachable: true,
status: frames.length ? frames[frames.length - 1] : null,
error: null,
});
}
});
socket.write(buildStatusRequest(), (err) => {
if (err) {
clearTimeout(timer);
finish(null, { reachable: false, status: null, error: err.message });
}
});
});
} catch (err) {
return { reachable: false, status: null, error: err.message };
}
}
/**
* Send a prepared job.
*
* Completion is signalled by the write flushing and the socket closing, NOT by
* a status frame — see probeStatus. If the printer does happen to answer, the
* frames come back as a bonus and a reported error is treated as authoritative.
*
* The write is followed by end(), not destroy(). destroy() can discard data
* still sitting in the kernel send buffer, which for a ~190 KB label job means
* a silently truncated print.
*
* @returns {Promise<{frames: Object[], completed: boolean, statusAvailable: boolean}>}
*/
async function sendJob(host, job, options = {}) {
const {
port = DEFAULT_PORT,
connectTimeout = DEFAULT_CONNECT_TIMEOUT,
jobTimeout = DEFAULT_JOB_TIMEOUT,
graceMs = 3000,
} = options;
return withSocket(host, port, connectTimeout, (socket, finish) => {
let received = Buffer.alloc(0);
const frames = [];
let sawCompleted = false;
let flushed = false;
const done = () =>
finish(null, {
frames,
completed: sawCompleted || flushed,
statusAvailable: frames.length > 0,
});
const timer = setTimeout(() => {
finish(
new Error(
`Printer at ${host}:${port} did not accept the whole job within ${jobTimeout} ms`
)
);
}, jobTimeout);
socket.on('data', (chunk) => {
received = Buffer.concat([received, chunk]);
while (received.length >= FRAME_LENGTH) {
const [frame] = decodeAll(received.subarray(0, FRAME_LENGTH));
received = received.subarray(FRAME_LENGTH);
if (!frame) continue;
frames.push(frame);
if (frame.hasError) {
clearTimeout(timer);
finish(null, { frames, completed: false, statusAvailable: true });
return;
}
if (frame.statusType === 'printing_completed') sawCompleted = true;
}
});
// Peer closed and our data has gone out: the job is delivered.
socket.once('close', () => {
clearTimeout(timer);
done();
});
socket.write(job, (err) => {
if (err) {
clearTimeout(timer);
finish(err);
return;
}
flushed = true;
// Half-close: flushes everything queued, then sends FIN.
socket.end();
// Some units never close their half of the connection. Once our data is
// out the job is delivered, so don't hold the queue open waiting on a FIN
// that may never arrive.
setTimeout(() => {
clearTimeout(timer);
done();
}, graceMs);
});
});
}
export { DEFAULT_PORT, probeStatus, sendJob };
+3 -95
View File
@@ -8,7 +8,6 @@ import { decryptPin, encryptPin, generatePin, pinLookup } from '../pins.js';
import { photoAbsolutePath, deletePhoto, purgeOldPhotos, savePhoto } from '../photos.js'; import { photoAbsolutePath, deletePhoto, purgeOldPhotos, savePhoto } from '../photos.js';
import * as sheets from '../sheets.js'; import * as sheets from '../sheets.js';
import * as tls from '../tls.js'; import * as tls from '../tls.js';
import * as printer from '../printer.js';
import * as users from '../users.js'; import * as users from '../users.js';
import { badgeHtml, listSites, shapeSite, uniqueSlug, escapeHtml as esc } from '../sites.js'; import { badgeHtml, listSites, shapeSite, uniqueSlug, escapeHtml as esc } from '../sites.js';
import { import {
@@ -364,12 +363,7 @@ router.post('/users/:id/reset-2fa', requireOwner, (req, res) => {
router.get('/sites', (req, res) => { router.get('/sites', (req, res) => {
const scope = scopedSiteId(req); const scope = scopedSiteId(req);
const rows = listSites().filter((s) => !scope || s.id === scope); const rows = listSites().filter((s) => !scope || s.id === scope);
res.json( res.json(rows.map(shapeSite));
rows.map((row) => ({
...shapeSite(row),
printerStatus: printer.printerStatus(row.id),
}))
);
}); });
router.post('/sites', requireOwner, (req, res) => { router.post('/sites', requireOwner, (req, res) => {
@@ -391,13 +385,11 @@ router.patch('/sites/:id', (req, res) => {
const badge = req.body?.badge || {}; const badge = req.body?.badge || {};
const branding = req.body?.branding || {}; const branding = req.body?.branding || {};
const printerCfg = req.body?.printer || {};
db.prepare( db.prepare(
`UPDATE sites SET name = ?, slug = ?, active = ?, badge_enabled = ?, badge_width_mm = ?, `UPDATE sites SET name = ?, slug = ?, active = ?, badge_enabled = ?, badge_width_mm = ?,
badge_height_mm = ?, badge_show_photo = ?, badge_accent = ?, badge_note = ?, badge_height_mm = ?, badge_show_photo = ?, badge_accent = ?, badge_note = ?,
colour_brand = ?, colour_signout = ?, colour_page = ?, colour_text = ?, colour_brand = ?, colour_signout = ?, colour_page = ?, colour_text = ?,
banner_height = ?, banner_align = ?, printer_enabled = ?, printer_host = ?, banner_height = ?, banner_align = ? WHERE id = ?`
printer_port = ?, printer_model = ?, printer_rotate = ?, printer_label = ? WHERE id = ?`
).run( ).run(
clean(req.body?.name ?? site.name, 100) || site.name, clean(req.body?.name ?? site.name, 100) || site.name,
req.body?.slug ? uniqueSlug(req.body.slug, site.id) : site.slug, req.body?.slug ? uniqueSlug(req.body.slug, site.id) : site.slug,
@@ -423,19 +415,7 @@ router.patch('/sites/:id', (req, res) => {
: site.banner_height, : site.banner_height,
branding.bannerAlign !== undefined branding.bannerAlign !== undefined
? normaliseAlign(branding.bannerAlign, site.banner_align) ? normaliseAlign(branding.bannerAlign, site.banner_align)
: site.banner_align, : site.banner_align
printerCfg.enabled !== undefined ? (printerCfg.enabled ? 1 : 0) : site.printer_enabled,
printerCfg.host !== undefined ? clean(printerCfg.host, 120) || null : site.printer_host,
printerCfg.port !== undefined
? Math.min(65535, Math.max(1, Number(printerCfg.port) || 9100))
: site.printer_port,
printerCfg.model !== undefined ? clean(printerCfg.model, 40) || 'QL-820NWB' : site.printer_model,
printerCfg.rotate !== undefined
? ([0, 90, 180, 270].includes(Number(printerCfg.rotate)) ? Number(printerCfg.rotate) : 0)
: site.printer_rotate,
printerCfg.label !== undefined
? (Object.hasOwn(printer.ROLL_TYPES, printerCfg.label) ? printerCfg.label : '62')
: site.printer_label
, site.id); , site.id);
res.json(shapeSite(db.prepare('SELECT * FROM sites WHERE id = ?').get(site.id))); res.json(shapeSite(db.prepare('SELECT * FROM sites WHERE id = ?').get(site.id)));
@@ -477,60 +457,6 @@ router.get('/sites/:id/banner', (req, res) => {
res.sendFile(abs); res.sendFile(abs);
}); });
/**
* The exact bitmap that would be sent to the printer, so the layout and the
* rotation can be checked without using a label.
*/
router.get('/sites/:id/badge-bitmap', async (req, res) => {
const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id);
if (!site) return res.status(404).send('Not found.');
try {
const visit = req.query.visitId
? db.prepare('SELECT * FROM visits WHERE id = ?').get(req.query.visitId)
: printer.sampleVisit(site);
if (!visit) return res.status(404).send('No such visit.');
const png = await printer.renderBadgePng(visit, site);
res.setHeader('Content-Type', 'image/png');
res.setHeader('Cache-Control', 'no-store');
res.send(png);
} catch (err) {
res.status(500).send(`Could not render the badge: ${err.message}`);
}
});
router.post('/sites/:id/test-print', async (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.' });
try {
assertSiteAllowed(req, site.id);
} catch (err) {
return res.status(403).json({ error: err.message });
}
try {
const result = await printer.printBadge(printer.sampleVisit(site), site);
res.json({ ok: true, ...result });
} catch (err) {
res.status(400).json({ error: err.message });
}
});
/** 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);
if (!visit) return res.status(404).json({ error: 'Not found.' });
const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(visit.site_id);
if (!site) return res.status(404).json({ error: 'That site no longer exists.' });
if (!printer.isConfigured(site)) {
return res.status(400).json({ error: 'Server printing is not turned on for this site.' });
}
try {
await printer.printBadge(visit, site);
res.json({ ok: true });
} catch (err) {
res.status(400).json({ error: err.message });
}
});
router.get('/sites/:id/badge-preview', (req, res) => { router.get('/sites/:id/badge-preview', (req, res) => {
const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id); const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id);
if (!site) return res.status(404).send('Not found.'); if (!site) return res.status(404).send('Not found.');
@@ -1227,24 +1153,6 @@ router.get('/tls/ca.crt', (req, res) => {
res.send(ca); res.send(ca);
}); });
/**
* The same authority in DER form, for Jamf Pro and anything else built on Apple's
* tooling. Offered as .cer and .der because different consoles insist on
* different extensions for the identical bytes.
*/
router.get(['/tls/ca.cer', '/tls/ca.der'], (req, res) => {
try {
const der = tls.caCertificateDer();
if (!der) return res.status(404).send('No certificate authority has been generated yet.');
const ext = req.path.endsWith('.der') ? 'der' : 'cer';
res.setHeader('Content-Type', 'application/pkix-cert');
res.setHeader('Content-Disposition', `attachment; filename="visitor-signin-ca.${ext}"`);
res.send(der);
} catch (err) {
res.status(500).send(`Could not convert the certificate: ${err.message}`);
}
});
router.post('/tls/renew', requireOwner, (req, res) => { router.post('/tls/renew', requireOwner, (req, res) => {
try { try {
// A brand new CA means every kiosk device has to trust it again, so it is // A brand new CA means every kiosk device has to trust it again, so it is
+2 -27
View File
@@ -7,7 +7,6 @@ import { mirror } from '../sheets.js';
import { verifyPin } from '../pins.js'; import { verifyPin } from '../pins.js';
import { listSites, resolveSite, badgeHtml } from '../sites.js'; import { listSites, resolveSite, badgeHtml } from '../sites.js';
import { themeFor, bannerAbsolutePath } from '../branding.js'; import { themeFor, bannerAbsolutePath } from '../branding.js';
import * as printer from '../printer.js';
import fs from 'node:fs'; import fs from 'node:fs';
import { import {
clean, clean,
@@ -110,15 +109,7 @@ function openVisitFor(siteId, lastName, phone, email) {
/* -------------------------------------------------------------- sign in */ /* -------------------------------------------------------------- sign in */
/** Rejects rather than hanging the front desk on a printer that never answers. */ router.post('/signin', signInLimiter, (req, res) => {
function withTimeout(promise, ms) {
return Promise.race([
promise,
new Promise((_, reject) => setTimeout(() => reject(new Error('Printing timed out.')), ms)),
]);
}
router.post('/signin', signInLimiter, async (req, res) => {
try { try {
const body = req.body || {}; const body = req.body || {};
const site = siteFrom(req); const site = siteFrom(req);
@@ -224,20 +215,6 @@ router.post('/signin', signInLimiter, async (req, res) => {
mirror(); mirror();
delete req.session.frequentVisitorId; delete req.session.frequentVisitorId;
// With a networked printer the server does the printing, so the tablet needs
// no driver and no default printer. It is awaited briefly rather than fired
// and forgotten: if the printer is unreachable the kiosk falls back to its
// own print dialog instead of the visitor walking off without a badge.
let serverPrinted = false;
if (site.badge_enabled && printer.isConfigured(site)) {
try {
await withTimeout(printer.printBadge(visit, site), config.printing.signInWaitMs);
serverPrinted = true;
} catch (err) {
console.error('[print] badge failed, kiosk will fall back:', err.message);
}
}
// Lets this kiosk session fetch the badge for the visit it just created. // Lets this kiosk session fetch the badge for the visit it just created.
req.session.badgeVisitId = visit.id; req.session.badgeVisitId = visit.id;
req.session.badgeIssuedAt = Date.now(); req.session.badgeIssuedAt = Date.now();
@@ -248,9 +225,7 @@ router.post('/signin', signInLimiter, async (req, res) => {
hostName: host.name, hostName: host.name,
signedInAt, signedInAt,
visitId: visit.id, visitId: visit.id,
serverPrinted, badgeUrl: site.badge_enabled ? `/api/badge/${visit.id}` : null,
// Only offered when the server did not already print it.
badgeUrl: site.badge_enabled && !serverPrinted ? `/api/badge/${visit.id}` : null,
}); });
} catch (err) { } catch (err) {
console.error('[signin]', err); console.error('[signin]', err);
-19
View File
@@ -121,25 +121,6 @@ function startRedirectServer() {
http http
.createServer((req, res) => { .createServer((req, res) => {
// DER for Apple tooling, PEM for everything else.
if (req.url === '/ca.cer' || req.url === '/ca.der') {
try {
const der = tls.caCertificateDer();
if (!der) {
res.writeHead(404, { 'Content-Type': 'text/plain' });
return res.end('No certificate authority has been generated yet.');
}
res.writeHead(200, {
'Content-Type': 'application/pkix-cert',
'Content-Disposition': 'attachment; filename="visitor-signin-ca.cer"',
});
return res.end(der);
} catch (err) {
res.writeHead(500, { 'Content-Type': 'text/plain' });
return res.end(`Could not convert the certificate: ${err.message}`);
}
}
if (req.url === '/ca.crt' || req.url === '/ca.pem') { if (req.url === '/ca.crt' || req.url === '/ca.pem') {
const ca = tls.caCertificate(); const ca = tls.caCertificate();
if (!ca) { if (!ca) {
-8
View File
@@ -59,14 +59,6 @@ export function shapeSite(site) {
text: site.colour_text, text: site.colour_text,
theme: themeFor(site), theme: themeFor(site),
}, },
printer: {
enabled: Boolean(site.printer_enabled),
host: site.printer_host,
port: site.printer_port || 9100,
model: site.printer_model || 'QL-820NWB',
rotate: site.printer_rotate || 0,
label: site.printer_label || '62',
},
badge: { badge: {
enabled: Boolean(site.badge_enabled), enabled: Boolean(site.badge_enabled),
widthMm: site.badge_width_mm, widthMm: site.badge_width_mm,
-25
View File
@@ -194,18 +194,6 @@ export function ensureCertificates({ force = false } = {}) {
fs.writeFileSync(p.names, JSON.stringify(config.https.hostnames)); fs.writeFileSync(p.names, JSON.stringify(config.https.hostnames));
} }
// A very common mistake is editing .env and then using `docker compose restart`,
// which reuses the old environment. The symptom is a certificate covering only
// the defaults, so say so rather than letting it fail later in a browser.
const configured = config.https.hostnames;
if (configured.length === 1 && configured[0] === 'visitors.local') {
console.warn(
'[tls] HTTPS_HOSTNAMES is at its default. If you set it in .env, bring the container\n' +
' back with "docker compose up -d" rather than "docker compose restart" — restart\n' +
' keeps the environment the container started with.'
);
}
return { return {
key: fs.readFileSync(p.key), key: fs.readFileSync(p.key),
cert: fs.readFileSync(p.cert), cert: fs.readFileSync(p.cert),
@@ -237,19 +225,6 @@ export function describe() {
}; };
} }
/**
* The CA in DER form.
*
* The .crt on disk is PEM: base64 text between BEGIN/END lines. Apple's tooling,
* and therefore Jamf Pro's certificate payload, wants the raw binary DER instead
* and rejects the file on its extension. Same certificate, different wrapper.
*/
export function caCertificateDer() {
const p = paths();
if (!fs.existsSync(p.caCert)) return null;
return openssl(['x509', '-in', p.caCert, '-outform', 'der']);
}
export function caCertificate() { export function caCertificate() {
const p = paths(); const p = paths();
return fs.existsSync(p.caCert) ? fs.readFileSync(p.caCert) : null; return fs.existsSync(p.caCert) ? fs.readFileSync(p.caCert) : null;