issue 1440: the scan module's manifest, settings template and README
mesh/merge-gate head 0821718d7ca9 merged into trunk a67f01963f0d — pass: builds new: modules/scan, sent nowhere; no bus step; every machine composes with t…
mesh/repo-check head 0821718d7ca9 merged into trunk a67f01963f0d — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered

This commit is contained in:
2026-10-20 20:13:58 +02:00
parent 27709b07e7
commit 0821718d7c
4 changed files with 293 additions and 0 deletions
+66
View File
@@ -0,0 +1,66 @@
# scan — scan a document, crop it, file it, say so
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 glass size and puts one job to it (`POST <scanner>/ScanJobs`): the platen, the whole
glass, at the configured resolution and colour mode, as a JPEG; it fetches the page once.
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 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 one-page PDF holding the JPEG: 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 |
| `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 tool says which and
writes and asks nothing.
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
| tool | does |
|---|---|
| `scan_now` | scans, crops, files and emits; answers the event's body |
| `scan_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 |
| `scan_list` | the newest filed scans, newest first (`limit`, default 20). Read-only |
## The event
`scan.added`, on the wire `mesh.mod.scan.event.scan.added`; a consumer declares `scan.scan.added`. Its body:
`path`, `node`, `bytes`, `sha256`, `width-mm`, `height-mm`, `resolution` (dpi, as the scanner gave it),
`color-mode`, `cropped`, `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 `scan_list` only reads.
## Tests
The unit tests use the captured answers of a real scanner (`cmd/scan/testdata/escl`, captured 2026-10-20) served
by a test HTTP server; no test reaches a scanner.
+118
View File
@@ -0,0 +1,118 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
type manifest struct {
Emits []string `json:"emits"`
Tools []string `json:"tools"`
Config map[string]map[string]any `json:"config"`
Resources []map[string]any `json:"resources"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
}
func readManifest(t *testing.T) manifest {
t.Helper()
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
if err != nil {
t.Fatal(err)
}
var m manifest
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatalf("module.json: %v", err)
}
return m
}
// 1440/AC9: the scan folder is no manifest resource, because a directory's path cannot take a config value
// (issue 1441, ADR 0464 clause 4): no resource's path names it, and no directory but the state one is declared.
func TestTheScanFolderIsNoManifestResource(t *testing.T) {
m := readManifest(t)
for _, r := range m.Resources {
if p, _ := r["path"].(string); strings.Contains(p, "${config:") || strings.Contains(p, "${setting:") {
t.Errorf("resource %v puts a config value in a path, which composition does not fill", r["id"])
}
if r["type"] == "directory" && r["id"] != "state" {
t.Errorf("directory %v: the code makes the scan folder", r["id"])
}
}
}
// 1440/AC9: the event the code emits is the one the manifest declares, and the tools it serves are named.
func TestTheManifestDeclaresWhatTheCodeServes(t *testing.T) {
m := readManifest(t)
if !reflect.DeepEqual(m.Emits, []string{EventAdded}) {
t.Errorf("emits = %v, want [%s]", m.Emits, EventAdded)
}
var served []string
for _, tl := range tools("", nil, nil) {
served = append(served, tl.Name)
}
if !reflect.DeepEqual(m.Tools, served) {
t.Errorf("manifest tools = %v, the bundle serves %v", m.Tools, served)
}
}
// 1440/AC9: the config reaches the code as the one file, under the name the code reads, and every key the
// code reads is declared, taken by the file and in its template, with the default the code lays under it.
func TestTheConfigReachesTheCodeThroughItsSettingsFile(t *testing.T) {
m := readManifest(t)
keys := []string{"scanner", "scan-folder", "resolution", "color-mode", "crop"}
for _, k := range keys {
if _, ok := m.Config[k]; !ok {
t.Errorf("config does not declare %s", k)
}
}
if len(m.Config) != len(keys) {
t.Errorf("config declares %d keys, the code reads %d", len(m.Config), len(keys))
}
var taken []any
for _, r := range m.Resources {
if r["id"] == "settings" {
taken, _ = r["takes-config"].([]any)
}
}
if len(taken) != len(keys) {
t.Errorf("the settings file takes %v", taken)
}
env := ""
for _, a := range m.Build.Artifacts {
if a["kind"] == "bundle" {
env, _ = a["env"].(map[string]any)["SCAN_SETTINGS"].(string)
}
}
if env != "${dir:state}/settings.json" {
t.Errorf("SCAN_SETTINGS = %q", env)
}
raw, err := os.ReadFile(filepath.Join("..", "..", "files", "settings.json"))
if err != nil {
t.Fatal(err)
}
var tpl map[string]any
if err := json.Unmarshal(raw, &tpl); err != nil {
t.Fatal(err)
}
// What the template and the manifest default say is what LoadSettings falls back to.
d, err := LoadSettings(settingsFile(t, string(raw)))
if err != nil {
t.Fatal(err)
}
for k, want := range map[string]any{"resolution": float64(d.Resolution), "color-mode": d.ColorMode, "crop": d.Crop} {
if m.Config[k]["default"] != want || tpl[k] != want {
t.Errorf("%s: manifest default %v, template %v, code %v", k, m.Config[k]["default"], tpl[k], want)
}
}
for _, k := range []string{"scanner", "scan-folder"} {
if m.Config[k]["required"] != true || m.Config[k]["trusted"] != true {
t.Errorf("%s is the operator's: required and trusted", k)
}
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"scanner": "",
"scan-folder": "",
"resolution": 300,
"color-mode": "color",
"crop": true
}
+102
View File
@@ -0,0 +1,102 @@
{
"module": "scan",
"version": "1",
"emits": [
"scan.added"
],
"config": {
"scanner": {
"type": "string",
"description": "The network scanner's eSCL address, such as http://192.0.2.10/eSCL: the scanner's own address and the path its eSCL service answers on; the operator's, with no default.",
"pattern": "^https?://.+",
"trusted": true,
"required": true
},
"scan-folder": {
"type": "string",
"description": "The absolute path of the folder the scans are filed in as PDF files; the module makes it when it files the first scan, and never touches a file in it that it did not write. The operator's, with no default.",
"pattern": "^/",
"trusted": true,
"required": true
},
"resolution": {
"type": "number",
"description": "The resolution asked of the scanner, in dpi: 100, 200, 300 or 600. A scanner may give another; the scan is filed at the resolution it gave.",
"enum": [
100,
200,
300,
600
],
"default": 300
},
"color-mode": {
"type": "string",
"description": "Whether the scan is in colour or in grays: color or gray.",
"enum": [
"color",
"gray"
],
"default": "color"
},
"crop": {
"type": "boolean",
"description": "Whether the document is cut out of the platen's white lid; false files the whole platen.",
"default": true
}
},
"tools": [
"scan_now",
"scan_status",
"scan_list"
],
"resources": [
{
"id": "state",
"type": "directory",
"mode": "0700",
"owner": "${machine:account}",
"place": ".",
"unknown": "program"
},
{
"id": "settings",
"type": "file",
"path": "${dir:state}/settings.json",
"owner": "${machine:account}",
"mode": "0600",
"takes-config": [
"scanner",
"scan-folder",
"resolution",
"color-mode",
"crop"
],
"merge": "json",
"template": "files/settings.json"
}
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/scan",
"binary": "scan",
"loads": [
"scan"
],
"env": {
"SCAN_SETTINGS": "${dir:state}/settings.json"
}
},
{
"name": "files",
"kind": "archive",
"from": "files"
}
]
}
}