Files
visitor-signin/src/printing/ql-print.js
T
jessikitty 851e021fc2 Make printer status best effort, not a precondition
A silent printer no longer blocks printing or reports as unhealthy. printPng
returns confirmed:false when no status came back, so callers don't claim a
label came out when all we know is the bytes were delivered.
2026-09-07 14:17:47 +10:00

219 lines
7.4 KiB
JavaScript

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 };