Compare commits
6 Commits
4f715e8ad6
...
developmen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c091cdce9 | ||
|
|
c0be50d02c | ||
|
|
bc79de133e | ||
|
|
6c4db03aba | ||
|
|
ae30cef89c | ||
|
|
8252a5f898 |
9
.gitignore
vendored
9
.gitignore
vendored
@@ -1,5 +1,14 @@
|
|||||||
|
# Repo dev artifacts. Mirrors the deny list in .npmignore so the two stay
|
||||||
|
# in sync — anything that shouldn't be committed AND shouldn't ship in the
|
||||||
|
# npm tarball goes in both files.
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
# Local stub generated by `npm install` in the submodule directory.
|
# Local stub generated by `npm install` in the submodule directory.
|
||||||
# generalFunctions has no production deps of its own.
|
# generalFunctions has no production deps of its own.
|
||||||
package-lock.json
|
package-lock.json
|
||||||
|
|
||||||
|
*.tgz
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
.DS_Store
|
||||||
|
npm-debug.log*
|
||||||
|
|||||||
28
.npmignore
Normal file
28
.npmignore
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# === Mirrors .gitignore — items below this block are also excluded from
|
||||||
|
# the npm tarball. Kept here verbatim so npm pack doesn't fall back to
|
||||||
|
# the .gitignore inheritance (silent + surprising). ===
|
||||||
|
node_modules/
|
||||||
|
package-lock.json
|
||||||
|
*.tgz
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
.DS_Store
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# === Dev-only content the npm tarball doesn't need ===
|
||||||
|
# Tests + their harness — consumers load index.js, not the test tree.
|
||||||
|
test/
|
||||||
|
*.test.js
|
||||||
|
|
||||||
|
# Wiki / docs — useful in the repo, big in the pack.
|
||||||
|
wiki/
|
||||||
|
|
||||||
|
# One-off maintenance tooling (wiki generator, etc.) not used at runtime.
|
||||||
|
scripts/
|
||||||
|
|
||||||
|
# Project memory + IDE configs.
|
||||||
|
.claude/
|
||||||
|
.codex/
|
||||||
|
.repo-mem/
|
||||||
|
CLAUDE.md
|
||||||
|
CLAUDE.local.md
|
||||||
@@ -134,6 +134,7 @@
|
|||||||
"type": "enum",
|
"type": "enum",
|
||||||
"values": [
|
"values": [
|
||||||
{ "value": "influxdb", "description": "InfluxDB line-protocol payload (default)." },
|
{ "value": "influxdb", "description": "InfluxDB line-protocol payload (default)." },
|
||||||
|
{ "value": "frost", "description": "FROST/SensorThings CoreSync payload." },
|
||||||
{ "value": "json", "description": "Raw JSON payload." },
|
{ "value": "json", "description": "Raw JSON payload." },
|
||||||
{ "value": "csv", "description": "CSV-formatted payload." }
|
{ "value": "csv", "description": "CSV-formatted payload." }
|
||||||
],
|
],
|
||||||
@@ -148,6 +149,13 @@
|
|||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "If true, every dispatch is routed through the rendezvous planner regardless of control strategy: per-pump moves are delayed so all pumps reach their setpoint at the same wall-clock instant t* = max(eta_i). If false, all flowmovement commands fire immediately and each pump ramps at its own speed (legacy behaviour)."
|
"description": "If true, every dispatch is routed through the rendezvous planner regardless of control strategy: per-pump moves are delayed so all pumps reach their setpoint at the same wall-clock instant t* = max(eta_i). If false, all flowmovement commands fire immediately and each pump ramps at its own speed (legacy behaviour)."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"emergencyPressurePa": {
|
||||||
|
"default": null,
|
||||||
|
"rules": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Safety threshold (canonical Pa) for the rendezvous emergency bypass. While a rendezvous is in flight new setpoints are locked out and queued sequentially; if the resolved header pressure reaches this value the lock is pre-empted and the group re-plans immediately. Null/unset (the default) leaves the bypass mechanism wired but INERT — it never fires until a real threshold is configured."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"mode": {
|
"mode": {
|
||||||
|
|||||||
@@ -120,6 +120,7 @@
|
|||||||
"type": "enum",
|
"type": "enum",
|
||||||
"values": [
|
"values": [
|
||||||
{ "value": "influxdb", "description": "InfluxDB line-protocol payload (default)." },
|
{ "value": "influxdb", "description": "InfluxDB line-protocol payload (default)." },
|
||||||
|
{ "value": "frost", "description": "FROST/SensorThings CoreSync payload." },
|
||||||
{ "value": "json", "description": "Raw JSON payload." },
|
{ "value": "json", "description": "Raw JSON payload." },
|
||||||
{ "value": "csv", "description": "CSV-formatted payload." }
|
{ "value": "csv", "description": "CSV-formatted payload." }
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -166,6 +166,10 @@
|
|||||||
"value": "influxdb",
|
"value": "influxdb",
|
||||||
"description": "InfluxDB telemetry payload."
|
"description": "InfluxDB telemetry payload."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"value": "frost",
|
||||||
|
"description": "FROST/SensorThings CoreSync payload."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"value": "json",
|
"value": "json",
|
||||||
"description": "JSON payload."
|
"description": "JSON payload."
|
||||||
@@ -498,7 +502,7 @@
|
|||||||
"rules": {
|
"rules": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"min": 0,
|
"min": 0,
|
||||||
"description": "Pump-on threshold (engagement edge for stopLevel hysteresis). Demand stays at 0 % between startLevel and inflowLevel — the ramp foot is inflowLevel, not startLevel. The ramp itself scales 0 → 100 % across [inflowLevel, maxLevel]. When enableShiftedRamp is on, startLevel also serves as the bottom of the held-then-ramp curve during draining."
|
"description": "Pump-on threshold (rising-edge engagement). Pumps stay off below startLevel until level rises through it; once engaged they remain on until level drops through stopLevel (falling-edge). Also serves as the bottom of the held-then-ramp curve during draining when enableShiftedRamp is on. Independent of basin geometry: NOT clamped against inflowLevel."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"stopLevel": {
|
"stopLevel": {
|
||||||
@@ -507,7 +511,25 @@
|
|||||||
"type": "number",
|
"type": "number",
|
||||||
"nullable": true,
|
"nullable": true,
|
||||||
"min": 0,
|
"min": 0,
|
||||||
"description": "Optional pump-off threshold. When set, PS sends an explicit turnOffAllMachines command to MGC the moment level drops below stopLevel. Independent of the ramp scaling — does NOT shift where the ramp starts. Pair with a startLevel above stopLevel to get hysteresis (pumps engage at startLevel rising, disengage at stopLevel falling). Must be ≥ minLevel and ≤ startLevel. NOTE: schema default stays null so omitting stopLevel keeps the hysteresis inactive (matching levelBased.js); the editor HTML provides a realistic 0.5 m default for drag-in UX."
|
"description": "Optional pump-off threshold. When set, PS sends an explicit turnOffAllMachines command to MGC the moment level drops below stopLevel. Does NOT shape the ramp. Pair with a startLevel above stopLevel to get hysteresis (engage at startLevel rising, disengage at stopLevel falling). Must be ≥ minLevel and ≤ startLevel. NOTE: schema default stays null so omitting stopLevel keeps the hysteresis inactive; the editor HTML provides a realistic 0.5 m default for drag-in UX."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"holdLevel": {
|
||||||
|
"default": null,
|
||||||
|
"rules": {
|
||||||
|
"type": "number",
|
||||||
|
"nullable": true,
|
||||||
|
"min": 0,
|
||||||
|
"description": "Optional `0 %` ramp foot. When set, pumps engage at startLevel but hold at 0 % (= flow.min via MGC) across [startLevel, holdLevel], then ramp 0 → 100 % across [holdLevel, maxLevel]. Default null → equals startLevel, i.e. no hold band and the ramp starts immediately at startLevel. Must satisfy startLevel ≤ holdLevel ≤ maxLevel."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"deadZoneKeepAlivePercent": {
|
||||||
|
"default": 1,
|
||||||
|
"rules": {
|
||||||
|
"type": "number",
|
||||||
|
"min": 0,
|
||||||
|
"max": 100,
|
||||||
|
"description": "Percent emitted to MGC across the falling-edge keep-alive band [stopLevel, startLevel] (i.e. once engaged, while draining back below startLevel but still above stopLevel). 0 maps to flow.min; the 1 % default sits just above min so MGC keeps at least one pump rotating instead of resting at the absolute minimum."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"maxLevel": {
|
"maxLevel": {
|
||||||
|
|||||||
@@ -134,6 +134,7 @@
|
|||||||
"type": "enum",
|
"type": "enum",
|
||||||
"values": [
|
"values": [
|
||||||
{ "value": "influxdb", "description": "InfluxDB line-protocol payload (default)." },
|
{ "value": "influxdb", "description": "InfluxDB line-protocol payload (default)." },
|
||||||
|
{ "value": "frost", "description": "FROST/SensorThings CoreSync payload." },
|
||||||
{ "value": "json", "description": "Raw JSON payload." },
|
{ "value": "json", "description": "Raw JSON payload." },
|
||||||
{ "value": "csv", "description": "CSV-formatted payload." }
|
{ "value": "csv", "description": "CSV-formatted payload." }
|
||||||
],
|
],
|
||||||
@@ -459,27 +460,6 @@
|
|||||||
"description": "Predefined sequences of states for the machine."
|
"description": "Predefined sequences of states for the machine."
|
||||||
|
|
||||||
},
|
},
|
||||||
"calculationMode": {
|
|
||||||
"default": "medium",
|
|
||||||
"rules": {
|
|
||||||
"type": "enum",
|
|
||||||
"values": [
|
|
||||||
{
|
|
||||||
"value": "low",
|
|
||||||
"description": "Calculations run at fixed intervals (time-based)."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"value": "medium",
|
|
||||||
"description": "Calculations run when new setpoints arrive or measured changes occur (event-driven)."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"value": "high",
|
|
||||||
"description": "Calculations run on all event-driven info, including every movement."
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "The frequency at which calculations are performed."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"flowNumber": {
|
"flowNumber": {
|
||||||
"default": 1,
|
"default": 1,
|
||||||
"rules": {
|
"rules": {
|
||||||
|
|||||||
@@ -205,47 +205,6 @@
|
|||||||
"description": "The operational mode of the machine."
|
"description": "The operational mode of the machine."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"allowedActions":{
|
|
||||||
"default":{},
|
|
||||||
"rules": {
|
|
||||||
"type": "object",
|
|
||||||
"schema":{
|
|
||||||
"auto": {
|
|
||||||
"default": ["statusCheck", "execMovement", "execSequence", "emergencyStop"],
|
|
||||||
"rules": {
|
|
||||||
"type": "set",
|
|
||||||
"itemType": "string",
|
|
||||||
"description": "Actions allowed in auto mode."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"virtualControl": {
|
|
||||||
"default": ["statusCheck", "execMovement", "execSequence", "emergencyStop"],
|
|
||||||
"rules": {
|
|
||||||
"type": "set",
|
|
||||||
"itemType": "string",
|
|
||||||
"description": "Actions allowed in virtualControl mode."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"fysicalControl": {
|
|
||||||
"default": ["statusCheck", "emergencyStop"],
|
|
||||||
"rules": {
|
|
||||||
"type": "set",
|
|
||||||
"itemType": "string",
|
|
||||||
"description": "Actions allowed in fysicalControl mode."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"maintenance": {
|
|
||||||
"default": ["statusCheck"],
|
|
||||||
"rules": {
|
|
||||||
"type": "set",
|
|
||||||
"itemType": "string",
|
|
||||||
"description": "Actions allowed in maintenance mode."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"description": "Information about valid command sources recognized by the machine."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"allowedSources":{
|
"allowedSources":{
|
||||||
"default": {},
|
"default": {},
|
||||||
"rules": {
|
"rules": {
|
||||||
@@ -342,27 +301,6 @@
|
|||||||
},
|
},
|
||||||
"description": "Predefined sequences of states for the machine."
|
"description": "Predefined sequences of states for the machine."
|
||||||
|
|
||||||
},
|
|
||||||
"calculationMode": {
|
|
||||||
"default": "medium",
|
|
||||||
"rules": {
|
|
||||||
"type": "enum",
|
|
||||||
"values": [
|
|
||||||
{
|
|
||||||
"value": "low",
|
|
||||||
"description": "Calculations run at fixed intervals (time-based)."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"value": "medium",
|
|
||||||
"description": "Calculations run when new setpoints arrive or measured changes occur (event-driven)."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"value": "high",
|
|
||||||
"description": "Calculations run on all event-driven info, including every movement."
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "The frequency at which calculations are performed."
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,47 +176,6 @@
|
|||||||
"description": "The operational mode of the valveGroupControl."
|
"description": "The operational mode of the valveGroupControl."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"allowedActions":{
|
|
||||||
"default":{},
|
|
||||||
"rules": {
|
|
||||||
"type": "object",
|
|
||||||
"schema":{
|
|
||||||
"auto": {
|
|
||||||
"default": ["statusCheck", "execSequence", "emergencyStop", "valvePositionChange", "totalFlowChange", "valveDeltaPchange"],
|
|
||||||
"rules": {
|
|
||||||
"type": "set",
|
|
||||||
"itemType": "string",
|
|
||||||
"description": "Actions allowed in auto mode."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"virtualControl": {
|
|
||||||
"default": ["statusCheck", "execSequence", "emergencyStop", "valvePositionChange", "totalFlowChange", "valveDeltaPchange"],
|
|
||||||
"rules": {
|
|
||||||
"type": "set",
|
|
||||||
"itemType": "string",
|
|
||||||
"description": "Actions allowed in virtualControl mode."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"fysicalControl": {
|
|
||||||
"default": ["statusCheck", "emergencyStop"],
|
|
||||||
"rules": {
|
|
||||||
"type": "set",
|
|
||||||
"itemType": "string",
|
|
||||||
"description": "Actions allowed in fysicalControl mode."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"maintenance": {
|
|
||||||
"default": ["statusCheck"],
|
|
||||||
"rules": {
|
|
||||||
"type": "set",
|
|
||||||
"itemType": "string",
|
|
||||||
"description": "Actions allowed in maintenance mode."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"description": "Information about valid command sources recognized by the valve."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"allowedSources":{
|
"allowedSources":{
|
||||||
"default": {},
|
"default": {},
|
||||||
"rules": {
|
"rules": {
|
||||||
@@ -346,26 +305,5 @@
|
|||||||
},
|
},
|
||||||
"description": "Predefined sequences of states for the valveGroupControl."
|
"description": "Predefined sequences of states for the valveGroupControl."
|
||||||
|
|
||||||
},
|
|
||||||
"calculationMode": {
|
|
||||||
"default": "medium",
|
|
||||||
"rules": {
|
|
||||||
"type": "enum",
|
|
||||||
"values": [
|
|
||||||
{
|
|
||||||
"value": "low",
|
|
||||||
"description": "Calculations run at fixed intervals (time-based)."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"value": "medium",
|
|
||||||
"description": "Calculations run when new setpoints arrive or measured changes occur (event-driven)."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"value": "high",
|
|
||||||
"description": "Calculations run on all event-driven info, including every movement."
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "The frequency at which calculations are performed."
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
23
src/helper/formatters/frostFormatter.js
Normal file
23
src/helper/formatters/frostFormatter.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* FROST handoff formatter
|
||||||
|
* -----------------------
|
||||||
|
* Keeps the same structured envelope as the InfluxDB formatter so a shared
|
||||||
|
* CoreSync collector can accept existing EVOLV dbase messages without coupling
|
||||||
|
* producing nodes to FROST HTTP details.
|
||||||
|
*/
|
||||||
|
function format(measurement, metadata) {
|
||||||
|
const { fields, tags, config } = metadata;
|
||||||
|
return {
|
||||||
|
measurement,
|
||||||
|
fields,
|
||||||
|
tags: tags || {},
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
source: {
|
||||||
|
nodeId: config?.general?.id,
|
||||||
|
softwareType: config?.functionality?.softwareType,
|
||||||
|
unit: config?.general?.unit || config?.asset?.unit,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { format };
|
||||||
@@ -14,6 +14,7 @@ const influxdbFormatter = require('./influxdbFormatter');
|
|||||||
const jsonFormatter = require('./jsonFormatter');
|
const jsonFormatter = require('./jsonFormatter');
|
||||||
const csvFormatter = require('./csvFormatter');
|
const csvFormatter = require('./csvFormatter');
|
||||||
const processFormatter = require('./processFormatter');
|
const processFormatter = require('./processFormatter');
|
||||||
|
const frostFormatter = require('./frostFormatter');
|
||||||
|
|
||||||
// Built-in registry
|
// Built-in registry
|
||||||
const registry = {
|
const registry = {
|
||||||
@@ -21,6 +22,7 @@ const registry = {
|
|||||||
json: jsonFormatter,
|
json: jsonFormatter,
|
||||||
csv: csvFormatter,
|
csv: csvFormatter,
|
||||||
process: processFormatter,
|
process: processFormatter,
|
||||||
|
frost: frostFormatter,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,8 +2,16 @@ const { getFormatter } = require('./formatters');
|
|||||||
|
|
||||||
//this class will handle the output events for the node red node
|
//this class will handle the output events for the node red node
|
||||||
class OutputUtils {
|
class OutputUtils {
|
||||||
constructor() {
|
// `options.alwaysEmit` is an optional list of field keys that bypass delta
|
||||||
|
// compression: they are re-emitted on every tick even when unchanged. Use it
|
||||||
|
// sparingly for slowly-varying values that must still trace as a continuous
|
||||||
|
// line downstream (e.g. a pump's realized control position `ctrl`, which sits
|
||||||
|
// constant in steady state and otherwise produces ~1 point per long stretch —
|
||||||
|
// invisible in a Grafana timeseries with createEmpty:false). Defaults to none,
|
||||||
|
// so existing nodes keep pure delta-compression behaviour.
|
||||||
|
constructor(options = {}) {
|
||||||
this.output = {};
|
this.output = {};
|
||||||
|
this.alwaysEmit = new Set(options.alwaysEmit || []);
|
||||||
}
|
}
|
||||||
|
|
||||||
checkForChanges(output, format) {
|
checkForChanges(output, format) {
|
||||||
@@ -13,7 +21,9 @@ class OutputUtils {
|
|||||||
this.output[format] = this.output[format] || {};
|
this.output[format] = this.output[format] || {};
|
||||||
const changedFields = {};
|
const changedFields = {};
|
||||||
for (const key in output) {
|
for (const key in output) {
|
||||||
if (Object.prototype.hasOwnProperty.call(output, key) && output[key] !== this.output[format][key]) {
|
if (!Object.prototype.hasOwnProperty.call(output, key)) continue;
|
||||||
|
const forced = this.alwaysEmit.has(key) && output[key] !== undefined;
|
||||||
|
if (forced || output[key] !== this.output[format][key]) {
|
||||||
let value = output[key];
|
let value = output[key];
|
||||||
// For fields: if the value is an object (and not a Date), stringify it.
|
// For fields: if the value is an object (and not a Date), stringify it.
|
||||||
if (value !== null && typeof value === 'object' && !(value instanceof Date)) {
|
if (value !== null && typeof value === 'object' && !(value instanceof Date)) {
|
||||||
@@ -79,7 +89,13 @@ class OutputUtils {
|
|||||||
for (const key in obj) {
|
for (const key in obj) {
|
||||||
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
||||||
const value = obj[key];
|
const value = obj[key];
|
||||||
if (value !== null && typeof value === 'object' && !(value instanceof Date)) {
|
// Skip tags that carry no information. When a config field is unset,
|
||||||
|
// extractRelevantConfig hands us `undefined`; stringifying that wrote
|
||||||
|
// literal `category="undefined"` / `geoLocation="undefined"` tags that
|
||||||
|
// clutter every Grafana legend and needlessly inflate tag cardinality.
|
||||||
|
// Drop null / undefined / empty-string before they reach InfluxDB.
|
||||||
|
if (value === null || value === undefined || value === '') continue;
|
||||||
|
if (typeof value === 'object' && !(value instanceof Date)) {
|
||||||
// Recursively flatten the nested object.
|
// Recursively flatten the nested object.
|
||||||
const flatChild = this.flattenTags(value);
|
const flatChild = this.flattenTags(value);
|
||||||
for (const childKey in flatChild) {
|
for (const childKey in flatChild) {
|
||||||
@@ -104,9 +120,10 @@ class OutputUtils {
|
|||||||
// functionality properties
|
// functionality properties
|
||||||
softwareType: config.functionality?.softwareType,
|
softwareType: config.functionality?.softwareType,
|
||||||
role: config.functionality?.role,
|
role: config.functionality?.role,
|
||||||
|
positionVsParent: config.functionality?.positionVsParent,
|
||||||
// asset properties (exclude machineCurve)
|
// asset properties (exclude machineCurve)
|
||||||
uuid: config.asset?.uuid,
|
uuid: config.asset?.uuid,
|
||||||
tagcode: config.asset?.tagcode,
|
tagcode: config.asset?.tagCode || config.asset?.tagcode,
|
||||||
geoLocation: config.asset?.geoLocation,
|
geoLocation: config.asset?.geoLocation,
|
||||||
category: config.asset?.category,
|
category: config.asset?.category,
|
||||||
type: config.asset?.type,
|
type: config.asset?.type,
|
||||||
|
|||||||
@@ -82,7 +82,9 @@ class BaseNodeAdapter {
|
|||||||
// pumpingStation/measurement nodeClass _attachInputHandler patterns.
|
// pumpingStation/measurement nodeClass _attachInputHandler patterns.
|
||||||
this.node.source = this.source;
|
this.node.source = this.source;
|
||||||
|
|
||||||
this._output = new OutputUtils();
|
// `static alwaysEmitFields = ['ctrl', …]` on a subclass exempts those
|
||||||
|
// fields from delta compression so they trace continuously downstream.
|
||||||
|
this._output = new OutputUtils({ alwaysEmit: ctor.alwaysEmitFields });
|
||||||
const userHasUnitsQuery = ctor.commands.some(
|
const userHasUnitsQuery = ctor.commands.some(
|
||||||
(c) => c && (c.topic === 'query.units' || (Array.isArray(c.aliases) && c.aliases.includes('query.units'))));
|
(c) => c && (c.topic === 'query.units' || (Array.isArray(c.aliases) && c.aliases.includes('query.units'))));
|
||||||
const mergedCommands = userHasUnitsQuery
|
const mergedCommands = userHasUnitsQuery
|
||||||
|
|||||||
@@ -79,65 +79,70 @@ class movementManager {
|
|||||||
// Clamp the final target into [minPosition, maxPosition]
|
// Clamp the final target into [minPosition, maxPosition]
|
||||||
targetPosition = this.constrain(targetPosition);
|
targetPosition = this.constrain(targetPosition);
|
||||||
|
|
||||||
// Compute direction and remaining distance
|
// Snapshot the starting point. Position is derived from ELAPSED WALL-TIME
|
||||||
const direction = targetPosition > this.currentPosition ? 1 : -1;
|
// (not accumulated per-tick steps) so an interruption that lands between
|
||||||
const distance = Math.abs(targetPosition - this.currentPosition);
|
// ticks — or before the very first tick — still leaves currentPosition at
|
||||||
|
// the real distance travelled. A fast re-commanding parent (e.g. MGC
|
||||||
|
// updating demand every tick) then re-bases from the true position instead
|
||||||
|
// of freezing at the start. See _settleAt / the abort handler below.
|
||||||
|
const startPosition = this.currentPosition;
|
||||||
|
const direction = targetPosition > startPosition ? 1 : -1;
|
||||||
|
const distance = Math.abs(targetPosition - startPosition);
|
||||||
|
|
||||||
const velocity = this.getVelocity(); // units per second
|
const velocity = this.getVelocity(); // units per second
|
||||||
if (velocity <= 0) {
|
if (velocity <= 0) {
|
||||||
return reject(new Error("Movement aborted: zero speed"));
|
return reject(new Error("Movement aborted: zero speed"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Duration and bookkeeping
|
const duration = distance / velocity; // seconds to go the full distance
|
||||||
const duration = distance / velocity; // seconds to go the remaining distance
|
|
||||||
this.timeleft = duration;
|
this.timeleft = duration;
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Linear move: dir=${direction}, dist=${distance}, vel=${velocity.toFixed(2)} u/s, dur=${duration.toFixed(2)}s`
|
`Linear move: dir=${direction}, dist=${distance}, vel=${velocity.toFixed(2)} u/s, dur=${duration.toFixed(2)}s`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Compute how much to move each tick
|
|
||||||
const intervalMs = this.interval;
|
const intervalMs = this.interval;
|
||||||
const intervalSec = intervalMs / 1000;
|
|
||||||
const stepSize = direction * velocity * intervalSec;
|
|
||||||
|
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
// Position reached after `elapsedSec` of travel, clamped to the target.
|
||||||
|
const posAt = (elapsedSec) =>
|
||||||
|
this.constrain(startPosition + direction * Math.min(distance, velocity * elapsedSec));
|
||||||
|
// Re-base currentPosition (and timeleft) onto the real elapsed progress.
|
||||||
|
const settle = () => {
|
||||||
|
const elapsed = (Date.now() - startTime) / 1000;
|
||||||
|
this.currentPosition = posAt(elapsed);
|
||||||
|
this.timeleft = Math.max(0, duration - elapsed);
|
||||||
|
this.emitPos(this.currentPosition);
|
||||||
|
return elapsed;
|
||||||
|
};
|
||||||
|
|
||||||
// Kick off the loop
|
// Kick off the loop
|
||||||
const intervalId = setInterval(() => {
|
const intervalId = setInterval(() => {
|
||||||
// 7a) Abort check
|
|
||||||
if (signal?.aborted) {
|
if (signal?.aborted) {
|
||||||
clearInterval(intervalId);
|
clearInterval(intervalId);
|
||||||
|
settle();
|
||||||
return reject(new Error("Movement aborted"));
|
return reject(new Error("Movement aborted"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Advance position and clamp
|
const elapsed = settle();
|
||||||
this.currentPosition += stepSize;
|
|
||||||
this.currentPosition = this.constrain(this.currentPosition);
|
|
||||||
this.emitPos(this.currentPosition);
|
|
||||||
|
|
||||||
// Update timeleft
|
|
||||||
const elapsed = (Date.now() - startTime) / 1000;
|
|
||||||
this.timeleft = Math.max(0, duration - elapsed);
|
|
||||||
|
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`pos=${this.currentPosition.toFixed(2)}, timeleft=${this.timeleft.toFixed(2)}`
|
`pos=${this.currentPosition.toFixed(2)}, timeleft=${this.timeleft.toFixed(2)}`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Completed the move?
|
// Completed the move? (time-based so it can't overshoot/undershoot)
|
||||||
if (
|
if (elapsed >= duration) {
|
||||||
(direction > 0 && this.currentPosition >= targetPosition) ||
|
|
||||||
(direction < 0 && this.currentPosition <= targetPosition)
|
|
||||||
) {
|
|
||||||
clearInterval(intervalId);
|
clearInterval(intervalId);
|
||||||
this.currentPosition = targetPosition;
|
this.currentPosition = targetPosition;
|
||||||
|
this.timeleft = 0;
|
||||||
this.emitPos(this.currentPosition);
|
this.emitPos(this.currentPosition);
|
||||||
return resolve("Reached target move.");
|
return resolve("Reached target move.");
|
||||||
}
|
}
|
||||||
}, intervalMs);
|
}, intervalMs);
|
||||||
|
|
||||||
// 8) Also catch aborts that happen before the first tick
|
// Catch aborts that happen between ticks (incl. before the first tick):
|
||||||
|
// capture the partial progress so the move re-bases instead of freezing.
|
||||||
signal?.addEventListener("abort", () => {
|
signal?.addEventListener("abort", () => {
|
||||||
clearInterval(intervalId);
|
clearInterval(intervalId);
|
||||||
|
settle();
|
||||||
reject(new Error("Movement aborted"));
|
reject(new Error("Movement aborted"));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -213,8 +218,8 @@ class movementManager {
|
|||||||
return reject(new Error("Movement aborted"));
|
return reject(new Error("Movement aborted"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalDistance = Math.abs(targetPosition - this.currentPosition);
|
|
||||||
const startPosition = this.currentPosition;
|
const startPosition = this.currentPosition;
|
||||||
|
const totalDistance = Math.abs(targetPosition - this.currentPosition);
|
||||||
const velocity = this.getVelocity();
|
const velocity = this.getVelocity();
|
||||||
if (velocity <= 0) {
|
if (velocity <= 0) {
|
||||||
return reject(new Error("Movement aborted: zero speed"));
|
return reject(new Error("Movement aborted: zero speed"));
|
||||||
@@ -223,45 +228,53 @@ class movementManager {
|
|||||||
const easeFunction = (t) =>
|
const easeFunction = (t) =>
|
||||||
t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
||||||
|
|
||||||
let elapsedTime = 0;
|
|
||||||
const duration = totalDistance / velocity;
|
const duration = totalDistance / velocity;
|
||||||
this.timeleft = duration;
|
this.timeleft = duration;
|
||||||
const interval = this.interval;
|
const interval = this.interval;
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
// Position from ELAPSED WALL-TIME (eased), so an interruption between
|
||||||
|
// ticks re-bases from the real position rather than freezing at the
|
||||||
|
// start — same rationale as moveLinear.
|
||||||
|
const posAt = (elapsedSec) => {
|
||||||
|
const progress = duration > 0 ? Math.min(elapsedSec / duration, 1) : 1;
|
||||||
|
return startPosition + (targetPosition - startPosition) * easeFunction(progress);
|
||||||
|
};
|
||||||
|
const settle = () => {
|
||||||
|
const elapsed = (Date.now() - startTime) / 1000;
|
||||||
|
this.currentPosition = posAt(elapsed);
|
||||||
|
this.timeleft = Math.max(0, duration - elapsed);
|
||||||
|
this.emitPos(this.currentPosition);
|
||||||
|
return elapsed;
|
||||||
|
};
|
||||||
|
|
||||||
// 2) Start the moving loop
|
// 2) Start the moving loop
|
||||||
const intervalId = setInterval(() => {
|
const intervalId = setInterval(() => {
|
||||||
// 3) Check for abort on each tick
|
// 3) Check for abort on each tick
|
||||||
if (signal?.aborted) {
|
if (signal?.aborted) {
|
||||||
clearInterval(intervalId);
|
clearInterval(intervalId);
|
||||||
|
settle();
|
||||||
return reject(new Error("Movement aborted"));
|
return reject(new Error("Movement aborted"));
|
||||||
}
|
}
|
||||||
|
|
||||||
elapsedTime += interval / 1000;
|
const elapsed = settle();
|
||||||
const progress = Math.min(elapsedTime / duration, 1);
|
|
||||||
this.timeleft = duration - elapsedTime;
|
|
||||||
const easedProgress = easeFunction(progress);
|
|
||||||
const newPosition =
|
|
||||||
startPosition + (targetPosition - startPosition) * easedProgress;
|
|
||||||
|
|
||||||
this.emitPos(newPosition);
|
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Using ${this.movementMode} => Progress=${progress.toFixed(
|
`Using ${this.movementMode} => elapsed=${elapsed.toFixed(2)}s, pos=${this.currentPosition.toFixed(2)}`
|
||||||
2
|
|
||||||
)}, Eased=${easedProgress.toFixed(2)}`
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (progress >= 1) {
|
if (elapsed >= duration) {
|
||||||
clearInterval(intervalId);
|
clearInterval(intervalId);
|
||||||
this.currentPosition = targetPosition;
|
this.currentPosition = targetPosition;
|
||||||
|
this.timeleft = 0;
|
||||||
|
this.emitPos(this.currentPosition);
|
||||||
resolve(`Reached target move.`);
|
resolve(`Reached target move.`);
|
||||||
} else {
|
|
||||||
this.currentPosition = newPosition;
|
|
||||||
}
|
}
|
||||||
}, interval);
|
}, interval);
|
||||||
|
|
||||||
// 4) Also listen once for abort before first tick
|
// 4) Capture partial progress on aborts between/before ticks.
|
||||||
signal?.addEventListener("abort", () => {
|
signal?.addEventListener("abort", () => {
|
||||||
clearInterval(intervalId);
|
clearInterval(intervalId);
|
||||||
|
settle();
|
||||||
reject(new Error("Movement aborted"));
|
reject(new Error("Movement aborted"));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
78
test/movement-manager.test.js
Normal file
78
test/movement-manager.test.js
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const EventEmitter = require('events');
|
||||||
|
|
||||||
|
const MovementManager = require('../src/state/movementManager');
|
||||||
|
|
||||||
|
const noopLogger = { debug() {}, info() {}, warn() {}, error() {} };
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
function makeManager({ mode = 'staticspeed', speed = 50, interval = 1000, initial = 0 } = {}) {
|
||||||
|
// speed%/s on a 0..100 range → velocity = speed %/s. interval defaults to the
|
||||||
|
// production 1000ms so the abort-before-first-tick race is reproduced exactly.
|
||||||
|
return new MovementManager(
|
||||||
|
{
|
||||||
|
position: { min: 0, max: 100, initial },
|
||||||
|
movement: { mode, speed, maxSpeed: 1000, interval },
|
||||||
|
},
|
||||||
|
noopLogger,
|
||||||
|
new EventEmitter(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regression: before the time-based fix, currentPosition only advanced inside
|
||||||
|
// setInterval(…, interval). An abort landing before the first tick (the MGC's
|
||||||
|
// ~1s re-command cadence vs the 1000ms tick) left the pump frozen at the start.
|
||||||
|
for (const mode of ['staticspeed', 'dynspeed']) {
|
||||||
|
test(`${mode}: abort before the first tick still advances position (no freeze)`, async () => {
|
||||||
|
const mgr = makeManager({ mode, speed: 50, interval: 1000 });
|
||||||
|
const ac = new AbortController();
|
||||||
|
const moving = mgr.moveTo(80, ac.signal); // ~1.6s of travel; first tick at 1000ms
|
||||||
|
await sleep(200); // interrupt well before the first tick
|
||||||
|
ac.abort();
|
||||||
|
await moving;
|
||||||
|
const pos = mgr.getCurrentPosition();
|
||||||
|
// The fix: any non-zero progress means the abort re-based instead of
|
||||||
|
// freezing at the start. (dynspeed eases in, so its early travel is small
|
||||||
|
// but must still be > 0; staticspeed travels ~velocity·elapsed.)
|
||||||
|
assert.ok(pos > 0, `expected partial progress, got frozen at ${pos}`);
|
||||||
|
assert.ok(pos < 80, `should not have reached target, got ${pos}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`${mode}: a fresh setpoint re-bases from the interrupted position`, async () => {
|
||||||
|
const mgr = makeManager({ mode, speed: 50, interval: 1000 });
|
||||||
|
const ac1 = new AbortController();
|
||||||
|
const m1 = mgr.moveTo(80, ac1.signal);
|
||||||
|
await sleep(200);
|
||||||
|
ac1.abort();
|
||||||
|
await m1;
|
||||||
|
const afterFirst = mgr.getCurrentPosition();
|
||||||
|
|
||||||
|
// New command toward 0 must start from afterFirst, not from 80 or a reset.
|
||||||
|
const ac2 = new AbortController();
|
||||||
|
const m2 = mgr.moveTo(0, ac2.signal);
|
||||||
|
await sleep(100);
|
||||||
|
ac2.abort();
|
||||||
|
await m2;
|
||||||
|
const afterSecond = mgr.getCurrentPosition();
|
||||||
|
assert.ok(afterSecond < afterFirst, `expected re-base downward from ${afterFirst}, got ${afterSecond}`);
|
||||||
|
assert.ok(afterSecond >= 0, `position must stay in range, got ${afterSecond}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('staticspeed: an uninterrupted move reaches the exact target', async () => {
|
||||||
|
const mgr = makeManager({ mode: 'staticspeed', speed: 500, interval: 10 }); // fast
|
||||||
|
await mgr.moveTo(40, new AbortController().signal);
|
||||||
|
assert.equal(mgr.getCurrentPosition(), 40);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('position is clamped to [min,max] on a re-based abort', async () => {
|
||||||
|
const mgr = makeManager({ mode: 'staticspeed', speed: 5000, interval: 1000, initial: 0 });
|
||||||
|
const ac = new AbortController();
|
||||||
|
const moving = mgr.moveTo(100, ac.signal);
|
||||||
|
await sleep(150);
|
||||||
|
ac.abort();
|
||||||
|
await moving;
|
||||||
|
const pos = mgr.getCurrentPosition();
|
||||||
|
assert.ok(pos >= 0 && pos <= 100, `clamped, got ${pos}`);
|
||||||
|
});
|
||||||
@@ -8,7 +8,7 @@ const config = {
|
|||||||
general: { id: 'abc', unit: 'mbar' },
|
general: { id: 'abc', unit: 'mbar' },
|
||||||
asset: {
|
asset: {
|
||||||
uuid: 'u1',
|
uuid: 'u1',
|
||||||
tagcode: 't1',
|
tagCode: 't1',
|
||||||
geoLocation: { lat: 51.6, lon: 4.7 },
|
geoLocation: { lat: 51.6, lon: 4.7 },
|
||||||
category: 'measurement',
|
category: 'measurement',
|
||||||
type: 'pressure',
|
type: 'pressure',
|
||||||
@@ -30,6 +30,35 @@ test('process format emits message with changed fields only', () => {
|
|||||||
assert.deepEqual(third.payload, { b: 3, c: JSON.stringify({ x: 1 }) });
|
assert.deepEqual(third.payload, { b: 3, c: JSON.stringify({ x: 1 }) });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('alwaysEmit fields bypass delta compression (re-emitted while unchanged)', () => {
|
||||||
|
const out = new OutputUtils({ alwaysEmit: ['ctrl'] });
|
||||||
|
|
||||||
|
const first = out.formatMsg({ ctrl: 40, flow: 12 }, config, 'influxdb');
|
||||||
|
assert.deepEqual(first.payload.fields, { ctrl: 40, flow: 12 });
|
||||||
|
|
||||||
|
// flow unchanged → dropped; ctrl unchanged but forced → still emitted.
|
||||||
|
const second = out.formatMsg({ ctrl: 40, flow: 12 }, config, 'influxdb');
|
||||||
|
assert.deepEqual(second.payload.fields, { ctrl: 40 });
|
||||||
|
|
||||||
|
// ctrl changed → emitted with its new value.
|
||||||
|
const third = out.formatMsg({ ctrl: 41, flow: 12 }, config, 'influxdb');
|
||||||
|
assert.deepEqual(third.payload.fields, { ctrl: 41 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('alwaysEmit is per-format and does not force a missing/undefined field', () => {
|
||||||
|
const out = new OutputUtils({ alwaysEmit: ['ctrl'] });
|
||||||
|
// ctrl absent from the output → nothing to force; with no other change the
|
||||||
|
// message is suppressed as usual.
|
||||||
|
out.formatMsg({ flow: 5 }, config, 'influxdb');
|
||||||
|
assert.equal(out.formatMsg({ flow: 5 }, config, 'influxdb'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('default OutputUtils keeps pure delta compression (no alwaysEmit)', () => {
|
||||||
|
const out = new OutputUtils();
|
||||||
|
out.formatMsg({ ctrl: 40 }, config, 'influxdb');
|
||||||
|
assert.equal(out.formatMsg({ ctrl: 40 }, config, 'influxdb'), null);
|
||||||
|
});
|
||||||
|
|
||||||
test('influx format flattens tags and stringifies tag values', () => {
|
test('influx format flattens tags and stringifies tag values', () => {
|
||||||
const out = new OutputUtils();
|
const out = new OutputUtils();
|
||||||
const msg = out.formatMsg({ value: 10 }, config, 'influxdb');
|
const msg = out.formatMsg({ value: 10 }, config, 'influxdb');
|
||||||
@@ -38,5 +67,41 @@ test('influx format flattens tags and stringifies tag values', () => {
|
|||||||
assert.equal(msg.payload.measurement, 'measurement_abc');
|
assert.equal(msg.payload.measurement, 'measurement_abc');
|
||||||
assert.equal(msg.payload.tags.geoLocation_lat, '51.6');
|
assert.equal(msg.payload.tags.geoLocation_lat, '51.6');
|
||||||
assert.equal(msg.payload.tags.geoLocation_lon, '4.7');
|
assert.equal(msg.payload.tags.geoLocation_lon, '4.7');
|
||||||
|
assert.equal(msg.payload.tags.tagcode, 't1');
|
||||||
assert.ok(msg.payload.timestamp instanceof Date);
|
assert.ok(msg.payload.timestamp instanceof Date);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('influx format omits tags whose config value is unset', () => {
|
||||||
|
const out = new OutputUtils();
|
||||||
|
// No asset block at all: uuid/tagcode/geoLocation/category/type/model are
|
||||||
|
// all undefined and must NOT appear as `="undefined"` tags.
|
||||||
|
const sparse = {
|
||||||
|
functionality: { softwareType: 'measurement' },
|
||||||
|
general: { id: 'abc' },
|
||||||
|
};
|
||||||
|
const msg = out.formatMsg({ value: 10 }, sparse, 'influxdb');
|
||||||
|
|
||||||
|
for (const t of ['geoLocation', 'category', 'type', 'model', 'uuid', 'tagcode', 'unit', 'role']) {
|
||||||
|
assert.ok(!(t in msg.payload.tags), `tag "${t}" should be omitted when unset, got "${msg.payload.tags[t]}"`);
|
||||||
|
}
|
||||||
|
// Tags that DO have values still come through.
|
||||||
|
assert.equal(msg.payload.tags.id, 'abc');
|
||||||
|
assert.equal(msg.payload.tags.softwareType, 'measurement');
|
||||||
|
// Nothing should stringify to the literal "undefined".
|
||||||
|
for (const v of Object.values(msg.payload.tags)) {
|
||||||
|
assert.notEqual(v, 'undefined');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('influx format drops empty-string tag values too', () => {
|
||||||
|
const out = new OutputUtils();
|
||||||
|
const cfg = {
|
||||||
|
functionality: { softwareType: 'pump', role: '' },
|
||||||
|
general: { id: 'p1' },
|
||||||
|
asset: { category: '', model: 'M9' },
|
||||||
|
};
|
||||||
|
const msg = out.formatMsg({ value: 1 }, cfg, 'influxdb');
|
||||||
|
assert.ok(!('role' in msg.payload.tags));
|
||||||
|
assert.ok(!('category' in msg.payload.tags));
|
||||||
|
assert.equal(msg.payload.tags.model, 'M9');
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user