# 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 /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. 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/.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 :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 `.pdf` and an upload as `uploads/.`. ### 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.