Public Access
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3ce46913d | ||
|
|
e6731dbdfa | ||
|
|
8c97c314e0 | ||
|
|
ff9f51eaf9 | ||
|
|
74792b2764 | ||
|
|
c21123885d | ||
|
|
cccba98d97 | ||
|
|
a7564b9033 | ||
|
|
9da7e88eb3 | ||
|
|
a011587d66 | ||
|
|
14678e13e8 | ||
|
|
b23ad422d0 | ||
|
|
ed77493817 |
@@ -76,13 +76,5 @@ GOOGLE_CREDENTIALS_PATH=/secrets/google-service-account.json
|
||||
GOOGLE_CREDENTIALS_B64=
|
||||
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
|
||||
DATA_DIR=/data
|
||||
|
||||
+1
-5
@@ -11,11 +11,7 @@ RUN npm install --omit=dev
|
||||
FROM node:22-bookworm-slim
|
||||
ENV NODE_ENV=production
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
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" \
|
||||
&& apt-get install -y --no-install-recommends openssl ca-certificates tini util-linux \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -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-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
|
||||
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
|
||||
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
|
||||
obvious across a room. It only works on a DK-22251 roll — on any other roll the printer renders
|
||||
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.
|
||||
|
||||
### 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 server then renders the badge itself and pushes it to the printer over the network, so:
|
||||
|
||||
- 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%.
|
||||
The QL-820NWB has Ethernet and Wi-Fi, so it does not need to hang off the kiosk tablet. Install
|
||||
it as a network printer on whichever device drives the kiosk browser.
|
||||
|
||||
## WWCC and VIT expiry warnings
|
||||
|
||||
@@ -277,25 +251,9 @@ HOST_PORT=8443
|
||||
HTTPS_PUBLIC_PORT=8443
|
||||
```
|
||||
|
||||
An address that isn't listed produces a browser warning.
|
||||
|
||||
After changing anything in `.env`, bring the container back with **`docker compose up -d`**, not
|
||||
`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.
|
||||
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
|
||||
it without any further work.
|
||||
|
||||
The kiosk is then at `https://visitors.local:8443`, admin at `https://visitors.local:8443/admin`.
|
||||
|
||||
|
||||
@@ -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 Floyd–Steinberg on the photo region before it goes
|
||||
on the canvas, inside `drawBadge`.
|
||||
|
||||
---
|
||||
|
||||
Created by: Jess Rogerson (yelling commands at Claude.AI)
|
||||
@@ -13,7 +13,6 @@
|
||||
"node": ">=20"
|
||||
},
|
||||
"dependencies": {
|
||||
"@napi-rs/canvas": "^1.0.8",
|
||||
"better-sqlite3": "^11.5.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.21.1",
|
||||
|
||||
+3
-122
@@ -184,7 +184,6 @@ async function loadOnsite() {
|
||||
<td class="mono">${stamp(v.signedInAt)}</td>
|
||||
<td class="actions">
|
||||
<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>
|
||||
</td>
|
||||
</tr>`
|
||||
@@ -202,19 +201,6 @@ async function loadOnsite() {
|
||||
$$('[data-badge]').forEach((btn) =>
|
||||
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());
|
||||
@@ -687,8 +673,6 @@ async function loadSites() {
|
||||
<div class="actions">
|
||||
<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-bitmap="${s.id}">Bitmap preview</button>
|
||||
${s.printer.enabled ? `<button class="ghost" data-test-print="${s.id}">Test print</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<dl class="site-meta">
|
||||
@@ -699,20 +683,6 @@ async function loadSites() {
|
||||
: 'Off'
|
||||
}</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>
|
||||
<dd>
|
||||
${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')
|
||||
)
|
||||
);
|
||||
$$('[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>
|
||||
<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>
|
||||
<p class="hint" id="accent-note"></p>
|
||||
<p class="hint">Two-colour printing is 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>
|
||||
<p class="hint">Red needs a two-colour roll such as the Brother DK-22251. On any other
|
||||
roll it prints as grey. Two-colour printing is also much slower than black alone.</p>
|
||||
${field('Line printed at the bottom', 'note', site.badge.note)}
|
||||
<h4 class="modal-section">Kiosk branding</h4>
|
||||
<div class="banner-editor">
|
||||
@@ -884,14 +803,6 @@ function openSiteModal(site) {
|
||||
accent: form.has('accent'),
|
||||
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: {
|
||||
brand: data.brand || null,
|
||||
signout: data.signout || null,
|
||||
@@ -1000,35 +911,6 @@ function wireBannerEditor() {
|
||||
[pageInput, textInput].forEach((el) => el.addEventListener('input', 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) => {
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
@@ -1391,8 +1273,7 @@ function renderTls(tls) {
|
||||
itself before it lapses, and devices that trust the authority keep working without being
|
||||
touched again.</p>
|
||||
<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.cer" download>CA certificate (.cer, for Jamf and Apple)</a>
|
||||
<a class="ghost" href="/admin/api/tls/ca.crt" download>Download the CA certificate</a>
|
||||
<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>
|
||||
</div>`;
|
||||
|
||||
+1
-4
@@ -382,12 +382,9 @@ async function submitSignIn() {
|
||||
});
|
||||
stopCamera();
|
||||
$('#done-in-message').textContent = `You're all set, ${result.firstName}.`;
|
||||
const printing = result.serverPrinted || result.badgeUrl;
|
||||
$('#done-in-detail').textContent = printing
|
||||
$('#done-in-detail').textContent = result.badgeUrl
|
||||
? `${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.`;
|
||||
// With server printing the badge is already coming out of the label printer,
|
||||
// so the kiosk neither prints nor offers to.
|
||||
lastBadgeUrl = result.badgeUrl;
|
||||
$('#reprint-badge').hidden = !result.badgeUrl;
|
||||
if (result.badgeUrl) printBadge(result.badgeUrl);
|
||||
|
||||
+20
-90
@@ -1,112 +1,42 @@
|
||||
# Pushes this folder to the Gitea repo.
|
||||
#
|
||||
# Run from PowerShell inside the visitor-signin folder:
|
||||
# Pushes this folder into the Gitea repo created for it.
|
||||
# Run from PowerShell, inside the visitor-signin folder:
|
||||
# .\push-to-gitea.ps1
|
||||
# If Windows blocks it:
|
||||
# powershell -ExecutionPolicy Bypass -File .\push-to-gitea.ps1
|
||||
# If Windows blocks it: powershell -ExecutionPolicy Bypass -File .\push-to-gitea.ps1
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$Remote = 'https://gitea.hideawaygaming.com.au/jessikitty/visitor-signin.git'
|
||||
|
||||
# git reports failure through its exit code, not as a PowerShell error, so every
|
||||
# call has to be checked. Without this the script reports success after a
|
||||
# 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
|
||||
if (-not (Test-Path 'package.json')) {
|
||||
Write-Error 'Run this from inside the visitor-signin folder.'
|
||||
exit 1
|
||||
}
|
||||
|
||||
if (-not (Test-Path 'package.json')) { Fail 'Run this from inside the visitor-signin folder.' }
|
||||
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
|
||||
|
||||
if (-not (Test-Path '.git')) {
|
||||
Write-Host 'Setting up a new local repository...'
|
||||
if ((Invoke-Git init -b main) -ne 0) { Fail 'git init failed.' }
|
||||
git init -b main
|
||||
} 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
|
||||
} else {
|
||||
git remote add origin $Remote
|
||||
}
|
||||
|
||||
# ------------------------------------------------- finish what was started
|
||||
|
||||
# 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.'
|
||||
}
|
||||
git push -u origin main
|
||||
|
||||
Write-Host ''
|
||||
Write-Host 'Pushed successfully.' -ForegroundColor Green
|
||||
Write-Host 'https://gitea.hideawaygaming.com.au/jessikitty/visitor-signin'
|
||||
Write-Host 'Pushed. Repo: https://gitea.hideawaygaming.com.au/jessikitty/visitor-signin' -ForegroundColor Green
|
||||
Write-Host 'Sign in with your Gitea username and password, or a token as the password.'
|
||||
|
||||
+12
-70
@@ -1,78 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
# Pushes this folder to the Gitea repo. Run from inside the visitor-signin folder.
|
||||
set -uo pipefail
|
||||
# Pushes this folder into the (empty) Gitea repo created for it.
|
||||
# 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"
|
||||
|
||||
fail() { printf '\n%s\n' "$1" >&2; exit 1; }
|
||||
|
||||
[ -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"
|
||||
if [ ! -f package.json ]; then
|
||||
echo "Run this from inside the visitor-signin folder." >&2
|
||||
exit 1
|
||||
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
|
||||
fi
|
||||
done
|
||||
|
||||
git add -A
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
read -r -p "Describe this change (enter for a dated default): " MSG
|
||||
[ -n "$MSG" ] || MSG="Update $(date '+%Y-%m-%d %H:%M')"
|
||||
git commit -m "$MSG" || fail "git commit failed."
|
||||
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."
|
||||
git init -b main
|
||||
git add .
|
||||
git commit -m "Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA"
|
||||
git remote add origin "$REMOTE" 2>/dev/null || git remote set-url origin "$REMOTE"
|
||||
git push -u origin main
|
||||
|
||||
echo
|
||||
echo "Pushed successfully."
|
||||
echo "https://gitea.hideawaygaming.com.au/jessikitty/visitor-signin"
|
||||
echo "Pushed. Repo: https://gitea.hideawaygaming.com.au/jessikitty/visitor-signin"
|
||||
|
||||
@@ -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
@@ -25,10 +25,8 @@ fi
|
||||
|
||||
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
|
||||
echo "Recreating the container so the new certificate is served..."
|
||||
# up -d rather than restart: restart keeps the environment the container was
|
||||
# started with, so an edited .env would be ignored.
|
||||
docker compose up -d visitor-signin
|
||||
echo "Restarting so the new certificate is served..."
|
||||
docker compose restart visitor-signin
|
||||
else
|
||||
node scripts/make-cert.mjs $FORCE
|
||||
fi
|
||||
|
||||
@@ -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');
|
||||
@@ -46,15 +46,6 @@ export const config = {
|
||||
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.
|
||||
expiryWarningDays: int(process.env.EXPIRY_WARNING_DAYS, 28),
|
||||
|
||||
|
||||
@@ -22,12 +22,6 @@ CREATE TABLE IF NOT EXISTS sites (
|
||||
badge_show_photo INTEGER NOT NULL DEFAULT 1,
|
||||
badge_accent INTEGER NOT NULL DEFAULT 0,
|
||||
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_height INTEGER NOT NULL DEFAULT 64,
|
||||
banner_align TEXT NOT NULL DEFAULT 'left',
|
||||
@@ -178,16 +172,6 @@ addColumn('sites', 'colour_page', 'TEXT');
|
||||
addColumn('sites', 'colour_text', 'TEXT');
|
||||
// Optional "who are you from", handy for contractors and visiting staff.
|
||||
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');
|
||||
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_visits_site ON visits(site_id, signed_out_at)');
|
||||
|
||||
-401
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
@@ -8,7 +8,6 @@ import { decryptPin, encryptPin, generatePin, pinLookup } from '../pins.js';
|
||||
import { photoAbsolutePath, deletePhoto, purgeOldPhotos, savePhoto } from '../photos.js';
|
||||
import * as sheets from '../sheets.js';
|
||||
import * as tls from '../tls.js';
|
||||
import * as printer from '../printer.js';
|
||||
import * as users from '../users.js';
|
||||
import { badgeHtml, listSites, shapeSite, uniqueSlug, escapeHtml as esc } from '../sites.js';
|
||||
import {
|
||||
@@ -364,12 +363,7 @@ router.post('/users/:id/reset-2fa', requireOwner, (req, res) => {
|
||||
router.get('/sites', (req, res) => {
|
||||
const scope = scopedSiteId(req);
|
||||
const rows = listSites().filter((s) => !scope || s.id === scope);
|
||||
res.json(
|
||||
rows.map((row) => ({
|
||||
...shapeSite(row),
|
||||
printerStatus: printer.printerStatus(row.id),
|
||||
}))
|
||||
);
|
||||
res.json(rows.map(shapeSite));
|
||||
});
|
||||
|
||||
router.post('/sites', requireOwner, (req, res) => {
|
||||
@@ -391,13 +385,11 @@ router.patch('/sites/:id', (req, res) => {
|
||||
|
||||
const badge = req.body?.badge || {};
|
||||
const branding = req.body?.branding || {};
|
||||
const printerCfg = req.body?.printer || {};
|
||||
db.prepare(
|
||||
`UPDATE sites SET name = ?, slug = ?, active = ?, badge_enabled = ?, badge_width_mm = ?,
|
||||
badge_height_mm = ?, badge_show_photo = ?, badge_accent = ?, badge_note = ?,
|
||||
colour_brand = ?, colour_signout = ?, colour_page = ?, colour_text = ?,
|
||||
banner_height = ?, banner_align = ?, printer_enabled = ?, printer_host = ?,
|
||||
printer_port = ?, printer_model = ?, printer_rotate = ?, printer_label = ? WHERE id = ?`
|
||||
banner_height = ?, banner_align = ? WHERE id = ?`
|
||||
).run(
|
||||
clean(req.body?.name ?? site.name, 100) || site.name,
|
||||
req.body?.slug ? uniqueSlug(req.body.slug, site.id) : site.slug,
|
||||
@@ -423,19 +415,7 @@ router.patch('/sites/:id', (req, res) => {
|
||||
: site.banner_height,
|
||||
branding.bannerAlign !== undefined
|
||||
? normaliseAlign(branding.bannerAlign, 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.banner_align
|
||||
, 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);
|
||||
});
|
||||
|
||||
/**
|
||||
* 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) => {
|
||||
const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id);
|
||||
if (!site) return res.status(404).send('Not found.');
|
||||
@@ -1227,24 +1153,6 @@ router.get('/tls/ca.crt', (req, res) => {
|
||||
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) => {
|
||||
try {
|
||||
// A brand new CA means every kiosk device has to trust it again, so it is
|
||||
|
||||
+2
-27
@@ -7,7 +7,6 @@ import { mirror } from '../sheets.js';
|
||||
import { verifyPin } from '../pins.js';
|
||||
import { listSites, resolveSite, badgeHtml } from '../sites.js';
|
||||
import { themeFor, bannerAbsolutePath } from '../branding.js';
|
||||
import * as printer from '../printer.js';
|
||||
import fs from 'node:fs';
|
||||
import {
|
||||
clean,
|
||||
@@ -110,15 +109,7 @@ function openVisitFor(siteId, lastName, phone, email) {
|
||||
|
||||
/* -------------------------------------------------------------- sign in */
|
||||
|
||||
/** Rejects rather than hanging the front desk on a printer that never answers. */
|
||||
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) => {
|
||||
router.post('/signin', signInLimiter, (req, res) => {
|
||||
try {
|
||||
const body = req.body || {};
|
||||
const site = siteFrom(req);
|
||||
@@ -224,20 +215,6 @@ router.post('/signin', signInLimiter, async (req, res) => {
|
||||
mirror();
|
||||
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.
|
||||
req.session.badgeVisitId = visit.id;
|
||||
req.session.badgeIssuedAt = Date.now();
|
||||
@@ -248,9 +225,7 @@ router.post('/signin', signInLimiter, async (req, res) => {
|
||||
hostName: host.name,
|
||||
signedInAt,
|
||||
visitId: visit.id,
|
||||
serverPrinted,
|
||||
// Only offered when the server did not already print it.
|
||||
badgeUrl: site.badge_enabled && !serverPrinted ? `/api/badge/${visit.id}` : null,
|
||||
badgeUrl: site.badge_enabled ? `/api/badge/${visit.id}` : null,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[signin]', err);
|
||||
|
||||
@@ -121,25 +121,6 @@ function startRedirectServer() {
|
||||
|
||||
http
|
||||
.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') {
|
||||
const ca = tls.caCertificate();
|
||||
if (!ca) {
|
||||
|
||||
@@ -59,14 +59,6 @@ export function shapeSite(site) {
|
||||
text: site.colour_text,
|
||||
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: {
|
||||
enabled: Boolean(site.badge_enabled),
|
||||
widthMm: site.badge_width_mm,
|
||||
|
||||
-25
@@ -194,18 +194,6 @@ export function ensureCertificates({ force = false } = {}) {
|
||||
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 {
|
||||
key: fs.readFileSync(p.key),
|
||||
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() {
|
||||
const p = paths();
|
||||
return fs.existsSync(p.caCert) ? fs.readFileSync(p.caCert) : null;
|
||||
|
||||
Reference in New Issue
Block a user