Architecture
Note
Every EVOLV node is a three-tier sandwich: the entry registers the type with Node-RED;
nodeClass(extendsBaseNodeAdapter) bridges runtime to domain;specificClass(extendsBaseDomain) holds pure-JS domain logic with zeroRED.*imports. Everything shared —BaseDomain,BaseNodeAdapter,ChildRouter, the commands registry,UnitPolicy,MeasurementContainer,statusBadge,HealthStatus,LatestWinsGate,logger,configManager— lives ingeneralFunctions. Source of truth:.claude/refactor/CONTRACTS.md.
The three-tier pattern
Node-RED runtime
|
+-- entry: nodes/<name>/<NodeName>.js
| RED.nodes.registerType('<nodeName>', NodeClass)
| HTTP admin endpoints (if any)
|
| +-- nodeClass: src/<...>NodeClass.js
| | extends BaseNodeAdapter (generalFunctions)
| | static DomainClass = SpecificClass
| | static commands = [...descriptors]
| | static tickInterval = null | <ms>
| | buildDomainConfig(uiConfig, nodeId)
| |
| | +-- specificClass: src/<...>SpecificClass.js
| | | extends BaseDomain (generalFunctions)
| | | static name = '<softwareType>'
| | | static unitPolicy = UnitPolicy.declare(...)
| | | configure() <- wire routers + concern modules
| | | tick() <- opt-in time-based math
| | | getOutput() <- Port 0 / 1 snapshot
| | | getStatusBadge() <- node.status badge
Tier responsibilities
| Tier | File path | Extends | Touches RED.* |
Unit-testable |
|---|---|---|---|---|
| entry | nodes/<name>/<NodeName>.js |
(top-level) | Yes | No (smoke only) |
| nodeClass | src/<...>NodeClass.js |
BaseNodeAdapter |
Yes | Integration only |
| specificClass | src/<...>SpecificClass.js |
BaseDomain |
No (never) | Yes |
Caution
The specificClass must never import Node-RED APIs. If you find
RED.*calls outside the entry or nodeClass tier, that is a bug. Pure-JS in the specificClass is what makes unit tests possible without spinning up a Node-RED runtime.
Source: .claude/refactor/CONTRACTS.md §2 and §3.
generalFunctions — what the library provides
The nodes/generalFunctions submodule is a plain-JS library every node depends on. Public exports from require('generalFunctions'):
generalFunctions
|
+-- Bases
| BaseDomain extend in specificClass.js
| BaseNodeAdapter extend in nodeClass.js
|
+-- Wiring
| ChildRouter onRegister / onMeasurement / onPrediction
| commandRegistry topic -> descriptor map
|
+-- Data
| UnitPolicy canonical + output unit declaration
| MeasurementContainer chainable type/variant/position/childId store
| convert unit conversion (m3/s <-> m3/h, ...)
|
+-- Concurrency
| LatestWinsGate supersede-semantics mutex
|
+-- Health and status
| statusBadge node.status({fill, shape, text}) composer
| HealthStatus {level, flags, message, source}
|
+-- Utilities
logger structured (never console.log)
configManager loads configs/<name>.json
MenuManager dynamic editor dropdowns
outputUtils delta-compressed Port 0 / 1 formatting
API one-liners
| Export | Contract |
|---|---|
BaseDomain |
Owns emitter, config, logger, measurements, child. Calls subclass configure() then _init?(). |
BaseNodeAdapter |
Builds merged config, instantiates DomainClass, emits Port-2 register, wires output strategy, wires status loop, wires input dispatcher. |
ChildRouter |
.onRegister(swType, handler) · .onMeasurement(swType, filter, handler) · .onPrediction(swType, filter, handler). |
commandRegistry |
Topic + alias map to handler. Coerces msg.unit to descriptor units.default. Logs one-time deprecation per alias. |
UnitPolicy |
.canonical(t) · .output(t) · .curve(t) · .resolve() · .convert() · .containerOptions(). Dual access: method form or frozen property bag (policy.canonical.flow). |
MeasurementContainer |
.type(t).variant(v).position(p).value(x, ts, unit). Keys: <type>.<variant>.<position>.<childId>. |
statusBadge |
.compose([..]) · .error(msg) · .idle(label). Returns {fill, shape, text}. |
HealthStatus |
{level: 0..3, flags: string[], message: string, source: string | null}. Lower level = healthier. |
LatestWinsGate |
.fire(v) (no-wait) · .fireAndWait(v) (per-call result; superseded calls resolve with sentinel {superseded: true}). |
logger |
.info · .warn · .error · .debug. Named after config.general.name. |
configManager |
buildConfig(uiConfig, baseConfig) — validates, merges, applies defaults. |
outputUtils |
formatMsg(snapshot, 'process' | 'influxdb') — delta-compressed; only changed fields are emitted. |
The full 34-row API surface is on the generalFunctions wiki Home under "API surface".
Output ports
Every node emits on three ports. Source: .claude/refactor/CONTRACTS.md §10.
flowchart LR
sc["specificClass — tick() or 'output-changed'"]
ou["outputUtils.formatMsg — delta-compress"]
p0[("Port 0 — process")]
p1[("Port 1 — InfluxDB line")]
p2[("Port 2 — register / control")]
dl["downstream Node-RED — dashboards, functions"]
influx[("InfluxDB")]
parent["parent EVOLV node"]
sc -- getOutput() --> ou
ou --> p0 --> dl
ou --> p1 --> influx
sc -. child.register .-> p2 --> parent
class sc tier3
class ou tier2
class p0 p0c
class p1 p1c
class p2 p2c
class dl,parent dn
class influx ext
classDef tier3 fill:#50a8d9,color:#000
classDef tier2 fill:#86bbdd,color:#000
classDef p0c fill:#0c99d9,color:#fff
classDef p1c fill:#50a8d9,color:#000
classDef p2c fill:#a9daee,color:#000
classDef dn fill:#dddddd,color:#000
classDef ext fill:#fff2cc,color:#000
| Port | Direction | Carries | When |
|---|---|---|---|
| 0 | out | Process data, formatted via outputUtils.formatMsg(..., 'process'). Object containing only keys that changed since last tick. |
'output-changed' fires on the emitter, or every tick if tick-driven |
| 1 | out | InfluxDB line-protocol string via outputUtils.formatMsg(..., 'influxdb'). Numeric fields only. |
Same trigger as Port 0 |
| 2 | out | child.register upward at init plus internal control plumbing. |
Once on init (after 100ms delay); on demand |
| in | in | Commands by msg.topic, dispatched through the commands/ registry. |
Any time another node sends a msg |
See Telemetry for the line-protocol layout and downstream wiring.
Lifecycle — what BaseNodeAdapter does
In order, in the constructor. Source: .claude/refactor/CONTRACTS.md §2.
sequenceDiagram
autonumber
participant rt as Node-RED runtime
participant nc as nodeClass (BaseNodeAdapter)
participant sc as specificClass (BaseDomain)
participant outs as Output pipeline
rt->>nc: new nodeClass(uiConfig)
nc->>nc: configManager.buildConfig(uiConfig)
nc->>nc: this.buildDomainConfig(uiConfig)
nc->>sc: new DomainClass(mergedConfig)
sc->>sc: configure() — wire ChildRouter + concerns
sc->>sc: _init?() — optional post-configure hook
Note over nc: 100ms delay
nc->>nc: emit Port 2 child.register
nc->>outs: subscribe to 'output-changed' OR start tick(N ms)
nc->>nc: start status loop (every 1000ms)
nc->>nc: attach input handler (commands dispatcher)
rt->>nc: msg.topic dispatch -> handler
nc->>sc: handler.call(source, msg, ctx)
sc->>sc: emit 'output-changed' if state shifted
outs->>rt: Port 0 + Port 1 send (delta-compressed)
Two output strategies
| Strategy | When to pick | What domain does | What adapter does |
|---|---|---|---|
| Event-driven (default) | Domain reacts to incoming events and has no genuinely time-driven math | Fire this.emitter.emit('output-changed') when public output state shifts |
Subscribes to 'output-changed'; on each fire, calls getOutput() and pushes the delta-compressed msg |
| Tick-driven (opt-in) | Domain has time-driven math — integrators, simulators, time-based thresholds | Implement tick(). Fire 'output-changed' from inside it when output state shifts |
Calls tick() every static tickInterval ms; listens to 'output-changed' the same way as event-driven |
Both strategies funnel into the same 'output-changed' → getOutput() → formatMsg → node.send pipeline.
The commands registry
Each node has src/commands/index.js exporting an array of descriptors. The base adapter builds a Map<topic | alias, descriptor> at construction. Dispatch is one lookup. Source: .claude/refactor/CONTRACTS.md §4.
module.exports = [
{
topic: 'set.demand',
aliases: ['setDemand', 'Qd'], // legacy names
units: { measure: 'volumeFlowRate', default: 'm3/h' },
payloadSchema: { type: 'number' },
description: 'Operator demand setpoint.',
handler: handlers.setDemand,
},
{
topic: 'cmd.calibrate',
payloadSchema: { type: 'none' }, // trigger-only
description: 'Trigger a one-shot calibration.',
handler: handlers.calibrate,
},
];
| Descriptor field | What it does |
|---|---|
topic |
Canonical name. See Topic Conventions. |
aliases |
Pre-refactor legacy names. First use of each fires a one-time deprecation warning. |
units |
{measure, default} — pre-dispatch unit normalisation. Handler always sees default unit. |
payloadSchema |
{type: 'string' | 'number' | 'boolean' | 'object' | 'any' | 'none'} — type-check before handler. |
description |
Free-text. Surfaced by .list() and wikiGen topic-contract autogen. |
handler |
(source, msg, ctx) => ... — pure function on the domain. |
Child registration — declarative routing
The ChildRouter declares which child softwareTypes the parent accepts and what to do with each. Source: .claude/refactor/CONTRACTS.md §5.
configure() {
this.router
.onRegister('machine', (child) => this.machines[child.id] = child)
.onRegister('measurement', (child) => this._subscribeMeasurement(child))
.onMeasurement('measurement', { type: 'pressure', position: 'upstream' },
(data, child) => this._onPressure('upstream', data));
}
sequenceDiagram
autonumber
participant child as Child nodeClass
participant reg as Parent commandRegistry
participant rt as Parent ChildRouter
participant sc as Parent specificClass
child->>reg: msg{topic: child.register, payload: {ref, softwareType}}
reg->>rt: dispatchRegister(child, softwareType)
rt->>rt: match softwareType in onRegister handlers
rt->>sc: invoke handler(child)
sc->>sc: store ref, wire emitter.on(<topic>, ...)
Who accepts what
Verified against each node's configure() in source.
| Parent | Accepted softwareTypes | Use |
|---|---|---|
| pumpingStation | measurement, machine, machinegroup, pumpingstation |
Basin sensors + pumps + groups + cascaded PS |
| machineGroupControl | machine, measurement |
Pumps + pressure sensors |
| valveGroupControl | valve, machine, machinegroup, pumpingstation, valvegroupcontrol |
Valves + four flow-source softwareTypes (peer-level, not S88 children) |
| reactor | measurement, reactor |
Sensors + upstream reactor in a chain |
| settler | measurement, reactor, machine |
Sensors + upstream reactor + return pump |
| monster | measurement |
Flow + quality sensors |
| diffuser | measurement |
DO + airflow sensors |
| rotatingMachine | measurement |
Pressure / flow / power sensors |
| valve | measurement |
Position / pressure sensors |
| dashboardAPI | any softwareType | Triggers Grafana dashboard generation per node |
Where business logic lives
Each node's src/ follows the same shape (concern modules).
nodes/<name>/
|
+-- <NodeName>.js entry
+-- <NodeName>.html editor form
+-- package.json
|
+-- src/
| <Name>NodeClass.js nodeClass (adapter)
| <Name>SpecificClass.js specificClass (orchestrator)
| |
| +-- commands/
| | index.js topic descriptors
| | handlers.js pure handler functions
| |
| +-- state/ FSM (if stateful)
| +-- <concern1>/ e.g. basin/, kinetics/, curves/
| +-- <concern2>/ e.g. safety/, dispatch/
| |
| +-- io/
| output.js getOutput() composition
| statusBadge.js getStatusBadge()
|
+-- test/
| basic/ · integration/ · edge/
|
+-- examples/
01-Basic.json · 02-Integration.json · 03-Dashboard.json
Important
The specificClass is stitching, not implementation. It instantiates concern modules in
configure()and calls them intick()or in router handlers. Concerns are individually testable; specificClass tests verify wiring, not math. Source:.claude/refactor/MODULE_SPLIT.md.
Reading order for newcomers
| # | Read | Why |
|---|---|---|
| 1 | .claude/refactor/CONTRACTS.md |
Every API shape this page summarises |
| 2 | .claude/refactor/CONVENTIONS.md |
Code style, file size, naming, imports, tests |
| 3 | .claude/refactor/MODULE_SPLIT.md |
Concern layout per node |
| 4 | pumpingStation wiki | The refactor pilot — most mature node |
| 5 | The corresponding src/ folder |
Top-down: specificClass → concern modules → handlers |
Related pages
| Page | Why |
|---|---|
| Topology Patterns | See the contracts above in action across a realistic plant |
| Topic Conventions | Full reference for set. / cmd. / data. / query. / child. / evt. |
| Telemetry | Port 0 / 1 / 2 InfluxDB schema details |
| Getting Started | First hands-on with the contracts |
EVOLV Wiki
Start here
Reference
Per-node wikis
- pumpingStation
- machineGroupControl
- valveGroupControl
- reactor
- settler
- monster
- rotatingMachine
- valve
- diffuser
- measurement
- dashboardAPI
- generalFunctions
Domain concepts
- ASM Models
- PID Control Theory
- Pump Affinity Laws
- Settling Models
- Signal Processing — Sensors
- InfluxDB Schema Design
- Wastewater Compliance NL
- OT Security IEC 62443
Operations findings
Node-RED / FlowFuse manuals
- Manual Index
- Runtime — Node.js
- Function Node Patterns
- Messages and Editor Structure
- FlowFuse ui-chart
- FlowFuse ui-button
- FlowFuse ui-gauge
- FlowFuse ui-text
- FlowFuse ui-template
- FlowFuse ui-config
- Dashboard Layout
- Widgets Catalog
Archive