Public Access
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8664a1dd13 | ||
|
|
cdc1bc4264 | ||
|
|
a9e07b324d | ||
|
|
2a62450238 | ||
|
|
a77d42a1e4 | ||
|
|
1a94c33304 | ||
|
|
cb7bcff93e | ||
|
|
8d2111ff41 | ||
|
|
b4001c97c8 | ||
|
|
851e021fc2 | ||
|
|
abb752c3ef | ||
|
|
52eb0147fd | ||
|
|
0c1ef93552 | ||
|
|
26701d0d3b | ||
|
|
1b4b9d662a |
No files matched your search
@@ -1,34 +0,0 @@
|
||||
# visitor-signin — overview
|
||||
|
||||
Self-hosted visitor sign-in/out kiosk for school campuses. Visitors sign in at an iPad kiosk
|
||||
(photo, WWCC/VIT, host), recurring visitors use mobile number + 4-digit PIN, and a label badge
|
||||
prints automatically from a networked Brother QL-820NWB. Staff manage everything from an admin
|
||||
console. A Google Sheet mirrors who is currently on site for evacuation roll calls.
|
||||
|
||||
## Stack
|
||||
- Node.js 22, Express, SQLite (`better-sqlite3`), vanilla JS front end — no build step.
|
||||
- Single Docker container on an Ubuntu docker host; authoring happens on Windows.
|
||||
- Server-side badge rendering with `@napi-rs/canvas`; printing via `brother_ql` (Python) over
|
||||
TCP 9100.
|
||||
- Self-signed TLS: local CA + auto-renewing server certificate, distributed to iPads via Jamf Pro.
|
||||
|
||||
## Surfaces
|
||||
- `/` — kiosk (guest sign in, PIN sign in, sign out). No admin link on purpose.
|
||||
- `/admin/login` — standalone login (email + password + TOTP).
|
||||
- `/admin` — console: on site, visit log, recurring visitors, people to visit, sites, admins, system.
|
||||
- HTTP helper port — serves the CA (`/ca.crt`, `/ca.cer`, `/ca.zip`) and redirects to HTTPS.
|
||||
|
||||
## Running it
|
||||
- Deploy branch: **`deploy`**. Host update:
|
||||
`git fetch origin && git reset --hard origin/deploy && docker compose up -d --build`
|
||||
- After changing `.env`: `docker compose up -d` (never `restart`).
|
||||
- Check what's deployed: `docker compose exec visitor-signin npm run version`.
|
||||
- Printing diagnostics: `docker compose exec visitor-signin node scripts/print-test.mjs`.
|
||||
|
||||
## Where to look
|
||||
- `01-architecture.md` — current design and the reasoning behind it.
|
||||
- `sessions/` — dated session records.
|
||||
- `README.md` in the repo root — operator documentation.
|
||||
|
||||
## Session Log
|
||||
- 2026-09-23 — Visitor sign-in kiosk: build, HTTPS, and server-side label printing — Built the app end to end; added multi-site, 2FA admin accounts, self-signed CA for Jamf-managed iPads, server-side QL-820NWB printing (found `--red` is mandatory on DK-22251), single-tab evacuation sheet, branding, and photo halftoning.
|
||||
@@ -1,71 +0,0 @@
|
||||
# visitor-signin — architecture
|
||||
|
||||
Current state of the design. Established 2026-09-23 (first recorded session). When a later
|
||||
session changes something here, edit the relevant section and note the date and reason.
|
||||
|
||||
## Process layout
|
||||
One Node process in one container:
|
||||
- HTTPS listener on container port 3000 (kiosk, admin, APIs).
|
||||
- Plain-HTTP helper on 3001: serves the CA certificate and redirects everything else to HTTPS.
|
||||
- Background jobs in-process: photo retention purge, optional nightly auto sign-out, daily
|
||||
certificate renewal check, Google Sheet sync retries.
|
||||
|
||||
## Data
|
||||
- SQLite at `/data/visitors.db` (bind-mounted `./data`), WAL mode.
|
||||
- Photos on disk under `/data/photos/YYYY/MM/`; banners under `/data/branding/`; certs under
|
||||
`/data/certs/`.
|
||||
- Migrations are additive and idempotent (`addColumn` in `src/db.js`) — never destructive.
|
||||
- `visits` denormalises name, contact, host and site name, so history survives deletion of the
|
||||
related records.
|
||||
|
||||
## Identity and access
|
||||
- Admin accounts: email + scrypt password + TOTP (RFC 6238), eight one-shot recovery codes.
|
||||
Roles `owner` (manages admins and sites) and `admin` (optionally scoped to one site).
|
||||
Optional email-domain restriction. First account created once from `ADMIN_BOOTSTRAP_*`.
|
||||
- Recurring visitors: mobile number is the username; PIN is 4 digits, stored AES-256-GCM
|
||||
encrypted with a keyed HMAC `pin_lookup` for uniqueness. Phone, email and PIN are each unique.
|
||||
- `APP_SECRET` keys sessions, PIN encryption and `pin_lookup`. Changing it breaks every PIN.
|
||||
|
||||
## Multi-site
|
||||
- `sites` owns: people to visit (`hosts`), badge settings, printer settings, branding.
|
||||
- A kiosk is bound to a site by `/?site=<slug>` (remembered in the tablet's local storage) or a
|
||||
first-run picker; a single-site install never asks.
|
||||
- Sign in, sign out and duplicate detection are scoped per site.
|
||||
|
||||
## TLS
|
||||
- Local CA (10 years) + server certificate (398 days), generated by the container with openssl.
|
||||
- Server cert renews inside 30 days and is hot-swapped with `setSecureContext`; the CA is never
|
||||
replaced except by an explicit `--force`, so devices trust it once.
|
||||
- Reissue is triggered by a change in `HTTPS_HOSTNAMES` (tracked in `certs/.hostnames.json`),
|
||||
not by the full SAN list, because the container's own bridge IP changes between restarts.
|
||||
- CA distributed as PEM `.crt`, DER `.cer` (required by Jamf Pro) and a zip bundle (browsers
|
||||
block bare certificate downloads).
|
||||
|
||||
## Printing
|
||||
- The server renders the badge (`src/printer.js`, `@napi-rs/canvas`) at 300 dpi, 696 dots across
|
||||
(QL-820NWB head width on a 62 mm roll), and sends it with `brother_ql` over TCP 9100.
|
||||
- Photos are halftoned in the renderer (Floyd–Steinberg by default) so preview equals print.
|
||||
- The loaded roll is an explicit per-site setting (`printer_label`: `62` or `62red`). `62red`
|
||||
always adds `--red`; without it the job is monochrome and the printer refuses it.
|
||||
- Sign in waits up to `PRINT_SIGNIN_WAIT_MS` for the print; on failure the kiosk falls back to a
|
||||
browser print of the HTML badge (`src/sites.js`).
|
||||
- `brother_ql`'s network backend never reads the socket and the QL-820NWB does not answer status
|
||||
requests on 9100, so the server cannot confirm a label came out. Brother Status Monitor on a
|
||||
connected PC is the source of truth for refusals.
|
||||
|
||||
## Google Sheet
|
||||
- Evacuation mirror only: one tab ("On site now"), rewritten in full on every sign in/out and
|
||||
every 15 minutes. No history is sent to Google; history lives in the app (Visit log, CSV).
|
||||
- A failed write marks the tab stale and is retried; it never blocks the kiosk.
|
||||
|
||||
## Front end
|
||||
- Three separate documents: kiosk (`index.html`), login (`login.html`), console (`admin.html`).
|
||||
- Kiosk theming is per site: four chosen colours, the rest derived with WCAG contrast rules.
|
||||
- HTML/JS/CSS served with `Cache-Control: no-cache` so an upgrade is never masked by a cached
|
||||
console.
|
||||
|
||||
## Deployment
|
||||
- Author on Windows, deploy on an Ubuntu docker host from the `deploy` branch with
|
||||
`git reset --hard origin/deploy` (the host is a mirror, never merges).
|
||||
- `docker-entrypoint.sh` fixes ownership of the bind-mounted `/data`, then drops to `node`.
|
||||
- `pull_policy: build` — the image is always built locally.
|
||||
@@ -1,45 +0,0 @@
|
||||
# Native Brother QL Protocol (replacing brother_ql subprocess)
|
||||
|
||||
**Date:** 2026-09-07
|
||||
**Project:** visitor-signin
|
||||
**Status:** ongoing
|
||||
**Tags:** printing, brother-ql, ql-820nwb, raster, tcp, node, dithering, feature-branch
|
||||
|
||||
## Context
|
||||
Badge printing shells out to Python `brother_ql`, whose network backend never reads the socket — so jobs the printer refuses can look successful — and which needed special handling for the two-colour DK-22251 roll. A native Node.js implementation of the QL raster protocol was built on a feature branch to remove the Python dependency and get honest error handling. This work is on `feature/native-ql-protocol` and has **not** been merged into `deploy`.
|
||||
|
||||
## Key Decisions
|
||||
- **Native Node raster builder + TCP transport** instead of `brother_ql` — removes the Python subprocess and lets the app control the byte stream directly.
|
||||
- **Command stream written against Brother's raster command manual**, not reverse-engineered from `brother_ql` — e.g. the invalidate sequence for this series is 400 bytes, not 200.
|
||||
- **Printer status is best effort, not a precondition** — the QL-820NWB does not return status frames over TCP (only USB, per the manual), so a silent printer is treated as normal. Flush before closing the socket.
|
||||
- **Drop-in `printPng` API** so `printer.js` can switch with a small patch rather than a rewrite.
|
||||
- **Patch scripts instead of hand-edits** — `scripts/wire-native-printing.mjs` is brace-matching, idempotent and writes a `.bak` first.
|
||||
- **Keep Python/`brother_ql` in the Dockerfile for now** as a rollback path; remove only after a stable day of native printing.
|
||||
- **Floyd–Steinberg dithering targeting a fixed ink coverage (default 33.3%)** for badge photos, to reduce blotchy faces.
|
||||
|
||||
## Technical Details
|
||||
- New modules: `ql-raster.js` (command builder), `ql-status.js` (status frame decoder), `ql-transport.js` (TCP 9100), `ql-print.js` (`printPng` drop-in).
|
||||
- Patch scripts: `scripts/wire-native-printing.mjs`; a second patch script adds photo dithering.
|
||||
- Docs: `docs/PRINTING.md`.
|
||||
- Badge bitmap width 696 dots (62 mm head) via `@napi-rs/canvas`.
|
||||
- DK-22205 and DK-22251 share 62×90 mm dimensions — roll *type* must be part of media matching.
|
||||
- Two labels printed successfully in shell-level testing.
|
||||
|
||||
## Code Changes
|
||||
- (feature branch) `ql-raster.js`, `ql-status.js`, `ql-transport.js`, `ql-print.js` — new.
|
||||
- (feature branch) `printer.js` wired to the native module (`a9e07b3`).
|
||||
- (feature branch) Floyd–Steinberg dithering + fixed ink coverage.
|
||||
- (feature branch) `docs/PRINTING.md`.
|
||||
|
||||
## Open Questions / Next Steps
|
||||
- `deploy` has since gained its own photo halftoning and printing fixes (2026-09-16). Before merging, diff the feature branch against `deploy` — expect conflicts in `printer.js` and the photo pipeline.
|
||||
- Handle the tri-state printer `ready` value (`true` / `false` / `null` = unknown) in the admin console.
|
||||
- After merge and a stable day: remove Python from the Dockerfile, delete dead `explainPrintError` / `runBrotherQl`.
|
||||
|
||||
## Artifacts
|
||||
- Branch: `feature/native-ql-protocol` (head `8664a1d`)
|
||||
- `1b4b9d6` — Add native Brother QL raster command builder
|
||||
- `b4001c9` — Correct the command stream against Brother's raster manual
|
||||
- `851e021` — Make printer status best effort, not a precondition
|
||||
- `a9e07b3` — Wire printer.js to the native QL protocol module
|
||||
- Repo: https://gitea.hideawaygaming.com.au/jessikitty/visitor-signin
|
||||
@@ -1,243 +0,0 @@
|
||||
# Visitor sign-in kiosk: build, HTTPS, and server-side label printing
|
||||
|
||||
**Date:** 2026-09-23
|
||||
**Project:** visitor-signin
|
||||
**Status:** ongoing
|
||||
**Tags:** node, express, sqlite, docker, kiosk, tls, jamf, brother-ql, ql-820nwb, label-printing, google-sheets, multi-site, 2fa, dithering
|
||||
|
||||
## Context
|
||||
Built an internally hosted visitor sign-in/out kiosk for school campuses from scratch: guest and
|
||||
recurring-visitor sign in with photo capture and WWCC/VIT recording, an admin console, and a
|
||||
Google Sheet mirror for evacuation use. The session then iterated through multi-site support,
|
||||
per-admin accounts with 2FA, self-signed HTTPS for iPad kiosks deployed via Jamf Pro, and
|
||||
moving badge printing off the iPad onto the server, driving a Brother QL-820NWB directly. It
|
||||
closed on getting two-colour (DK-22251) printing accepted by the printer and on photo halftoning.
|
||||
|
||||
## Key Decisions
|
||||
- **Node/Express + SQLite + vanilla JS in one container, no front-end build step.** Simple to
|
||||
run on a single docker host; no database server to manage. Rejected a React/SPA build as
|
||||
unnecessary weight for a kiosk.
|
||||
- **Recurring-visitor PINs are encrypted (AES-256-GCM keyed from `APP_SECRET`), not hashed.**
|
||||
A 4-digit PIN has 10,000 values, so hashing gives no protection against someone holding the
|
||||
DB file; encryption lets admins reprint a lost card. Real protection is the lockout (5 wrong
|
||||
PINs = 15 min). Consequence: changing `APP_SECRET` makes every PIN unreadable.
|
||||
- **Phone, email and PIN must each be unique.** PINs use a keyed HMAC column (`pin_lookup`)
|
||||
because the ciphertext has a random IV and cannot be compared. Enforced in app code *and* by
|
||||
partial unique indexes; migration backfills `pin_lookup` and names existing duplicates rather
|
||||
than failing startup.
|
||||
- **A stored recurring-visitor photo is copied onto each visit, not referenced.** Keeps the visit
|
||||
log a true snapshot, and stops photo-retention purging from deleting a live profile photo.
|
||||
- **"Remove" on a recurring visitor is a hard delete that keeps visit history.** Visits
|
||||
denormalise name/contact/host, so history survives; identifiers are freed for reuse. "Active"
|
||||
untick is offered as the reversible alternative.
|
||||
- **TLS: a 10-year local CA plus a 398-day server certificate, auto-renewed.** Apple/Chrome reject
|
||||
server certs over ~398 days, so a single self-signed cert would need reinstalling on every
|
||||
tablet yearly. The server cert renews inside 30 days and hot-swaps via `setSecureContext`;
|
||||
the CA never changes, so devices are configured once. Rejected: single long-lived self-signed
|
||||
cert; reverse proxy (still supported as an option).
|
||||
- **Cert change detection tracks configured names via a sidecar file, not the full SAN list.**
|
||||
The container's docker bridge IP is included automatically and changes on restart; comparing
|
||||
full SANs would reissue the cert on every boot.
|
||||
- **Plain-HTTP helper port serves the CA (`/ca.crt`, `/ca.cer`, `/ca.zip`) and redirects the
|
||||
rest to HTTPS.** Solves the chicken-and-egg of downloading a cert you don't yet trust.
|
||||
- **CA offered as DER `.cer` and as a zip bundle.** Jamf's certificate payload rejects PEM `.crt`
|
||||
on extension; browsers block bare `.crt/.cer` downloads. The zip (all encodings + README with
|
||||
fingerprint and install steps) is written by an in-repo ZIP writer rather than a dependency,
|
||||
validated against Python's `zipfile`.
|
||||
- **Google Sheet holds only who is currently on site.** One tab, rewritten in full on every sign
|
||||
in/out (never patched, so it can't hold stale names). The earlier append-only history tab was
|
||||
dropped at the user's request; full history stays in the app (Visit log + CSV). Keeps visitor
|
||||
contact/movement history off the cloud.
|
||||
- **Server-side printing: render the badge to a bitmap with `@napi-rs/canvas`, send via
|
||||
`brother_ql` over TCP 9100.** Takes the iPad out of the print path (it was mangling label
|
||||
format). Chose `brother_ql` over hand-rolling Brother's raster protocol for hardware
|
||||
reliability. Sign-in awaits the print briefly; on failure the kiosk falls back to browser print.
|
||||
- **Roll type is an explicit per-site setting (`printer_label`), decoupled from the red styling
|
||||
option.** Originally the media id was inferred from the "print in red" checkbox, which caused
|
||||
unexplainable "Wrong Roll Type" refusals. See *Technical Details* for the `--red` finding.
|
||||
- **Photo halftoning happens in the renderer (Floyd–Steinberg), not in the printer driver.** The
|
||||
printer is 1-bit; `brother_ql`'s default threshold turned faces into solid blocks. Doing it
|
||||
server-side makes the preview and the printed label the same image.
|
||||
- **Admin login is a separate document at `/admin/login`.** Replaced an embedded panel that
|
||||
"unfolded" inside the console. Routes are declared before `express.static` so the redirect
|
||||
guard can't be bypassed.
|
||||
- **Branding: four settable colours; everything else derived.** Text on coloured backgrounds is
|
||||
chosen by WCAG luminance; the muted label colour is mixed toward the background only as far as
|
||||
it can go while staying ≥4.5:1. A fixed grey was the cause of "text blending in". SVG banners
|
||||
rejected (can carry script, served to every kiosk).
|
||||
- **Kiosk has no admin link and no author credit.** A kiosk is a public terminal. Credit line
|
||||
remains on staff-facing pages only.
|
||||
- **Company is recorded, not printed.** Adding it to the badge overflowed 62×90 mm labels.
|
||||
|
||||
## Technical Details
|
||||
**Ports / env:** container `3000` (HTTPS app) and `3001` (HTTP helper); host `HOST_PORT=8443`,
|
||||
`HOST_HTTP_PORT=8080`. Key env: `HTTPS_ENABLED`, `HTTPS_HOSTNAMES` (comma list of names/IPs
|
||||
the kiosks use), `HTTPS_PUBLIC_PORT` (must equal `HOST_PORT`), `APP_SECRET`,
|
||||
`ADMIN_BOOTSTRAP_EMAIL`/`ADMIN_BOOTSTRAP_PASSWORD` (first account only; `ADMIN_PASSWORD` is
|
||||
dead), `ADMIN_REQUIRE_2FA`, `ADMIN_ALLOWED_DOMAINS`, `EXPIRY_WARNING_DAYS`,
|
||||
`SHEETS_ONSITE_TAB`, `PRINT_TIMEOUT_MS`, `PRINT_SIGNIN_WAIT_MS`.
|
||||
|
||||
**Operational gotchas (all hit during this session):**
|
||||
- After editing `.env`, use `docker compose up -d`, **not** `docker compose restart` — restart
|
||||
keeps the old environment. Symptom: cert covers only `localhost`, `visitors.local` and a
|
||||
`172.x` bridge IP. Server now warns at boot when `HTTPS_HOSTNAMES` is at its default.
|
||||
- Host `.env` predates many settings; compare with `diff <(grep -oE '^[A-Z_]+=' .env | tr -d '=' | sort -u) <(grep -oE '^[A-Z_]+=' .env.example | tr -d '=' | sort -u)`.
|
||||
- Bind-mounted `./data` is created root-owned; `docker-entrypoint.sh` starts as root, chowns
|
||||
`/data` only if ownership is wrong, then drops to `node` via `setpriv` (`util-linux` installed
|
||||
explicitly).
|
||||
- `pull_policy: build` in compose — the image is never pulled from a registry.
|
||||
- `.gitattributes` forces LF (CRLF only for `.ps1`/`.bat`) so shell scripts survive Windows.
|
||||
- `data/`, `.env`, `secrets/` are gitignored and survive `git reset --hard` (verified).
|
||||
|
||||
**Deploy flow (host):** branch is **`deploy`**.
|
||||
`git fetch origin && git reset --hard origin/deploy && docker compose up -d --build`
|
||||
then `docker compose exec visitor-signin npm run version` (reports which features are present).
|
||||
|
||||
**Printer — QL-820NWB findings (hard-won):**
|
||||
- Head width on a 62 mm roll is **696 dots** at 300 dpi; bitmap is always 696 wide. 90 mm = 1063
|
||||
raster lines. Rotation is applied to the finished image, not the layout.
|
||||
- **DK-22251 (black/red) requires `brother_ql ... --label 62red --red` — even for an all-black
|
||||
badge.** Proven on the wire: `--label 62red` alone is byte-identical to `--label 62` (1063
|
||||
mono `g` lines, `ESC i K` mode byte `0x08`); adding `--red` produces 2126 two-plane `w` lines
|
||||
and mode byte `0x09`. Printer message when missing: *"Black/Red on White paper is installed
|
||||
now. Change it to Monochrome media."* Mismatch fails in both directions.
|
||||
- `brother_ql`'s network backend **only writes; it never reads**, so it reports success even when
|
||||
the printer refuses the job.
|
||||
- The QL-820NWB **does not answer status requests on port 9100** (write-only print server).
|
||||
**Brother Status Monitor on a connected Windows PC** gives the real refusal reason — use it
|
||||
first.
|
||||
- `62` = continuous roll; `62x100` etc. = die-cut. Sending one to the other is "Wrong Roll Type".
|
||||
|
||||
**Schema (SQLite, `src/db.js`, idempotent `addColumn` migrations):** tables `sites`, `hosts`,
|
||||
`frequent_visitors`, `visits`, `admin_users`, `login_attempts`, `pin_attempts`, `settings`,
|
||||
`sheet_queue` (now unused). Notable additions this session: `sites.printer_{enabled,host,port,
|
||||
model,rotate,label}`, `sites.photo_{mode,threshold,contrast}`, `sites.badge_*`,
|
||||
`sites.banner_{path,height,align}`, `sites.colour_{brand,signout,page,text}`,
|
||||
`frequent_visitors.{pin_lookup,photo_path,company,site_id}`, `visits.{company,site_id,site_name}`.
|
||||
|
||||
**Diagnostic scripts (run inside the container):** `scripts/version.mjs` (`npm run version`),
|
||||
`scripts/print-test.mjs [--dry] [--label ID] [--rotate DEG]`, `scripts/printer-status.mjs`
|
||||
(won't work on the QL-820NWB — see above), `scripts/print-probe.mjs` (interactive, needs
|
||||
`docker compose exec -it`), `scripts/make-cert.mjs [--force]`, `scripts/gen-cert.sh`.
|
||||
|
||||
**Push scripts:** `push-to-gitea.ps1/.bat/.sh`. Bugs fixed in-session: PowerShell functions
|
||||
return their whole output stream (a wrapper made every git call look failed); branch was
|
||||
hard-coded to `main` while work was on `deploy`; now detects unfinished rebases, checks git
|
||||
identity, uses the checked-out branch, and verifies `$LASTEXITCODE` after each call.
|
||||
|
||||
## Code Changes
|
||||
Whole project created this session. Principal files:
|
||||
- `src/server.js` — Express app, HTTPS listener with cert hot-swap, HTTP helper (CA downloads +
|
||||
redirect), admin page routing before static, `Cache-Control: no-cache` on html/js/css, jobs.
|
||||
- `src/db.js` — schema, migrations, default site, `pin_lookup` backfill, duplicate reporting.
|
||||
- `src/config.js` — env parsing incl. `https`, `sheets`, `printing`, `admin` groups.
|
||||
- `src/auth.js` — scrypt passwords, RFC 6238 TOTP (verified against RFC vectors), recovery codes.
|
||||
- `src/users.js` — admin accounts, domain restriction, lockout, bootstrap.
|
||||
- `src/pins.js` — PIN encrypt/decrypt, keyed `pinLookup`, collision-aware `generatePin`.
|
||||
- `src/photos.js` — save/copy/purge visit and profile photos.
|
||||
- `src/sites.js` — site helpers, HTML badge renderer (browser fallback).
|
||||
- `src/branding.js` — theme derivation, WCAG contrast, banner storage.
|
||||
- `src/tls.js` — CA + server cert lifecycle, DER export, zip bundle.
|
||||
- `src/zip.js` — dependency-free ZIP writer.
|
||||
- `src/sheets.js` — single-tab "On site now" rewrite, human-readable Google error mapping.
|
||||
- `src/printer.js` — canvas badge render, Floyd–Steinberg halftone, `brother_ql` invocation with
|
||||
`--red` for `62red`, photo preview sheet, error translation.
|
||||
- `src/routes/kiosk.js` — sign in/out, PIN auth, site scoping, server-print with fallback.
|
||||
- `src/routes/admin.js` — auth/2FA, users, sites, hosts, recurring visitors, visits, printer
|
||||
diagnostics/test/preview, TLS endpoints, status.
|
||||
- `public/index.html`, `public/js/kiosk.js`, `public/css/kiosk.css` — kiosk flow (filter input
|
||||
above host dropdown, square photo capture, badge fallback).
|
||||
- `public/login.html`, `public/js/login.js`, `public/css/login.css` — standalone paged login.
|
||||
- `public/admin.html`, `public/js/admin.js`, `public/css/admin.css` — console.
|
||||
- `Dockerfile`, `docker-compose.yml`, `docker-entrypoint.sh`, `.gitattributes`, `.env.example`.
|
||||
- `scripts/*` — diagnostics and cert tooling listed above.
|
||||
|
||||
## Open Questions / Next Steps
|
||||
- **Confirm the `--red` fix prints on real hardware.** Pending: deploy, then **Test print**. The
|
||||
site's roll setting is already `62red` with red on.
|
||||
- **Latest changes exist only in the handed-over tarball, not in git:** CA zip bundle, printer
|
||||
diagnostics/probe scripts, `--red` flag, photo halftoning. Next: extract over the Windows
|
||||
working copy → commit → push `deploy` → host `reset --hard origin/deploy` → rebuild.
|
||||
- **Tune photo halftoning from a printed label** (defaults: error diffusion, threshold 50,
|
||||
contrast 20). 300 dpi dithering reads differently in the hand than on screen.
|
||||
- Physical print output not yet verified: margins, cut position, rotation on the roll, red plane.
|
||||
- Host `.env` is stale; recommend rebuilding from `.env.example`, preserving `APP_SECRET` exactly.
|
||||
- Google Sheet returned 403 "caller does not have permission" — fix is sharing the sheet with the
|
||||
service account (shown in Admin → System). Outcome not confirmed.
|
||||
- Recommend a DNS name for the kiosk instead of a bare IP (IP-only certs are unreliable on
|
||||
Android; DHCP changes break everything).
|
||||
- Consider making `deploy` the default branch in Gitea / retiring the tangled `main`.
|
||||
- Offered, not built: die-cut roll options in the roll dropdown; an admin field for
|
||||
`HTTPS_HOSTNAMES`; banner/logo on the printed pass; theming the admin console.
|
||||
- Housekeeping: `sheet_queue` table is unused; `brother_ql==0.9.4` is unmaintained (fork
|
||||
`brother_ql_next` exists).
|
||||
|
||||
## Artifacts
|
||||
- Repository: `<redacted-gitea-host>/<redacted-owner>/visitor-signin`, working branch `deploy`.
|
||||
- Commits on `deploy`: `8bc7179` Server-side printing, roll type setting, cache headers;
|
||||
`eb98658` Printing Debug; `b1a7933` Printer Debug 2.
|
||||
- `brother_ql` 0.9.4 (PyPI) — `print --help` documents the `--red` requirement.
|
||||
- Brother QL-820NWB specification: 12–62 mm media, 60.96 mm max print width, 300×300 dpi.
|
||||
- RFC 6238 (TOTP) — test vectors used to verify `src/auth.js`.
|
||||
|
||||
---
|
||||
|
||||
## Addendum — 2026-09-23, PIN reset bug
|
||||
|
||||
**Symptom:** after changing a recurring visitor's PIN, phone + PIN was rejected at the kiosk.
|
||||
|
||||
**Cause:** the Edit form carried a "PIN" field whose value `PATCH /admin/api/frequent/:id`
|
||||
never read. The record saved, the field was silently discarded, and the admin handed out a PIN
|
||||
the system had never stored. (`POST /admin/api/frequent/:id/pin`, the "New PIN" button, was
|
||||
always correct — verified by reproduction.)
|
||||
|
||||
**Fix:** `PATCH` now honours a supplied `pin`: 4 digits, checked for collisions via
|
||||
`allocatePin(pin, row.id)`, written to `pin_enc` and `pin_lookup`. Blank keeps the current PIN.
|
||||
A malformed or already-used PIN is rejected with a message instead of ignored. The response
|
||||
carries `pinChanged`, and the console shows the printable card when it is true. Saving a record
|
||||
also clears `pin_attempts` for both the old and new phone number, so a changed number cannot
|
||||
leave a stale lockout.
|
||||
|
||||
**Files:** `src/routes/admin.js` (PATCH handler), `public/js/admin.js` (edit modal shows the
|
||||
current PIN, relabelled field, shows the card after a change), `public/css/admin.css`, `README.md`.
|
||||
|
||||
---
|
||||
|
||||
## Addendum — iPad trust troubleshooting
|
||||
|
||||
Reported: Safari and Chrome on a Jamf-managed iPad still report an insecure connection after the
|
||||
certificate was imported into Jamf.
|
||||
|
||||
**Found:** the server certificate was being issued for *exactly* 398 days — Apple's ceiling, with
|
||||
zero margin for rounding or clock skew. `SERVER_DAYS` in `src/tls.js` reduced 398 → 396.
|
||||
|
||||
**Added:** `scripts/tls-check.mjs` (`npm run tls-check`), which connects to a given URL and checks
|
||||
what iOS requires: address covered by a SAN, validity span, SAN present, `serverAuth` EKU,
|
||||
signature algorithm, key size, and that the served leaf was issued by this server's CA. It prints
|
||||
the authority and leaf fingerprints side by side.
|
||||
|
||||
Note: Node's `X509Certificate` does not expose extended key usage and `toString()` returns PEM,
|
||||
so the EKU and signature checks shell out to `openssl x509 -noout -text`.
|
||||
|
||||
**Most likely causes, still unconfirmed on the device:** the *served* certificate was uploaded to
|
||||
Jamf instead of the *authority*; or the address the iPad uses is not in `HTTPS_HOSTNAMES` (the
|
||||
host `.env` was previously missing that line entirely).
|
||||
|
||||
---
|
||||
|
||||
## Addendum — promote a guest to a recurring visitor
|
||||
|
||||
Added **Make regular** to the On site list for any visit with a mobile number that is not already
|
||||
a recurring visitor.
|
||||
|
||||
`POST /admin/api/visits/:id/make-regular` copies name, company, phone, email, check type/number/
|
||||
expiry, host (as their default) and site from the visit, issues a random unique PIN via
|
||||
`allocatePin(null)`, and copies the visit photo into a separate profile photo with
|
||||
`copyStoredPhoto` so retention purging the visit cannot delete it. Returns the shaped record plus
|
||||
`pin` and `photoCarriedOver`; the console opens the printable card.
|
||||
|
||||
Refusals: no mobile number on the visit (the number is the sign in name); phone or email already
|
||||
belongs to a recurring visitor (error names them and returns `existingId`). Site-scoped admins are
|
||||
checked with `assertSiteAllowed`. The visit row is deliberately not modified or relinked.
|
||||
|
||||
**Files:** `src/routes/admin.js`, `public/js/admin.js`, `README.md`.
|
||||
@@ -108,15 +108,6 @@ same person can be signed in at two sites at once without the system arguing abo
|
||||
An admin account can be limited to a single site. Those admins see only that site's visitors,
|
||||
staff list and log, and cannot touch the others.
|
||||
|
||||
### Choosing who to visit
|
||||
|
||||
The kiosk asks for a few letters of the name rather than presenting a long list. Once the filter
|
||||
narrows to five or fewer people, their names appear as large buttons to tap and the full-list
|
||||
dropdown gets out of the way; above that the dropdown carries everyone, because a page of eighty
|
||||
buttons is worse than a scroll wheel. Pressing enter picks the person when exactly one is left.
|
||||
|
||||
The cut-off is `TAP_LIST_LIMIT` in `public/js/kiosk.js`.
|
||||
|
||||
## Branding the kiosk
|
||||
|
||||
Each site can carry its own banner and colours, set under **Sites → Edit**.
|
||||
@@ -188,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-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**
|
||||
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
|
||||
@@ -246,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
|
||||
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
|
||||
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.
|
||||
@@ -489,20 +406,6 @@ retention cleaning up old visits can never delete a live profile photo.
|
||||
|
||||
Leave the photo blank and they are asked at the kiosk as before.
|
||||
|
||||
### Turning a guest into a regular
|
||||
|
||||
Anyone currently on site who gave a mobile number gets a **Make regular** button in the **On
|
||||
site** list. It takes their name, company, mobile, email and check details straight from the
|
||||
visit, issues a random PIN, and opens the printable card.
|
||||
|
||||
Their sign in photo is kept on file as their profile photo, so from the next visit the kiosk
|
||||
skips the camera: mobile number, PIN, pick a host, badge prints.
|
||||
|
||||
The visit itself is left alone — it happened as a guest visit and the log goes on saying so.
|
||||
The button does not appear for someone who only left an email address, because the mobile number
|
||||
is their sign in name, and promoting the same person twice is refused with the name of the
|
||||
existing record.
|
||||
|
||||
### Removing someone
|
||||
|
||||
**Remove** on the recurring visitors list deletes the saved record for good — the PIN stops
|
||||
@@ -526,16 +429,6 @@ digits is only 10,000 combinations, so hashing would add nothing against anyone
|
||||
of the database — the real protection is the lockout: five wrong PINs on a number locks it for
|
||||
15 minutes.
|
||||
|
||||
Two ways to change someone's PIN, and they now behave the same:
|
||||
|
||||
- **New PIN** on the recurring visitors list issues a random one.
|
||||
- **Edit** shows the current PIN and has a box to type a replacement. Leave it blank to keep the
|
||||
current one. A typed PIN must be 4 digits and not already in use; either problem is reported
|
||||
rather than ignored.
|
||||
|
||||
Saving a record also clears any PIN lockout on that visitor, under both the old and the new
|
||||
mobile number.
|
||||
|
||||
Four digits gives 10,000 combinations and each must be unique, so that is the ceiling on
|
||||
simultaneous recurring visitors. Deactivating someone frees theirs.
|
||||
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
# Site Sign In — Administrator Guide
|
||||
|
||||
Last updated: 4 October 2026
|
||||
|
||||
A plain-language guide to the Site Sign In admin console: checking who is on site, fixing a stuck sign-in, keeping hosts and sites current, and getting badge printing going again. No command line needed for anything in here.
|
||||
|
||||
## Getting in
|
||||
|
||||
The console lives at **`https://<your server>:8443/admin`**, and you open it from a staff machine. The kiosk deliberately has no link to it — a kiosk is a public terminal, so administration does not belong on it.
|
||||
|
||||
Sign in with your own email address and password. At your first sign in you will be asked to set a real password and to scan a QR code into an authenticator app (Google Authenticator, Authy, 1Password and Bitwarden all work). **Save the eight recovery codes somewhere you can get to without your phone** — each works once.
|
||||
|
||||
Two roles:
|
||||
|
||||
| Role | Can do |
|
||||
| --- | --- |
|
||||
| Owner | Everything, including adding admins and sites |
|
||||
| Admin | Day-to-day work; can be limited to a single site, and then sees only that site's visitors, staff list and log |
|
||||
|
||||
If you are locked out, six wrong passwords locks that email address for 15 minutes. An owner can reset another admin's password (a temporary one appears on screen) or clear their two-factor so they can re-enrol on a new phone.
|
||||
|
||||
A browser certificate warning means that machine does not yet trust the local authority. Install it from **System → Certificate**, or browse to `http://<your server>:8080/ca.crt`.
|
||||
|
||||
## Who is on site right now
|
||||
|
||||
**On site** is the tab you will live in. It lists everyone currently signed in, with their host, the time they arrived and how long they have been here.
|
||||
|
||||
> *Illustration in the online guide: the On site tab, with placeholder visitors.*
|
||||
|
||||
Three things you can do from each row:
|
||||
|
||||
- **Sign out** — for the visitor who walked out without touching the kiosk.
|
||||
- **Reprint the badge** — for a jam, a mis-feed, or a badge left on a desk.
|
||||
- **Make regular** — turns a guest into a recurring visitor. It takes their name, company, mobile, email and check details straight from the visit, issues a PIN and opens the printable card. Their sign-in photo is kept as their profile photo, so from the next visit the kiosk skips the camera. The button only appears for someone who gave a mobile number.
|
||||
|
||||
The matching offsite copy is the **On site now** tab of the Google Sheet. It is rewritten in full every time anyone signs in or out, so it holds current occupants only, never history. The top row shows the head count and the time it was last updated — if that time is stale, the writes are queuing.
|
||||
|
||||
**Visit log** holds the complete history: searchable (it matches on company, so you can pull up every visit from one contractor) and downloadable as CSV. That history stays in the application and is never sent to Google.
|
||||
|
||||
Anyone still showing as on site at `AUTO_SIGNOUT_TIME` is closed off automatically overnight.
|
||||
|
||||
## Keeping the lists current
|
||||
|
||||
Two lists need an eye kept on them: the people a visitor can ask for, and the regulars who sign in with a PIN.
|
||||
|
||||
**People to visit.** Pick a site, then paste or upload your staff CSV (`docs/hosts-sample.csv` shows the shape). This is the list the kiosk offers a visitor, so a departed staff member left on it is the usual cause of a badge naming the wrong host.
|
||||
|
||||
**Recurring visitors.** Each saved person is one record, and their mobile number, email address and PIN must all be unique. The mobile number is the username; the PIN is four digits.
|
||||
|
||||
| To do this | Use this |
|
||||
| --- | --- |
|
||||
| Issue a fresh random PIN | **New PIN** on the list |
|
||||
| Set a specific PIN, or read back the current one | **Edit** — leave the PIN box blank to keep the current one |
|
||||
| Stop someone signing in, but keep their record | Untick **Active** in Edit |
|
||||
| Delete them for good | **Remove** — the PIN stops working and the stored photo goes |
|
||||
| Stop the kiosk asking them to pose | Add a photo in Edit, from the camera or a file |
|
||||
|
||||
Removing someone keeps their visit history: visits store the name, contact details and host in their own columns. It does **not** sign them out if they are currently on site — the confirmation says so, and their visit can still be closed at the kiosk with their last name and mobile number.
|
||||
|
||||
Five wrong PINs locks that mobile number for 15 minutes. Saving the record clears the lockout, under both the old and the new number.
|
||||
|
||||
**WWCC and VIT expiry.** Give a recurring visitor an expiry date and the console watches it. Inside 28 days the row turns amber; past the date it turns red, a count appears on the **Recurring visitors** tab, and a banner sits across the top of every screen. Nothing is blocked automatically — an expired check is a conversation at the desk, not a door the software slams.
|
||||
|
||||
> *Illustration in the online guide: the recurring visitors list, one check expiring and one expired.*
|
||||
|
||||
## Settings worth knowing
|
||||
|
||||
Almost everything a site needs sits under **Sites → Edit**. Each site has its own staff list, its own badge settings and its own look.
|
||||
|
||||
| Setting | What to know |
|
||||
| --- | --- |
|
||||
| Badge printing | Turn it on per site. With *Print from the server* ticked, the server renders the badge and pushes it to the printer, so the kiosk tablet needs no driver and shows no print dialog |
|
||||
| Roll loaded in the printer | Must match what is physically in the machine. This is the single most common cause of a refused job |
|
||||
| Label stock | A shortcut that fills in size, roll type and colour option. It is not itself saved, which is why it can appear to revert |
|
||||
| Rotation | 0° lays the badge across the 62 mm width; 90° turns it for a label that hangs from its short edge. Check the bitmap preview — only one will suit how you hang them |
|
||||
| Photo settings | Error diffusion is the default and much the best for faces. *Preview the photo settings* renders a real visitor photo at several settings side by side |
|
||||
| Banner and colours | PNG with a transparent background, up to 2 MB. The editor shows the contrast ratio as you type — aim for 7:1 on a screen people read standing up |
|
||||
|
||||
**Before committing a new roll**, use **Bitmap preview** (shows the exact image that will be sent) and **Test print** on the site card.
|
||||
|
||||
> *Illustration in the online guide: a 62 × 90 mm badge with every element labelled.*
|
||||
|
||||
### The evacuation sheet
|
||||
|
||||
The Google Sheet exists for one job: so somebody standing in a car park can see who is in the building. Check it from **System → Test the sheet connection**, which also shows the service account address and the last successful write.
|
||||
|
||||
Bookmark the sheet on the phones that would actually be used in an evacuation, and confirm it loads **for them**, not just for you. Check it again occasionally — a revoked key or changed sharing queues rows silently, and the head count and *Updated* time in the first row are the giveaway.
|
||||
|
||||
## When badges stop printing
|
||||
|
||||
First, the reassuring part: **sign in still completes**. The visitor is recorded, the kiosk falls back to its own browser print dialog, and the failure is shown against the site in **Sites** with the reason. You can reprint any badge from the **On site** list.
|
||||
|
||||
Work through these in order — the first two account for nearly every case.
|
||||
|
||||
1. **Does the roll setting match the roll?** Open **Sites → Edit → Printer** and compare *Roll loaded in the printer* with what is actually in the machine. A DK-22251 must be set as the black/red roll even when nothing on the badge is red. The printer's own message — *Black/Red on White paper is installed now. Change it to Monochrome media* — sounds like a driver setting but means swap the roll or fix this field.
|
||||
2. **Continuous or die-cut?** `62` means a continuous roll cut to length; `62x100` means pre-cut labels. Sending one to the other kind is refused as a wrong roll.
|
||||
3. **Can the printer be reached?** It needs a fixed IP — a DHCP lease change silently stops badges. Check the address under **Sites → Edit → Printer**.
|
||||
4. **Press Test print** on the site card. A failed test shows the printer's own words rather than a summary, and **Diagnostics** shows exactly what would be sent.
|
||||
5. **Ask the printer what it has loaded**, on the docker host:
|
||||
|
||||
```
|
||||
docker compose exec visitor-signin node scripts/printer-status.mjs
|
||||
```
|
||||
|
||||
The QL-820NWB is write-only over port 9100, so it often will not answer. When it doesn't, **Brother Status Monitor on a PC with the printer installed** gives the exact reason a job was refused — more than anything on the server can tell you.
|
||||
6. **Still stuck?** `docker compose exec -it visitor-signin node scripts/print-probe.mjs` sends one label per roll id and asks you after each whether anything came out. The `-it` matters: it needs a terminal.
|
||||
|
||||
## Everything else that goes wrong
|
||||
|
||||
| What you see | What it means | What to do |
|
||||
| --- | --- | --- |
|
||||
| Browser warns about the certificate | That device does not trust the local authority | Install it from **System → Certificate**. On iPad there is a second, separate step: Settings → General → About → Certificate Trust Settings |
|
||||
| Sheet says *The caller does not have permission* | The spreadsheet was never shared with the service account | **System** shows the address. Share the sheet with it as **Editor**. This is the cause nine times in ten |
|
||||
| Head count on the sheet looks stale | Writes are queuing | **System** shows the last successful write and any error. Rows retry every minute and are never lost |
|
||||
| An admin cannot sign in | Six wrong passwords locks the address for 15 minutes | Wait, or have an owner reset the password |
|
||||
| An admin lost their phone | Their authenticator is gone | An owner clears their two-factor; they re-enrol at next sign in |
|
||||
| A regular's PIN is rejected | Five wrong attempts locks that mobile number for 15 minutes | Open their record and save it — that clears the lockout |
|
||||
| The kiosk asks which site it is on | It was never pointed at one | Visit `http://<server>:8088/?site=<site-slug>` once; the tablet remembers |
|
||||
| Someone is still on site who left hours ago | They never signed out | Sign them out from the **On site** list. `AUTO_SIGNOUT_TIME` catches the rest overnight |
|
||||
|
||||
### Two things to be careful with
|
||||
|
||||
**Changing `APP_SECRET` makes every stored PIN unreadable.** If it has to change, reissue PINs from the console afterwards.
|
||||
|
||||
**Starting a new certificate authority** means every kiosk device has to trust the new one. *Renew the server certificate* is the safe option and touches no devices.
|
||||
|
||||
If you edit `.env` on the host, bring the container back with `docker compose up -d`, not `docker compose restart` — restart reuses the old environment and the change appears to do nothing.
|
||||
|
||||
---
|
||||
|
||||
Created by: Jess Rogerson (yelling commands at Claude.AI)
|
||||
@@ -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)
|
||||
Binary file not shown.
+1
-6
@@ -7,12 +7,7 @@
|
||||
"scripts": {
|
||||
"start": "node 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'))\"",
|
||||
"print-test": "node scripts/print-test.mjs",
|
||||
"printer-status": "node scripts/printer-status.mjs",
|
||||
"print-probe": "node scripts/print-probe.mjs",
|
||||
"tls-check": "node scripts/tls-check.mjs"
|
||||
"gen-secret": "node -e \"console.log(require('crypto').randomBytes(32).toString('hex'))\""
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
|
||||
@@ -400,54 +400,3 @@ tr.row-bad td { background: #fdf0f2; }
|
||||
vertical-align: -2px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
pre.raw {
|
||||
margin: 0 0 14px;
|
||||
padding: 12px;
|
||||
background: var(--paper);
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 3px;
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.45;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
.pin-inline {
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--deep);
|
||||
}
|
||||
@@ -234,21 +234,6 @@ a:focus-visible {
|
||||
margin-bottom: 20px;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* The shortlist is the thing to tap, so make it read as the answer rather than
|
||||
as another field. */
|
||||
#host-list .host-option,
|
||||
#freq-host-list .host-option {
|
||||
border-color: var(--deep);
|
||||
box-shadow: inset 0 0 0 1px var(--deep);
|
||||
min-height: 64px;
|
||||
font-size: 19px;
|
||||
animation: rise 160ms ease-out;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#host-list .host-option,
|
||||
#freq-host-list .host-option { animation: none; }
|
||||
}
|
||||
.host-option small { display: block; color: var(--muted); font-size: 14px; }
|
||||
.host-empty {
|
||||
color: var(--muted);
|
||||
|
||||
+8
-16
@@ -68,16 +68,12 @@
|
||||
<section class="screen" id="screen-guest-host" data-step="2">
|
||||
<div class="rail" data-rail></div>
|
||||
<h2>Who are you here to see?</h2>
|
||||
<p class="hint">Type the first few letters of their name. Their name will appear below —
|
||||
tap it.</p>
|
||||
<label class="field">
|
||||
<span>Their name</span>
|
||||
<input id="in-host-search" autocomplete="off" enterkeyhint="search"
|
||||
placeholder="e.g. Smi for Smith">
|
||||
<span>Start typing a name to narrow the list</span>
|
||||
<input id="in-host-search" autocomplete="off" enterkeyhint="search" placeholder="e.g. Rogerson">
|
||||
</label>
|
||||
<div class="host-list" id="host-list" role="list" hidden></div>
|
||||
<label class="field" id="host-select-field">
|
||||
<span id="host-select-label">Or pick from the full list</span>
|
||||
<label class="field">
|
||||
<span>Then choose a name</span>
|
||||
<select id="host-select" class="picker"></select>
|
||||
</label>
|
||||
<p class="host-empty" id="host-empty" hidden></p>
|
||||
@@ -175,16 +171,12 @@
|
||||
<!-- --------------------------------------------- recurring: host -->
|
||||
<section class="screen" id="screen-freq-host">
|
||||
<h2 id="freq-greeting">Who are you here to see?</h2>
|
||||
<p class="hint">Type the first few letters of their name. Their name will appear below —
|
||||
tap it.</p>
|
||||
<label class="field">
|
||||
<span>Their name</span>
|
||||
<input id="in-freq-host-search" autocomplete="off" enterkeyhint="search"
|
||||
placeholder="e.g. Smi for Smith">
|
||||
<span>Start typing a name to narrow the list</span>
|
||||
<input id="in-freq-host-search" autocomplete="off" enterkeyhint="search">
|
||||
</label>
|
||||
<div class="host-list" id="freq-host-list" role="list" hidden></div>
|
||||
<label class="field" id="freq-host-select-field">
|
||||
<span id="freq-host-select-label">Or pick from the full list</span>
|
||||
<label class="field">
|
||||
<span>Then choose a name</span>
|
||||
<select id="freq-host-select" class="picker"></select>
|
||||
</label>
|
||||
<p class="host-empty" id="freq-host-empty" hidden></p>
|
||||
|
||||
+19
-154
@@ -185,11 +185,6 @@ async function loadOnsite() {
|
||||
<td class="actions">
|
||||
<button class="ghost" data-badge="${v.id}">Badge</button>
|
||||
<button class="ghost" data-print="${v.id}">Print</button>
|
||||
${
|
||||
v.visitorType !== 'frequent' && v.phone
|
||||
? `<button class="ghost" data-make-regular="${v.id}" title="Save them with a PIN so they can sign in themselves next time">Make regular</button>`
|
||||
: ''
|
||||
}
|
||||
<button class="ghost" data-signout="${v.id}">Sign out</button>
|
||||
</td>
|
||||
</tr>`
|
||||
@@ -207,32 +202,6 @@ async function loadOnsite() {
|
||||
$$('[data-badge]').forEach((btn) =>
|
||||
btn.addEventListener('click', () => window.open(`/admin/api/badge/${btn.dataset.badge}`, '_blank'))
|
||||
);
|
||||
$$('[data-make-regular]').forEach((btn) =>
|
||||
btn.addEventListener('click', async () => {
|
||||
const row = btn.closest('tr');
|
||||
const name = row?.querySelector('strong')?.textContent?.trim() || 'this visitor';
|
||||
openModal(
|
||||
`Save ${esc(name)} as a regular visitor?`,
|
||||
`<p class="hint">Their name, mobile number and check details are taken from this visit, a
|
||||
PIN is issued, and their sign in photo is kept on file. Next time they enter their mobile
|
||||
number and PIN, pick who they are seeing, and the badge prints — no form, no camera.</p>
|
||||
<p class="hint">This visit stays in the log as a guest visit.</p>`,
|
||||
async () => {
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const saved = await api(`/visits/${btn.dataset.makeRegular}/make-regular`, { method: 'POST' });
|
||||
loadOnsite();
|
||||
showPin(saved.pin, saved.id);
|
||||
} catch (err) {
|
||||
toast(err.message, true);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
},
|
||||
{ saveLabel: 'Save and issue a PIN' }
|
||||
);
|
||||
})
|
||||
);
|
||||
$$('[data-print]').forEach((btn) =>
|
||||
btn.addEventListener('click', async () => {
|
||||
btn.disabled = true;
|
||||
@@ -533,13 +502,7 @@ function openRecurringModal(person = null) {
|
||||
<select name="siteId">${siteOptions(person?.siteId)}</select></label>
|
||||
<label class="modal-field"><span>Usually visiting</span>
|
||||
<select name="defaultHostId">${hostOptions(person?.defaultHostId)}</select></label>
|
||||
${
|
||||
editing
|
||||
? `<label class="modal-field"><span>PIN — currently <b class="pin-inline">${esc(person.pin || '????')}</b></span>
|
||||
<input name="pin" type="text" inputmode="numeric" maxlength="4" value=""
|
||||
placeholder="Type 4 digits to change it, or leave blank"></label>`
|
||||
: field('PIN (leave blank to generate one)', 'pin', '')
|
||||
}
|
||||
${field('PIN (leave blank to generate one)', 'pin', '')}
|
||||
${field('Notes', 'notes', person?.notes)}
|
||||
<h4 class="modal-section">Photo on file</h4>
|
||||
${photoEditorMarkup(person)}
|
||||
@@ -556,9 +519,8 @@ function openRecurringModal(person = null) {
|
||||
? await api(`/frequent/${person.id}`, { method: 'PATCH', body: payload })
|
||||
: await api('/frequent', { method: 'POST', body: payload });
|
||||
loadRecurring();
|
||||
if (!editing) showPin(saved.pin, saved.id);
|
||||
else if (saved.pinChanged) showPin(saved.pin, saved.id);
|
||||
else toast('Saved.');
|
||||
if (editing) toast('Saved.');
|
||||
else showPin(saved.pin, saved.id);
|
||||
} catch (err) {
|
||||
toast(err.message, true);
|
||||
}
|
||||
@@ -727,7 +689,6 @@ async function loadSites() {
|
||||
<button class="ghost" data-preview-badge="${s.id}">Preview badge</button>
|
||||
<button class="ghost" data-bitmap="${s.id}">Bitmap preview</button>
|
||||
${s.printer.enabled ? `<button class="ghost" data-test-print="${s.id}">Test print</button>` : ''}
|
||||
${s.printer.enabled ? `<button class="ghost" data-print-diag="${s.id}">Diagnostics</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<dl class="site-meta">
|
||||
@@ -779,17 +740,6 @@ async function loadSites() {
|
||||
window.open(`/admin/api/sites/${btn.dataset.bitmap}/badge-bitmap`, '_blank')
|
||||
)
|
||||
);
|
||||
$$('[data-print-diag]').forEach((btn) =>
|
||||
btn.addEventListener('click', async () => {
|
||||
const d = await api(`/sites/${btn.dataset.printDiag}/printer-diagnostics`);
|
||||
openModal(
|
||||
'Printer diagnostics',
|
||||
`<pre class="raw">${esc(JSON.stringify(d, null, 2))}</pre>`,
|
||||
null,
|
||||
{ saveLabel: 'Close', hideCancel: true }
|
||||
);
|
||||
})
|
||||
);
|
||||
$$('[data-test-print]').forEach((btn) =>
|
||||
btn.addEventListener('click', async () => {
|
||||
btn.disabled = true;
|
||||
@@ -798,17 +748,7 @@ async function loadSites() {
|
||||
await api(`/sites/${btn.dataset.testPrint}/test-print`, { method: 'POST' });
|
||||
toast('Sent to the printer.');
|
||||
} catch (err) {
|
||||
// Show the printer's own words as well as the summary, because a refusal
|
||||
// usually names the reason and the summary cannot cover every case.
|
||||
const raw = err.payload?.raw;
|
||||
openModal(
|
||||
'The printer refused the job',
|
||||
`<p>${esc(err.message)}</p>
|
||||
${err.payload?.command ? `<p class="hint">Command:</p><pre class="raw">${esc(err.payload.command)}</pre>` : ''}
|
||||
${raw ? `<p class="hint">What the printer and brother_ql said:</p><pre class="raw">${esc(raw)}</pre>` : ''}`,
|
||||
null,
|
||||
{ saveLabel: 'Close', hideCancel: true }
|
||||
);
|
||||
toast(err.message, true);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Test print';
|
||||
@@ -824,29 +764,16 @@ async function loadSites() {
|
||||
* 62 mm is for a different printer.
|
||||
*/
|
||||
const LABEL_PRESETS = [
|
||||
{ id: 'dk22205-90', label: 'Brother DK-22205 continuous, cut at 90 mm', w: 62, h: 90, photo: true, roll: '62' },
|
||||
{ id: 'dk11202', label: 'Brother DK-11202 die-cut 62 × 100 mm', w: 62, h: 100, photo: true, roll: '62' },
|
||||
{ id: 'dk22251-90', label: 'Brother DK-22251 black/red continuous, cut at 90 mm', w: 62, h: 90, photo: true, roll: '62red', accent: true },
|
||||
{ id: 'dk11208', label: 'Brother DK-11208 die-cut 38 × 90 mm', w: 38, h: 90, photo: false, roll: '62' },
|
||||
{ id: 'dk11209', label: 'Brother DK-11209 die-cut 29 × 62 mm', w: 29, h: 62, photo: false, roll: '62' },
|
||||
{ id: 'dk11201', label: 'Brother DK-11201 die-cut 29 × 90 mm', w: 29, h: 90, photo: false, roll: '62' },
|
||||
{ id: 'card', label: 'Card size 86 × 54 mm (not a QL-820NWB size)', w: 86, h: 54, photo: true, roll: '62' },
|
||||
{ id: 'dymo99014', label: 'Dymo 99014 101 × 54 mm', w: 101, h: 54, photo: true, roll: '62' },
|
||||
{ id: 'dk22205-90', label: 'Brother DK-22205 continuous, cut at 90 mm', w: 62, h: 90, photo: true },
|
||||
{ id: 'dk11202', label: 'Brother DK-11202 die-cut 62 × 100 mm', w: 62, h: 100, photo: true },
|
||||
{ id: 'dk22251-90', label: 'Brother DK-22251 black/red continuous, cut at 90 mm', w: 62, h: 90, photo: true, accent: true },
|
||||
{ id: 'dk11208', label: 'Brother DK-11208 die-cut 38 × 90 mm', w: 38, h: 90, photo: false },
|
||||
{ id: 'dk11209', label: 'Brother DK-11209 die-cut 29 × 62 mm', w: 29, h: 62, photo: false },
|
||||
{ id: 'dk11201', label: 'Brother DK-11201 die-cut 29 × 90 mm', w: 29, h: 90, photo: false },
|
||||
{ id: 'card', label: 'Card size 86 × 54 mm (not a QL-820NWB size)', w: 86, h: 54, photo: true },
|
||||
{ id: 'dymo99014', label: 'Dymo 99014 101 × 54 mm', w: 101, h: 54, photo: true },
|
||||
];
|
||||
|
||||
/**
|
||||
* Which preset the saved settings correspond to. Dimensions alone are ambiguous —
|
||||
* DK-22205 and DK-22251 are both 62 x 90 — so the roll type decides between them.
|
||||
*/
|
||||
function matchingPreset(site) {
|
||||
return LABEL_PRESETS.find(
|
||||
(p) =>
|
||||
Number(site.badge.widthMm) === p.w &&
|
||||
Number(site.badge.heightMm) === p.h &&
|
||||
p.roll === (site.printer.label === '62red' ? '62red' : '62')
|
||||
);
|
||||
}
|
||||
|
||||
function openSiteModal(site) {
|
||||
openModal(
|
||||
`Edit ${site.name}`,
|
||||
@@ -860,35 +787,17 @@ function openSiteModal(site) {
|
||||
<option value="">Custom size</option>
|
||||
${LABEL_PRESETS.map(
|
||||
(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('')}
|
||||
</select></label>
|
||||
<p class="hint">A shortcut that fills in the boxes below. The size, the roll type and the
|
||||
colour option are what actually get saved.</p>
|
||||
<div class="modal-row">
|
||||
${field('Width (mm)', 'widthMm', site.badge.widthMm, 'number')}
|
||||
${field('Height (mm)', 'heightMm', site.badge.heightMm, 'number')}
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
<p class="hint" id="accent-note"></p>
|
||||
<p class="hint">Two-colour printing is much slower than black alone.</p>
|
||||
@@ -903,10 +812,8 @@ function openSiteModal(site) {
|
||||
<option value="62" ${site.printer.label !== '62red' ? 'selected' : ''}>62 mm continuous, black only</option>
|
||||
<option value="62red" ${site.printer.label === '62red' ? 'selected' : ''}>62 mm continuous, black and red (DK-22251)</option>
|
||||
</select></label>
|
||||
<p class="hint">This must match the roll actually in the machine, and it matters in both
|
||||
directions. A two-colour job on a plain roll is refused as <em>Wrong Roll Type</em>; a
|
||||
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>
|
||||
<p class="hint">This must match the roll actually in the machine. Send a two-colour job to a
|
||||
plain roll and the printer answers <em>Wrong Roll Type</em> and prints nothing.</p>
|
||||
<div class="modal-row">
|
||||
${field('Model', 'printerModel', site.printer.model)}
|
||||
<label class="modal-field"><span>Rotation</span>
|
||||
@@ -985,11 +892,6 @@ function openSiteModal(site) {
|
||||
rotate: Number(data.printerRotate) || 0,
|
||||
label: data.printerLabel,
|
||||
},
|
||||
photo: {
|
||||
mode: data.photoMode,
|
||||
threshold: Number(data.photoThreshold),
|
||||
contrast: Number(data.photoContrast),
|
||||
},
|
||||
branding: {
|
||||
brand: data.brand || null,
|
||||
signout: data.signout || null,
|
||||
@@ -1098,34 +1000,6 @@ function wireBannerEditor() {
|
||||
[pageInput, textInput].forEach((el) => el.addEventListener('input', 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.
|
||||
const accentBox = $('#badge-accent');
|
||||
const rollSelect = $('#modal-form [name="printerLabel"]');
|
||||
@@ -1209,14 +1083,6 @@ function wireBadgePreset() {
|
||||
height.value = preset.h;
|
||||
$('#badge-photo').checked = preset.photo;
|
||||
$('#badge-accent').checked = Boolean(preset.accent);
|
||||
// The preset knows which roll it describes, so keep the two in step. Picking
|
||||
// the DK-22251 preset while the roll stayed on "black only" is what makes the
|
||||
// printer refuse the job.
|
||||
const roll = $('#modal-form [name="printerLabel"]');
|
||||
if (roll && preset.roll) {
|
||||
roll.value = preset.roll;
|
||||
roll.dispatchEvent(new Event('change'));
|
||||
}
|
||||
check();
|
||||
});
|
||||
|
||||
@@ -1525,9 +1391,8 @@ function renderTls(tls) {
|
||||
itself before it lapses, and devices that trust the authority keep working without being
|
||||
touched again.</p>
|
||||
<div class="sys-actions">
|
||||
<a class="ghost 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>.crt only</a>
|
||||
<a class="ghost" href="/admin/api/tls/ca.cer" download>.cer only</a>
|
||||
<a class="ghost" href="/admin/api/tls/ca.crt" download>CA certificate (.crt)</a>
|
||||
<a class="ghost" href="/admin/api/tls/ca.cer" download>CA certificate (.cer, for Jamf and Apple)</a>
|
||||
<button class="ghost owner-only" id="renew-cert">Renew the server certificate</button>
|
||||
<button class="ghost danger owner-only" id="new-ca">Start a new authority</button>
|
||||
</div>`;
|
||||
|
||||
+19
-60
@@ -188,16 +188,12 @@ tickClock();
|
||||
/* --------------------------------------------------------------- hosts */
|
||||
|
||||
/**
|
||||
* Who you are here to see.
|
||||
*
|
||||
* Narrow it down by typing, then tap. Once the list is short enough to show
|
||||
* without scrolling, the names appear as buttons — one tap beats opening a
|
||||
* picker, choosing, and dismissing it. Above that count the dropdown carries the
|
||||
* full list, because a page of eighty buttons is worse than a scroll wheel.
|
||||
* Who you are here to see: type to narrow, then choose from the dropdown. The
|
||||
* dropdown is a native select, so a tablet gives it a proper full-screen picker
|
||||
* with its own scrolling, which handles a long staff list better than a page of
|
||||
* buttons ever did.
|
||||
*/
|
||||
const TAP_LIST_LIMIT = 5;
|
||||
|
||||
function renderHosts(selectEl, searchValue, onPick, emptyEl = null, listEl = null) {
|
||||
function renderHosts(selectEl, searchValue, onPick, emptyEl = null) {
|
||||
const term = String(searchValue || '').trim().toLowerCase();
|
||||
const matches = term
|
||||
? hosts.filter(
|
||||
@@ -205,47 +201,14 @@ function renderHosts(selectEl, searchValue, onPick, emptyEl = null, listEl = nul
|
||||
)
|
||||
: hosts;
|
||||
|
||||
const choose = (host) => {
|
||||
state.hostId = host.id;
|
||||
state.hostName = host.name;
|
||||
onPick();
|
||||
};
|
||||
|
||||
const showAsButtons = matches.length > 0 && matches.length <= TAP_LIST_LIMIT;
|
||||
|
||||
// The tappable shortlist.
|
||||
if (listEl) {
|
||||
listEl.hidden = !showAsButtons;
|
||||
listEl.innerHTML = showAsButtons
|
||||
? matches
|
||||
.map(
|
||||
(h) =>
|
||||
`<button class="host-option" data-host-id="${h.id}">
|
||||
${escapeHtml(h.name)}${h.area ? `<small>${escapeHtml(h.area)}</small>` : ''}
|
||||
</button>`
|
||||
)
|
||||
.join('')
|
||||
: '';
|
||||
listEl.querySelectorAll('[data-host-id]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const picked = hosts.find((h) => h.id === Number(btn.dataset.hostId));
|
||||
if (picked) choose(picked);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// The full list, kept out of the way once the shortlist is showing.
|
||||
const field = selectEl.closest('.field');
|
||||
if (field) field.hidden = showAsButtons;
|
||||
|
||||
const label = !hosts.length
|
||||
? 'Nobody has been added yet'
|
||||
: `Or pick from the full list of ${hosts.length}`;
|
||||
const labelEl = field?.querySelector('span');
|
||||
if (labelEl) labelEl.textContent = label;
|
||||
: matches.length === hosts.length
|
||||
? `Choose one of ${hosts.length}`
|
||||
: `${matches.length} ${matches.length === 1 ? 'match' : 'matches'} — choose one`;
|
||||
|
||||
selectEl.innerHTML =
|
||||
`<option value="">${escapeHtml(matches.length === hosts.length ? 'Choose a name' : `${matches.length} match${matches.length === 1 ? '' : 'es'}`)}</option>` +
|
||||
`<option value="">${escapeHtml(label)}</option>` +
|
||||
matches
|
||||
.map(
|
||||
(h) =>
|
||||
@@ -256,16 +219,20 @@ function renderHosts(selectEl, searchValue, onPick, emptyEl = null, listEl = nul
|
||||
|
||||
selectEl.onchange = () => {
|
||||
const picked = hosts.find((h) => h.id === Number(selectEl.value));
|
||||
if (picked) choose(picked);
|
||||
if (!picked) return;
|
||||
state.hostId = picked.id;
|
||||
state.hostName = picked.name;
|
||||
onPick();
|
||||
};
|
||||
|
||||
if (emptyEl) {
|
||||
emptyEl.hidden = matches.length > 0;
|
||||
emptyEl.textContent = hosts.length
|
||||
? 'No one matches that. Check the spelling, or ask at the front desk.'
|
||||
? 'No one matches that. Check the spelling, or ask the front desk.'
|
||||
: 'No one has been added for this site yet. Please see the front desk.';
|
||||
}
|
||||
|
||||
// Typing a name and pressing enter should just work when only one person is left.
|
||||
selectEl.dataset.only = matches.length === 1 ? String(matches[0].id) : '';
|
||||
}
|
||||
|
||||
@@ -529,7 +496,7 @@ async function loadSiteContext() {
|
||||
change.textContent = siteConfig.site ? `Site: ${siteConfig.site.name} — change` : 'Choose site';
|
||||
|
||||
hosts = await api('/api/hosts');
|
||||
renderHosts($('#host-select'), '', () => show('guest-contact'), $('#host-empty'), $('#host-list'));
|
||||
renderHosts($('#host-select'), '', () => show('guest-contact'), $('#host-empty'));
|
||||
}
|
||||
|
||||
$('#change-site').addEventListener('click', chooseSite);
|
||||
@@ -565,13 +532,7 @@ $('[data-next="guest-name"]').addEventListener('click', () => {
|
||||
});
|
||||
|
||||
$('#in-host-search').addEventListener('input', (e) =>
|
||||
renderHosts(
|
||||
$('#host-select'),
|
||||
e.target.value,
|
||||
() => show('guest-contact'),
|
||||
$('#host-empty'),
|
||||
$('#host-list')
|
||||
)
|
||||
renderHosts($('#host-select'), e.target.value, () => show('guest-contact'), $('#host-empty'))
|
||||
);
|
||||
$('#in-host-search').addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter') {
|
||||
@@ -655,8 +616,7 @@ $('#do-freq-auth').addEventListener('click', async () => {
|
||||
$('#freq-host-select'),
|
||||
'',
|
||||
() => (state.hasStoredPhoto ? submitSignIn() : show('photo')),
|
||||
$('#freq-host-empty'),
|
||||
$('#freq-host-list')
|
||||
$('#freq-host-empty')
|
||||
);
|
||||
show('freq-host');
|
||||
} catch (err) {
|
||||
@@ -672,8 +632,7 @@ $('#in-freq-host-search').addEventListener('input', (e) =>
|
||||
$('#freq-host-select'),
|
||||
e.target.value,
|
||||
() => (state.hasStoredPhoto ? submitSignIn() : show('photo')),
|
||||
$('#freq-host-empty'),
|
||||
$('#freq-host-list')
|
||||
$('#freq-host-empty')
|
||||
)
|
||||
);
|
||||
$('#in-freq-host-search').addEventListener('keydown', (event) => {
|
||||
|
||||
+53
-51
@@ -7,10 +7,15 @@
|
||||
|
||||
$Remote = 'https://gitea.hideawaygaming.com.au/jessikitty/visitor-signin.git'
|
||||
|
||||
# git is called directly and $LASTEXITCODE checked straight afterwards. Wrapping
|
||||
# it in a function does not work: a PowerShell function returns everything written
|
||||
# to the output stream, so the caller receives git's console output as well as the
|
||||
# exit code, and comparing that array against 0 reports failure every time.
|
||||
# git reports failure through its exit code, not as a PowerShell error, so every
|
||||
# call has to be checked. Without this the script reports success after a
|
||||
# rejected push, which is exactly what it used to do.
|
||||
function Invoke-Git {
|
||||
param([Parameter(ValueFromRemainingArguments = $true)][string[]]$Arguments)
|
||||
& git @Arguments
|
||||
return $LASTEXITCODE
|
||||
}
|
||||
|
||||
function Fail($message) {
|
||||
Write-Host ''
|
||||
Write-Host $message -ForegroundColor Red
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
# Keeps line endings sane between Windows and the Ubuntu docker host.
|
||||
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')) {
|
||||
Write-Host 'Setting up a new local repository...'
|
||||
git init -b main
|
||||
if ($LASTEXITCODE -ne 0) { Fail 'git init failed.' }
|
||||
if ((Invoke-Git init -b main) -ne 0) { Fail 'git init failed.' }
|
||||
}
|
||||
|
||||
if ((git remote) -match '^origin$') { git remote set-url origin $Remote }
|
||||
else { git remote add origin $Remote }
|
||||
if ((git remote) -match '^origin$') {
|
||||
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
|
||||
# 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
|
||||
# ------------------------------------------------- finish what was started
|
||||
|
||||
# ------------------------------------------- an unfinished rebase blocks everything
|
||||
|
||||
$gitDir = git rev-parse --git-dir 2>$null
|
||||
# A rebase or merge left half-done blocks everything that follows, and the error
|
||||
# git gives is easy to mistake for a push problem. Catch it here and say plainly
|
||||
# what to do.
|
||||
$gitDir = (git rev-parse --git-dir 2>$null)
|
||||
if ($gitDir) {
|
||||
$stuck = @('rebase-merge', 'rebase-apply', 'MERGE_HEAD', 'CHERRY_PICK_HEAD') |
|
||||
Where-Object { Test-Path (Join-Path $gitDir $_) }
|
||||
if ($stuck) {
|
||||
Write-Host ''
|
||||
Write-Host 'There is an unfinished rebase or merge here.' -ForegroundColor Red
|
||||
Write-Host ' git rebase --abort throw it away, back to how things were'
|
||||
Write-Host ' git status see which files need attention'
|
||||
Write-Host ' git rebase --continue after fixing those files'
|
||||
Fail 'Nothing was done.'
|
||||
Write-Host 'There is an unfinished rebase or merge in this folder.' -ForegroundColor Red
|
||||
Write-Host 'Nothing else can happen until it is settled. Your options:' -ForegroundColor Yellow
|
||||
Write-Host ''
|
||||
Write-Host ' git rebase --abort throw the attempt away and go back to how things were'
|
||||
Write-Host ' git status see which files still need attention'
|
||||
Write-Host ' git rebase --continue after fixing the files git listed'
|
||||
Write-Host ''
|
||||
Write-Host 'If you are unsure, "git rebase --abort" is the safe one. It puts the' -ForegroundColor Yellow
|
||||
Write-Host 'folder back exactly as it was before the rebase started.' -ForegroundColor Yellow
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- commit
|
||||
|
||||
git add -A
|
||||
if (git status --porcelain) {
|
||||
$pending = git status --porcelain
|
||||
if ($pending) {
|
||||
$message = Read-Host 'Describe this change (press enter for a dated default)'
|
||||
if (-not $message) { $message = "Update $(Get-Date -Format 'yyyy-MM-dd HH:mm')" }
|
||||
git commit -m $message
|
||||
if ($LASTEXITCODE -ne 0) { Fail 'git commit failed. The message above says why.' }
|
||||
if ((Invoke-Git commit -m $message) -ne 0) { Fail 'git commit failed.' }
|
||||
Write-Host 'Committed.' -ForegroundColor Green
|
||||
} 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
|
||||
|
||||
git fetch origin
|
||||
if ($LASTEXITCODE -ne 0) { Fail 'Could not reach Gitea. Check the network and your sign in details.' }
|
||||
Write-Host 'Checking what is on the server...'
|
||||
if ((Invoke-Git fetch origin) -ne 0) {
|
||||
Fail 'Could not reach Gitea. Check the network and your sign in details.'
|
||||
}
|
||||
|
||||
if (git ls-remote --heads origin $branch) {
|
||||
$behind = git rev-list --count "HEAD..origin/$branch" 2>$null
|
||||
if (git ls-remote --heads origin main) {
|
||||
$behind = (git rev-list --count HEAD..origin/main 2>$null)
|
||||
if ($behind -and [int]$behind -gt 0) {
|
||||
Write-Host "The server has $behind commit(s) this folder does not. Replaying your work on top..."
|
||||
git pull --rebase origin $branch
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
if ((Invoke-Git pull --rebase origin main) -ne 0) {
|
||||
Write-Host ''
|
||||
Write-Host 'The two histories could not be joined automatically.' -ForegroundColor Red
|
||||
Write-Host " git log --oneline HEAD..origin/$branch what is on the server"
|
||||
Write-Host " git push --force-with-lease origin $branch if this folder is the good copy"
|
||||
Fail 'Nothing was sent.'
|
||||
Write-Host ''
|
||||
Write-Host 'See what is on the server that you do not have:' -ForegroundColor Yellow
|
||||
Write-Host ' git log --oneline HEAD..origin/main'
|
||||
Write-Host ''
|
||||
Write-Host 'If that is nothing you need, and this folder is the good copy:' -ForegroundColor Yellow
|
||||
Write-Host ' git push --force-with-lease origin main'
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Write-Host "Branch '$branch' is not on the server yet; it will be created."
|
||||
}
|
||||
|
||||
git push -u origin $branch
|
||||
if ($LASTEXITCODE -ne 0) { Fail 'The push was rejected. The message above says why. Nothing was sent.' }
|
||||
if ((Invoke-Git push -u origin main) -ne 0) {
|
||||
Fail 'The push was rejected. Read the message above. Nothing was sent.'
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host "Pushed $branch successfully." -ForegroundColor Green
|
||||
git log --oneline -1
|
||||
Write-Host 'Pushed successfully.' -ForegroundColor Green
|
||||
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
|
||||
|
||||
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."
|
||||
|
||||
if git remote | grep -qx origin; then
|
||||
@@ -28,11 +19,6 @@ else
|
||||
git remote add origin "$REMOTE"
|
||||
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
|
||||
# easy to mistake for a push problem.
|
||||
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."
|
||||
|
||||
if git ls-remote --heads origin "$BRANCH" | grep -q "$BRANCH"; then
|
||||
BEHIND=$(git rev-list --count "HEAD..origin/$BRANCH" 2>/dev/null || echo 0)
|
||||
if git ls-remote --heads origin main | grep -q main; then
|
||||
BEHIND=$(git rev-list --count HEAD..origin/main 2>/dev/null || echo 0)
|
||||
if [ "$BEHIND" -gt 0 ]; then
|
||||
echo "The server has $BEHIND commit(s) this folder does not. Replaying your work on top..."
|
||||
if ! git pull --rebase origin "$BRANCH"; then
|
||||
if ! git pull --rebase origin main; then
|
||||
cat >&2 <<'MSG'
|
||||
|
||||
The two histories could not be joined automatically.
|
||||
|
||||
See what is on the server that you do not have:
|
||||
git log --oneline HEAD..origin/$BRANCH
|
||||
git log --oneline HEAD..origin/main
|
||||
|
||||
If that is nothing you need, and this folder is the good copy:
|
||||
git push --force-with-lease origin $BRANCH
|
||||
git push --force-with-lease origin main
|
||||
MSG
|
||||
exit 1
|
||||
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 "Pushed $BRANCH successfully."
|
||||
git log --oneline -1
|
||||
echo "Pushed successfully."
|
||||
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,140 +0,0 @@
|
||||
/**
|
||||
* Checks the certificate actually being served against what iOS requires.
|
||||
*
|
||||
* docker compose exec visitor-signin node scripts/tls-check.mjs
|
||||
* docker compose exec visitor-signin node scripts/tls-check.mjs --url https://10.0.0.5:8443
|
||||
*
|
||||
* Pass the address the iPad uses. Most "not secure" reports are not a trust
|
||||
* problem at all: the name being typed is simply not in the certificate.
|
||||
*/
|
||||
import tls from 'node:tls';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import config from '../src/config.js';
|
||||
import { caCertificate, describe } from '../src/tls.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 url = new URL(arg('url', `https://127.0.0.1:${config.port}`));
|
||||
const host = url.hostname;
|
||||
const port = Number(url.port || 443);
|
||||
|
||||
const pass = (m) => console.log(` PASS ${m}`);
|
||||
const fail = (m) => console.log(` FAIL ${m}`);
|
||||
const warn = (m) => console.log(` WARN ${m}`);
|
||||
|
||||
console.log(`\n Connecting to ${host}:${port}\n`);
|
||||
|
||||
const socket = tls.connect(
|
||||
{ host, port, servername: /^\d+\.\d+\.\d+\.\d+$/.test(host) ? undefined : host, rejectUnauthorized: false, timeout: 8000 },
|
||||
() => {
|
||||
const peer = socket.getPeerX509Certificate?.() || null;
|
||||
if (!peer) {
|
||||
fail('The server did not present a certificate.');
|
||||
socket.end();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 1. Does the certificate cover the address being used?
|
||||
const sans = (peer.subjectAltName || '')
|
||||
.split(',')
|
||||
.map((s) => s.trim().replace(/^IP Address:/, 'IP:'));
|
||||
const isIp = /^\d+\.\d+\.\d+\.\d+$/.test(host);
|
||||
const covered = sans.includes(isIp ? `IP:${host}` : `DNS:${host}`);
|
||||
if (covered) pass(`the certificate covers ${host}`);
|
||||
else {
|
||||
fail(`the certificate does NOT cover ${host}`);
|
||||
console.log(` it covers: ${sans.join(', ')}`);
|
||||
console.log(` add ${host} to HTTPS_HOSTNAMES, then: docker compose up -d`);
|
||||
}
|
||||
if (isIp) {
|
||||
warn('this is a bare IP address. Safari accepts IP certificates but Chrome on');
|
||||
console.log(' Android does not, and a DHCP change silently breaks every kiosk.');
|
||||
}
|
||||
|
||||
// 2. Validity window. iOS refuses anything over 398 days.
|
||||
const span = (new Date(peer.validTo) - new Date(peer.validFrom)) / 86400000;
|
||||
if (span <= 397) pass(`valid for ${span.toFixed(1)} days (iOS allows up to 398)`);
|
||||
else if (span <= 398) warn(`valid for ${span.toFixed(2)} days — on Apple's limit, no margin`);
|
||||
else fail(`valid for ${span.toFixed(1)} days — iOS rejects anything over 398`);
|
||||
|
||||
const daysLeft = (new Date(peer.validTo) - Date.now()) / 86400000;
|
||||
if (daysLeft > 0) pass(`expires in ${Math.floor(daysLeft)} days`);
|
||||
else fail('the certificate has expired');
|
||||
|
||||
// 3. The extensions iOS insists on. Node's X509Certificate does not expose
|
||||
// extended key usage, and toString() gives PEM rather than a readable dump,
|
||||
// so decode it with openssl.
|
||||
let text = '';
|
||||
try {
|
||||
text = execFileSync('openssl', ['x509', '-noout', '-text'], {
|
||||
input: peer.toString(),
|
||||
encoding: 'utf8',
|
||||
});
|
||||
} catch {
|
||||
text = '';
|
||||
}
|
||||
if (peer.subjectAltName) pass('has a subject alternative name (iOS ignores the common name)');
|
||||
else fail('no subject alternative name — iOS will reject it outright');
|
||||
|
||||
if (!text) warn('could not decode the certificate to check extended key usage');
|
||||
else if (/TLS Web Server Authentication/.test(text)) pass('extended key usage includes serverAuth');
|
||||
else fail('extended key usage does not include serverAuth — required since iOS 13');
|
||||
|
||||
const sigAlg = (text.match(/Signature Algorithm: (\S+)/) || [])[1];
|
||||
if (!sigAlg) warn('could not read the signature algorithm');
|
||||
else if (/sha1|md5/i.test(sigAlg)) fail(`signed with ${sigAlg} — iOS requires SHA-256 or better`);
|
||||
else pass(`signed with ${sigAlg}`);
|
||||
|
||||
const key = peer.publicKey?.asymmetricKeyDetails;
|
||||
if (!key?.modulusLength || key.modulusLength >= 2048) {
|
||||
pass(`key size ${key?.modulusLength || 'n/a'} bits`);
|
||||
} else {
|
||||
fail(`key size ${key.modulusLength} bits — iOS requires at least 2048`);
|
||||
}
|
||||
|
||||
// 4. Is the served certificate the one signed by our own authority?
|
||||
const ca = caCertificate();
|
||||
if (ca) {
|
||||
const caCert = new crypto.X509Certificate(ca);
|
||||
if (peer.checkIssued(caCert)) pass('issued by this server\'s certificate authority');
|
||||
else fail('NOT issued by this server\'s authority — the device is trusting the wrong thing');
|
||||
|
||||
console.log('');
|
||||
console.log(' The authority the iPad must trust:');
|
||||
console.log(` subject ${caCert.subject.replace(/\n/g, ', ')}`);
|
||||
console.log(` fingerprint ${caCert.fingerprint256}`);
|
||||
console.log(' Compare that with the certificate shown on the device under');
|
||||
console.log(' Settings > General > VPN & Device Management.');
|
||||
}
|
||||
|
||||
// 5. A frequent mistake: uploading the server certificate to the MDM.
|
||||
console.log('');
|
||||
console.log(' Served certificate (this is NOT what goes into Jamf):');
|
||||
console.log(` subject ${peer.subject.replace(/\n/g, ', ')}`);
|
||||
console.log(` fingerprint ${peer.fingerprint256}`);
|
||||
console.log('');
|
||||
console.log(' Jamf must be given the AUTHORITY certificate, the one whose name ends');
|
||||
console.log(' "Local CA". Uploading the served certificate above instead is the most');
|
||||
console.log(' common reason a profile installs cleanly and Safari still objects.');
|
||||
console.log('');
|
||||
|
||||
socket.end();
|
||||
}
|
||||
);
|
||||
|
||||
socket.on('timeout', () => {
|
||||
fail('timed out connecting');
|
||||
socket.destroy();
|
||||
process.exit(1);
|
||||
});
|
||||
socket.on('error', (err) => {
|
||||
fail(`could not connect: ${err.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -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_rotate INTEGER NOT NULL DEFAULT 0,
|
||||
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_height INTEGER NOT NULL DEFAULT 64,
|
||||
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
|
||||
// from a design setting means a wrong-roll error nobody can explain.
|
||||
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');
|
||||
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_visits_site ON visits(site_id, signed_out_at)');
|
||||
|
||||
+45
-190
@@ -6,6 +6,8 @@ import { execFile } from 'node:child_process';
|
||||
import { createCanvas, loadImage, GlobalFonts } from '@napi-rs/canvas';
|
||||
import config from './config.js';
|
||||
import { photoAbsolutePath } from './photos.js';
|
||||
import { ditherPhoto } from './printing/dither.js';
|
||||
import { printPng, printerHealth } from './printing/ql-print.js';
|
||||
|
||||
/**
|
||||
* Printing happens on the server, not in the kiosk browser.
|
||||
@@ -63,155 +65,6 @@ export function accentWillPrintRed(site) {
|
||||
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 */
|
||||
|
||||
function wrapText(ctx, text, maxWidth, maxLines) {
|
||||
@@ -262,6 +115,17 @@ async function drawBadge(ctx, widthDots, heightDots, visit, site, accent, startY
|
||||
}
|
||||
|
||||
const photoSize = photo ? Math.round(unit * (portrait ? 0.52 : 0.5)) : 0;
|
||||
|
||||
// Thermal heads print pure black or nothing, so a photo has to be reduced to
|
||||
// 1-bit before it lands on the canvas — otherwise the plane conversion
|
||||
// thresholds it into a solid blob. Done once here rather than at each draw
|
||||
// site, and at exactly photoSize: rescaling a dithered image resamples the
|
||||
// dot pattern back into greys and undoes the whole thing.
|
||||
//
|
||||
// Skipped on the measuring pass, which never paints.
|
||||
if (photo && photoSize > 0 && startY !== null) {
|
||||
photo = ditherPhoto(photo, photoSize);
|
||||
}
|
||||
let cursorY = startY === null ? pad : startY;
|
||||
let textLeft = pad;
|
||||
let textWidth = widthDots - pad * 2;
|
||||
@@ -271,10 +135,7 @@ async function drawBadge(ctx, widthDots, heightDots, visit, site, accent, startY
|
||||
if (photo) {
|
||||
if (portrait) {
|
||||
const x = Math.round((widthDots - photoSize) / 2);
|
||||
if (paint) {
|
||||
ctx.drawImage(photo, x, cursorY, photoSize, photoSize);
|
||||
halftone(ctx, x, cursorY, photoSize, photoSettings(site));
|
||||
}
|
||||
if (paint) ctx.drawImage(photo, x, cursorY, photoSize, photoSize);
|
||||
if (paint) {
|
||||
ctx.strokeStyle = black;
|
||||
ctx.lineWidth = Math.max(2, Math.round(mm(0.3)));
|
||||
@@ -285,7 +146,6 @@ async function drawBadge(ctx, widthDots, heightDots, visit, site, accent, startY
|
||||
const y = Math.round((heightDots - photoSize) / 2);
|
||||
if (paint) {
|
||||
ctx.drawImage(photo, pad, y, photoSize, photoSize);
|
||||
halftone(ctx, pad, y, photoSize, photoSettings(site));
|
||||
ctx.strokeStyle = black;
|
||||
ctx.lineWidth = Math.max(2, Math.round(mm(0.3)));
|
||||
ctx.strokeRect(pad, y, photoSize, photoSize);
|
||||
@@ -444,9 +304,6 @@ function explainPrintError(output, host) {
|
||||
if (/Unknown label|label/i.test(last) && /identifier/i.test(last)) {
|
||||
return 'The printer rejected the label size. Check the roll loaded matches the badge settings.';
|
||||
}
|
||||
if (/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)) {
|
||||
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.';
|
||||
}
|
||||
@@ -486,42 +343,24 @@ 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 file = path.join(os.tmpdir(), `badge-${crypto.randomBytes(6).toString('hex')}.png`);
|
||||
fs.writeFileSync(file, png);
|
||||
|
||||
const port = Number(site.printer_port) || 9100;
|
||||
const target = `tcp://${site.printer_host}:${port}`;
|
||||
|
||||
const label = labelFor(site);
|
||||
const args = [
|
||||
'--backend', 'network',
|
||||
'--model', site.printer_model || 'QL-820NWB',
|
||||
'--printer', target,
|
||||
'print',
|
||||
'--label', label,
|
||||
];
|
||||
// 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),
|
||||
});
|
||||
|
||||
// brother_ql: "You must use this option when printing on black/red tape, even
|
||||
// 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');
|
||||
note(site.id, result.ok, result.message);
|
||||
if (!result.ok) throw new Error(result.message);
|
||||
|
||||
args.push(file);
|
||||
|
||||
try {
|
||||
await runBrotherQl(args, config.printing.timeoutMs);
|
||||
note(site.id, true, `Printed to ${site.printer_host}`);
|
||||
return { ok: true, target };
|
||||
} catch (err) {
|
||||
const friendly = explainPrintError(err.message, site.printer_host);
|
||||
note(site.id, false, friendly);
|
||||
const wrapped = new Error(friendly);
|
||||
wrapped.raw = err.message;
|
||||
wrapped.command = `${config.printing.command} ${args.join(' ')}`;
|
||||
throw wrapped;
|
||||
} finally {
|
||||
fs.rm(file, { force: true }, () => {});
|
||||
}
|
||||
// confirmed is false when the printer accepted the bytes but never said the
|
||||
// label came out. Over the network that is the normal case, not a fault.
|
||||
return { ok: true, confirmed: Boolean(result.confirmed), target };
|
||||
}
|
||||
|
||||
/** A sample badge, for checking the printer and the layout without a real visit. */
|
||||
@@ -540,7 +379,23 @@ export function sampleVisit(site) {
|
||||
}
|
||||
|
||||
export function available() {
|
||||
return new Promise((resolve) => {
|
||||
execFile(config.printing.command, ['--version'], (err) => resolve(!err));
|
||||
// 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),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import { createCanvas } from '@napi-rs/canvas';
|
||||
|
||||
/**
|
||||
* Reduce a photo to pure black and white for a thermal print head.
|
||||
*
|
||||
* The head has no grey: every dot is burnt or not. Sending a photo straight
|
||||
* through means the plane conversion thresholds it, and since most of a face
|
||||
* sits on one side of any threshold you get a solid blob. Floyd-Steinberg
|
||||
* error diffusion trades spatial resolution for apparent tone instead, which
|
||||
* is what makes a photo readable at 300 dpi.
|
||||
*
|
||||
* Three things matter for this to look like a face rather than noise:
|
||||
*
|
||||
* 1. Dither at the exact pixel size the photo will occupy. Rescaling a
|
||||
* dithered image resamples the dot pattern back into greys, and the later
|
||||
* threshold turns those into the same blob we were avoiding.
|
||||
*
|
||||
* 2. Stretch the levels first. Webcam captures under office lighting are
|
||||
* usually squeezed into the middle of the range; dithering that directly
|
||||
* produces flat mush. Normalising to the full range first gives the error
|
||||
* diffusion something to work with.
|
||||
*
|
||||
* 3. Aim for a fixed ink coverage. Thermal dots spread as the paper heats, so
|
||||
* they come out fatter on the label than they look on screen, and a
|
||||
* digitally "correct" image prints muddy. Targeting coverage also makes
|
||||
* every badge print at the same density regardless of how the visitor
|
||||
* happened to be lit.
|
||||
*
|
||||
* Text is deliberately NOT dithered anywhere — dithered glyph edges look furry
|
||||
* at this resolution. Only photographs go through here.
|
||||
*/
|
||||
|
||||
/** Rec. 601 luma, which matches how the eye weights these channels. */
|
||||
function luminance(r, g, b) {
|
||||
return 0.299 * r + 0.587 * g + 0.114 * b;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stretch contrast so the darkest few percent land near black and the
|
||||
* lightest few near white.
|
||||
*
|
||||
* Percentiles rather than absolute min/max, so one bright window or one dark
|
||||
* shadow doesn't waste the whole range.
|
||||
*/
|
||||
function autoLevels(grey, { lowPercentile = 0.02, highPercentile = 0.98 } = {}) {
|
||||
const histogram = new Uint32Array(256);
|
||||
for (let i = 0; i < grey.length; i++) histogram[grey[i] | 0]++;
|
||||
|
||||
const lowTarget = grey.length * lowPercentile;
|
||||
const highTarget = grey.length * highPercentile;
|
||||
|
||||
let low = 0;
|
||||
let high = 255;
|
||||
let seen = 0;
|
||||
for (let v = 0; v < 256; v++) {
|
||||
seen += histogram[v];
|
||||
if (seen >= lowTarget) { low = v; break; }
|
||||
}
|
||||
seen = 0;
|
||||
for (let v = 255; v >= 0; v--) {
|
||||
seen += histogram[v];
|
||||
if (seen >= grey.length - highTarget) { high = v; break; }
|
||||
}
|
||||
|
||||
// A nearly flat image would blow up into noise; leave it alone.
|
||||
if (high - low < 24) return;
|
||||
|
||||
const scale = 255 / (high - low);
|
||||
for (let i = 0; i < grey.length; i++) {
|
||||
grey[i] = Math.min(255, Math.max(0, (grey[i] - low) * scale));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Floyd-Steinberg error diffusion over a luminance buffer, in place.
|
||||
*
|
||||
* Serpentine scanning: alternating direction per row avoids the diagonal
|
||||
* banding a plain left-to-right pass leaves in smooth gradients like skin.
|
||||
*/
|
||||
function floydSteinberg(grey, width, height) {
|
||||
const at = (x, y) => y * width + x;
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
const leftToRight = y % 2 === 0;
|
||||
const from = leftToRight ? 0 : width - 1;
|
||||
const to = leftToRight ? width : -1;
|
||||
const step = leftToRight ? 1 : -1;
|
||||
|
||||
for (let x = from; x !== to; x += step) {
|
||||
const p = at(x, y);
|
||||
const old = grey[p];
|
||||
const next = old < 128 ? 0 : 255;
|
||||
grey[p] = next;
|
||||
const error = old - next;
|
||||
|
||||
const ahead = x + step;
|
||||
const behind = x - step;
|
||||
|
||||
if (ahead >= 0 && ahead < width) grey[at(ahead, y)] += error * (7 / 16);
|
||||
if (y + 1 < height) {
|
||||
if (behind >= 0 && behind < width) grey[at(behind, y + 1)] += error * (3 / 16);
|
||||
grey[at(x, y + 1)] += error * (5 / 16);
|
||||
if (ahead >= 0 && ahead < width) grey[at(ahead, y + 1)] += error * (1 / 16);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Add a constant to every sample, clamped to the printable range. */
|
||||
function applyShift(grey, shift) {
|
||||
if (!shift) return;
|
||||
for (let p = 0; p < grey.length; p++) {
|
||||
grey[p] = Math.min(255, Math.max(0, grey[p] + shift));
|
||||
}
|
||||
}
|
||||
|
||||
/** Fraction of dots that would burn, for a given luminance buffer. */
|
||||
function coverageOf(grey, width, height) {
|
||||
const trial = Float32Array.from(grey);
|
||||
floydSteinberg(trial, width, height);
|
||||
let black = 0;
|
||||
for (let p = 0; p < trial.length; p++) if (trial[p] < 128) black++;
|
||||
return black / trial.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the brightness offset that lands the dithered result on a given ink
|
||||
* coverage.
|
||||
*
|
||||
* Error diffusion preserves mean tone, so coverage is roughly 1 - mean/255 and
|
||||
* the first guess can be computed directly rather than searched for. Clipping
|
||||
* at the ends of the range spoils that slightly, so up to three cheap
|
||||
* correction passes follow. Each pass is one dither over a few tens of
|
||||
* thousands of samples — the whole thing runs in about 6 ms for a 24 mm photo.
|
||||
*
|
||||
* Capped at +/-120 so a very dark or very bright capture degrades into
|
||||
* something faint rather than a blank square.
|
||||
*/
|
||||
function solveShiftForCoverage(grey, width, height, target, maxPasses = 3) {
|
||||
const clamp = (v) => Math.min(120, Math.max(-120, v));
|
||||
|
||||
let mean = 0;
|
||||
for (let p = 0; p < grey.length; p++) mean += grey[p];
|
||||
mean /= grey.length;
|
||||
|
||||
let shift = clamp(255 * (1 - target) - mean);
|
||||
|
||||
for (let pass = 0; pass < maxPasses; pass++) {
|
||||
const trial = Float32Array.from(grey);
|
||||
applyShift(trial, shift);
|
||||
const actual = coverageOf(trial, width, height);
|
||||
const error = actual - target;
|
||||
if (Math.abs(error) < 0.005) break;
|
||||
// Coverage moves roughly linearly with the offset over this range.
|
||||
shift = clamp(shift + error * 255);
|
||||
}
|
||||
|
||||
return shift;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dither a photo to 1-bit at a given square size.
|
||||
*
|
||||
* @param {import('@napi-rs/canvas').Image} image Loaded photo
|
||||
* @param {number} size Final size in printer dots — must match the size it
|
||||
* will be drawn at, or the dither is destroyed
|
||||
* @param {Object} [options]
|
||||
* @param {boolean} [options.levels=true] Stretch contrast first
|
||||
* @param {number} [options.brightness=0] -100..100, nudge before dithering.
|
||||
* Negative darkens.
|
||||
* @param {number} [options.targetCoverage=0.333]
|
||||
* Fraction of dots to burn, 0..1. The brightness needed to hit it is
|
||||
* solved for, so every photo prints at the same density however it was
|
||||
* lit. Raise it for a heavier print, lower it for a lighter one. Pass
|
||||
* null to leave density alone and print whatever the photo gives.
|
||||
* @returns {import('@napi-rs/canvas').Canvas} ready to pass to drawImage
|
||||
*/
|
||||
export function ditherPhoto(image, size, options = {}) {
|
||||
const { levels = true, brightness = 0, targetCoverage = 0.333 } = options;
|
||||
|
||||
const canvas = createCanvas(size, size);
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
|
||||
// Cover-fit rather than stretch, so a non-square capture isn't distorted.
|
||||
const scale = Math.max(size / image.width, size / image.height);
|
||||
const drawWidth = image.width * scale;
|
||||
const drawHeight = image.height * scale;
|
||||
ctx.drawImage(
|
||||
image,
|
||||
(size - drawWidth) / 2,
|
||||
(size - drawHeight) / 2,
|
||||
drawWidth,
|
||||
drawHeight
|
||||
);
|
||||
|
||||
const pixels = ctx.getImageData(0, 0, size, size);
|
||||
const { data } = pixels;
|
||||
const grey = new Float32Array(size * size);
|
||||
|
||||
for (let p = 0, i = 0; p < grey.length; p++, i += 4) {
|
||||
const a = data[i + 3] / 255;
|
||||
grey[p] = luminance(
|
||||
data[i] * a + 255 * (1 - a),
|
||||
data[i + 1] * a + 255 * (1 - a),
|
||||
data[i + 2] * a + 255 * (1 - a)
|
||||
);
|
||||
}
|
||||
|
||||
if (levels) autoLevels(grey);
|
||||
|
||||
if (brightness !== 0) applyShift(grey, (brightness / 100) * 255);
|
||||
|
||||
if (targetCoverage != null) {
|
||||
applyShift(grey, solveShiftForCoverage(grey, size, size, targetCoverage));
|
||||
}
|
||||
|
||||
floydSteinberg(grey, size, size);
|
||||
|
||||
for (let p = 0, i = 0; p < grey.length; p++, i += 4) {
|
||||
const v = grey[p] < 128 ? 0 : 255;
|
||||
data[i] = v;
|
||||
data[i + 1] = v;
|
||||
data[i + 2] = v;
|
||||
data[i + 3] = 255;
|
||||
}
|
||||
|
||||
ctx.putImageData(pixels, 0, 0);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
export default ditherPhoto;
|
||||
@@ -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 };
|
||||
+5
-208
@@ -5,13 +5,7 @@ import fs from 'node:fs';
|
||||
import db from '../db.js';
|
||||
import config from '../config.js';
|
||||
import { decryptPin, encryptPin, generatePin, pinLookup } from '../pins.js';
|
||||
import {
|
||||
photoAbsolutePath,
|
||||
deletePhoto,
|
||||
purgeOldPhotos,
|
||||
savePhoto,
|
||||
copyStoredPhoto,
|
||||
} from '../photos.js';
|
||||
import { photoAbsolutePath, deletePhoto, purgeOldPhotos, savePhoto } from '../photos.js';
|
||||
import * as sheets from '../sheets.js';
|
||||
import * as tls from '../tls.js';
|
||||
import * as printer from '../printer.js';
|
||||
@@ -398,14 +392,12 @@ router.patch('/sites/:id', (req, res) => {
|
||||
const badge = req.body?.badge || {};
|
||||
const branding = req.body?.branding || {};
|
||||
const printerCfg = req.body?.printer || {};
|
||||
const photoCfg = req.body?.photo || {};
|
||||
db.prepare(
|
||||
`UPDATE sites SET name = ?, slug = ?, active = ?, badge_enabled = ?, badge_width_mm = ?,
|
||||
badge_height_mm = ?, badge_show_photo = ?, badge_accent = ?, badge_note = ?,
|
||||
colour_brand = ?, colour_signout = ?, colour_page = ?, colour_text = ?,
|
||||
banner_height = ?, banner_align = ?, printer_enabled = ?, printer_host = ?,
|
||||
printer_port = ?, printer_model = ?, printer_rotate = ?, printer_label = ?,
|
||||
photo_mode = ?, photo_threshold = ?, photo_contrast = ? WHERE id = ?`
|
||||
printer_port = ?, printer_model = ?, printer_rotate = ?, printer_label = ? WHERE id = ?`
|
||||
).run(
|
||||
clean(req.body?.name ?? site.name, 100) || site.name,
|
||||
req.body?.slug ? uniqueSlug(req.body.slug, site.id) : site.slug,
|
||||
@@ -443,16 +435,7 @@ router.patch('/sites/:id', (req, res) => {
|
||||
: site.printer_rotate,
|
||||
printerCfg.label !== undefined
|
||||
? (Object.hasOwn(printer.ROLL_TYPES, printerCfg.label) ? printerCfg.label : '62')
|
||||
: 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.printer_label
|
||||
, site.id);
|
||||
|
||||
res.json(shapeSite(db.prepare('SELECT * FROM sites WHERE id = ?').get(site.id)));
|
||||
@@ -527,40 +510,10 @@ router.post('/sites/:id/test-print', async (req, res) => {
|
||||
const result = await printer.printBadge(printer.sampleVisit(site), site);
|
||||
res.json({ ok: true, ...result });
|
||||
} catch (err) {
|
||||
// The raw output and the exact command matter when the friendly message is
|
||||
// not enough — a printer refusing a job says why in its own words.
|
||||
res.status(400).json({ error: err.message, raw: err.raw || null, command: err.command || null });
|
||||
res.status(400).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/** 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. */
|
||||
router.post('/visits/:id/print', async (req, res) => {
|
||||
const visit = db.prepare('SELECT * FROM visits WHERE id = ?').get(req.params.id);
|
||||
@@ -578,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) => {
|
||||
const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id);
|
||||
if (!site) return res.status(404).send('Not found.');
|
||||
@@ -947,18 +869,6 @@ router.patch('/frequent/:id', (req, res) => {
|
||||
photoPath = null;
|
||||
}
|
||||
|
||||
// A PIN typed into the edit form has to actually be set. It used to be
|
||||
// accepted and ignored, so an admin would hand out a PIN the kiosk had never
|
||||
// heard of. Blank means "leave the current one alone".
|
||||
const requestedPin = String(req.body?.pin ?? '').trim();
|
||||
let newPin = null;
|
||||
if (requestedPin) {
|
||||
if (!/^\d{4}$/.test(requestedPin)) {
|
||||
throw new Error('A PIN must be exactly 4 digits. Leave it blank to keep the current one.');
|
||||
}
|
||||
newPin = allocatePin(requestedPin, row.id);
|
||||
}
|
||||
|
||||
db.prepare(
|
||||
`UPDATE frequent_visitors SET first_name = ?, last_name = ?, company = ?, phone = ?, email = ?,
|
||||
check_type = ?, check_number = ?, check_expiry = ?, default_host_id = ?, site_id = ?,
|
||||
@@ -972,21 +882,7 @@ router.patch('/frequent/:id', (req, res) => {
|
||||
req.body?.active !== undefined ? (req.body.active ? 1 : 0) : row.active,
|
||||
row.id
|
||||
);
|
||||
|
||||
if (newPin) {
|
||||
db.prepare(
|
||||
"UPDATE frequent_visitors SET pin_enc = ?, pin_lookup = ?, updated_at = datetime('now') WHERE id = ?"
|
||||
).run(encryptPin(newPin), pinLookup(newPin), row.id);
|
||||
}
|
||||
|
||||
// Attempt counters are keyed by phone number, so a changed number would
|
||||
// otherwise leave a stale lockout behind under the old one.
|
||||
db.prepare('DELETE FROM pin_attempts WHERE phone IN (?, ?)').run(row.phone, v.phone);
|
||||
|
||||
res.json({
|
||||
...shapeFrequent(db.prepare('SELECT * FROM frequent_visitors WHERE id = ?').get(row.id), true),
|
||||
pinChanged: Boolean(newPin),
|
||||
});
|
||||
res.json(shapeFrequent(db.prepare('SELECT * FROM frequent_visitors WHERE id = ?').get(row.id), true));
|
||||
} catch (err) {
|
||||
res.status(400).json({ error: err.message });
|
||||
}
|
||||
@@ -1122,91 +1018,6 @@ router.get('/visits', (req, res) => {
|
||||
res.json(db.prepare(sql).all(...params).map(shapeVisit));
|
||||
});
|
||||
|
||||
/**
|
||||
* Promotes someone who is signing in as a guest into a saved recurring visitor,
|
||||
* using the details they already gave at the kiosk. A random PIN is issued, and
|
||||
* their photo is carried across so the kiosk stops asking them to pose.
|
||||
*
|
||||
* The visit itself is left alone: it happened as a guest visit and the log should
|
||||
* go on saying so.
|
||||
*/
|
||||
router.post('/visits/:id/make-regular', (req, res) => {
|
||||
const visit = db.prepare('SELECT * FROM visits WHERE id = ?').get(req.params.id);
|
||||
if (!visit) return res.status(404).json({ error: 'Not found.' });
|
||||
|
||||
try {
|
||||
if (scopedSiteId(req)) assertSiteAllowed(req, visit.site_id);
|
||||
} catch (err) {
|
||||
return res.status(403).json({ error: err.message });
|
||||
}
|
||||
|
||||
if (!visit.phone) {
|
||||
return res.status(400).json({
|
||||
error: 'A mobile number is needed, because it becomes their sign in name. This visitor only left an email address.',
|
||||
});
|
||||
}
|
||||
|
||||
const phone = normalisePhone(visit.phone);
|
||||
const email = normaliseEmail(visit.email);
|
||||
|
||||
const byPhone = db
|
||||
.prepare('SELECT id, first_name, last_name FROM frequent_visitors WHERE phone = ?')
|
||||
.get(phone);
|
||||
if (byPhone) {
|
||||
return res.status(409).json({
|
||||
error: `${byPhone.first_name} ${byPhone.last_name} is already saved with that mobile number.`,
|
||||
existingId: byPhone.id,
|
||||
});
|
||||
}
|
||||
if (email) {
|
||||
const byEmail = db
|
||||
.prepare('SELECT id, first_name, last_name FROM frequent_visitors WHERE lower(email) = ?')
|
||||
.get(email);
|
||||
if (byEmail) {
|
||||
return res.status(409).json({
|
||||
error: `${byEmail.first_name} ${byEmail.last_name} is already saved with that email address.`,
|
||||
existingId: byEmail.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const pin = allocatePin(null);
|
||||
// Their sign-in photo becomes the one kept on file. Copied rather than shared,
|
||||
// so retention purging the visit cannot remove their profile photo.
|
||||
const photoPath = visit.photo_path ? copyStoredPhoto(visit.photo_path) : null;
|
||||
|
||||
const info = db
|
||||
.prepare(
|
||||
`INSERT INTO frequent_visitors
|
||||
(first_name, last_name, company, phone, email, check_type, check_number, check_expiry,
|
||||
default_host_id, site_id, pin_enc, pin_lookup, photo_path, notes, active)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`
|
||||
)
|
||||
.run(
|
||||
visit.first_name,
|
||||
visit.last_name,
|
||||
visit.company || null,
|
||||
phone,
|
||||
email || null,
|
||||
visit.check_type || 'NONE',
|
||||
visit.check_number || null,
|
||||
visit.check_expiry || null,
|
||||
visit.host_id || null,
|
||||
visit.site_id || null,
|
||||
encryptPin(pin),
|
||||
pinLookup(pin),
|
||||
photoPath,
|
||||
null
|
||||
);
|
||||
|
||||
const created = db.prepare('SELECT * FROM frequent_visitors WHERE id = ?').get(info.lastInsertRowid);
|
||||
res.json({ ...shapeFrequent(created, true), photoCarriedOver: Boolean(photoPath) });
|
||||
} catch (err) {
|
||||
res.status(400).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/visits/:id/signout', (req, res) => {
|
||||
const visit = db
|
||||
.prepare('SELECT * FROM visits WHERE id = ? AND signed_out_at IS NULL')
|
||||
@@ -1434,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) => {
|
||||
try {
|
||||
// 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.
|
||||
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
|
||||
// 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.use(express.static(publicDir, { extensions: ['html'], index: false }));
|
||||
app.get('/favicon.ico', (req, res) => res.redirect(301, '/favicon.svg'));
|
||||
app.use((req, res) => res.status(404).sendFile(path.join(publicDir, 'index.html')));
|
||||
|
||||
@@ -133,26 +121,6 @@ function startRedirectServer() {
|
||||
|
||||
http
|
||||
.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.
|
||||
if (req.url === '/ca.cer' || req.url === '/ca.der') {
|
||||
try {
|
||||
|
||||
@@ -67,11 +67,6 @@ export function shapeSite(site) {
|
||||
rotate: site.printer_rotate || 0,
|
||||
label: site.printer_label || '62',
|
||||
},
|
||||
photo: {
|
||||
mode: site.photo_mode || 'dither',
|
||||
threshold: site.photo_threshold ?? 50,
|
||||
contrast: site.photo_contrast ?? 20,
|
||||
},
|
||||
badge: {
|
||||
enabled: Boolean(site.badge_enabled),
|
||||
widthMm: site.badge_width_mm,
|
||||
|
||||
+1
-78
@@ -4,7 +4,6 @@ import os from 'node:os';
|
||||
import crypto from 'node:crypto';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import config from './config.js';
|
||||
import { createZip } from './zip.js';
|
||||
|
||||
/**
|
||||
* Certificates for an internal-only kiosk.
|
||||
@@ -17,9 +16,7 @@ import { createZip } from './zip.js';
|
||||
*/
|
||||
|
||||
const CA_DAYS = 3650;
|
||||
// Apple rejects a TLS server certificate whose validity exceeds 398 days. Exactly
|
||||
// 398 sits on the line with no room for rounding or clock skew, so leave a margin.
|
||||
const SERVER_DAYS = 396;
|
||||
const SERVER_DAYS = 398;
|
||||
const RENEW_WITHIN_DAYS = 30;
|
||||
|
||||
function certDir() {
|
||||
@@ -253,80 +250,6 @@ export function caCertificateDer() {
|
||||
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() {
|
||||
const p = paths();
|
||||
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