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
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:
@@ -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.
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"scanner": "",
|
||||||
|
"scan-folder": "",
|
||||||
|
"resolution": 300,
|
||||||
|
"color-mode": "color",
|
||||||
|
"crop": true
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user