Public Access
Compare commits
12
Commits
deploy
..
2a62450238
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a62450238 | ||
|
|
a77d42a1e4 | ||
|
|
1a94c33304 | ||
|
|
cb7bcff93e | ||
|
|
8d2111ff41 | ||
|
|
b4001c97c8 | ||
|
|
851e021fc2 | ||
|
|
abb752c3ef | ||
|
|
52eb0147fd | ||
|
|
0c1ef93552 | ||
|
|
26701d0d3b | ||
|
|
1b4b9d662a |
@@ -179,31 +179,6 @@ the widest roll it will accept — the console warns you if you enter anything w
|
|||||||
| DK-11208 | 38 × 90 mm die-cut | Narrower; turn the photo off |
|
| DK-11208 | 38 × 90 mm die-cut | Narrower; turn the photo off |
|
||||||
| DK-11209 | 29 × 62 mm die-cut | Name and host only |
|
| DK-11209 | 29 × 62 mm die-cut | Name and host only |
|
||||||
|
|
||||||
**A DK-22251 must be set as the black/red roll even if nothing on the badge is red.** The printer
|
|
||||||
refuses a monochrome job on two-colour tape, saying *Black/Red on White paper is installed now.
|
|
||||||
Change it to Monochrome media.* The roll setting controls how the job is built, not just its
|
|
||||||
colour.
|
|
||||||
|
|
||||||
### Photos on the badge
|
|
||||||
|
|
||||||
A thermal printer has one bit per dot: every pixel is either burnt or not. A photo therefore has
|
|
||||||
to be reduced to pure black and white, and how that is done makes the difference between a
|
|
||||||
recognisable face and a few solid blobs.
|
|
||||||
|
|
||||||
Under **Sites → Edit → Badge printing**:
|
|
||||||
|
|
||||||
| Setting | What it does |
|
|
||||||
|---|---|
|
|
||||||
| Error diffusion | Scatters the rounding error into neighbouring dots, so mid tones survive as a pattern. The default, and much the best for faces. |
|
|
||||||
| Hard threshold | Every pixel darker than the cut becomes solid black. Crisp for line art, ruinous for photographs. |
|
|
||||||
| Leave it to the driver | Sends greyscale and lets `brother_ql` decide. |
|
|
||||||
| Threshold | Where the cut falls. Higher is darker. |
|
|
||||||
| Contrast | Applied before the reduction. Webcam photos are flat and flatten further at one bit, so a lift of 20 to 30 usually helps. |
|
|
||||||
|
|
||||||
**Preview the photo settings** renders the most recent real visitor photo at several settings side
|
|
||||||
by side, so the choice is made by eye. The halftoning happens in the server's renderer, not in the
|
|
||||||
printer driver, so the preview and the printed label are the same image.
|
|
||||||
|
|
||||||
**Tell it which roll is loaded.** *Roll loaded in the printer* under **Sites → Edit → Printer**
|
**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
|
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
|
outright: the printer shows **Wrong Roll Type** and nothing comes out. The setting is separate
|
||||||
@@ -237,55 +212,6 @@ it is laid out along the length and turned before printing, which is what you wa
|
|||||||
hangs from its short edge. Set it per site and check the bitmap preview — the two look very
|
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.
|
different and only one will suit how you hang them.
|
||||||
|
|
||||||
**Label stock** at the top of the badge settings is a shortcut that fills in the size, the roll
|
|
||||||
type and the colour option. It is not itself a saved setting — the three fields it fills are what
|
|
||||||
get stored, which is why it can appear to "revert" when the same dimensions describe two rolls.
|
|
||||||
|
|
||||||
**Ask the printer what it has loaded.** This is the first thing to run when a job is refused:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose exec visitor-signin node scripts/printer-status.mjs
|
|
||||||
```
|
|
||||||
|
|
||||||
It reports the media width, whether the roll is continuous or die-cut, any error the printer is
|
|
||||||
holding, and the roll id that matches.
|
|
||||||
|
|
||||||
**It does not work on every printer.** Many Brother network print servers are write-only on port
|
|
||||||
9100: they accept jobs but never answer a status request, even though the same printer reports
|
|
||||||
happily over USB. The QL-820NWB is one of them. When that happens, **Brother Status Monitor on a
|
|
||||||
PC with the printer installed is the thing to use** — it gives the exact reason a job was refused,
|
|
||||||
in plain words, which is more than anything on the server can tell you. `brother_ql`'s network
|
|
||||||
backend never reads the socket at all, so it reports success no matter what the printer does.
|
|
||||||
|
|
||||||
If the printer will not answer, work through the possibilities one at a time:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose exec -it visitor-signin node scripts/print-probe.mjs
|
|
||||||
```
|
|
||||||
|
|
||||||
It sends one label per roll id and waits for you to say whether anything came out, then moves on.
|
|
||||||
The `-it` matters: it asks questions and needs a terminal.
|
|
||||||
|
|
||||||
**Diagnostics from the command line.** When the console is not enough:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose exec visitor-signin node scripts/print-test.mjs # render and print
|
|
||||||
docker compose exec visitor-signin node scripts/print-test.mjs --dry # render only
|
|
||||||
docker compose exec visitor-signin node scripts/print-test.mjs --label 62x100
|
|
||||||
```
|
|
||||||
|
|
||||||
It prints the settings it is using, the exact `brother_ql` command, and the printer's full reply.
|
|
||||||
`--label` and `--rotate` override the saved settings for one run, so alternatives can be tried
|
|
||||||
without saving anything.
|
|
||||||
|
|
||||||
**Continuous versus die-cut matters.** `62` means a continuous roll cut to length; `62x100` means
|
|
||||||
pre-cut labels. Sending one id to the other kind of roll is refused as a wrong roll, and this is
|
|
||||||
the most common cause of that error after the two-colour setting.
|
|
||||||
|
|
||||||
**Diagnostics.** The site card has a **Diagnostics** button showing exactly what would be sent,
|
|
||||||
including the `brother_ql` command line, so it can be run by hand on the host. A failed test print
|
|
||||||
shows the printer's own words rather than a summary.
|
|
||||||
|
|
||||||
**If the printer cannot be reached**, sign in still completes. The kiosk falls back to its own
|
**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
|
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.
|
reason. Admins can reprint any badge from the **On site** list.
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
# 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)
|
||||||
+1
-5
@@ -7,11 +7,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node src/server.js",
|
"start": "node src/server.js",
|
||||||
"dev": "node --watch src/server.js",
|
"dev": "node --watch src/server.js",
|
||||||
"version": "node scripts/version.mjs",
|
"gen-secret": "node -e \"console.log(require('crypto').randomBytes(32).toString('hex'))\""
|
||||||
"gen-secret": "node -e \"console.log(require('crypto').randomBytes(32).toString('hex'))\"",
|
|
||||||
"print-test": "node scripts/print-test.mjs",
|
|
||||||
"printer-status": "node scripts/printer-status.mjs",
|
|
||||||
"print-probe": "node scripts/print-probe.mjs"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
|
|||||||
@@ -400,48 +400,3 @@ tr.row-bad td { background: #fdf0f2; }
|
|||||||
vertical-align: -2px;
|
vertical-align: -2px;
|
||||||
margin-right: 2px;
|
margin-right: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
pre.raw {
|
|
||||||
margin: 0 0 14px;
|
|
||||||
padding: 12px;
|
|
||||||
background: var(--paper);
|
|
||||||
border: 1px solid var(--rule);
|
|
||||||
border-radius: 3px;
|
|
||||||
font-family: ui-monospace, Menlo, Consolas, monospace;
|
|
||||||
font-size: 12.5px;
|
|
||||||
line-height: 1.45;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
|
||||||
max-height: 320px;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The recommended download should not look identical to the two fallbacks. */
|
|
||||||
.sys-actions .primary-link {
|
|
||||||
border-color: var(--deep);
|
|
||||||
background: var(--deep);
|
|
||||||
color: #fff;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --------------------------------------------------- photo halftoning */
|
|
||||||
|
|
||||||
.photo-tuning {
|
|
||||||
margin: 12px 0 16px;
|
|
||||||
padding: 14px;
|
|
||||||
border: 1px solid var(--rule);
|
|
||||||
border-radius: 3px;
|
|
||||||
background: var(--paper);
|
|
||||||
}
|
|
||||||
.photo-tuning input[type="range"] { width: 100%; }
|
|
||||||
.photo-tuning .modal-field span b { font-variant-numeric: tabular-nums; }
|
|
||||||
.photo-tuning .hint { margin: 10px 0; }
|
|
||||||
|
|
||||||
.photo-preview {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
margin-top: 12px;
|
|
||||||
border: 1px solid var(--rule);
|
|
||||||
border-radius: 3px;
|
|
||||||
background: #fff;
|
|
||||||
}
|
|
||||||
|
|||||||
+16
-113
@@ -689,7 +689,6 @@ async function loadSites() {
|
|||||||
<button class="ghost" data-preview-badge="${s.id}">Preview badge</button>
|
<button class="ghost" data-preview-badge="${s.id}">Preview badge</button>
|
||||||
<button class="ghost" data-bitmap="${s.id}">Bitmap preview</button>
|
<button class="ghost" data-bitmap="${s.id}">Bitmap preview</button>
|
||||||
${s.printer.enabled ? `<button class="ghost" data-test-print="${s.id}">Test print</button>` : ''}
|
${s.printer.enabled ? `<button class="ghost" data-test-print="${s.id}">Test print</button>` : ''}
|
||||||
${s.printer.enabled ? `<button class="ghost" data-print-diag="${s.id}">Diagnostics</button>` : ''}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<dl class="site-meta">
|
<dl class="site-meta">
|
||||||
@@ -741,17 +740,6 @@ async function loadSites() {
|
|||||||
window.open(`/admin/api/sites/${btn.dataset.bitmap}/badge-bitmap`, '_blank')
|
window.open(`/admin/api/sites/${btn.dataset.bitmap}/badge-bitmap`, '_blank')
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
$$('[data-print-diag]').forEach((btn) =>
|
|
||||||
btn.addEventListener('click', async () => {
|
|
||||||
const d = await api(`/sites/${btn.dataset.printDiag}/printer-diagnostics`);
|
|
||||||
openModal(
|
|
||||||
'Printer diagnostics',
|
|
||||||
`<pre class="raw">${esc(JSON.stringify(d, null, 2))}</pre>`,
|
|
||||||
null,
|
|
||||||
{ saveLabel: 'Close', hideCancel: true }
|
|
||||||
);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
$$('[data-test-print]').forEach((btn) =>
|
$$('[data-test-print]').forEach((btn) =>
|
||||||
btn.addEventListener('click', async () => {
|
btn.addEventListener('click', async () => {
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
@@ -760,17 +748,7 @@ async function loadSites() {
|
|||||||
await api(`/sites/${btn.dataset.testPrint}/test-print`, { method: 'POST' });
|
await api(`/sites/${btn.dataset.testPrint}/test-print`, { method: 'POST' });
|
||||||
toast('Sent to the printer.');
|
toast('Sent to the printer.');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Show the printer's own words as well as the summary, because a refusal
|
toast(err.message, true);
|
||||||
// usually names the reason and the summary cannot cover every case.
|
|
||||||
const raw = err.payload?.raw;
|
|
||||||
openModal(
|
|
||||||
'The printer refused the job',
|
|
||||||
`<p>${esc(err.message)}</p>
|
|
||||||
${err.payload?.command ? `<p class="hint">Command:</p><pre class="raw">${esc(err.payload.command)}</pre>` : ''}
|
|
||||||
${raw ? `<p class="hint">What the printer and brother_ql said:</p><pre class="raw">${esc(raw)}</pre>` : ''}`,
|
|
||||||
null,
|
|
||||||
{ saveLabel: 'Close', hideCancel: true }
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.textContent = 'Test print';
|
btn.textContent = 'Test print';
|
||||||
@@ -786,29 +764,16 @@ async function loadSites() {
|
|||||||
* 62 mm is for a different printer.
|
* 62 mm is for a different printer.
|
||||||
*/
|
*/
|
||||||
const LABEL_PRESETS = [
|
const LABEL_PRESETS = [
|
||||||
{ id: 'dk22205-90', label: 'Brother DK-22205 continuous, cut at 90 mm', w: 62, h: 90, photo: true, roll: '62' },
|
{ id: 'dk22205-90', label: 'Brother DK-22205 continuous, cut at 90 mm', w: 62, h: 90, photo: true },
|
||||||
{ id: 'dk11202', label: 'Brother DK-11202 die-cut 62 × 100 mm', w: 62, h: 100, photo: true, roll: '62' },
|
{ id: 'dk11202', label: 'Brother DK-11202 die-cut 62 × 100 mm', w: 62, h: 100, photo: true },
|
||||||
{ id: 'dk22251-90', label: 'Brother DK-22251 black/red continuous, cut at 90 mm', w: 62, h: 90, photo: true, roll: '62red', accent: true },
|
{ id: 'dk22251-90', label: 'Brother DK-22251 black/red continuous, cut at 90 mm', w: 62, h: 90, photo: true, accent: true },
|
||||||
{ id: 'dk11208', label: 'Brother DK-11208 die-cut 38 × 90 mm', w: 38, h: 90, photo: false, roll: '62' },
|
{ id: 'dk11208', label: 'Brother DK-11208 die-cut 38 × 90 mm', w: 38, h: 90, photo: false },
|
||||||
{ id: 'dk11209', label: 'Brother DK-11209 die-cut 29 × 62 mm', w: 29, h: 62, photo: false, roll: '62' },
|
{ id: 'dk11209', label: 'Brother DK-11209 die-cut 29 × 62 mm', w: 29, h: 62, photo: false },
|
||||||
{ id: 'dk11201', label: 'Brother DK-11201 die-cut 29 × 90 mm', w: 29, h: 90, photo: false, roll: '62' },
|
{ id: 'dk11201', label: 'Brother DK-11201 die-cut 29 × 90 mm', w: 29, h: 90, photo: false },
|
||||||
{ id: 'card', label: 'Card size 86 × 54 mm (not a QL-820NWB size)', w: 86, h: 54, photo: true, roll: '62' },
|
{ id: 'card', label: 'Card size 86 × 54 mm (not a QL-820NWB size)', w: 86, h: 54, photo: true },
|
||||||
{ id: 'dymo99014', label: 'Dymo 99014 101 × 54 mm', w: 101, h: 54, photo: true, roll: '62' },
|
{ id: 'dymo99014', label: 'Dymo 99014 101 × 54 mm', w: 101, h: 54, photo: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
|
||||||
* Which preset the saved settings correspond to. Dimensions alone are ambiguous —
|
|
||||||
* DK-22205 and DK-22251 are both 62 x 90 — so the roll type decides between them.
|
|
||||||
*/
|
|
||||||
function matchingPreset(site) {
|
|
||||||
return LABEL_PRESETS.find(
|
|
||||||
(p) =>
|
|
||||||
Number(site.badge.widthMm) === p.w &&
|
|
||||||
Number(site.badge.heightMm) === p.h &&
|
|
||||||
p.roll === (site.printer.label === '62red' ? '62red' : '62')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function openSiteModal(site) {
|
function openSiteModal(site) {
|
||||||
openModal(
|
openModal(
|
||||||
`Edit ${site.name}`,
|
`Edit ${site.name}`,
|
||||||
@@ -822,35 +787,17 @@ function openSiteModal(site) {
|
|||||||
<option value="">Custom size</option>
|
<option value="">Custom size</option>
|
||||||
${LABEL_PRESETS.map(
|
${LABEL_PRESETS.map(
|
||||||
(p) =>
|
(p) =>
|
||||||
`<option value="${p.id}" ${matchingPreset(site)?.id === p.id ? 'selected' : ''}>${esc(p.label)}</option>`
|
`<option value="${p.id}" ${
|
||||||
|
Number(site.badge.widthMm) === p.w && Number(site.badge.heightMm) === p.h ? 'selected' : ''
|
||||||
|
}>${esc(p.label)}</option>`
|
||||||
).join('')}
|
).join('')}
|
||||||
</select></label>
|
</select></label>
|
||||||
<p class="hint">A shortcut that fills in the boxes below. The size, the roll type and the
|
|
||||||
colour option are what actually get saved.</p>
|
|
||||||
<div class="modal-row">
|
<div class="modal-row">
|
||||||
${field('Width (mm)', 'widthMm', site.badge.widthMm, 'number')}
|
${field('Width (mm)', 'widthMm', site.badge.widthMm, 'number')}
|
||||||
${field('Height (mm)', 'heightMm', site.badge.heightMm, 'number')}
|
${field('Height (mm)', 'heightMm', site.badge.heightMm, 'number')}
|
||||||
</div>
|
</div>
|
||||||
<p class="hint" id="badge-warning" hidden></p>
|
<p class="hint" id="badge-warning" hidden></p>
|
||||||
<label class="inline"><input type="checkbox" name="showPhoto" id="badge-photo" ${site.badge.showPhoto ? 'checked' : ''}> Include the visitor's photo</label>
|
<label class="inline"><input type="checkbox" name="showPhoto" id="badge-photo" ${site.badge.showPhoto ? 'checked' : ''}> Include the visitor's photo</label>
|
||||||
<div class="photo-tuning">
|
|
||||||
<label class="modal-field"><span>Photo rendering</span>
|
|
||||||
<select name="photoMode" id="photo-mode">
|
|
||||||
<option value="dither" ${site.photo.mode === 'dither' ? 'selected' : ''}>Error diffusion — best for faces</option>
|
|
||||||
<option value="threshold" ${site.photo.mode === 'threshold' ? 'selected' : ''}>Hard threshold — crisp, loses detail</option>
|
|
||||||
<option value="none" ${site.photo.mode === 'none' ? 'selected' : ''}>Leave it to the printer driver</option>
|
|
||||||
</select></label>
|
|
||||||
<label class="modal-field"><span>Threshold <b id="photo-threshold-value">${site.photo.threshold}</b>%</span>
|
|
||||||
<input type="range" name="photoThreshold" id="photo-threshold" min="5" max="95" step="5" value="${site.photo.threshold}"></label>
|
|
||||||
<label class="modal-field"><span>Contrast <b id="photo-contrast-value">${site.photo.contrast}</b></span>
|
|
||||||
<input type="range" name="photoContrast" id="photo-contrast" min="-50" max="100" step="10" value="${site.photo.contrast}"></label>
|
|
||||||
<button type="button" class="ghost" id="photo-preview-btn">Preview the photo settings</button>
|
|
||||||
<p class="hint">The printer has one bit per dot, so a photo has to become pure black and
|
|
||||||
white. A hard threshold turns a face into solid blocks; error diffusion scatters the
|
|
||||||
rounding error into neighbouring dots and keeps the tones readable. Higher threshold means
|
|
||||||
darker. The preview shows the most recent real visitor photo.</p>
|
|
||||||
<img id="photo-preview" class="photo-preview" alt="" hidden>
|
|
||||||
</div>
|
|
||||||
<label class="inline"><input type="checkbox" name="accent" id="badge-accent" ${site.badge.accent ? 'checked' : ''}> Print the heading and the no-check warning in red</label>
|
<label class="inline"><input type="checkbox" name="accent" id="badge-accent" ${site.badge.accent ? 'checked' : ''}> Print the heading and the no-check warning in red</label>
|
||||||
<p class="hint" id="accent-note"></p>
|
<p class="hint" id="accent-note"></p>
|
||||||
<p class="hint">Two-colour printing is much slower than black alone.</p>
|
<p class="hint">Two-colour printing is much slower than black alone.</p>
|
||||||
@@ -865,10 +812,8 @@ function openSiteModal(site) {
|
|||||||
<option value="62" ${site.printer.label !== '62red' ? 'selected' : ''}>62 mm continuous, black only</option>
|
<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>
|
<option value="62red" ${site.printer.label === '62red' ? 'selected' : ''}>62 mm continuous, black and red (DK-22251)</option>
|
||||||
</select></label>
|
</select></label>
|
||||||
<p class="hint">This must match the roll actually in the machine, and it matters in both
|
<p class="hint">This must match the roll actually in the machine. Send a two-colour job to a
|
||||||
directions. A two-colour job on a plain roll is refused as <em>Wrong Roll Type</em>; a
|
plain roll and the printer answers <em>Wrong Roll Type</em> and prints nothing.</p>
|
||||||
monochrome job on a DK-22251 is refused with <em>Black/Red on White paper is installed
|
|
||||||
now</em>. The DK-22251 needs the two-colour option even when the badge is entirely black.</p>
|
|
||||||
<div class="modal-row">
|
<div class="modal-row">
|
||||||
${field('Model', 'printerModel', site.printer.model)}
|
${field('Model', 'printerModel', site.printer.model)}
|
||||||
<label class="modal-field"><span>Rotation</span>
|
<label class="modal-field"><span>Rotation</span>
|
||||||
@@ -947,11 +892,6 @@ function openSiteModal(site) {
|
|||||||
rotate: Number(data.printerRotate) || 0,
|
rotate: Number(data.printerRotate) || 0,
|
||||||
label: data.printerLabel,
|
label: data.printerLabel,
|
||||||
},
|
},
|
||||||
photo: {
|
|
||||||
mode: data.photoMode,
|
|
||||||
threshold: Number(data.photoThreshold),
|
|
||||||
contrast: Number(data.photoContrast),
|
|
||||||
},
|
|
||||||
branding: {
|
branding: {
|
||||||
brand: data.brand || null,
|
brand: data.brand || null,
|
||||||
signout: data.signout || null,
|
signout: data.signout || null,
|
||||||
@@ -1060,34 +1000,6 @@ function wireBannerEditor() {
|
|||||||
[pageInput, textInput].forEach((el) => el.addEventListener('input', showContrast));
|
[pageInput, textInput].forEach((el) => el.addEventListener('input', showContrast));
|
||||||
showContrast();
|
showContrast();
|
||||||
|
|
||||||
// Live readouts for the halftone sliders, and a preview on demand. The preview
|
|
||||||
// is not automatic: it re-renders a real photo five times and is not free.
|
|
||||||
const modeSel = $('#photo-mode');
|
|
||||||
const thr = $('#photo-threshold');
|
|
||||||
const con = $('#photo-contrast');
|
|
||||||
const refreshLabels = () => {
|
|
||||||
$('#photo-threshold-value').textContent = thr.value;
|
|
||||||
$('#photo-contrast-value').textContent = con.value;
|
|
||||||
const off = modeSel.value === 'none';
|
|
||||||
thr.disabled = off;
|
|
||||||
con.disabled = off;
|
|
||||||
};
|
|
||||||
[thr, con].forEach((el) => el?.addEventListener('input', refreshLabels));
|
|
||||||
modeSel?.addEventListener('change', refreshLabels);
|
|
||||||
refreshLabels();
|
|
||||||
|
|
||||||
$('#photo-preview-btn')?.addEventListener('click', () => {
|
|
||||||
const img = $('#photo-preview');
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
mode: modeSel.value,
|
|
||||||
threshold: thr.value,
|
|
||||||
contrast: con.value,
|
|
||||||
t: Date.now(),
|
|
||||||
});
|
|
||||||
img.src = `/admin/api/sites/${site.id}/photo-preview?${params}`;
|
|
||||||
img.hidden = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Red is only possible on the two-colour roll, so say so as the two settings change.
|
// Red is only possible on the two-colour roll, so say so as the two settings change.
|
||||||
const accentBox = $('#badge-accent');
|
const accentBox = $('#badge-accent');
|
||||||
const rollSelect = $('#modal-form [name="printerLabel"]');
|
const rollSelect = $('#modal-form [name="printerLabel"]');
|
||||||
@@ -1171,14 +1083,6 @@ function wireBadgePreset() {
|
|||||||
height.value = preset.h;
|
height.value = preset.h;
|
||||||
$('#badge-photo').checked = preset.photo;
|
$('#badge-photo').checked = preset.photo;
|
||||||
$('#badge-accent').checked = Boolean(preset.accent);
|
$('#badge-accent').checked = Boolean(preset.accent);
|
||||||
// The preset knows which roll it describes, so keep the two in step. Picking
|
|
||||||
// the DK-22251 preset while the roll stayed on "black only" is what makes the
|
|
||||||
// printer refuse the job.
|
|
||||||
const roll = $('#modal-form [name="printerLabel"]');
|
|
||||||
if (roll && preset.roll) {
|
|
||||||
roll.value = preset.roll;
|
|
||||||
roll.dispatchEvent(new Event('change'));
|
|
||||||
}
|
|
||||||
check();
|
check();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1487,9 +1391,8 @@ function renderTls(tls) {
|
|||||||
itself before it lapses, and devices that trust the authority keep working without being
|
itself before it lapses, and devices that trust the authority keep working without being
|
||||||
touched again.</p>
|
touched again.</p>
|
||||||
<div class="sys-actions">
|
<div class="sys-actions">
|
||||||
<a class="ghost primary-link" href="/admin/api/tls/ca-bundle.zip" download>Download all certificates (.zip)</a>
|
<a class="ghost" href="/admin/api/tls/ca.crt" download>CA certificate (.crt)</a>
|
||||||
<a class="ghost" href="/admin/api/tls/ca.crt" download>.crt only</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.cer" download>.cer only</a>
|
|
||||||
<button class="ghost owner-only" id="renew-cert">Renew the server certificate</button>
|
<button class="ghost owner-only" id="renew-cert">Renew the server certificate</button>
|
||||||
<button class="ghost danger owner-only" id="new-ca">Start a new authority</button>
|
<button class="ghost danger owner-only" id="new-ca">Start a new authority</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|||||||
+53
-51
@@ -7,10 +7,15 @@
|
|||||||
|
|
||||||
$Remote = 'https://gitea.hideawaygaming.com.au/jessikitty/visitor-signin.git'
|
$Remote = 'https://gitea.hideawaygaming.com.au/jessikitty/visitor-signin.git'
|
||||||
|
|
||||||
# git is called directly and $LASTEXITCODE checked straight afterwards. Wrapping
|
# git reports failure through its exit code, not as a PowerShell error, so every
|
||||||
# it in a function does not work: a PowerShell function returns everything written
|
# call has to be checked. Without this the script reports success after a
|
||||||
# to the output stream, so the caller receives git's console output as well as the
|
# rejected push, which is exactly what it used to do.
|
||||||
# exit code, and comparing that array against 0 reports failure every time.
|
function Invoke-Git {
|
||||||
|
param([Parameter(ValueFromRemainingArguments = $true)][string[]]$Arguments)
|
||||||
|
& git @Arguments
|
||||||
|
return $LASTEXITCODE
|
||||||
|
}
|
||||||
|
|
||||||
function Fail($message) {
|
function Fail($message) {
|
||||||
Write-Host ''
|
Write-Host ''
|
||||||
Write-Host $message -ForegroundColor Red
|
Write-Host $message -ForegroundColor Red
|
||||||
@@ -22,89 +27,86 @@ 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'
|
Fail 'Git is not installed or not on PATH. Get it from https://git-scm.com/download/win'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Keeps line endings sane between Windows and the Ubuntu docker host.
|
||||||
git config --global core.autocrlf input | Out-Null
|
git config --global core.autocrlf input | Out-Null
|
||||||
|
|
||||||
# git refuses to commit without an identity, and says so in a way that is easy to
|
|
||||||
# miss among its other output.
|
|
||||||
$who = git config user.email
|
|
||||||
if (-not $who) { $who = git config --global user.email }
|
|
||||||
if (-not $who) {
|
|
||||||
Write-Host 'Git does not know who you are. Set that once:' -ForegroundColor Yellow
|
|
||||||
Write-Host ' git config --global user.email "you@example.com"'
|
|
||||||
Write-Host ' git config --global user.name "Your Name"'
|
|
||||||
Fail 'Nothing was committed.'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not (Test-Path '.git')) {
|
if (-not (Test-Path '.git')) {
|
||||||
Write-Host 'Setting up a new local repository...'
|
Write-Host 'Setting up a new local repository...'
|
||||||
git init -b main
|
if ((Invoke-Git init -b main) -ne 0) { Fail 'git init failed.' }
|
||||||
if ($LASTEXITCODE -ne 0) { Fail 'git init failed.' }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((git remote) -match '^origin$') { git remote set-url origin $Remote }
|
if ((git remote) -match '^origin$') {
|
||||||
else { git remote add origin $Remote }
|
git remote set-url origin $Remote
|
||||||
|
} else {
|
||||||
|
git remote add origin $Remote
|
||||||
|
}
|
||||||
|
|
||||||
# Whatever branch is checked out, not a hard-coded one. Pushing 'main' while the
|
# ------------------------------------------------- finish what was started
|
||||||
# work sits on 'deploy' reports "Everything up-to-date" and sends nothing.
|
|
||||||
$branch = (git rev-parse --abbrev-ref HEAD).Trim()
|
|
||||||
if (-not $branch -or $branch -eq 'HEAD') { Fail 'No branch is checked out here.' }
|
|
||||||
Write-Host "Branch: $branch" -ForegroundColor Cyan
|
|
||||||
|
|
||||||
# ------------------------------------------- an unfinished rebase blocks everything
|
# 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
|
||||||
$gitDir = git rev-parse --git-dir 2>$null
|
# what to do.
|
||||||
|
$gitDir = (git rev-parse --git-dir 2>$null)
|
||||||
if ($gitDir) {
|
if ($gitDir) {
|
||||||
$stuck = @('rebase-merge', 'rebase-apply', 'MERGE_HEAD', 'CHERRY_PICK_HEAD') |
|
$stuck = @('rebase-merge', 'rebase-apply', 'MERGE_HEAD', 'CHERRY_PICK_HEAD') |
|
||||||
Where-Object { Test-Path (Join-Path $gitDir $_) }
|
Where-Object { Test-Path (Join-Path $gitDir $_) }
|
||||||
if ($stuck) {
|
if ($stuck) {
|
||||||
Write-Host ''
|
Write-Host ''
|
||||||
Write-Host 'There is an unfinished rebase or merge here.' -ForegroundColor Red
|
Write-Host 'There is an unfinished rebase or merge in this folder.' -ForegroundColor Red
|
||||||
Write-Host ' git rebase --abort throw it away, back to how things were'
|
Write-Host 'Nothing else can happen until it is settled. Your options:' -ForegroundColor Yellow
|
||||||
Write-Host ' git status see which files need attention'
|
Write-Host ''
|
||||||
Write-Host ' git rebase --continue after fixing those files'
|
Write-Host ' git rebase --abort throw the attempt away and go back to how things were'
|
||||||
Fail 'Nothing was done.'
|
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
|
# ---------------------------------------------------------------- commit
|
||||||
|
|
||||||
git add -A
|
git add -A
|
||||||
if (git status --porcelain) {
|
$pending = git status --porcelain
|
||||||
|
if ($pending) {
|
||||||
$message = Read-Host 'Describe this change (press enter for a dated default)'
|
$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 (-not $message) { $message = "Update $(Get-Date -Format 'yyyy-MM-dd HH:mm')" }
|
||||||
git commit -m $message
|
if ((Invoke-Git commit -m $message) -ne 0) { Fail 'git commit failed.' }
|
||||||
if ($LASTEXITCODE -ne 0) { Fail 'git commit failed. The message above says why.' }
|
|
||||||
Write-Host 'Committed.' -ForegroundColor Green
|
Write-Host 'Committed.' -ForegroundColor Green
|
||||||
} else {
|
} else {
|
||||||
Write-Host 'Nothing new to commit.' -ForegroundColor Yellow
|
Write-Host 'No file changes to commit. Checking for anything unpushed...' -ForegroundColor Yellow
|
||||||
}
|
}
|
||||||
|
|
||||||
# ------------------------------------------------------- catch up, then push
|
# ------------------------------------------------------- catch up, then push
|
||||||
|
|
||||||
git fetch origin
|
Write-Host 'Checking what is on the server...'
|
||||||
if ($LASTEXITCODE -ne 0) { Fail 'Could not reach Gitea. Check the network and your sign in details.' }
|
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 $branch) {
|
if (git ls-remote --heads origin main) {
|
||||||
$behind = git rev-list --count "HEAD..origin/$branch" 2>$null
|
$behind = (git rev-list --count HEAD..origin/main 2>$null)
|
||||||
if ($behind -and [int]$behind -gt 0) {
|
if ($behind -and [int]$behind -gt 0) {
|
||||||
Write-Host "The server has $behind commit(s) this folder does not. Replaying your work on top..."
|
Write-Host "The server has $behind commit(s) this folder does not. Replaying your work on top..."
|
||||||
git pull --rebase origin $branch
|
if ((Invoke-Git pull --rebase origin main) -ne 0) {
|
||||||
if ($LASTEXITCODE -ne 0) {
|
|
||||||
Write-Host ''
|
Write-Host ''
|
||||||
Write-Host 'The two histories could not be joined automatically.' -ForegroundColor Red
|
Write-Host 'The two histories could not be joined automatically.' -ForegroundColor Red
|
||||||
Write-Host " git log --oneline HEAD..origin/$branch what is on the server"
|
Write-Host ''
|
||||||
Write-Host " git push --force-with-lease origin $branch if this folder is the good copy"
|
Write-Host 'See what is on the server that you do not have:' -ForegroundColor Yellow
|
||||||
Fail 'Nothing was sent.'
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
Write-Host "Branch '$branch' is not on the server yet; it will be created."
|
|
||||||
}
|
}
|
||||||
|
|
||||||
git push -u origin $branch
|
if ((Invoke-Git push -u origin main) -ne 0) {
|
||||||
if ($LASTEXITCODE -ne 0) { Fail 'The push was rejected. The message above says why. Nothing was sent.' }
|
Fail 'The push was rejected. Read the message above. Nothing was sent.'
|
||||||
|
}
|
||||||
|
|
||||||
Write-Host ''
|
Write-Host ''
|
||||||
Write-Host "Pushed $branch successfully." -ForegroundColor Green
|
Write-Host 'Pushed successfully.' -ForegroundColor Green
|
||||||
git log --oneline -1
|
|
||||||
Write-Host 'https://gitea.hideawaygaming.com.au/jessikitty/visitor-signin'
|
Write-Host 'https://gitea.hideawaygaming.com.au/jessikitty/visitor-signin'
|
||||||
|
|||||||
+7
-22
@@ -11,15 +11,6 @@ command -v git >/dev/null || fail "Git is not installed."
|
|||||||
|
|
||||||
git config --global core.autocrlf input >/dev/null 2>&1 || true
|
git config --global core.autocrlf input >/dev/null 2>&1 || true
|
||||||
|
|
||||||
if [ -z "$(git config user.email || git config --global user.email)" ]; then
|
|
||||||
cat >&2 <<'MSG'
|
|
||||||
Git does not know who you are. Set that once:
|
|
||||||
git config --global user.email "you@example.com"
|
|
||||||
git config --global user.name "Your Name"
|
|
||||||
MSG
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
[ -d .git ] || git init -b main || fail "git init failed."
|
[ -d .git ] || git init -b main || fail "git init failed."
|
||||||
|
|
||||||
if git remote | grep -qx origin; then
|
if git remote | grep -qx origin; then
|
||||||
@@ -28,11 +19,6 @@ else
|
|||||||
git remote add origin "$REMOTE"
|
git remote add origin "$REMOTE"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Whatever branch is checked out, not a hard-coded one.
|
|
||||||
BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
|
||||||
[ -n "$BRANCH" ] && [ "$BRANCH" != "HEAD" ] || fail "No branch is checked out here."
|
|
||||||
echo "Branch: $BRANCH"
|
|
||||||
|
|
||||||
# An unfinished rebase or merge blocks everything below, and git's own error is
|
# An unfinished rebase or merge blocks everything below, and git's own error is
|
||||||
# easy to mistake for a push problem.
|
# easy to mistake for a push problem.
|
||||||
GIT_DIR_PATH=$(git rev-parse --git-dir 2>/dev/null || echo .git)
|
GIT_DIR_PATH=$(git rev-parse --git-dir 2>/dev/null || echo .git)
|
||||||
@@ -65,29 +51,28 @@ fi
|
|||||||
|
|
||||||
git fetch origin || fail "Could not reach Gitea."
|
git fetch origin || fail "Could not reach Gitea."
|
||||||
|
|
||||||
if git ls-remote --heads origin "$BRANCH" | grep -q "$BRANCH"; then
|
if git ls-remote --heads origin main | grep -q main; then
|
||||||
BEHIND=$(git rev-list --count "HEAD..origin/$BRANCH" 2>/dev/null || echo 0)
|
BEHIND=$(git rev-list --count HEAD..origin/main 2>/dev/null || echo 0)
|
||||||
if [ "$BEHIND" -gt 0 ]; then
|
if [ "$BEHIND" -gt 0 ]; then
|
||||||
echo "The server has $BEHIND commit(s) this folder does not. Replaying your work on top..."
|
echo "The server has $BEHIND commit(s) this folder does not. Replaying your work on top..."
|
||||||
if ! git pull --rebase origin "$BRANCH"; then
|
if ! git pull --rebase origin main; then
|
||||||
cat >&2 <<'MSG'
|
cat >&2 <<'MSG'
|
||||||
|
|
||||||
The two histories could not be joined automatically.
|
The two histories could not be joined automatically.
|
||||||
|
|
||||||
See what is on the server that you do not have:
|
See what is on the server that you do not have:
|
||||||
git log --oneline HEAD..origin/$BRANCH
|
git log --oneline HEAD..origin/main
|
||||||
|
|
||||||
If that is nothing you need, and this folder is the good copy:
|
If that is nothing you need, and this folder is the good copy:
|
||||||
git push --force-with-lease origin $BRANCH
|
git push --force-with-lease origin main
|
||||||
MSG
|
MSG
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
git push -u origin "$BRANCH" || fail "The push was rejected. Read the message above. Nothing was sent."
|
git push -u origin main || fail "The push was rejected. Read the message above. Nothing was sent."
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "Pushed $BRANCH successfully."
|
echo "Pushed successfully."
|
||||||
git log --oneline -1
|
|
||||||
echo "https://gitea.hideawaygaming.com.au/jessikitty/visitor-signin"
|
echo "https://gitea.hideawaygaming.com.au/jessikitty/visitor-signin"
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
#!/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');
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
/**
|
|
||||||
* Tries roll ids one at a time, waiting for you to say what came out.
|
|
||||||
*
|
|
||||||
* docker compose exec -it visitor-signin node scripts/print-probe.mjs
|
|
||||||
*
|
|
||||||
* Note the -it: this asks questions, so the container needs a terminal attached.
|
|
||||||
*
|
|
||||||
* Start with printer-status.mjs — if the printer answers, it tells you the right
|
|
||||||
* id outright and this is unnecessary. Use this when the printer will not report
|
|
||||||
* its status, or when it does and the job is still refused.
|
|
||||||
*/
|
|
||||||
import fs from 'node:fs';
|
|
||||||
import readline from 'node:readline/promises';
|
|
||||||
import { execFileSync } from 'node:child_process';
|
|
||||||
import db from '../src/db.js';
|
|
||||||
import config from '../src/config.js';
|
|
||||||
import * as printer from '../src/printer.js';
|
|
||||||
|
|
||||||
// Ordered by how likely each is on a 62 mm machine, cheapest guesses first.
|
|
||||||
const CANDIDATES = [
|
|
||||||
['62', '62 mm continuous, black only'],
|
|
||||||
['62x100', '62 x 100 mm die-cut'],
|
|
||||||
['62red', '62 mm continuous, black and red (DK-22251)'],
|
|
||||||
['62x29', '62 x 29 mm die-cut'],
|
|
||||||
['29', '29 mm continuous'],
|
|
||||||
['29x90', '29 x 90 mm die-cut'],
|
|
||||||
['38', '38 mm continuous'],
|
|
||||||
['50', '50 mm continuous'],
|
|
||||||
['54', '54 mm continuous'],
|
|
||||||
];
|
|
||||||
|
|
||||||
const site = db
|
|
||||||
.prepare('SELECT * FROM sites WHERE printer_host IS NOT NULL ORDER BY id LIMIT 1')
|
|
||||||
.get();
|
|
||||||
|
|
||||||
if (!site) {
|
|
||||||
console.error('No site has a printer address set.');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const target = `tcp://${site.printer_host}:${site.printer_port || 9100}`;
|
|
||||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
||||||
|
|
||||||
console.log(`\n Printer: ${site.printer_model || 'QL-820NWB'} at ${target}`);
|
|
||||||
console.log(' One label will be sent per attempt. After each, say whether anything came out.');
|
|
||||||
console.log(' Press Ctrl+C at any point to stop.\n');
|
|
||||||
|
|
||||||
const results = [];
|
|
||||||
|
|
||||||
for (const [label, description] of CANDIDATES) {
|
|
||||||
const answer = (await rl.question(` Try "${label}" (${description})? [Y/n/q] `)).trim().toLowerCase();
|
|
||||||
if (answer === 'q') break;
|
|
||||||
if (answer === 'n') {
|
|
||||||
results.push([label, 'skipped']);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const png = await printer.renderBadgePng(printer.sampleVisit(site), { ...site, printer_label: label });
|
|
||||||
const file = '/tmp/probe.png';
|
|
||||||
fs.writeFileSync(file, png);
|
|
||||||
|
|
||||||
let sent = true;
|
|
||||||
let detail = '';
|
|
||||||
try {
|
|
||||||
execFileSync(
|
|
||||||
config.printing.command,
|
|
||||||
[
|
|
||||||
'--backend', 'network',
|
|
||||||
'--model', site.printer_model || 'QL-820NWB',
|
|
||||||
'--printer', target,
|
|
||||||
'print', '--label', label, ...(label === '62red' ? ['--red'] : []), file,
|
|
||||||
],
|
|
||||||
{ stdio: ['ignore', 'pipe', 'pipe'], timeout: config.printing.timeoutMs }
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
sent = false;
|
|
||||||
detail = `${err.stdout || ''}${err.stderr || ''}`.trim().split('\n').pop() || err.message;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!sent) {
|
|
||||||
console.log(` could not send: ${detail}\n`);
|
|
||||||
results.push([label, `send failed: ${detail}`]);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const came = (await rl.question(' Did a label print? [y/N] ')).trim().toLowerCase();
|
|
||||||
if (came === 'y') {
|
|
||||||
results.push([label, 'PRINTED']);
|
|
||||||
console.log(`\n That is the one. Set "Roll loaded in the printer" so it sends ${label}.\n`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
results.push([label, 'nothing came out']);
|
|
||||||
console.log(' Clear the error on the printer (open and close the cover) before the next try.\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
rl.close();
|
|
||||||
|
|
||||||
console.log(' Summary');
|
|
||||||
for (const [label, outcome] of results) console.log(` ${label.padEnd(8)} ${outcome}`);
|
|
||||||
console.log('');
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
/**
|
|
||||||
* Renders a badge and prints it, showing everything on the way through.
|
|
||||||
*
|
|
||||||
* For working out why a printer will not accept a job, without going through the
|
|
||||||
* kiosk or the admin console. Run inside the container:
|
|
||||||
*
|
|
||||||
* docker compose exec visitor-signin node scripts/print-test.mjs
|
|
||||||
* docker compose exec visitor-signin node scripts/print-test.mjs --label 62x100
|
|
||||||
* docker compose exec visitor-signin node scripts/print-test.mjs --dry
|
|
||||||
*
|
|
||||||
* Options:
|
|
||||||
* --site N which site to use (default: the first one)
|
|
||||||
* --label ID override the roll id sent to the printer, without saving it
|
|
||||||
* --rotate DEG override the rotation, without saving it
|
|
||||||
* --dry render only, do not print
|
|
||||||
*/
|
|
||||||
import fs from 'node:fs';
|
|
||||||
import { execFileSync } from 'node:child_process';
|
|
||||||
import db from '../src/db.js';
|
|
||||||
import config from '../src/config.js';
|
|
||||||
import * as printer from '../src/printer.js';
|
|
||||||
|
|
||||||
function arg(name, fallback = null) {
|
|
||||||
const i = process.argv.indexOf(`--${name}`);
|
|
||||||
return i > -1 && process.argv[i + 1] && !process.argv[i + 1].startsWith('--')
|
|
||||||
? process.argv[i + 1]
|
|
||||||
: fallback;
|
|
||||||
}
|
|
||||||
const has = (name) => process.argv.includes(`--${name}`);
|
|
||||||
|
|
||||||
const siteId = Number(arg('site', 0));
|
|
||||||
const site = siteId
|
|
||||||
? db.prepare('SELECT * FROM sites WHERE id = ?').get(siteId)
|
|
||||||
: db.prepare('SELECT * FROM sites ORDER BY id LIMIT 1').get();
|
|
||||||
|
|
||||||
if (!site) {
|
|
||||||
console.error('No sites exist yet.');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (arg('label')) site.printer_label = arg('label');
|
|
||||||
if (arg('rotate')) site.printer_rotate = Number(arg('rotate'));
|
|
||||||
|
|
||||||
const label = printer.labelFor(site);
|
|
||||||
const target = `tcp://${site.printer_host}:${site.printer_port || 9100}`;
|
|
||||||
|
|
||||||
console.log('');
|
|
||||||
console.log(` site ${site.name}`);
|
|
||||||
console.log(` printer ${site.printer_model || 'QL-820NWB'} at ${target}`);
|
|
||||||
console.log(` server printing ${site.printer_enabled ? 'on' : 'OFF — the kiosk would print instead'}`);
|
|
||||||
console.log(` badge ${site.badge_width_mm} x ${site.badge_height_mm} mm, rotate ${site.printer_rotate || 0}`);
|
|
||||||
console.log(` roll setting ${site.printer_label || '(unset)'} -> --label ${label}`);
|
|
||||||
console.log(` red requested ${Boolean(site.badge_accent)}, will print ${printer.accentWillPrintRed(site)}`);
|
|
||||||
|
|
||||||
const png = await printer.renderBadgePng(printer.sampleVisit(site), site);
|
|
||||||
const file = '/tmp/print-test.png';
|
|
||||||
fs.writeFileSync(file, png);
|
|
||||||
console.log(` rendered ${png.readUInt32BE(16)} x ${png.readUInt32BE(20)} dots -> ${file}`);
|
|
||||||
|
|
||||||
if (!site.printer_host) {
|
|
||||||
console.error('\n No printer address set for this site. Set one in Admin -> Sites -> Edit.');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (has('dry')) {
|
|
||||||
console.log('\n --dry given, so nothing was sent.\n');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
const args = [
|
|
||||||
'--backend', 'network',
|
|
||||||
'--model', site.printer_model || 'QL-820NWB',
|
|
||||||
'--printer', target,
|
|
||||||
'print',
|
|
||||||
'--label', label,
|
|
||||||
];
|
|
||||||
// Required on black/red tape even when the badge is entirely black.
|
|
||||||
if (label === '62red') args.push('--red');
|
|
||||||
args.push(file);
|
|
||||||
|
|
||||||
console.log('');
|
|
||||||
console.log(` running: ${config.printing.command} ${args.join(' ')}`);
|
|
||||||
console.log('');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const out = execFileSync(config.printing.command, args, {
|
|
||||||
encoding: 'utf8',
|
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
|
||||||
timeout: config.printing.timeoutMs,
|
|
||||||
});
|
|
||||||
console.log(out.trim() || ' (no output)');
|
|
||||||
console.log('\n Sent. If nothing came out, the printer rejected it silently — check its display.\n');
|
|
||||||
} catch (err) {
|
|
||||||
console.error(' FAILED\n');
|
|
||||||
console.error(`${err.stdout || ''}${err.stderr || ''}`.trim() || err.message);
|
|
||||||
console.error('');
|
|
||||||
console.error(' Roll ids this printer understands:');
|
|
||||||
console.error(' 62 62 mm continuous, black only');
|
|
||||||
console.error(' 62red 62 mm continuous, black and red (DK-22251)');
|
|
||||||
console.error(' 62x100 62 x 100 mm DIE-CUT (pre-cut labels, not a continuous roll)');
|
|
||||||
console.error(' 62x29 62 x 29 mm die-cut');
|
|
||||||
console.error('');
|
|
||||||
console.error(' A continuous roll sent a die-cut id, or the reverse, is refused as a wrong roll.');
|
|
||||||
console.error(' Try: node scripts/print-test.mjs --label 62x100');
|
|
||||||
console.error('');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
/**
|
|
||||||
* Asks the printer what it actually has loaded, and what it is complaining about.
|
|
||||||
*
|
|
||||||
* docker compose exec visitor-signin node scripts/printer-status.mjs
|
|
||||||
* docker compose exec visitor-signin node scripts/printer-status.mjs --host 10.0.0.5
|
|
||||||
*
|
|
||||||
* brother_ql's network backend only writes to the socket; it never reads, which is
|
|
||||||
* why a refused job still looks like a success. The Brother raster protocol has a
|
|
||||||
* status request that returns a 32 byte block describing the media in the machine
|
|
||||||
* and any error, so we ask directly.
|
|
||||||
*/
|
|
||||||
import net from 'node:net';
|
|
||||||
import db from '../src/db.js';
|
|
||||||
|
|
||||||
function arg(name, fallback = null) {
|
|
||||||
const i = process.argv.indexOf(`--${name}`);
|
|
||||||
return i > -1 && process.argv[i + 1] && !process.argv[i + 1].startsWith('--')
|
|
||||||
? process.argv[i + 1]
|
|
||||||
: fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
const site = arg('site')
|
|
||||||
? db.prepare('SELECT * FROM sites WHERE id = ?').get(Number(arg('site')))
|
|
||||||
: db.prepare('SELECT * FROM sites WHERE printer_host IS NOT NULL ORDER BY id LIMIT 1').get();
|
|
||||||
|
|
||||||
const host = arg('host', site?.printer_host);
|
|
||||||
const port = Number(arg('port', site?.printer_port || 9100));
|
|
||||||
|
|
||||||
if (!host) {
|
|
||||||
console.error('No printer address. Set one in Admin -> Sites, or pass --host.');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ------------------------------------------------------------- decoding */
|
|
||||||
|
|
||||||
const MEDIA_TYPES = {
|
|
||||||
0x00: 'no media loaded',
|
|
||||||
0x0a: 'continuous roll',
|
|
||||||
0x0b: 'die-cut labels',
|
|
||||||
0x4a: 'continuous roll (cleaning)',
|
|
||||||
0x4b: 'die-cut labels (cleaning)',
|
|
||||||
};
|
|
||||||
|
|
||||||
const ERRORS_1 = [
|
|
||||||
[0x01, 'no media loaded'],
|
|
||||||
[0x02, 'end of media'],
|
|
||||||
[0x04, 'cutter jam'],
|
|
||||||
[0x08, 'weak batteries'],
|
|
||||||
[0x10, 'printer in use'],
|
|
||||||
[0x80, 'printer turned off'],
|
|
||||||
];
|
|
||||||
|
|
||||||
const ERRORS_2 = [
|
|
||||||
[0x01, 'wrong media — the job does not match the roll that is loaded'],
|
|
||||||
[0x04, 'expansion buffer full'],
|
|
||||||
[0x08, 'communication error'],
|
|
||||||
[0x10, 'communication buffer full'],
|
|
||||||
[0x20, 'cover is open'],
|
|
||||||
[0x40, 'cancel key pressed'],
|
|
||||||
[0x80, 'media cannot be fed'],
|
|
||||||
];
|
|
||||||
|
|
||||||
function decode(buf) {
|
|
||||||
if (buf.length < 32) return { error: `Short reply (${buf.length} bytes).` };
|
|
||||||
const mediaWidth = buf[10];
|
|
||||||
const mediaType = buf[11];
|
|
||||||
const mediaLength = buf[17];
|
|
||||||
return {
|
|
||||||
mediaWidthMm: mediaWidth,
|
|
||||||
mediaLengthMm: mediaLength,
|
|
||||||
mediaType: MEDIA_TYPES[mediaType] || `unknown (0x${mediaType.toString(16)})`,
|
|
||||||
mediaTypeRaw: mediaType,
|
|
||||||
errors: [
|
|
||||||
...ERRORS_1.filter(([bit]) => buf[8] & bit).map(([, text]) => text),
|
|
||||||
...ERRORS_2.filter(([bit]) => buf[9] & bit).map(([, text]) => text),
|
|
||||||
],
|
|
||||||
raw: buf.subarray(0, 32).toString('hex').replace(/(..)/g, '$1 ').trim(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The label id brother_ql should be given, worked out from what is loaded. */
|
|
||||||
function suggestLabel(status) {
|
|
||||||
if (status.mediaTypeRaw === 0x00) return null;
|
|
||||||
const continuous = status.mediaTypeRaw === 0x0a || status.mediaTypeRaw === 0x4a;
|
|
||||||
if (continuous) {
|
|
||||||
return String(status.mediaWidthMm); // 62, 29, 12 ...
|
|
||||||
}
|
|
||||||
return status.mediaLengthMm
|
|
||||||
? `${status.mediaWidthMm}x${status.mediaLengthMm}`
|
|
||||||
: `${status.mediaWidthMm} (die-cut, length unknown)`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --------------------------------------------------------------- asking */
|
|
||||||
|
|
||||||
console.log(`\n Asking ${host}:${port} what it has loaded...\n`);
|
|
||||||
|
|
||||||
const socket = net.createConnection({ host, port, timeout: 8000 });
|
|
||||||
const chunks = [];
|
|
||||||
|
|
||||||
socket.on('connect', () => {
|
|
||||||
// 200 null bytes clears any half-finished job, then initialise, then ask.
|
|
||||||
socket.write(Buffer.alloc(200, 0x00));
|
|
||||||
socket.write(Buffer.from([0x1b, 0x40]));
|
|
||||||
socket.write(Buffer.from([0x1b, 0x69, 0x53]));
|
|
||||||
|
|
||||||
// Some firmware only answers once it is in raster mode, so ask again that way
|
|
||||||
// before giving up.
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!chunks.length && !socket.destroyed) {
|
|
||||||
socket.write(Buffer.from([0x1b, 0x69, 0x61, 0x01]));
|
|
||||||
socket.write(Buffer.from([0x1b, 0x69, 0x53]));
|
|
||||||
}
|
|
||||||
}, 1500);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('data', (d) => {
|
|
||||||
chunks.push(d);
|
|
||||||
if (Buffer.concat(chunks).length >= 32) socket.end();
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
if (!chunks.length) {
|
|
||||||
console.error(' The printer accepted the connection but sent nothing back.');
|
|
||||||
console.error('');
|
|
||||||
console.error(' Many Brother network print servers are write-only on port 9100: they accept');
|
|
||||||
console.error(' jobs but never report status, even though the same printer answers happily');
|
|
||||||
console.error(' over USB. If this is one of them, no amount of asking will help.');
|
|
||||||
console.error('');
|
|
||||||
console.error(' Read the printer instead from:');
|
|
||||||
console.error(' - the display on the machine itself');
|
|
||||||
console.error(' - Brother Status Monitor, on a PC with the printer installed');
|
|
||||||
console.error(' - the printer\'s own web page, at http://' + host + '/');
|
|
||||||
console.error('');
|
|
||||||
console.error(' Status Monitor in particular gives the exact reason a job was refused,');
|
|
||||||
console.error(' which is more than brother_ql can tell you — its network backend never');
|
|
||||||
console.error(' reads the socket, so it reports success whatever the printer does.');
|
|
||||||
console.error('');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (err) => {
|
|
||||||
console.error(` Could not reach it: ${err.message}\n`);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('close', () => {
|
|
||||||
const buf = Buffer.concat(chunks);
|
|
||||||
if (!buf.length) process.exit(1);
|
|
||||||
|
|
||||||
const status = decode(buf);
|
|
||||||
if (status.error) {
|
|
||||||
console.error(` ${status.error}\n raw: ${buf.toString('hex')}\n`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(` media loaded ${status.mediaWidthMm} mm ${status.mediaType}`);
|
|
||||||
if (status.mediaLengthMm) console.log(` label length ${status.mediaLengthMm} mm`);
|
|
||||||
console.log(` errors ${status.errors.length ? status.errors.join('; ') : 'none reported'}`);
|
|
||||||
console.log(` raw status ${status.raw}`);
|
|
||||||
|
|
||||||
const suggested = suggestLabel(status);
|
|
||||||
console.log('');
|
|
||||||
if (!suggested) {
|
|
||||||
console.log(' No media detected. Open and close the cover to make it re-read the roll.');
|
|
||||||
} else {
|
|
||||||
console.log(` Use this roll id: --label ${suggested}`);
|
|
||||||
console.log(` Try it with: node scripts/print-test.mjs --label ${suggested}`);
|
|
||||||
}
|
|
||||||
console.log('');
|
|
||||||
});
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
// Reports what this container is actually running, so an upgrade that did not
|
|
||||||
// land can be spotted without guesswork.
|
|
||||||
import fs from 'node:fs';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
|
|
||||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
||||||
const root = path.join(here, '..');
|
|
||||||
|
|
||||||
const FEATURES = [
|
|
||||||
['server-side printing', 'src/printer.js', null],
|
|
||||||
['roll type is a separate setting', 'src/routes/admin.js', 'printer_label'],
|
|
||||||
['roll type in the console', 'public/js/admin.js', 'printerLabel'],
|
|
||||||
['company field', 'src/routes/kiosk.js', 'company'],
|
|
||||||
['separate login page', 'public/login.html', 'step-password'],
|
|
||||||
['per-site branding', 'src/branding.js', null],
|
|
||||||
['CA in DER form for Jamf', 'src/tls.js', 'caCertificateDer'],
|
|
||||||
];
|
|
||||||
|
|
||||||
console.log('');
|
|
||||||
for (const [name, file, needle] of FEATURES) {
|
|
||||||
const full = path.join(root, file);
|
|
||||||
let state = 'MISSING';
|
|
||||||
if (fs.existsSync(full)) {
|
|
||||||
state = !needle || fs.readFileSync(full, 'utf8').includes(needle) ? 'present' : 'OLD VERSION';
|
|
||||||
}
|
|
||||||
console.log(` ${state.padEnd(12)} ${name}`);
|
|
||||||
}
|
|
||||||
console.log('');
|
|
||||||
console.log('Anything not "present" means this container is running older code.');
|
|
||||||
console.log('On the docker host: git pull && docker compose up -d --build');
|
|
||||||
console.log('');
|
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
#!/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');
|
||||||
@@ -28,9 +28,6 @@ CREATE TABLE IF NOT EXISTS sites (
|
|||||||
printer_model TEXT NOT NULL DEFAULT 'QL-820NWB',
|
printer_model TEXT NOT NULL DEFAULT 'QL-820NWB',
|
||||||
printer_rotate INTEGER NOT NULL DEFAULT 0,
|
printer_rotate INTEGER NOT NULL DEFAULT 0,
|
||||||
printer_label TEXT NOT NULL DEFAULT '62',
|
printer_label TEXT NOT NULL DEFAULT '62',
|
||||||
photo_mode TEXT NOT NULL DEFAULT 'dither',
|
|
||||||
photo_threshold INTEGER NOT NULL DEFAULT 50,
|
|
||||||
photo_contrast INTEGER NOT NULL DEFAULT 20,
|
|
||||||
banner_path TEXT,
|
banner_path TEXT,
|
||||||
banner_height INTEGER NOT NULL DEFAULT 64,
|
banner_height INTEGER NOT NULL DEFAULT 64,
|
||||||
banner_align TEXT NOT NULL DEFAULT 'left',
|
banner_align TEXT NOT NULL DEFAULT 'left',
|
||||||
@@ -191,11 +188,6 @@ addColumn('sites', 'printer_rotate', 'INTEGER NOT NULL DEFAULT 0');
|
|||||||
// the printer refuses a two-colour job on a plain roll, so guessing the media
|
// 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.
|
// from a design setting means a wrong-roll error nobody can explain.
|
||||||
addColumn('sites', 'printer_label', "TEXT NOT NULL DEFAULT '62'");
|
addColumn('sites', 'printer_label', "TEXT NOT NULL DEFAULT '62'");
|
||||||
// How the photo is reduced to the printer's one bit per dot. A plain threshold
|
|
||||||
// turns a face into solid blocks; error diffusion keeps the tones readable.
|
|
||||||
addColumn('sites', 'photo_mode', "TEXT NOT NULL DEFAULT 'dither'");
|
|
||||||
addColumn('sites', 'photo_threshold', 'INTEGER NOT NULL DEFAULT 50');
|
|
||||||
addColumn('sites', 'photo_contrast', 'INTEGER NOT NULL DEFAULT 20');
|
|
||||||
addColumn('frequent_visitors', 'company', 'TEXT');
|
addColumn('frequent_visitors', 'company', 'TEXT');
|
||||||
|
|
||||||
db.exec('CREATE INDEX IF NOT EXISTS idx_visits_site ON visits(site_id, signed_out_at)');
|
db.exec('CREATE INDEX IF NOT EXISTS idx_visits_site ON visits(site_id, signed_out_at)');
|
||||||
|
|||||||
+10
-175
@@ -63,155 +63,6 @@ export function accentWillPrintRed(site) {
|
|||||||
return Boolean(site?.badge_accent) && labelFor(site) === '62red';
|
return Boolean(site?.badge_accent) && labelFor(site) === '62red';
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------- photo halftoning */
|
|
||||||
|
|
||||||
export const PHOTO_MODES = {
|
|
||||||
dither: 'Error diffusion — best for faces',
|
|
||||||
threshold: 'Hard threshold — crisp, loses detail',
|
|
||||||
none: 'Leave it to the printer driver',
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reduces a photo to the one bit per dot the printer actually has.
|
|
||||||
*
|
|
||||||
* Done here rather than left to brother_ql so the preview and the label agree,
|
|
||||||
* and because the default is a plain threshold: every pixel darker than the cut
|
|
||||||
* becomes solid black, which turns a face into a few featureless blobs. Error
|
|
||||||
* diffusion spreads the rounding error into neighbouring pixels instead, so mid
|
|
||||||
* tones survive as a pattern of dots.
|
|
||||||
*/
|
|
||||||
function halftone(ctx, x, y, size, { mode = 'dither', threshold = 50, contrast = 20 } = {}) {
|
|
||||||
if (mode === 'none') return;
|
|
||||||
|
|
||||||
const image = ctx.getImageData(x, y, size, size);
|
|
||||||
const { data, width, height } = image;
|
|
||||||
const cut = Math.max(1, Math.min(99, threshold)) * 2.55;
|
|
||||||
|
|
||||||
// Standard contrast curve, pivoting on mid grey. Webcam photos are flat and
|
|
||||||
// flatten further when reduced to two tones, so a little lift helps.
|
|
||||||
const c = Math.max(-100, Math.min(100, contrast));
|
|
||||||
const factor = (259 * (c + 255)) / (255 * (259 - c));
|
|
||||||
|
|
||||||
// Greyscale first, into a float buffer so the diffused error does not clip.
|
|
||||||
const grey = new Float32Array(width * height);
|
|
||||||
for (let i = 0; i < width * height; i += 1) {
|
|
||||||
const r = data[i * 4];
|
|
||||||
const g = data[i * 4 + 1];
|
|
||||||
const b = data[i * 4 + 2];
|
|
||||||
const luma = 0.299 * r + 0.587 * g + 0.114 * b;
|
|
||||||
grey[i] = Math.max(0, Math.min(255, factor * (luma - 128) + 128));
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let py = 0; py < height; py += 1) {
|
|
||||||
for (let px = 0; px < width; px += 1) {
|
|
||||||
const i = py * width + px;
|
|
||||||
const old = grey[i];
|
|
||||||
const next = old < cut ? 0 : 255;
|
|
||||||
grey[i] = next;
|
|
||||||
|
|
||||||
if (mode === 'dither') {
|
|
||||||
// Floyd-Steinberg: push the rounding error to pixels not yet visited.
|
|
||||||
const err = old - next;
|
|
||||||
const spread = (dx, dy, weight) => {
|
|
||||||
const nx = px + dx;
|
|
||||||
const ny = py + dy;
|
|
||||||
if (nx < 0 || nx >= width || ny >= height) return;
|
|
||||||
grey[ny * width + nx] += err * weight;
|
|
||||||
};
|
|
||||||
spread(1, 0, 7 / 16);
|
|
||||||
spread(-1, 1, 3 / 16);
|
|
||||||
spread(0, 1, 5 / 16);
|
|
||||||
spread(1, 1, 1 / 16);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < width * height; i += 1) {
|
|
||||||
const v = grey[i] < 128 ? 0 : 255;
|
|
||||||
data[i * 4] = v;
|
|
||||||
data[i * 4 + 1] = v;
|
|
||||||
data[i * 4 + 2] = v;
|
|
||||||
data[i * 4 + 3] = 255;
|
|
||||||
}
|
|
||||||
ctx.putImageData(image, x, y);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function photoSettings(site) {
|
|
||||||
return {
|
|
||||||
mode: Object.hasOwn(PHOTO_MODES, site?.photo_mode) ? site.photo_mode : 'dither',
|
|
||||||
threshold: Number.isFinite(Number(site?.photo_threshold)) ? Number(site.photo_threshold) : 50,
|
|
||||||
contrast: Number.isFinite(Number(site?.photo_contrast)) ? Number(site.photo_contrast) : 20,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A comparison sheet: the same photo at several settings, side by side, so the
|
|
||||||
* right one can be chosen by eye rather than by guessing at numbers.
|
|
||||||
*/
|
|
||||||
export async function photoPreviewPng(photoPath, current = {}) {
|
|
||||||
const tile = 260;
|
|
||||||
const gap = 18;
|
|
||||||
const caption = 46;
|
|
||||||
|
|
||||||
const settings = photoSettings(current);
|
|
||||||
const variants = [
|
|
||||||
{ label: `Current: ${PHOTO_MODES[settings.mode].split(' —')[0]} ${settings.threshold}%, contrast ${settings.contrast}`, ...settings },
|
|
||||||
{ label: 'Dither, threshold 50, contrast 0', mode: 'dither', threshold: 50, contrast: 0 },
|
|
||||||
{ label: 'Dither, threshold 50, contrast 30', mode: 'dither', threshold: 50, contrast: 30 },
|
|
||||||
{ label: 'Dither, threshold 60, contrast 30', mode: 'dither', threshold: 60, contrast: 30 },
|
|
||||||
{ label: 'Hard threshold 50', mode: 'threshold', threshold: 50, contrast: 0 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const canvas = createCanvas(
|
|
||||||
gap + variants.length * (tile + gap),
|
|
||||||
gap + tile + caption
|
|
||||||
);
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
ctx.fillStyle = '#ffffff';
|
|
||||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
||||||
|
|
||||||
const abs = photoAbsolutePath(photoPath);
|
|
||||||
let image = null;
|
|
||||||
if (abs) {
|
|
||||||
try {
|
|
||||||
image = await loadImage(abs);
|
|
||||||
} catch {
|
|
||||||
image = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [index, variant] of variants.entries()) {
|
|
||||||
const x = gap + index * (tile + gap);
|
|
||||||
if (image) {
|
|
||||||
ctx.drawImage(image, x, gap, tile, tile);
|
|
||||||
halftone(ctx, x, gap, tile, variant);
|
|
||||||
} else {
|
|
||||||
ctx.fillStyle = '#f0f0f0';
|
|
||||||
ctx.fillRect(x, gap, tile, tile);
|
|
||||||
ctx.fillStyle = '#555555';
|
|
||||||
ctx.font = '14px sans-serif';
|
|
||||||
ctx.textAlign = 'center';
|
|
||||||
ctx.fillText('no photo yet', x + tile / 2, gap + tile / 2);
|
|
||||||
}
|
|
||||||
ctx.strokeStyle = '#999999';
|
|
||||||
ctx.lineWidth = 1;
|
|
||||||
ctx.strokeRect(x + 0.5, gap + 0.5, tile, tile);
|
|
||||||
|
|
||||||
ctx.fillStyle = index === 0 ? '#0b4f4a' : '#222222';
|
|
||||||
ctx.font = `${index === 0 ? 'bold ' : ''}13px sans-serif`;
|
|
||||||
ctx.textAlign = 'center';
|
|
||||||
for (const [line, text] of variant.label.split(', contrast').entries()) {
|
|
||||||
ctx.fillText(
|
|
||||||
line === 0 ? text : `contrast${text}`,
|
|
||||||
x + tile / 2,
|
|
||||||
gap + tile + 20 + line * 16
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return canvas.toBuffer('image/png');
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ------------------------------------------------------------ rendering */
|
/* ------------------------------------------------------------ rendering */
|
||||||
|
|
||||||
function wrapText(ctx, text, maxWidth, maxLines) {
|
function wrapText(ctx, text, maxWidth, maxLines) {
|
||||||
@@ -271,10 +122,7 @@ async function drawBadge(ctx, widthDots, heightDots, visit, site, accent, startY
|
|||||||
if (photo) {
|
if (photo) {
|
||||||
if (portrait) {
|
if (portrait) {
|
||||||
const x = Math.round((widthDots - photoSize) / 2);
|
const x = Math.round((widthDots - photoSize) / 2);
|
||||||
if (paint) {
|
if (paint) ctx.drawImage(photo, x, cursorY, photoSize, photoSize);
|
||||||
ctx.drawImage(photo, x, cursorY, photoSize, photoSize);
|
|
||||||
halftone(ctx, x, cursorY, photoSize, photoSettings(site));
|
|
||||||
}
|
|
||||||
if (paint) {
|
if (paint) {
|
||||||
ctx.strokeStyle = black;
|
ctx.strokeStyle = black;
|
||||||
ctx.lineWidth = Math.max(2, Math.round(mm(0.3)));
|
ctx.lineWidth = Math.max(2, Math.round(mm(0.3)));
|
||||||
@@ -285,7 +133,6 @@ async function drawBadge(ctx, widthDots, heightDots, visit, site, accent, startY
|
|||||||
const y = Math.round((heightDots - photoSize) / 2);
|
const y = Math.round((heightDots - photoSize) / 2);
|
||||||
if (paint) {
|
if (paint) {
|
||||||
ctx.drawImage(photo, pad, y, photoSize, photoSize);
|
ctx.drawImage(photo, pad, y, photoSize, photoSize);
|
||||||
halftone(ctx, pad, y, photoSize, photoSettings(site));
|
|
||||||
ctx.strokeStyle = black;
|
ctx.strokeStyle = black;
|
||||||
ctx.lineWidth = Math.max(2, Math.round(mm(0.3)));
|
ctx.lineWidth = Math.max(2, Math.round(mm(0.3)));
|
||||||
ctx.strokeRect(pad, y, photoSize, photoSize);
|
ctx.strokeRect(pad, y, photoSize, photoSize);
|
||||||
@@ -444,9 +291,6 @@ function explainPrintError(output, host) {
|
|||||||
if (/Unknown label|label/i.test(last) && /identifier/i.test(last)) {
|
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.';
|
return 'The printer rejected the label size. Check the roll loaded matches the badge settings.';
|
||||||
}
|
}
|
||||||
if (/Black\/Red|Monochrome media/i.test(output)) {
|
|
||||||
return 'The printer has a black/red roll loaded but received a monochrome job. Set "Roll loaded in the printer" to the DK-22251 option so the job is built for two-colour tape.';
|
|
||||||
}
|
|
||||||
if (/wrong roll|WrongMedia|media/i.test(last)) {
|
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 '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.';
|
||||||
}
|
}
|
||||||
@@ -492,33 +336,24 @@ export async function printBadge(visit, site) {
|
|||||||
const port = Number(site.printer_port) || 9100;
|
const port = Number(site.printer_port) || 9100;
|
||||||
const target = `tcp://${site.printer_host}:${port}`;
|
const target = `tcp://${site.printer_host}:${port}`;
|
||||||
|
|
||||||
const label = labelFor(site);
|
try {
|
||||||
const args = [
|
await runBrotherQl(
|
||||||
|
[
|
||||||
'--backend', 'network',
|
'--backend', 'network',
|
||||||
'--model', site.printer_model || 'QL-820NWB',
|
'--model', site.printer_model || 'QL-820NWB',
|
||||||
'--printer', target,
|
'--printer', target,
|
||||||
'print',
|
'print',
|
||||||
'--label', label,
|
'--label', labelFor(site),
|
||||||
];
|
file,
|
||||||
|
],
|
||||||
// brother_ql: "You must use this option when printing on black/red tape, even
|
config.printing.timeoutMs
|
||||||
// when not printing red." Without it the job is built as monochrome, the
|
);
|
||||||
// printer sees two-colour media, and refuses the whole thing.
|
|
||||||
if (label === '62red') args.push('--red');
|
|
||||||
|
|
||||||
args.push(file);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await runBrotherQl(args, config.printing.timeoutMs);
|
|
||||||
note(site.id, true, `Printed to ${site.printer_host}`);
|
note(site.id, true, `Printed to ${site.printer_host}`);
|
||||||
return { ok: true, target };
|
return { ok: true, target };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const friendly = explainPrintError(err.message, site.printer_host);
|
const friendly = explainPrintError(err.message, site.printer_host);
|
||||||
note(site.id, false, friendly);
|
note(site.id, false, friendly);
|
||||||
const wrapped = new Error(friendly);
|
throw new Error(friendly);
|
||||||
wrapped.raw = err.message;
|
|
||||||
wrapped.command = `${config.printing.command} ${args.join(' ')}`;
|
|
||||||
throw wrapped;
|
|
||||||
} finally {
|
} finally {
|
||||||
fs.rm(file, { force: true }, () => {});
|
fs.rm(file, { force: true }, () => {});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
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.
|
||||||
|
*
|
||||||
|
* Two 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.
|
||||||
|
*
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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; useful if badges
|
||||||
|
* print washed out on old rolls.
|
||||||
|
* @returns {import('@napi-rs/canvas').Canvas} ready to pass to drawImage
|
||||||
|
*/
|
||||||
|
export function ditherPhoto(image, size, options = {}) {
|
||||||
|
const { levels = true, brightness = 0 } = 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) {
|
||||||
|
const shift = (brightness / 100) * 255;
|
||||||
|
for (let p = 0; p < grey.length; p++) {
|
||||||
|
grey[p] = Math.min(255, Math.max(0, grey[p] + shift));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
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 };
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
};
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
};
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
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
-89
@@ -392,14 +392,12 @@ router.patch('/sites/:id', (req, res) => {
|
|||||||
const badge = req.body?.badge || {};
|
const badge = req.body?.badge || {};
|
||||||
const branding = req.body?.branding || {};
|
const branding = req.body?.branding || {};
|
||||||
const printerCfg = req.body?.printer || {};
|
const printerCfg = req.body?.printer || {};
|
||||||
const photoCfg = req.body?.photo || {};
|
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`UPDATE sites SET name = ?, slug = ?, active = ?, badge_enabled = ?, badge_width_mm = ?,
|
`UPDATE sites SET name = ?, slug = ?, active = ?, badge_enabled = ?, badge_width_mm = ?,
|
||||||
badge_height_mm = ?, badge_show_photo = ?, badge_accent = ?, badge_note = ?,
|
badge_height_mm = ?, badge_show_photo = ?, badge_accent = ?, badge_note = ?,
|
||||||
colour_brand = ?, colour_signout = ?, colour_page = ?, colour_text = ?,
|
colour_brand = ?, colour_signout = ?, colour_page = ?, colour_text = ?,
|
||||||
banner_height = ?, banner_align = ?, printer_enabled = ?, printer_host = ?,
|
banner_height = ?, banner_align = ?, printer_enabled = ?, printer_host = ?,
|
||||||
printer_port = ?, printer_model = ?, printer_rotate = ?, printer_label = ?,
|
printer_port = ?, printer_model = ?, printer_rotate = ?, printer_label = ? WHERE id = ?`
|
||||||
photo_mode = ?, photo_threshold = ?, photo_contrast = ? WHERE id = ?`
|
|
||||||
).run(
|
).run(
|
||||||
clean(req.body?.name ?? site.name, 100) || site.name,
|
clean(req.body?.name ?? site.name, 100) || site.name,
|
||||||
req.body?.slug ? uniqueSlug(req.body.slug, site.id) : site.slug,
|
req.body?.slug ? uniqueSlug(req.body.slug, site.id) : site.slug,
|
||||||
@@ -437,16 +435,7 @@ router.patch('/sites/:id', (req, res) => {
|
|||||||
: site.printer_rotate,
|
: site.printer_rotate,
|
||||||
printerCfg.label !== undefined
|
printerCfg.label !== undefined
|
||||||
? (Object.hasOwn(printer.ROLL_TYPES, printerCfg.label) ? printerCfg.label : '62')
|
? (Object.hasOwn(printer.ROLL_TYPES, printerCfg.label) ? printerCfg.label : '62')
|
||||||
: site.printer_label,
|
: site.printer_label
|
||||||
photoCfg.mode !== undefined
|
|
||||||
? (Object.hasOwn(printer.PHOTO_MODES, photoCfg.mode) ? photoCfg.mode : 'dither')
|
|
||||||
: site.photo_mode,
|
|
||||||
photoCfg.threshold !== undefined
|
|
||||||
? Math.min(95, Math.max(5, Number(photoCfg.threshold) || 50))
|
|
||||||
: site.photo_threshold,
|
|
||||||
photoCfg.contrast !== undefined
|
|
||||||
? Math.min(100, Math.max(-100, Number(photoCfg.contrast) || 0))
|
|
||||||
: site.photo_contrast
|
|
||||||
, site.id);
|
, site.id);
|
||||||
|
|
||||||
res.json(shapeSite(db.prepare('SELECT * FROM sites WHERE id = ?').get(site.id)));
|
res.json(shapeSite(db.prepare('SELECT * FROM sites WHERE id = ?').get(site.id)));
|
||||||
@@ -521,40 +510,10 @@ router.post('/sites/:id/test-print', async (req, res) => {
|
|||||||
const result = await printer.printBadge(printer.sampleVisit(site), site);
|
const result = await printer.printBadge(printer.sampleVisit(site), site);
|
||||||
res.json({ ok: true, ...result });
|
res.json({ ok: true, ...result });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// The raw output and the exact command matter when the friendly message is
|
res.status(400).json({ error: err.message });
|
||||||
// not enough — a printer refusing a job says why in its own words.
|
|
||||||
res.status(400).json({ error: err.message, raw: err.raw || null, command: err.command || null });
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Everything about how this site would print, for working out why it will not. */
|
|
||||||
router.get('/sites/:id/printer-diagnostics', (req, res) => {
|
|
||||||
const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id);
|
|
||||||
if (!site) return res.status(404).json({ error: 'Not found.' });
|
|
||||||
res.json({
|
|
||||||
site: site.name,
|
|
||||||
printing: {
|
|
||||||
enabled: Boolean(site.printer_enabled),
|
|
||||||
host: site.printer_host,
|
|
||||||
port: site.printer_port,
|
|
||||||
model: site.printer_model,
|
|
||||||
rotate: site.printer_rotate,
|
|
||||||
},
|
|
||||||
rollSetting: site.printer_label,
|
|
||||||
labelSentToPrinter: printer.labelFor(site),
|
|
||||||
redRequested: Boolean(site.badge_accent),
|
|
||||||
redWillPrint: printer.accentWillPrintRed(site),
|
|
||||||
badgeMm: { width: site.badge_width_mm, height: site.badge_height_mm },
|
|
||||||
lastResult: printer.printerStatus(site.id),
|
|
||||||
command: [
|
|
||||||
'brother_ql --backend network',
|
|
||||||
`--model ${site.printer_model || 'QL-820NWB'}`,
|
|
||||||
`--printer tcp://${site.printer_host}:${site.printer_port || 9100}`,
|
|
||||||
`print --label ${printer.labelFor(site)}${printer.labelFor(site) === '62red' ? ' --red' : ''} badge.png`,
|
|
||||||
].join(' '),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Reprints a real visitor's badge on the server's printer. */
|
/** Reprints a real visitor's badge on the server's printer. */
|
||||||
router.post('/visits/:id/print', async (req, res) => {
|
router.post('/visits/:id/print', async (req, res) => {
|
||||||
const visit = db.prepare('SELECT * FROM visits WHERE id = ?').get(req.params.id);
|
const visit = db.prepare('SELECT * FROM visits WHERE id = ?').get(req.params.id);
|
||||||
@@ -572,37 +531,6 @@ router.post('/visits/:id/print', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/** The same photo at several halftone settings, to choose between by eye. */
|
|
||||||
router.get('/sites/:id/photo-preview', 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.');
|
|
||||||
|
|
||||||
// Whatever real photo is closest to hand: the most recent visit at this site,
|
|
||||||
// then any stored recurring visitor photo.
|
|
||||||
const recent =
|
|
||||||
db
|
|
||||||
.prepare(
|
|
||||||
'SELECT photo_path FROM visits WHERE site_id = ? AND photo_path IS NOT NULL ORDER BY id DESC LIMIT 1'
|
|
||||||
)
|
|
||||||
.get(site.id) ||
|
|
||||||
db.prepare('SELECT photo_path FROM frequent_visitors WHERE photo_path IS NOT NULL LIMIT 1').get();
|
|
||||||
|
|
||||||
const overrides = {
|
|
||||||
photo_mode: req.query.mode || site.photo_mode,
|
|
||||||
photo_threshold: req.query.threshold || site.photo_threshold,
|
|
||||||
photo_contrast: req.query.contrast || site.photo_contrast,
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const png = await printer.photoPreviewPng(recent?.photo_path || null, overrides);
|
|
||||||
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 preview: ${err.message}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/sites/:id/badge-preview', (req, res) => {
|
router.get('/sites/:id/badge-preview', (req, res) => {
|
||||||
const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id);
|
const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id);
|
||||||
if (!site) return res.status(404).send('Not found.');
|
if (!site) return res.status(404).send('Not found.');
|
||||||
@@ -1317,20 +1245,6 @@ router.get(['/tls/ca.cer', '/tls/ca.der'], (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/** All encodings plus instructions, as one archive browsers will actually download. */
|
|
||||||
router.get('/tls/ca-bundle.zip', (req, res) => {
|
|
||||||
try {
|
|
||||||
const zip = tls.caBundleZip();
|
|
||||||
if (!zip) return res.status(404).send('No certificate authority has been generated yet.');
|
|
||||||
res.setHeader('Content-Type', 'application/zip');
|
|
||||||
res.setHeader('Content-Disposition', 'attachment; filename="visitor-signin-certificates.zip"');
|
|
||||||
res.setHeader('Content-Length', zip.length);
|
|
||||||
res.send(zip);
|
|
||||||
} catch (err) {
|
|
||||||
res.status(500).send(`Could not build the bundle: ${err.message}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post('/tls/renew', requireOwner, (req, res) => {
|
router.post('/tls/renew', requireOwner, (req, res) => {
|
||||||
try {
|
try {
|
||||||
// A brand new CA means every kiosk device has to trust it again, so it is
|
// A brand new CA means every kiosk device has to trust it again, so it is
|
||||||
|
|||||||
+1
-33
@@ -72,19 +72,7 @@ app.get('/admin/login', (req, res) => {
|
|||||||
// Nobody should land on the raw filenames; keep one address per page.
|
// Nobody should land on the raw filenames; keep one address per page.
|
||||||
app.get(['/admin.html', '/login.html'], (req, res) => res.redirect('/admin'));
|
app.get(['/admin.html', '/login.html'], (req, res) => res.redirect('/admin'));
|
||||||
|
|
||||||
// no-cache still allows a 304 on an unchanged file, but forces the browser to ask
|
app.use(express.static(publicDir, { extensions: ['html'], index: false }));
|
||||||
// first. Without it a cached admin.js survives an upgrade and the console keeps
|
|
||||||
// running yesterday's code against today's server, which is impossible to diagnose
|
|
||||||
// from the outside.
|
|
||||||
app.use(
|
|
||||||
express.static(publicDir, {
|
|
||||||
extensions: ['html'],
|
|
||||||
index: false,
|
|
||||||
setHeaders: (res, filePath) => {
|
|
||||||
if (/\.(html|js|css)$/.test(filePath)) res.setHeader('Cache-Control', 'no-cache');
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
app.get('/favicon.ico', (req, res) => res.redirect(301, '/favicon.svg'));
|
app.get('/favicon.ico', (req, res) => res.redirect(301, '/favicon.svg'));
|
||||||
app.use((req, res) => res.status(404).sendFile(path.join(publicDir, 'index.html')));
|
app.use((req, res) => res.status(404).sendFile(path.join(publicDir, 'index.html')));
|
||||||
|
|
||||||
@@ -133,26 +121,6 @@ function startRedirectServer() {
|
|||||||
|
|
||||||
http
|
http
|
||||||
.createServer((req, res) => {
|
.createServer((req, res) => {
|
||||||
// A zip, because browsers block bare certificate downloads.
|
|
||||||
if (req.url === '/ca.zip') {
|
|
||||||
try {
|
|
||||||
const zip = tls.caBundleZip();
|
|
||||||
if (!zip) {
|
|
||||||
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
||||||
return res.end('No certificate authority has been generated yet.');
|
|
||||||
}
|
|
||||||
res.writeHead(200, {
|
|
||||||
'Content-Type': 'application/zip',
|
|
||||||
'Content-Disposition': 'attachment; filename="visitor-signin-certificates.zip"',
|
|
||||||
'Content-Length': zip.length,
|
|
||||||
});
|
|
||||||
return res.end(zip);
|
|
||||||
} catch (err) {
|
|
||||||
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
||||||
return res.end(`Could not build the bundle: ${err.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// DER for Apple tooling, PEM for everything else.
|
// DER for Apple tooling, PEM for everything else.
|
||||||
if (req.url === '/ca.cer' || req.url === '/ca.der') {
|
if (req.url === '/ca.cer' || req.url === '/ca.der') {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -67,11 +67,6 @@ export function shapeSite(site) {
|
|||||||
rotate: site.printer_rotate || 0,
|
rotate: site.printer_rotate || 0,
|
||||||
label: site.printer_label || '62',
|
label: site.printer_label || '62',
|
||||||
},
|
},
|
||||||
photo: {
|
|
||||||
mode: site.photo_mode || 'dither',
|
|
||||||
threshold: site.photo_threshold ?? 50,
|
|
||||||
contrast: site.photo_contrast ?? 20,
|
|
||||||
},
|
|
||||||
badge: {
|
badge: {
|
||||||
enabled: Boolean(site.badge_enabled),
|
enabled: Boolean(site.badge_enabled),
|
||||||
widthMm: site.badge_width_mm,
|
widthMm: site.badge_width_mm,
|
||||||
|
|||||||
-75
@@ -4,7 +4,6 @@ import os from 'node:os';
|
|||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
import { execFileSync } from 'node:child_process';
|
import { execFileSync } from 'node:child_process';
|
||||||
import config from './config.js';
|
import config from './config.js';
|
||||||
import { createZip } from './zip.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Certificates for an internal-only kiosk.
|
* Certificates for an internal-only kiosk.
|
||||||
@@ -251,80 +250,6 @@ export function caCertificateDer() {
|
|||||||
return openssl(['x509', '-in', p.caCert, '-outform', 'der']);
|
return openssl(['x509', '-in', p.caCert, '-outform', 'der']);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Every form of the authority certificate in one archive, with instructions.
|
|
||||||
*
|
|
||||||
* Browsers increasingly refuse to download a bare .crt or .cer as a dangerous
|
|
||||||
* file type, which leaves no way to get the certificate onto a device. A zip is
|
|
||||||
* accepted, and carrying all the encodings means whichever tool is being fed —
|
|
||||||
* Jamf, Windows, Android — has the one it wants.
|
|
||||||
*/
|
|
||||||
export function caBundleZip() {
|
|
||||||
const p = paths();
|
|
||||||
if (!fs.existsSync(p.caCert)) return null;
|
|
||||||
|
|
||||||
const pem = fs.readFileSync(p.caCert);
|
|
||||||
const der = caCertificateDer();
|
|
||||||
const info = describe();
|
|
||||||
|
|
||||||
const readme = [
|
|
||||||
`${config.siteName} — certificate authority`,
|
|
||||||
'='.repeat(60),
|
|
||||||
'',
|
|
||||||
'Install ONE of these on each device. They are the same certificate in',
|
|
||||||
'different encodings; which one you need depends on the tool.',
|
|
||||||
'',
|
|
||||||
' visitor-signin-ca.cer binary DER. Jamf Pro, Apple Configurator, iOS, macOS.',
|
|
||||||
' visitor-signin-ca.crt PEM text. Windows, Android, Chromebook, Linux.',
|
|
||||||
' visitor-signin-ca.pem identical to the .crt, for tools expecting .pem.',
|
|
||||||
'',
|
|
||||||
'Fingerprint (SHA-256)',
|
|
||||||
` ${info.ca?.fingerprint || 'unknown'}`,
|
|
||||||
'',
|
|
||||||
'Check this matches what the device shows before trusting it.',
|
|
||||||
'',
|
|
||||||
'Valid until',
|
|
||||||
` ${info.ca?.validTo || 'unknown'}`,
|
|
||||||
'',
|
|
||||||
'The server certificate currently covers',
|
|
||||||
` ${(info.server?.names || ['unknown']).join('\n ')}`,
|
|
||||||
'',
|
|
||||||
'Installing',
|
|
||||||
'----------',
|
|
||||||
'Jamf Pro Devices > Configuration Profiles > New > Certificate payload.',
|
|
||||||
' Upload the .cer, scope to the kiosk devices, save. A root',
|
|
||||||
' certificate delivered by MDM is trusted for TLS automatically.',
|
|
||||||
'',
|
|
||||||
'Windows Double-click the .crt > Install Certificate > Local Machine >',
|
|
||||||
' Place all certificates in the following store > Trusted Root',
|
|
||||||
' Certification Authorities.',
|
|
||||||
'',
|
|
||||||
'Android Settings > Security > Encryption & credentials > Install a',
|
|
||||||
' certificate > CA certificate, then pick the .crt. Chrome on',
|
|
||||||
' Android will not accept a certificate for a bare IP address,',
|
|
||||||
' so reach the kiosk by hostname.',
|
|
||||||
'',
|
|
||||||
'Chromebook Settings > Privacy and security > Security > Manage',
|
|
||||||
' certificates > Authorities > Import, then pick the .crt.',
|
|
||||||
'',
|
|
||||||
'iOS by hand Open the .crt in Safari, allow the profile, install it under',
|
|
||||||
' Settings > General > VPN & Device Management, THEN turn it on',
|
|
||||||
' under Settings > General > About > Certificate Trust Settings.',
|
|
||||||
' Both steps are needed when installing by hand.',
|
|
||||||
'',
|
|
||||||
'Renewing the server certificate does not change this authority, so devices',
|
|
||||||
'only need this done once.',
|
|
||||||
'',
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
return createZip([
|
|
||||||
{ name: 'visitor-signin-ca.cer', data: der },
|
|
||||||
{ name: 'visitor-signin-ca.crt', data: pem },
|
|
||||||
{ name: 'visitor-signin-ca.pem', data: pem },
|
|
||||||
{ name: 'README.txt', data: readme },
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function caCertificate() {
|
export function caCertificate() {
|
||||||
const p = paths();
|
const p = paths();
|
||||||
return fs.existsSync(p.caCert) ? fs.readFileSync(p.caCert) : null;
|
return fs.existsSync(p.caCert) ? fs.readFileSync(p.caCert) : null;
|
||||||
|
|||||||
-108
@@ -1,108 +0,0 @@
|
|||||||
import zlib from 'node:zlib';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A small ZIP writer, so a bundle of certificates can be offered as a single
|
|
||||||
* download. Browsers increasingly refuse .crt and .cer files as dangerous types,
|
|
||||||
* and a zip is accepted where the bare certificate is not.
|
|
||||||
*
|
|
||||||
* Only what is needed here: a handful of small files, no directories, no
|
|
||||||
* encryption, no zip64. Written directly rather than pulling in a dependency for
|
|
||||||
* sixty lines of header packing.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const CRC_TABLE = (() => {
|
|
||||||
const table = new Int32Array(256);
|
|
||||||
for (let n = 0; n < 256; n += 1) {
|
|
||||||
let c = n;
|
|
||||||
for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
||||||
table[n] = c;
|
|
||||||
}
|
|
||||||
return table;
|
|
||||||
})();
|
|
||||||
|
|
||||||
function crc32(buffer) {
|
|
||||||
let crc = -1;
|
|
||||||
for (const byte of buffer) crc = (crc >>> 8) ^ CRC_TABLE[(crc ^ byte) & 0xff];
|
|
||||||
return (crc ^ -1) >>> 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** MS-DOS packs the date and time into two 16 bit words, with two second resolution. */
|
|
||||||
function dosStamp(date) {
|
|
||||||
const time =
|
|
||||||
(date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2);
|
|
||||||
const day = ((date.getFullYear() - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate();
|
|
||||||
return { time, day };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {Array<{name: string, data: Buffer|string}>} files
|
|
||||||
* @returns {Buffer} the complete archive
|
|
||||||
*/
|
|
||||||
export function createZip(files) {
|
|
||||||
const now = new Date();
|
|
||||||
const { time, day } = dosStamp(now);
|
|
||||||
const locals = [];
|
|
||||||
const central = [];
|
|
||||||
let offset = 0;
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
const name = Buffer.from(file.name, 'utf8');
|
|
||||||
const raw = Buffer.isBuffer(file.data) ? file.data : Buffer.from(file.data, 'utf8');
|
|
||||||
const compressed = zlib.deflateRawSync(raw);
|
|
||||||
// Storing uncompressed is allowed and is smaller for data that does not shrink.
|
|
||||||
const useDeflate = compressed.length < raw.length;
|
|
||||||
const data = useDeflate ? compressed : raw;
|
|
||||||
const method = useDeflate ? 8 : 0;
|
|
||||||
const crc = crc32(raw);
|
|
||||||
|
|
||||||
const localHeader = Buffer.alloc(30);
|
|
||||||
localHeader.writeUInt32LE(0x04034b50, 0); // local file header signature
|
|
||||||
localHeader.writeUInt16LE(20, 4); // version needed
|
|
||||||
localHeader.writeUInt16LE(0, 6); // flags
|
|
||||||
localHeader.writeUInt16LE(method, 8);
|
|
||||||
localHeader.writeUInt16LE(time, 10);
|
|
||||||
localHeader.writeUInt16LE(day, 12);
|
|
||||||
localHeader.writeUInt32LE(crc, 14);
|
|
||||||
localHeader.writeUInt32LE(data.length, 18);
|
|
||||||
localHeader.writeUInt32LE(raw.length, 22);
|
|
||||||
localHeader.writeUInt16LE(name.length, 26);
|
|
||||||
localHeader.writeUInt16LE(0, 28); // extra field length
|
|
||||||
|
|
||||||
locals.push(localHeader, name, data);
|
|
||||||
|
|
||||||
const centralHeader = Buffer.alloc(46);
|
|
||||||
centralHeader.writeUInt32LE(0x02014b50, 0); // central directory signature
|
|
||||||
centralHeader.writeUInt16LE(20, 4); // version made by
|
|
||||||
centralHeader.writeUInt16LE(20, 6); // version needed
|
|
||||||
centralHeader.writeUInt16LE(0, 8);
|
|
||||||
centralHeader.writeUInt16LE(method, 10);
|
|
||||||
centralHeader.writeUInt16LE(time, 12);
|
|
||||||
centralHeader.writeUInt16LE(day, 14);
|
|
||||||
centralHeader.writeUInt32LE(crc, 16);
|
|
||||||
centralHeader.writeUInt32LE(data.length, 20);
|
|
||||||
centralHeader.writeUInt32LE(raw.length, 24);
|
|
||||||
centralHeader.writeUInt16LE(name.length, 28);
|
|
||||||
centralHeader.writeUInt16LE(0, 30); // extra
|
|
||||||
centralHeader.writeUInt16LE(0, 32); // comment
|
|
||||||
centralHeader.writeUInt16LE(0, 34); // disk number
|
|
||||||
centralHeader.writeUInt16LE(0, 36); // internal attributes
|
|
||||||
centralHeader.writeUInt32LE(0, 38); // external attributes
|
|
||||||
centralHeader.writeUInt32LE(offset, 42); // offset of local header
|
|
||||||
|
|
||||||
central.push(centralHeader, name);
|
|
||||||
offset += localHeader.length + name.length + data.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
const centralBuffer = Buffer.concat(central);
|
|
||||||
const end = Buffer.alloc(22);
|
|
||||||
end.writeUInt32LE(0x06054b50, 0); // end of central directory
|
|
||||||
end.writeUInt16LE(0, 4);
|
|
||||||
end.writeUInt16LE(0, 6);
|
|
||||||
end.writeUInt16LE(files.length, 8);
|
|
||||||
end.writeUInt16LE(files.length, 10);
|
|
||||||
end.writeUInt32LE(centralBuffer.length, 12);
|
|
||||||
end.writeUInt32LE(offset, 16);
|
|
||||||
end.writeUInt16LE(0, 20); // comment length
|
|
||||||
|
|
||||||
return Buffer.concat([...locals, centralBuffer, end]);
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user