From 0821718d7ca94e61a0994160ec250c2835d27283 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 20 Oct 2026 20:13:58 +0200 Subject: [PATCH] issue 1440: the scan module's manifest, settings template and README --- modules/scan/README.md | 66 ++++++++++++++ modules/scan/cmd/scan/manifest_test.go | 118 +++++++++++++++++++++++++ modules/scan/files/settings.json | 7 ++ modules/scan/module.json | 102 +++++++++++++++++++++ 4 files changed, 293 insertions(+) create mode 100644 modules/scan/README.md create mode 100644 modules/scan/cmd/scan/manifest_test.go create mode 100644 modules/scan/files/settings.json create mode 100644 modules/scan/module.json diff --git a/modules/scan/README.md b/modules/scan/README.md new file mode 100644 index 00000000..0b339f15 --- /dev/null +++ b/modules/scan/README.md @@ -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 /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. diff --git a/modules/scan/cmd/scan/manifest_test.go b/modules/scan/cmd/scan/manifest_test.go new file mode 100644 index 00000000..c250e66d --- /dev/null +++ b/modules/scan/cmd/scan/manifest_test.go @@ -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) + } + } +} diff --git a/modules/scan/files/settings.json b/modules/scan/files/settings.json new file mode 100644 index 00000000..5bf936f4 --- /dev/null +++ b/modules/scan/files/settings.json @@ -0,0 +1,7 @@ +{ + "scanner": "", + "scan-folder": "", + "resolution": 300, + "color-mode": "color", + "crop": true +} diff --git a/modules/scan/module.json b/modules/scan/module.json new file mode 100644 index 00000000..e5f3bba5 --- /dev/null +++ b/modules/scan/module.json @@ -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" + } + ] + } +}