Compare commits
36 Commits
1854431ba3
...
developmen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3a20f53d6 | ||
|
|
c932382022 | ||
|
|
2c50cac908 | ||
|
|
efbc0d7273 | ||
|
|
b76202190d | ||
|
|
2d98d97b34 | ||
|
|
4668901122 | ||
|
|
0826d62997 | ||
|
|
f8d8b64be3 | ||
|
|
2b38560d5f | ||
|
|
e66e4566e6 | ||
|
|
9071d6acb4 | ||
|
|
6d03d4301f | ||
|
|
d8f14610cd | ||
|
|
473cbb6951 | ||
|
|
3cc8b65b69 | ||
|
|
b04b3bb628 | ||
|
|
ba4e41e640 | ||
|
|
2aafc38968 | ||
|
|
aaf8dd1498 | ||
|
|
d830a6a991 | ||
|
|
cb42740ee1 | ||
|
|
c529819696 | ||
|
|
62ad5605e8 | ||
|
|
2562ed2e9f | ||
|
|
52c091bd92 | ||
| 00a6fc5306 | |||
| d1412bc53c | |||
| d036646060 | |||
| 8224d15d51 | |||
| b3e24175de | |||
| 9a9bfdafc1 | |||
| 52bf827e9d | |||
| f867929634 | |||
| 042a5cc4ba | |||
| a65cdc3562 |
@@ -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'
|
||||
};
|
||||
```
|
||||
|
||||
|
||||
@@ -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.<noun>` 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.
|
||||
|
||||
@@ -203,8 +203,13 @@ If you only fill the top-level fields, `payload_type=json` is silently treated a
|
||||
|
||||
✅ Do:
|
||||
|
||||
- Generate flows from a Python builder (`build_flow.py`) — it's the source of truth.
|
||||
- Use deterministic IDs (`pump_a`, `meas_pump_a_u`, `lin_demand_to_mgc`) — reproducible diffs across regenerations.
|
||||
- **Hand-author per-node `examples/` flows — they are one-offs, not generated.**
|
||||
The JSON is the source of truth; edit it directly and validate with `flow-lint`.
|
||||
Do **not** add a per-node flow generator: it rots out of sync, silently emits
|
||||
lint-failing flows, and costs more to maintain than the one-off it produces.
|
||||
(Scripting layout *may* still pay off for a large, frequently-rebuilt production
|
||||
dashboard — but that is an explicit, owned tool, never a per-node example.)
|
||||
- Use stable, readable IDs (`pump_a`, `meas_pump_a_u`, `lin_demand_to_mgc`) so diffs stay legible.
|
||||
- Tag every channel name with `cmd:` / `evt:` / `setup:`.
|
||||
- Comment every section, even short ones.
|
||||
- Verify trends with a `ui-chart` of synthetic data first, before plumbing real data through.
|
||||
|
||||
5
.claude/settings.json
Normal file
5
.claude/settings.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"env": {
|
||||
"CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1"
|
||||
}
|
||||
}
|
||||
@@ -67,7 +67,10 @@ services:
|
||||
# Grafana — dashboard visualization
|
||||
# ---------------------------------------------------------
|
||||
grafana:
|
||||
image: grafana/grafana:latest
|
||||
# Pinned per dashboardAPI v2 PRD: legacy POST /api/dashboards/db is the
|
||||
# generator target; Grafana 12 K8s-style API is out of scope. Bump
|
||||
# deliberately, not via `pull --latest`.
|
||||
image: grafana/grafana:11.3.0
|
||||
container_name: evolv-grafana
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
|
||||
Submodule nodes/coresync updated: aefec90485...e05fe62db9
Submodule nodes/dashboardAPI updated: dac8576cab...a7e9b1efe8
Submodule nodes/diffuser updated: f5fd8039f5...4b0080cc60
Submodule nodes/generalFunctions updated: 5c091cdce9...ce4fb4e5d0
Submodule nodes/machineGroupControl updated: f18f3cc673...ef265dd811
Submodule nodes/monster updated: 6c88b6464d...965e3ba305
Submodule nodes/pumpingStation updated: 2fb083da63...177a37e15c
Submodule nodes/rotatingMachine updated: 889221fffd...00e35302b4
Submodule nodes/valve updated: 74951e7a23...b40b2c736f
Submodule nodes/valveGroupControl updated: bd67b22197...c96ad94c40
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "EVOLV",
|
||||
"version": "1.0.32",
|
||||
"version": "1.0.35",
|
||||
"description": "Modular Node-RED package containing all control and automation nodes developed under the EVOLV project.",
|
||||
"keywords": [
|
||||
"node-red",
|
||||
|
||||
@@ -185,7 +185,13 @@ function checkFunctionFanOut(n, findings) {
|
||||
msg: `function "${n.name || n.id}" declares outputs=${outputs} but wires has ${Array.isArray(n.wires) ? n.wires.length : 'no'} arrays.`,
|
||||
});
|
||||
}
|
||||
if (typeof n.func === 'string' && /payload\s*:\s*null\b/.test(n.func)) {
|
||||
// Strip comments first — a comment that merely *mentions* `{ payload: null }`
|
||||
// (e.g. explaining why the code avoids it) must not trip the rule. Heuristic,
|
||||
// not a full parser, but it kills the common false positive.
|
||||
const funcCode = typeof n.func === 'string'
|
||||
? n.func.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '')
|
||||
: '';
|
||||
if (/payload\s*:\s*null\b/.test(funcCode)) {
|
||||
findings.push({
|
||||
rule: 'FN_PAYLOAD_NULL_LITERAL',
|
||||
severity: 'error',
|
||||
|
||||
@@ -17,7 +17,16 @@ function loadRegistry(nodeDir) {
|
||||
if (!Array.isArray(descriptors)) {
|
||||
throw new Error('commands/index.js must export an array of 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) {
|
||||
|
||||
Reference in New Issue
Block a user