diff --git a/.claude/refactor/CONTRACTS.md b/.claude/refactor/CONTRACTS.md index 3623520..d115f53 100644 --- a/.claude/refactor/CONTRACTS.md +++ b/.claude/refactor/CONTRACTS.md @@ -378,45 +378,71 @@ A descriptor may include a free-text 1-line `description` string. It is surfaced { topic: 'cmd.calibrate', payloadSchema: { type: 'none' }, description: 'Trigger a one-shot calibration.', handler: handlers.calibrate } ``` -### Optional `units` field — pre-dispatch unit normalisation +### Optional `unit` field — pre-dispatch unit normalisation -A descriptor for a numeric setter / data topic may declare: +A descriptor for a numeric setter / data topic declares the unit the handler +always receives. **Preferred form** is the shorthand: ```js -units: { measure: '', default: '' } +unit: 'm3/h' // operator-friendly: 'm3/h', 'mbar', 'kW', 'C', 'm', ... ``` -- `measure`: a `convert`-recognised measure name (`volumeFlowRate`, `pressure`, `power`, `temperature`, `volume`, `length`, …). -- `default`: the unit the handler always receives. Operator-friendly (e.g. `m3/h`, `mbar`, `kW`, `C`). +The dimensional **measure is derived from the unit** (`volumeFlowRate` for `m3/h`, +`length` for `m`, …) — it is never declared separately, so it can never drift +from the unit. The legacy `units: { measure, default }` object is still accepted +(its `measure` is ignored and re-derived from `default`). An unrecognised unit +**throws at construction**, catching descriptor typos early. -Validation: if `units` is present, both fields must be non-empty strings. The registry throws at construction otherwise. +At dispatch time, **before** the handler runs and **before** payload-schema +validation, the registry normalises the incoming msg so the handler always sees +a finite number in the declared unit: -At dispatch time, **before** the handler runs and **before** payload-schema validation, the registry normalises the incoming msg: - -1. Extract value + unit. Three accepted shapes: - - `msg.payload` is a number → `value = msg.payload`, `unit = msg.unit`. - - `msg.payload = { value: , unit?: }` → use those (falls back to `msg.unit` if `payload.unit` is absent). - - Anything else (string, object without `value`, missing payload, …) → normalisation is skipped; the handler receives the raw msg unchanged. No crash. +1. Extract value + unit. Accepted shapes (a numeric **string** counts as a + number everywhere — `"60"` is normalised exactly like `60`): + - `msg.payload` is a number / numeric string → `value = msg.payload`, `unit = msg.unit`. + - `msg.payload = { value, unit? }` (value number or numeric string) → use those (falls back to `msg.unit` if `payload.unit` is absent). + - Anything else (non-numeric string, object without numeric `value`, missing payload) → normalisation is skipped; the handler receives the raw msg. No crash. 2. Determine the unit-of-record: - - **No unit supplied** → silently assume `units.default`. - - **Unit recognised + correct measure** → `convert(value).from(unit).to(default)`. - - **Unit recognised but wrong measure** → log `warn` with the topic, the actual measure, the expected measure, and the accepted-unit list. Fall through with the supplied value assumed to already be in `default`. - - **Unit unrecognised** → log `warn` with the topic, the unknown unit, and the accepted-unit list. Fall through with the supplied value assumed to already be in `default`. -3. Rewrite the msg so the handler sees uniform inputs: - - `msg.payload` becomes the normalised number in `units.default` (the object form `{value, unit}` is flattened to a number). - - `msg.unit` is set to `units.default`. + - **No unit supplied** → silently assume the declared unit. + - **Unit recognised + correct measure** → `convert(value).from(unit).to(declared)`. + - **Unit recognised but wrong measure** / **unit unrecognised** → log `warn` (topic, the unit, the accepted-unit list) and fall through, assuming the value is already in the declared unit. +3. Rewrite the msg: `msg.payload` becomes the normalised number in the declared + unit (the object form `{value, unit}` is flattened), and `msg.unit` is set to + the declared unit. -Accepted-unit lists come from `convert.possibilities(measure)`. If that helper is unavailable, the warn falls back to `(see convert docs)`. +Accepted-unit lists come from `convert.possibilities(measure)`. The normalised +`{ measure, default }` pair is surfaced by `.list()` (so wikiGen + `query.units` +render the contract) and is `null` for descriptors that don't declare a unit. -The `units` field is surfaced by `.list()` (so wikiGen + `query.units` can render the contract) and is `null` for descriptors that don't declare it. +### `msg.origin` + optional `gated` field — control-authority arbitration + +Every dispatch stamps **`msg.origin`** — the control authority that issued the +command — before the handler runs. Values reuse the `allowedSources` vocabulary: + +| `msg.origin` | Meaning | +|---|---| +| `parent` | automation / parent controller (**default** when `msg.origin` is absent) | +| `GUI` | SCADA / HMI operator | +| `fysical` | physical buttons / panel | + +Precedence is **fysical > GUI > parent** (physical > virtual > auto). A descriptor +opts into enforcement with `gated: true`: the registry then accepts the command +only if `msg.origin ∈ source.config.mode.allowedSources[source.currentMode]`, +logging a `warn` and dropping it otherwise. Nodes without a control-mode model +are advisory (allow-all), so `gated` is inert until a node has a mode. **Releasing +control is done by changing the mode** (`set.mode`), not a separate command. + +Handlers read `msg.origin` for provenance (control nodes keep a legacy fallback +to `payload.source` for old flows). Descriptors may also set `defaultOrigin` to +override the global `parent` default. Example: ```js { topic: 'set.demand', - units: { measure: 'volumeFlowRate', default: 'm3/h' }, - payloadSchema: { type: 'number' }, + unit: 'm3/h', // measure (volumeFlowRate) derived from the unit + payloadSchema: { type: 'any' }, description: 'Operator demand setpoint.', handler: handlers.setDemand, } @@ -431,7 +457,7 @@ exports.setMode = (source, msg, ctx) => { }; exports.startup = async (source, msg, ctx) => { - await source.handleInput(msg.payload?.source ?? 'parent', 'execSequence', 'startup'); + await source.handleInput(msg.origin, 'execSequence', 'startup'); // msg.origin defaults to 'parent' }; ``` diff --git a/.claude/refactor/OPEN_QUESTIONS.md b/.claude/refactor/OPEN_QUESTIONS.md index f110acb..e9521bc 100644 --- a/.claude/refactor/OPEN_QUESTIONS.md +++ b/.claude/refactor/OPEN_QUESTIONS.md @@ -763,3 +763,29 @@ guard here. **Open follow-ups:** - If `coresync` ends up classified as a process-data node rather than infrastructure, repick a non-slate hue. - Consider a `tools/palette-lint/` check that diffs declared palette hexes vs. this table to catch future drift (low priority). + +--- + +## 2026-05-29 — Command-message envelope: origin provenance, mode-gated arbitration, derive-measure-from-unit (in progress) + +**Context:** Review of `cmd.calibrate.level` / `set.inflow` exposed three smells in the shared command path (`generalFunctions/src/nodered/commandRegistry.js`, consumed by every node's `commands/index.js`): (1) two accepted input shapes (`payload:number + msg.unit` AND `payload:{value,unit}`) re-parsed by hand in each handler (`setInflow`/`setOutflow` duplicated 10 lines the registry already did); (2) "always convert" was false — numeric *string* payloads bypassed `_extractValueAndUnit` and reached handlers unconverted; (3) `units.measure` was declared redundantly alongside `units.default` (derivable from the unit, prone to drift). Separately, the user wants command **provenance** tracked globally with control-authority precedence **physical > virtual > auto** for every field asset that accepts commands. + +**Prior art (the model — already fully implemented in rotatingMachine):** `mode.current` ∈ `auto | virtualControl | fysicalControl` (+`maintenance`); `mode.allowedSources` per mode = `auto→[parent,GUI,fysical]`, `virtualControl→[GUI,fysical]`, `fysicalControl→[fysical]`; enforced in `flowController.handle` via `isValidSourceForMode`/`isValidActionForMode`. Release = `setMode`. The decision is to **lift this into the shared registry** so every node inherits it. + +**Decisions (user-confirmed 2026-05-29):** +- Provenance field on the message = **`msg.origin`** (NOT `source` — that's the handler's domain-instance arg). Values reuse the existing `allowedSources` vocabulary **`parent | GUI | fysical`**, default **`parent`**. (Considered `auto|virtual|physical`; rejected to avoid renaming `allowedSources` across the schema — lowest churn, "how it works now".) +- **Release/handback is handled by the mode**, not a separate command — exactly as rotatingMachine does today. +- Primary on-wire shape = **payload envelope**: `msg.topic='set.level'`, `msg.payload={value,unit}` (or bare scalar), `msg.origin='...'`. `msg.set.` namespace sugar deferred. +- **Drop `units.measure`** — descriptors declare only the unit (`unit:'m3/h'` shorthand, or `units:{default:'m3/h'}`); the registry derives the measure via `convert().describe(unit).measure`. Legacy `units:{measure,default}` still accepted (measure ignored, re-derived). An unrecognised declared unit now **throws at construction** (catches descriptor typos early). +- **Always convert** — `_extractValueAndUnit` now accepts numeric strings and `{value:"60"}`; after normalisation `msg.payload` is always a finite number in the descriptor unit. Non-numeric strings still pass through unchanged (handler guards). + +**Implemented (slice 1):** +- `commandRegistry.js`: numeric-string extraction; `unit:` shorthand + derive-measure (`_validateUnits`); `msg.origin` stamped on every dispatch (default `parent`); opt-in **`gated: true`** origin arbitration (`_originAllowed`) consulting `source.config.mode.allowedSources[currentMode]` — advisory allow-all when a node has no mode model, so it's inert until opted in. Handles Set- or array-valued `allowedSources`. +18 tests (45 total, green). +- `pumpingStation`: 5 descriptors switched to `unit:` shorthand; `setInflow`/`setOutflow` collapsed to guarded one-liners. 143/143 green. rotatingMachine (legacy `units` form) 117/117, diffuser green, generalFunctions 170 green. + +**Open follow-ups (slice 2+):** +- Migrate rotatingMachine to read `msg.origin` (instead of `payload.source`) and consume the shared `gated` arbiter, retiring the inline `flowController` gating duplication. +- Roll `unit:` shorthand to the remaining `units:{measure,default}` descriptors (diffuser, rotatingMachine). +- Lift the `mode`/`allowedSources`/`allowedActions` schema fragment into a shared schema partial so non-pump nodes gain the mode model. +- Decide canonical-unit storage (m³/s vs the m³/h display default currently stored in MeasurementContainer) — separate task, ripples into basin-balance math + output formatting. +- Per-node `CONTRACT.md` updates for `msg.origin` + the envelope shape; `tools/contract-verify` run. diff --git a/nodes/dashboardAPI b/nodes/dashboardAPI index de957cb..a7e9b1e 160000 --- a/nodes/dashboardAPI +++ b/nodes/dashboardAPI @@ -1 +1 @@ -Subproject commit de957cb971f0ed8b2d27449275dda951a7c78f66 +Subproject commit a7e9b1efe8a568ff15898e88b46315894bf9b2be diff --git a/nodes/diffuser b/nodes/diffuser index f5fd803..4b0080c 160000 --- a/nodes/diffuser +++ b/nodes/diffuser @@ -1 +1 @@ -Subproject commit f5fd8039f502086ee57b027d22f62a900f313749 +Subproject commit 4b0080cc60c2f74c213c59e0489e8a811a33d7a7 diff --git a/nodes/generalFunctions b/nodes/generalFunctions index 5c091cd..ce4fb4e 160000 --- a/nodes/generalFunctions +++ b/nodes/generalFunctions @@ -1 +1 @@ -Subproject commit 5c091cdce95fd260022aa653a97ca84dda9b7608 +Subproject commit ce4fb4e5d0c27d1d4503ea0706d62e86b29753c2 diff --git a/nodes/machineGroupControl b/nodes/machineGroupControl index f18f3cc..19720bd 160000 --- a/nodes/machineGroupControl +++ b/nodes/machineGroupControl @@ -1 +1 @@ -Subproject commit f18f3cc67330839e317d28f28c5a8d15ae55f8e7 +Subproject commit 19720bd67f8c6fffc1705f53c63a4a04538845d4 diff --git a/nodes/monster b/nodes/monster index 6c88b64..9427b64 160000 --- a/nodes/monster +++ b/nodes/monster @@ -1 +1 @@ -Subproject commit 6c88b6464d63d678e1dd4d4a97b4ed459e042302 +Subproject commit 9427b64bbeaf0f39ac33bc35509be12676d7c77d diff --git a/nodes/pumpingStation b/nodes/pumpingStation index fc6491d..e47de87 160000 --- a/nodes/pumpingStation +++ b/nodes/pumpingStation @@ -1 +1 @@ -Subproject commit fc6491dc235ae04c8717cd5120671858eef7becb +Subproject commit e47de87adbb0e311c14dc2ac4ce5596702a34d2c diff --git a/nodes/rotatingMachine b/nodes/rotatingMachine index 889221f..0a3a0be 160000 --- a/nodes/rotatingMachine +++ b/nodes/rotatingMachine @@ -1 +1 @@ -Subproject commit 889221fffda315d4b135e5fee5a16909ec74895f +Subproject commit 0a3a0be15bab24c4d03180c66146451e9bfec57b diff --git a/nodes/valve b/nodes/valve index 74951e7..b40b2c7 160000 --- a/nodes/valve +++ b/nodes/valve @@ -1 +1 @@ -Subproject commit 74951e7a233b0f9f755b4a53a4e2243c34195e55 +Subproject commit b40b2c736ff58af4cc68b57dc2ebe321dd61454d diff --git a/nodes/valveGroupControl b/nodes/valveGroupControl index bd67b22..c96ad94 160000 --- a/nodes/valveGroupControl +++ b/nodes/valveGroupControl @@ -1 +1 @@ -Subproject commit bd67b22197a2564dae3344e759acc3db47f8a73e +Subproject commit c96ad94c40fa209f17b3f7154a5edada8becf709 diff --git a/tools/wiki-gen/bin/wiki-gen.js b/tools/wiki-gen/bin/wiki-gen.js index 8ea51bb..b7dcc8e 100644 --- a/tools/wiki-gen/bin/wiki-gen.js +++ b/tools/wiki-gen/bin/wiki-gen.js @@ -17,7 +17,16 @@ function loadRegistry(nodeDir) { if (!Array.isArray(descriptors)) { throw new Error('commands/index.js must export an array of descriptors'); } - return descriptors; + // Normalise through the registry so both the legacy `units: {measure, default}` + // and the `unit: 'm3/h'` shorthand resolve to the same `{measure, default}` + // shape (measure derived from the unit). Falls back to the raw descriptors if + // the registry can't be loaded so the tool never hard-fails on an edge case. + try { + const { createRegistry } = require('../../../nodes/generalFunctions'); + return createRegistry(descriptors).list(); + } catch (_) { + return descriptors; + } } function renderTopicTable(descriptors) {