docs: finalise CONTRACTS.md §4 + WIKI_TEMPLATE.md tweaks
CONTRACTS.md §4: full payloadSchema.type table including 'none', plus the optional description field example. Matches the B3.2 implementation. WIKI_TEMPLATE.md §5: Unit column appears with explanatory paragraph. Matches the P11.4 wikiGen output. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -340,6 +340,7 @@ module.exports = [
|
||||
topic: 'set.mode',
|
||||
aliases: ['setMode', 'changemode'], // legacy names
|
||||
payloadSchema: { type: 'string' },
|
||||
description: 'Switch the node between auto and manual control modes.',
|
||||
handler: handlers.setMode,
|
||||
},
|
||||
{
|
||||
@@ -348,10 +349,79 @@ module.exports = [
|
||||
payloadSchema: { type: 'object', properties: { source: { type: 'string' } } },
|
||||
handler: handlers.startup,
|
||||
},
|
||||
{
|
||||
topic: 'cmd.calibrate',
|
||||
payloadSchema: { type: 'none' },
|
||||
description: 'Trigger a one-shot calibration. Payload is ignored.',
|
||||
handler: handlers.calibrate,
|
||||
},
|
||||
...
|
||||
];
|
||||
```
|
||||
|
||||
### `payloadSchema.type` values
|
||||
|
||||
| Type | Meaning |
|
||||
|---|---|
|
||||
| `'string'` | `typeof payload === 'string'`. |
|
||||
| `'number'` | `typeof payload === 'number'`. |
|
||||
| `'boolean'` | `typeof payload === 'boolean'`. |
|
||||
| `'object'` | Non-null object. Optional `properties: { key: 'typeName' }` enforces per-key `typeof` (missing keys allowed). |
|
||||
| `'any'` | Anything passes. Use when the handler accepts heterogeneous payloads. |
|
||||
| `'none'` | **Trigger-only.** Handler is invoked regardless of payload. If `msg.payload` is anything other than `undefined`/`null`, the registry logs a `warn` (`"<topic>: payload ignored — this is a trigger-only topic"`) and still invokes the handler. Use for pure triggers (`cmd.calibrate`, `cmd.estop`, `set.simulator`, ...) — strict alternative to `'any'`. |
|
||||
|
||||
### Optional `description` field
|
||||
|
||||
A descriptor may include a free-text 1-line `description` string. It is surfaced by `.list()` (the docs surface) and consumed by `wikiGen`'s topic-contract auto-gen. Example:
|
||||
|
||||
```js
|
||||
{ topic: 'cmd.calibrate', payloadSchema: { type: 'none' }, description: 'Trigger a one-shot calibration.', handler: handlers.calibrate }
|
||||
```
|
||||
|
||||
### Optional `units` field — pre-dispatch unit normalisation
|
||||
|
||||
A descriptor for a numeric setter / data topic may declare:
|
||||
|
||||
```js
|
||||
units: { measure: '<measureName>', default: '<unitAbbr>' }
|
||||
```
|
||||
|
||||
- `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`).
|
||||
|
||||
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:
|
||||
|
||||
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.
|
||||
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`.
|
||||
|
||||
Accepted-unit lists come from `convert.possibilities(measure)`. If that helper is unavailable, the warn falls back to `(see convert docs)`.
|
||||
|
||||
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.
|
||||
|
||||
Example:
|
||||
|
||||
```js
|
||||
{
|
||||
topic: 'set.demand',
|
||||
units: { measure: 'volumeFlowRate', default: 'm3/h' },
|
||||
payloadSchema: { type: 'number' },
|
||||
description: 'Operator demand setpoint.',
|
||||
handler: handlers.setDemand,
|
||||
}
|
||||
```
|
||||
|
||||
A handler is a pure function:
|
||||
|
||||
```js
|
||||
@@ -429,10 +499,36 @@ Methods on the resulting policy:
|
||||
|---|---|
|
||||
| `policy.canonical(type)` | Canonical unit for a measurement type. |
|
||||
| `policy.output(type)` | Display / IO unit for a measurement type. |
|
||||
| `policy.curve(type)` | Curve-input unit for a measurement type (returns `null` if no `curve` was declared). |
|
||||
| `policy.resolve(candidate, expectedMeasure, fallback, label)` | Validate a user-supplied unit, fall back if invalid (logs `warn`). |
|
||||
| `policy.convert(value, fromUnit, toUnit, contextLabel)` | Strict conversion. |
|
||||
| `policy.containerOptions()` | Returns the option bag for a `MeasurementContainer`. |
|
||||
|
||||
### Dual access shape (method OR frozen property bag)
|
||||
|
||||
`canonical`, `output`, and `curve` each work both as a method call AND as a
|
||||
frozen own-property map. They are functions with `Object.defineProperty`-installed
|
||||
non-writable, non-configurable own properties, frozen via `Object.freeze`:
|
||||
|
||||
```js
|
||||
policy.canonical('flow') // 'm3/s' (method)
|
||||
policy.canonical.flow // 'm3/s' (property)
|
||||
policy.output.pressure // 'mbar' (property)
|
||||
policy.curve.control // '%' (property)
|
||||
|
||||
policy.canonical.flow = 'tampered'; // TypeError in strict mode
|
||||
delete policy.canonical.pressure; // TypeError
|
||||
Object.isFrozen(policy.canonical); // true
|
||||
```
|
||||
|
||||
The property-bag form is preferred in hot paths and tight inner loops (one
|
||||
lookup vs one function call). The method form is preferred when the type is
|
||||
itself dynamic (`policy.canonical(typeName)`). Both forms are first-class
|
||||
parts of the contract — call sites may use whichever reads best.
|
||||
|
||||
This replaces the per-node `_unitView` / `unitPolicyView` mirror that
|
||||
pre-dated the dual-shape accessor — domains read `this.unitPolicy` directly.
|
||||
|
||||
`BaseDomain` reads `static unitPolicy` and passes
|
||||
`policy.containerOptions()` straight into `new MeasurementContainer(...)`.
|
||||
|
||||
@@ -473,8 +569,14 @@ this.demandGate = new LatestWinsGate(async (demand) => {
|
||||
await this._dispatchDemandToChildren(demand);
|
||||
});
|
||||
|
||||
// Caller side — never blocks. The latest demand always wins.
|
||||
// Fire-and-forget — never blocks. The latest demand always wins.
|
||||
this.demandGate.fire(demand);
|
||||
|
||||
// Await the per-fire settlement.
|
||||
const result = await this.demandGate.fireAndWait(demand);
|
||||
if (result && result.superseded === true) {
|
||||
// A later fire/fireAndWait overwrote this one in the pending slot.
|
||||
}
|
||||
```
|
||||
|
||||
Guarantees:
|
||||
@@ -483,6 +585,25 @@ Guarantees:
|
||||
enqueued; intermediate ones are dropped.
|
||||
- After the in-flight call settles, the latest pending value fires.
|
||||
|
||||
### `fire(value)` vs `fireAndWait(value)`
|
||||
|
||||
| Method | Returns | Settles when |
|
||||
|---|---|---|
|
||||
| `fire(value)` | `void` | n/a — caller never awaits. |
|
||||
| `fireAndWait(value)` | `Promise<result \| SUPERSEDED \| undefined>` | THIS specific fire's dispatch settles. If a later fire (plain or awaited) overwrites this one in the pending slot, the returned promise **resolves** with the frozen sentinel `LatestWinsGate.SUPERSEDED = { superseded: true }`. If the dispatch itself throws, the promise still resolves (with `undefined`) and the error is recorded on `gate.lastError` — callers don't need try/catch. |
|
||||
|
||||
The supersede-resolves-with-sentinel choice (rather than rejecting with
|
||||
`'superseded'`) means consumers branch on a value:
|
||||
|
||||
```js
|
||||
const r = await gate.fireAndWait(v);
|
||||
if (r && r.superseded) return; // dropped by a later fire
|
||||
// ... otherwise r is the dispatch's return value
|
||||
```
|
||||
|
||||
`drain()` remains the right tool for "wait until idle" (returns one
|
||||
promise regardless of how many fires landed); `fireAndWait` is per-fire.
|
||||
|
||||
## 9. `HealthStatus`
|
||||
|
||||
A standardised shape for nodes that compute prediction quality / drift
|
||||
|
||||
@@ -126,12 +126,15 @@ Update this section when you rename or split a directory.
|
||||
|
||||
> **Auto-generated** from `src/commands/index.js`. Do NOT hand-edit between the markers. Re-run `npm run wiki:contract`.
|
||||
|
||||
The **Unit** column reflects the descriptor's `units: { measure, default }` declaration, rendered as `<measure> (default <unit>)`. Topics without a `units` field (non-quantity payloads — mode strings, child ids, sequence triggers) show `—`. The default unit is what the commandRegistry coerces incoming `msg.unit` values to before the handler runs. The **Effect** column is sourced from the descriptor's `description` field; topics without one fall back to a generic per-prefix sentence.
|
||||
|
||||
<!-- BEGIN AUTOGEN: topic-contract -->
|
||||
|
||||
| Canonical topic | Aliases | Payload | Effect |
|
||||
|---|---|---|---|
|
||||
| `set.mode` | `setMode` | `string` (`auto`\|`manual`\|`maintenance`) | Switches operating mode. |
|
||||
| `cmd.startup` | `execSequence` (with `payload.action='startup'`) | `{source: string}` | Triggers startup sequence. |
|
||||
| Canonical topic | Aliases | Payload | Unit | Effect |
|
||||
|---|---|---|---|---|
|
||||
| `set.mode` | `setMode` | `string` (`auto`\|`manual`\|`maintenance`) | — | Switches operating mode. |
|
||||
| `set.demand` | `Qd` | `number` | `volumeFlowRate` (default `m3/h`) | Sets the manual demand setpoint. |
|
||||
| `cmd.startup` | `execSequence` (with `payload.action='startup'`) | `{source: string}` | — | Triggers startup sequence. |
|
||||
|
||||
<!-- END AUTOGEN: topic-contract -->
|
||||
|
||||
|
||||
Reference in New Issue
Block a user