feat(commands): unify command envelope across all nodes — msg.origin + unit shorthand

Platform-wide command-message contract:
- Document the envelope in .claude/refactor/CONTRACTS.md §4: unit shorthand +
  derived measure, always-convert (incl. numeric strings), msg.origin
  provenance (parent|GUI|fysical, default parent) + gated mode arbitration.
- wiki-gen: normalise descriptors through createRegistry().list() so the Unit
  column resolves both the unit: shorthand and legacy units:{} shapes.
- Bump submodule pointers: generalFunctions (registry), rotatingMachine, valve,
  valveGroupControl, machineGroupControl (msg.origin), diffuser, pumpingStation,
  monster (unit shorthand + handler dedup), dashboardAPI (wiki sync).
- Log decision in OPEN_QUESTIONS.md (2026-05-29).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
znetsixe
2026-05-29 18:41:56 +02:00
parent b76202190d
commit efbc0d7273
12 changed files with 95 additions and 34 deletions

View File

@@ -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: '<measureName>', default: '<unitAbbr>' }
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: <number>, unit?: <string> }` → 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'
};
```