144 lines
13 KiB
Markdown
144 lines
13 KiB
Markdown
# brother — the Brother multifunction device: scan, printer state, admin pages
|
|
|
|
One module for one device (novox/hq ADR 0467, issue 1453): the scanner and the printer are one machine on the
|
|
network, so one module serves both. It claims two seats, each held once for the mesh: `scanner` (verbs `scan`,
|
|
`status`, `list`, `document`) and `printer` (verbs `status`, `settings`, `print`, `upload`; `restart` follows in a later round). It replaces the
|
|
`scan` module of issue 1440 (ADR 0464), which ran on no node and is gone from the catalogue.
|
|
|
|
## The scanner seat
|
|
|
|
Built for issue 1440 as novox/hq ADR 0464 decides. It scans the glass of a network scanner (any scanner that
|
|
speaks eSCL, also called AirScan or Mopria), cuts the document out of the lid's white, files it as a one-page PDF
|
|
in a folder and says `scan.added` on the bus, so other modules can react. A scan happens when a tool is called:
|
|
the module runs no process of its own, and it runs as the node's operator account.
|
|
|
|
## What a scan does
|
|
|
|
1. Asks the scanner for its sizes and puts one job to it (`POST <scanner>/ScanJobs`) at the configured resolution
|
|
and colour mode, as a JPEG: from the glass (`source` glass, the default) or from the document feeder (`source`
|
|
feeder, with `duplex` true for both sides of each sheet; refused on the glass). The region is A4, cut to what the
|
|
input can scan (the glass is 3507 high, not A4's 3508). The body is written on one line: the Brother ignores the
|
|
whole body, and runs its default job, when it holds a line break (issue 1452). The glass page is fetched once;
|
|
a feeder job is asked `NextDocument` until the scanner answers 404, at most 100 pages. A job that fails is deleted.
|
|
2. Reads the resolution from the image's own JFIF density, never from the request: a scanner may answer a
|
|
300 dpi request with 200 dpi, and the page size and the event's millimetres follow what it gave.
|
|
3. Crops the glass page (a feeder page is a whole sheet and is never cropped): the lid's level is measured in the corner farthest from where documents lie, a pixel is document when
|
|
it is darker than the lid, and the rows and columns holding enough of them give the box. No document, or one
|
|
filling nearly the whole glass, keeps the whole image (`cropped` false), and so does `crop` false.
|
|
Several documents lying apart on the glass are each cut out and filed as a one-page PDF of its own (own
|
|
object, own `scan.added`, own file); the answer then lists them as `documents` (each with its `path`) and
|
|
`count`, in reading order, and one that could not be kept or filed is named in `failed` without holding back
|
|
the others. The rule (`Boxes` in `crop.go`): the picture is cut into blocks of 1 mm; a block is document when a
|
|
quarter of its pixels are darker than the lid; blocks not at least three across (dust, JPEG ringing) are dropped;
|
|
blocks within 10 mm of each other are one region, so a white band narrower than that does not split a document;
|
|
a region under 20 mm on a side is a stain, not a document; regions whose boxes overlap are one document (a frame
|
|
and the text inside it). One document, or none, is cropped exactly as before.
|
|
4. Files a PDF holding the JPEGs, one page for the glass and one per image for the feeder (so six for three
|
|
sheets both sides), each page as large as its image really is: written whole beside its place as `.scan-*.tmp` and renamed to
|
|
`scan-YYYY-MM-DD-HHMMSS.pdf` (local time); a taken name gets `-2`, `-3`, and no file is ever overwritten.
|
|
5. Says `scan.added` once, after the rename, under the file's sha256 as its message id. It never carries the image.
|
|
|
|
A scanner that refuses or gives no page files nothing and says nothing on the bus; the tool's error says why.
|
|
|
|
## Config
|
|
|
|
| key | meaning | default |
|
|
|---|---|---|
|
|
| `scanner` | the scanner's eSCL address, such as `http://192.0.2.10/eSCL` (required, trusted) | none |
|
|
| `scan-folder` | the absolute path the PDFs are filed in (required, trusted) | none |
|
|
| `printer` | the printer's host, such as `192.0.2.10`, for its admin page and IPP (required, trusted) | none |
|
|
| `printer-certificate` | the SHA-256 fingerprint of the certificate the admin page may serve, `AA:BB:...`; several, separated by commas, when it serves more than one (required, trusted) | none |
|
|
| `resolution` | dpi asked: 100, 200, 300 or 600 | 300 |
|
|
| `color-mode` | `color` or `gray` | `color` |
|
|
| `crop` | cut the document out of the lid's white | true |
|
|
|
|
The values reach the code in one file, `settings.json` in the module's state directory, which the mesh writes
|
|
from the config; the code reads it at every call. With `scanner` or `scan-folder` unset, every scanner verb says which and
|
|
writes and asks nothing; `printer` and `printer-certificate` are the printer verbs' to say.
|
|
|
|
The scan folder is not a manifest resource: a directory's path cannot take a config value (issue 1441), so the code
|
|
makes it (mode 0755) when it files the first scan.
|
|
|
|
## Tools
|
|
|
|
| verb (tool) | does |
|
|
|---|---|
|
|
| `scanner.scan` | `source` (`glass`, default, or `feeder`) and `duplex` (feeder only): asks the device process to scan, crop (glass only), keep the PDF in the bucket under its sha256 and say `scan.added`; then fetches it in parts, checks the sha256 and files it in `scan-folder`, in the background, so a scan is filed whether or not the caller still waits. A glass scan is waited for up to 180 s. A feeder scan is waited for 25 s (the mesh console gives up after 30 s); if it is not done, the answer is `started: true` with its `id`, and `scanner.scan {id}` reads the outcome (`state` running, done with the event's body and `path`, or failed with `why`). The device process ends a feeder job at 20 s a page (at least 3 min, at most 30 min). A scan whose PDF the tool could not fetch (over 100 MiB) is refused before it is kept or announced. Answers the event's body and `path` |
|
|
| `scanner.document` | one part of a kept scan (`object`, `part` from 1): `part`, `parts`, `size`, `sha256` (of the whole), `data` (base64). Join the parts and check the sha256 |
|
|
| `scanner.status` | whether the scanner answers, its state (Idle or Processing) and its document feeder's state; a scanner that does not answer is `answers: false` with why. This is how its health is read: the module has no long-running resource to declare a health check on |
|
|
| `scanner.list` | the newest filed scans, newest first (`limit`, default 20). Read-only |
|
|
|
|
## The printer seat
|
|
|
|
| verb (tool) | does |
|
|
|---|---|
|
|
| `printer.status` | without a login: over IPP (`Get-Printer-Attributes`, encoded by hand, on port 631) the state (idle, processing, stopped), the state reasons (such as `toner-low-warning`) and the markers' names, levels (percent; below 0 is unknown) and colours; over eSCL the scanner's state and its feeder's. A part that does not answer is `answers: false` with why. Needs `printer` and `scanner` |
|
|
| `printer.upload` | one part of a document to print later (`upload` id, `part`, `parts`, `sha256` of the whole, `data` base64): stored as `uploads/<upload>.pdf` or `.jpg` once all parts came and the sha256 matches; a wrong sha256, a part out of range or a document that is neither PDF nor JPEG stores nothing |
|
|
| `printer.print` | prints a file on the machine the tool runs on (`path`, absolute: the tool reads it, uploads it in parts and prints it in one call; PDF or JPEG by its bytes, never its name), a kept scan (`object`) or a completed upload (`upload`, for modules that send bytes over the bus) as an IPP Print-Job (operation 0x0002, port 631) in its own format, `copies` 1 to 99 (default 1), `sides` one-sided, two-sided-long-edge or two-sided-short-edge (default one-sided), `media` (default `iso_a4_210x297mm`); answers the job's id and state (with `path`, the upload id and the size when `path` was given) |
|
|
| `printer.settings` | asks the device process (below) to log in, read one admin page and log out, and waits up to 60 s for its answer. Argument `page`: a path on the admin site, starting with `/` and ending in `.html` (default `/general/status.html`). Answers the page's title and forms with their fields, each with its current value (a radio group as one field with the checked button's value, a checkbox with `checked`, a select with the selected option) and `options`; the page's links only with `links: true`, hidden fields only with `hidden: true`; a password field's value is never given. Read-only: it sends the login and the logout and nothing else |
|
|
|
|
### Trust and the password
|
|
|
|
The admin page is HTTPS with a self-signed certificate. The module verifies no chain: it accepts the one certificate
|
|
whose SHA-256 fingerprint is one of those in `printer-certificate` and refuses any other, in the handshake, so no
|
|
password is sent to it; the error names the served and the pinned fingerprints. Give each as 32 upper-case hex pairs
|
|
joined by colons, several separated by commas. The printer serves two self-signed certificates for one name, an
|
|
ECDSA one and an RSA one, and which it serves depends on the signature algorithms the client offers, so pin both
|
|
(issue 1503): read them with `openssl s_client -connect <printer>:443 -sigalgs ECDSA+SHA256` and `-sigalgs
|
|
RSA-PSS+SHA256`, each piped to `openssl x509 -noout -fingerprint -sha256`.
|
|
|
|
The admin password is the module's own secret `admin-password` (ADR 0467 clause 4; ADR 0283): the operator gives it
|
|
at their desk, and the mesh never generates or rotates it. It is owned by the account `brother-device`, which the
|
|
module makes, because the tools run as the operator account and must never read it (ADR 0436 §1, §12; the shape of
|
|
mesh-mail's sender). The file is `${dir:state}/admin-password.secret`, mode 0600, and `BROTHER_ADMIN_PASSWORD` names
|
|
it for the device process alone; the tools' bundle is given no such name, and `admin_boundary_test.go` checks that
|
|
no tool file imports the package that reads it. While it is not given, `printer.settings` answers that the secret
|
|
is not given, and the printer is not contacted.
|
|
|
|
### Documents in parts
|
|
|
|
The bus refuses a message over 1 MiB, so a document crosses it in parts of at most 700 KiB (both the size this module
|
|
cuts and the most it accepts, as ADR 0468 clause 3 says),
|
|
counted from 1, each carried base64-encoded in JSON. A caller joins the parts and checks the SHA-256. The bucket
|
|
(`s3-bucket`, from `minio`) is spoken to by the device process alone, from `store.json` (owner `brother-device`, mode
|
|
0600), with the S3 client mesh-mail uses; a scan is kept once as `<sha256>.pdf` and an upload as `uploads/<id>.<ext>`.
|
|
|
|
### The device process
|
|
|
|
`brother device` runs as `brother-device` with a bus account of its own (the own secret `broker-device`), never a
|
|
seat, and does all the device and bucket work: the scan (and `scan.added`, emitted by it with the object's key and no
|
|
path, since filing in the scan folder is the tool's), the documents, the printing, and the admin pages. It consumes the work-queue stream `device-asks` (event `device.asked`, body `id`, `page`, `asked-at`) and
|
|
writes each answer, once, into the state `device-answers` under the ask's id (ten minutes' life): the page's
|
|
title, links and form fields, or `error`. It alone reads the password, pins the certificate, logs in, reads the
|
|
page and logs out. An ask older than two minutes is dropped without contacting the printer; at most three
|
|
deliveries. It reads `settings.json` (mode 0644, no secret in it) and the state directory is 0711 so that it can
|
|
reach its own files; both are the choices that let its account read what it needs. Its health is its unit.
|
|
|
|
The login reads the form each time: the device generates the password field's name, so the code takes the name from
|
|
the form, posts it with the form's hidden fields, and keeps the session cookie. A login is failed when the answer is
|
|
the login page again. The logout follows a logout link or form on the page; a page with none is said in the answer
|
|
(`logout`), and the session is left to expire on the device.
|
|
|
|
## The event
|
|
|
|
`scan.added`, said by this module, on the wire `mesh.mod.brother.event.scan.added`; a consumer declares `brother.scan.added`. Its body:
|
|
`path`, `node`, `bytes`, `sha256`, `width-mm`, `height-mm`, `resolution` (dpi, as the scanner gave it),
|
|
`color-mode`, `cropped`, `pages` (1 for the glass), `scanned-at` (RFC 3339, UTC).
|
|
|
|
## Files it does not know
|
|
|
|
Each directory or archive this module declares says how it treats a file it did not write (ADR 0422, ADR 0416).
|
|
|
|
- `${dir:state}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
|
|
- The scan folder (`scan-folder`) is declared by no manifest. The module writes only the `scan-*.pdf` files it
|
|
files and their `.scan-*.tmp` while they are written; it never changes, moves or removes any other file there,
|
|
and `scanner.list` only reads.
|
|
|
|
## Tests
|
|
|
|
The unit tests use the captured answers of a real scanner (`cmd/brother/testdata/escl`, captured 2026-10-20) served
|
|
by a test HTTP server, the captured IPP exchange (`testdata/ipp`) replayed, and the captured login page
|
|
(`testdata/admin/login-page.html`) served by a test TLS server with its own certificate. The pages behind the login
|
|
are synthetic (`testdata/admin/synthetic-*.html`) until round 2 captures the real ones. No test reaches the real
|
|
device.
|