- package.json: remove @tensorflow/tfjs and @tensorflow/tfjs-node. Monster's TF code was already stripped; the deps were stale and kept pulling a heavy native binary back into every install. - .gitignore: ignore .repo-mem/ regenerable indexes and per-session .claude/*.lock runtime files. - CLAUDE.md: prepend READ-FIRST pointer to .claude/rules/repo-mem.md; collapse the 'three outputs' bullet to a pointer at node-architecture. - .claude/rules/telemetry.md: drop Port 0/1/2 duplication; reference node-architecture.md. - .claude/rules/testing.md: stop requiring a separate test/edge tier and the basic/integration/edge example flow trio. Reflects what nodes actually do. - .claude/rules/repo-mem.md (new): when-to-call-which guide for the per-repo memory MCP, anti-patterns, refresh model. - .mcp.json (new): wire repo-mem stdio server. - docs/DEVELOPER_GUIDE.md (new): step-by-step guide for adding a new EVOLV node under the three-layer pattern. - Bump nodes/pumpingStation to 6ab585b (docs + simulations refresh, spill-flow path renames consistent with d8490aa). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
19 KiB
EVOLV Developer Guide: Creating a New Node
This guide walks through creating a new EVOLV node from scratch, following the project's three-layer architecture pattern.
Prerequisites
- Node.js (v18+)
- Node-RED installed globally or as a dev dependency
- Clone the repo and run
npm installin the root (no build step required) - The
generalFunctionssubmodule must be initialized (git submodule update --init)
Architecture Overview
Every EVOLV node follows a three-layer pattern:
| Layer | File | Responsibility |
|---|---|---|
| 1 - Wrapper | <name>.js |
Registers the node type with Node-RED, sets up HTTP endpoints for menus/config |
| 2 - Node Adapter | src/nodeClass.js |
Bridges Node-RED with domain logic: config loading, tick loop, input routing, lifecycle |
| 3 - Domain Logic | src/specificClass.js |
Pure business logic with no Node-RED dependencies |
Plus a UI definition: <name>.html for the Node-RED editor.
Step-by-Step: Creating a New Node
We will create a hypothetical flowMeter node as an example.
Step 1: Create Directory Structure
nodes/flowMeter/
flowMeter.js # Layer 1 - wrapper
flowMeter.html # UI definition
src/
nodeClass.js # Layer 2 - node adapter
specificClass.js # Layer 3 - domain logic
test/
specificClass.test.js
Step 2: Write the Wrapper (flowMeter.js)
The wrapper registers the node type with Node-RED and exposes HTTP endpoints for dynamic menus and config data.
const nameOfNode = 'flowMeter';
const nodeClass = require('./src/nodeClass.js');
const { MenuManager, configManager } = require('generalFunctions');
module.exports = function(RED) {
// Register the node type
RED.nodes.registerType(nameOfNode, function(config) {
RED.nodes.createNode(this, config);
this.nodeClass = new nodeClass(config, RED, this, nameOfNode);
});
// Menu endpoint (dynamic dropdowns in the editor UI)
const menuMgr = new MenuManager();
RED.httpAdmin.get(`/${nameOfNode}/menu.js`, (req, res) => {
try {
const script = menuMgr.createEndpoint(nameOfNode, ['asset', 'logger', 'position']);
res.type('application/javascript').send(script);
} catch (err) {
res.status(500).send(`// Error generating menu: ${err.message}`);
}
});
// Config data endpoint (exposes JSON config to the editor)
const cfgMgr = new configManager();
RED.httpAdmin.get(`/${nameOfNode}/configData.js`, (req, res) => {
try {
const script = cfgMgr.createEndpoint(nameOfNode);
res.type('application/javascript').send(script);
} catch (err) {
res.status(500).send(`// Error generating configData: ${err.message}`);
}
});
};
Key points:
nameOfNodemust match the file name, theregisterTypename, and the HTMLdata-template-name.- Menu categories (
['asset', 'logger', 'position']) control which shared UI sections appear. - The config endpoint is optional if you do not need dynamic config in the editor.
Step 3: Write nodeClass.js (Node Adapter)
This class bridges Node-RED's API with your domain logic.
const { outputUtils, configManager } = require('generalFunctions');
const Specific = require('./specificClass');
class nodeClass {
constructor(uiConfig, RED, nodeInstance, nameOfNode) {
this.node = nodeInstance;
this.RED = RED;
this.name = nameOfNode;
this._loadConfig(uiConfig);
this._setupSpecificClass();
this._bindEvents();
this._registerChild();
this._startTickLoop();
this._attachInputHandler();
this._attachCloseHandler();
}
_loadConfig(uiConfig) {
const cfgMgr = new configManager();
this.defaultConfig = cfgMgr.getConfig(this.name);
// buildConfig merges base sections (general, asset, functionality)
// with node-specific domain config from the UI
this.config = cfgMgr.buildConfig(this.name, uiConfig, this.node.id, {
// Add domain-specific config sections here:
flowSettings: {
maxFlow: uiConfig.maxFlow,
pipeSize: uiConfig.pipeSize,
},
});
this._output = new outputUtils();
}
_setupSpecificClass() {
this.source = new Specific(this.config);
this.node.source = this.source;
}
_bindEvents() {
// Subscribe to domain events for Node-RED status display
this.source.emitter.on('flowUpdate', (val) => {
this.node.status({ fill: 'green', shape: 'dot', text: `${val} ${this.config.general.unit}` });
});
}
_registerChild() {
// Delayed to avoid Node-RED startup race conditions
setTimeout(() => {
this.node.send([
null,
null,
{
topic: 'registerChild',
payload: this.node.id,
positionVsParent: this.config?.functionality?.positionVsParent || 'atEquipment',
distance: this.config?.functionality?.distance || null,
},
]);
}, 100);
}
_startTickLoop() {
setTimeout(() => {
this._tickInterval = setInterval(() => this._tick(), 1000);
}, 1000);
}
_tick() {
this.source.tick();
const raw = this.source.getOutput();
const processMsg = this._output.formatMsg(raw, this.config, 'process');
const influxMsg = this._output.formatMsg(raw, this.config, 'influxdb');
this.node.send([processMsg, influxMsg]);
}
_attachInputHandler() {
this.node.on('input', (msg, send, done) => {
switch (msg.topic) {
case 'measurement':
if (typeof msg.payload === 'number') {
this.source.inputValue = parseFloat(msg.payload);
}
break;
// Add more input topics as needed
}
done();
});
}
_attachCloseHandler() {
this.node.on('close', (done) => {
clearInterval(this._tickInterval);
done();
});
}
}
module.exports = nodeClass;
Essential methods every nodeClass must implement:
_loadConfig()-- merges default JSON config with UI config viaconfigManager.buildConfig()_setupSpecificClass()-- instantiates the domain class_registerChild()-- sends aregisterChildmessage on output port 2 (parent)_startTickLoop()-- drives periodic output at 1-second intervals_tick()-- callssource.getOutput()and formats viaoutputUtils.formatMsg()_attachInputHandler()-- routes incomingmsg.topicto domain methods_attachCloseHandler()-- clears timers on node removal
Step 4: Write specificClass.js (Domain Logic)
This is pure JavaScript with no Node-RED dependencies.
const EventEmitter = require('events');
const { logger, configUtils, configManager, MeasurementContainer } = require('generalFunctions');
class FlowMeter {
constructor(config = {}) {
this.emitter = new EventEmitter();
this.configManager = new configManager();
this.defaultConfig = this.configManager.getConfig('flowMeter');
this.configUtils = new configUtils(this.defaultConfig);
this.config = this.configUtils.initConfig(config);
this.logger = new logger(
this.config.general.logging.enabled,
this.config.general.logging.logLevel,
this.config.general.name
);
// MeasurementContainer stores typed/positioned values
this.measurements = new MeasurementContainer({
autoConvert: true,
windowSize: this.config.smoothing?.smoothWindow || 10,
});
this.measurements.setChildId(this.config.general.id);
this.measurements.setChildName(this.config.general.name);
// Domain state
this.currentFlow = 0;
}
tick() {
// Called every 1 second by nodeClass._tick()
this.calculateFlow();
}
calculateFlow() {
// Your domain logic here
const flow = this.currentFlow;
// Store in MeasurementContainer using the chainable API:
// .type(measType).variant(variant).position(pos).value(val, timestamp, unit)
this.measurements
.type(this.config.asset.type)
.variant('measured')
.position(this.config.functionality.positionVsParent)
.value(flow, Date.now(), this.config.asset.unit);
this.emitter.emit('flowUpdate', flow);
}
getOutput() {
return {
flow: this.currentFlow,
};
}
}
module.exports = FlowMeter;
Key patterns:
- Always create an
emitter(EventEmitter) -- parents subscribe to child events through it. - Use
MeasurementContainerfor storing measurements. The chainable API follows the pattern:measurements.type(t).variant(v).position(p).value(val, timestamp, unit). - Expose
tick()andgetOutput()for the node adapter to call. - Use
loggerinstead ofconsole.log.
Step 5: Write the HTML (UI Definition)
<script src="/flowMeter/menu.js"></script>
<script src="/flowMeter/configData.js"></script>
<script>
RED.nodes.registerType("flowMeter", {
category: "EVOLV",
color: "#a9daee", // S88 Control Module color
defaults: {
name: { value: "flowMeter" },
maxFlow: { value: 100, required: true },
pipeSize: { value: 0.1, required: true },
// Standard fields (asset, logger, position)
uuid: { value: "" },
supplier: { value: "" },
category: { value: "" },
assetType: { value: "" },
model: { value: "" },
unit: { value: "" },
enableLog: { value: false },
logLevel: { value: "error" },
positionVsParent: { value: "" },
positionIcon: { value: "" },
},
inputs: 1,
outputs: 3,
outputLabels: ["process", "dbase", "parent"],
icon: "font-awesome/fa-tachometer",
label: function() {
return this.name || "flowMeter";
},
oneditprepare: function() {
// Wait for shared menu system to initialize
const waitForMenuData = () => {
if (window.EVOLV?.nodes?.flowMeter?.initEditor) {
window.EVOLV.nodes.flowMeter.initEditor(this);
} else {
setTimeout(waitForMenuData, 50);
}
};
waitForMenuData();
},
oneditsave: function() {
if (window.EVOLV?.nodes?.flowMeter?.assetMenu?.saveEditor) {
window.EVOLV.nodes.flowMeter.assetMenu.saveEditor(this);
}
if (window.EVOLV?.nodes?.flowMeter?.loggerMenu?.saveEditor) {
window.EVOLV.nodes.flowMeter.loggerMenu.saveEditor(this);
}
if (window.EVOLV?.nodes?.flowMeter?.positionMenu?.saveEditor) {
window.EVOLV.nodes.flowMeter.positionMenu.saveEditor(this);
}
},
});
</script>
<script type="text/html" data-template-name="flowMeter">
<div class="form-row">
<label for="node-input-maxFlow"><i class="fa fa-arrows-v"></i> Max Flow</label>
<input type="number" id="node-input-maxFlow" placeholder="100" />
</div>
<div class="form-row">
<label for="node-input-pipeSize"><i class="fa fa-circle-o"></i> Pipe Size (m)</label>
<input type="number" id="node-input-pipeSize" placeholder="0.1" step="0.01" />
</div>
<!-- Shared UI sections injected by MenuManager -->
<div id="asset-fields-placeholder"></div>
<div id="logger-fields-placeholder"></div>
<div id="position-fields-placeholder"></div>
</script>
<script type="text/html" data-help-name="flowMeter">
<p><b>Flow Meter Node</b>: Measures and processes flow data.</p>
</script>
S88 color scheme (pick based on your node's hierarchy level):
| S88 Level | Color | Text Color |
|---|---|---|
| Area | #0f52a5 |
white |
| Process Cell | #0c99d9 |
white |
| Unit | #50a8d9 |
black |
| Equipment | #86bbdd |
black |
| Control Module | #a9daee |
black |
All nodes must have 3 outputs: [process, dbase, parent].
Step 6: Create Config JSON Schema
Create nodes/generalFunctions/src/configs/flowMeter.json. This defines defaults and validation rules for every config property. The configManager reads this file by node name.
{
"general": {
"name": {
"default": "FlowMeter",
"rules": { "type": "string", "description": "Human-readable name." }
},
"id": {
"default": null,
"rules": { "type": "string", "nullable": true }
},
"unit": {
"default": "m3/h",
"rules": { "type": "string" }
},
"logging": {
"logLevel": {
"default": "info",
"rules": {
"type": "enum",
"values": [
{ "value": "debug" }, { "value": "info" },
{ "value": "warn" }, { "value": "error" }
]
}
},
"enabled": { "default": true, "rules": { "type": "boolean" } }
}
},
"functionality": {
"softwareType": { "default": "flowMeter", "rules": { "type": "string" } },
"role": { "default": "Sensor", "rules": { "type": "string" } },
"positionVsParent": {
"default": "atEquipment",
"rules": {
"type": "enum",
"values": [
{ "value": "atEquipment" }, { "value": "upstream" }, { "value": "downstream" }
]
}
}
},
"asset": {
"supplier": { "default": "Unknown", "rules": { "type": "string" } },
"category": { "default": "sensor", "rules": { "type": "string" } },
"type": { "default": "flow", "rules": { "type": "string" } },
"model": { "default": "Unknown", "rules": { "type": "string" } },
"unit": { "default": "m3/h", "rules": { "type": "string" } }
}
}
Each property has a default value and a rules object specifying the type (string, number, boolean, enum, object), optional constraints (min, max, nullable), and a description.
Step 7: Register with package.json
Add your node to the root package.json under node-red.nodes:
{
"node-red": {
"nodes": {
"flowMeter": "nodes/flowMeter/flowMeter.js"
}
}
}
Restart Node-RED to pick up the new node.
Step 8: Add Tests
Create nodes/flowMeter/test/specificClass.test.js. Tests target Layer 3 (domain logic) directly, without Node-RED.
const FlowMeter = require('../src/specificClass');
function makeConfig(overrides = {}) {
const base = {
general: { name: 'TestFlow', id: 'test-1', logging: { enabled: false, logLevel: 'error' } },
functionality: { softwareType: 'flowMeter', role: 'sensor', positionVsParent: 'atEquipment' },
asset: { category: 'sensor', type: 'flow', model: 'test', supplier: 'Test', unit: 'm3/h' },
};
for (const key of Object.keys(overrides)) {
base[key] = typeof overrides[key] === 'object' ? { ...base[key], ...overrides[key] } : overrides[key];
}
return base;
}
describe('FlowMeter specificClass', () => {
it('should create an instance', () => {
const fm = new FlowMeter(makeConfig());
expect(fm).toBeDefined();
});
it('should return output with expected keys', () => {
const fm = new FlowMeter(makeConfig());
const out = fm.getOutput();
expect(out).toHaveProperty('flow');
});
it('tick() should not throw', () => {
const fm = new FlowMeter(makeConfig());
expect(() => fm.tick()).not.toThrow();
});
});
Run tests with: npm test (uses Jest). The project also supports node:test for basic smoke tests.
Test organization conventions (based on existing nodes):
test/specificClass.test.js-- unit tests for domain logictest/basic/*.test.js-- structural/smoke tests (module loads, exports exist)test/edge/*.test.js-- edge case and boundary teststest/integration/*.test.js-- multi-component integration tests
Key APIs Reference
MeasurementContainer
Chainable storage for typed, positioned measurements. Used by every domain class.
const { MeasurementContainer } = require('generalFunctions');
const mc = new MeasurementContainer({ autoConvert: true, windowSize: 10 });
mc.setChildId('node-id');
mc.setChildName('PT-001');
// Store a value
mc.type('pressure').variant('measured').position('upstream').value(3.5, Date.now(), 'bar');
// Parents subscribe to events via mc.emitter
mc.emitter.on('pressure.measured.upstream', (data) => { /* { value, unit, ... } */ });
The event name follows the pattern: {type}.{variant}.{position}.
configManager.buildConfig()
Merges the JSON config schema defaults with UI-provided values. Called in nodeClass._loadConfig().
const { configManager } = require('generalFunctions');
const cfgMgr = new configManager();
const defaults = cfgMgr.getConfig('myNode'); // loads myNode.json
const config = cfgMgr.buildConfig('myNode', uiConfig, nodeId, domainOverrides);
POSITIONS
Canonical position constants. Use these instead of hardcoded strings.
const { POSITIONS } = require('generalFunctions');
// POSITIONS.UPSTREAM = 'upstream'
// POSITIONS.DOWNSTREAM = 'downstream'
// POSITIONS.AT_EQUIPMENT = 'atEquipment'
// POSITIONS.DELTA = 'delta'
outputUtils.formatMsg()
Formats raw output data into either process or influxdb messages. Only sends changed fields.
const { outputUtils } = require('generalFunctions');
const out = new outputUtils();
const processMsg = out.formatMsg(rawData, config, 'process');
const influxMsg = out.formatMsg(rawData, config, 'influxdb');
node.send([processMsg, influxMsg]);
childRegistrationUtils
Manages parent-child node relationships. Parents use this to accept child registrations.
const { childRegistrationUtils } = require('generalFunctions');
const regUtils = new childRegistrationUtils(this); // 'this' is the parent specificClass
// Called when a child's registerChild message arrives:
regUtils.registerChild(childSource, positionVsParent, distance);
The parent's registerChild() method subscribes to the child's measurements.emitter events for data propagation.
Common Patterns
Parent-Child Registration
- Child sends
{ topic: 'registerChild', payload: nodeId, positionVsParent }on output port 2. - Parent's
nodeClass._attachInputHandler()catchesmsg.topic === 'registerChild'. - Parent calls
childRegistrationUtils.registerChild(child, position). - Parent subscribes to child's
measurements.emitterevents (e.g.,'flow.measured.downstream'). - When the child updates a measurement, the parent's listener fires and updates its own state.
Tick Loop
Every node runs a 1-second tick loop that:
- Calls
source.tick()to advance domain logic. - Calls
source.getOutput()for current state. - Formats into
processandinfluxdbmessages viaoutputUtils.formatMsg(). - Sends on ports 0 (process) and 1 (dbase).
The tick loop starts with a 1-second delay to allow child registration to complete.
Three-Output Format
All nodes send on three ports: node.send([processMsg, influxMsg, parentMsg]).
| Port | Purpose | When |
|---|---|---|
| 0 | Process data for downstream nodes | Every tick (if changed) |
| 1 | InfluxDB line protocol for persistence | Every tick (if changed) |
| 2 | Parent registration/control messages | On startup; on parent commands |
Event-Driven Communication
Nodes communicate via EventEmitter, not Node-RED wires:
measurements.emitterfires{type}.{variant}.{position}events.- Parents listen to children's emitters after registration.
- The
emitteron the specificClass itself is used for internal state changes (e.g., updating Node-RED node status display).
Checklist
Before submitting a new node, verify:
- Three-layer structure: wrapper, nodeClass, specificClass
- Config JSON in
generalFunctions/src/configs/<name>.json - Registered in root
package.jsonundernode-red.nodes - HTML registers under category
'EVOLV'with correct S88 color - Three outputs:
[process, dbase, parent] - Uses
logger(notconsole.log) - Uses
MeasurementContainerfor measurement storage - Uses
outputUtils.formatMsg()for output formatting - Tick loop cleans up in
_attachCloseHandler() - Tests exist for specificClass domain logic
- Node-specific UI fields plus shared placeholders (asset, logger, position)