[ { "id": "cse_tab", "type": "tab", "label": "CoreSync FROST + Influx + Grafana", "disabled": false, "info": "Measurement and rotatingMachine telemetry are written as raw samples to local InfluxDB and as CoreSync-reduced knots to FROST and InfluxDB." }, { "id": "cse_note", "type": "comment", "z": "cse_tab", "name": "Set FROST_USER and FROST_PASSWORD in the Node-RED environment before deploying.", "info": "CoreSync emits FROST-ready HTTP requests. The HTTP request response is fed back with topic=frost.response so metadata lookup/create can continue.", "x": 450, "y": 40, "wires": [] }, { "id": "cse_ui_base", "type": "ui-base", "name": "EVOLV CoreSync", "path": "/dashboard", "appIcon": "", "includeClientData": true, "acceptsClientConfig": [ "ui-notification", "ui-control" ], "showPathInSidebar": false, "headerContent": "page", "navigationStyle": "default", "titleBarStyle": "default" }, { "id": "cse_ui_theme", "type": "ui-theme", "name": "CoreSync Theme", "colors": { "surface": "#ffffff", "primary": "#2364aa", "bgPage": "#f3f5f7", "groupBg": "#ffffff", "groupOutline": "#d5dce3" }, "sizes": { "density": "compact", "pagePadding": "12px", "groupGap": "12px", "groupBorderRadius": "6px", "widgetGap": "8px" } }, { "id": "cse_ui_page", "type": "ui-page", "name": "CoreSync FROST", "ui": "cse_ui_base", "path": "/coresync-frost", "icon": "timeline", "layout": "grid", "theme": "cse_ui_theme", "breakpoints": [ { "name": "Default", "px": "0", "cols": "12" } ], "order": 1, "className": "" }, { "id": "cse_ui_group_kpi", "type": "ui-group", "name": "Reduction Counters", "page": "cse_ui_page", "width": "12", "height": "1", "order": 1, "showTitle": true, "className": "" }, { "id": "cse_ui_group_chart", "type": "ui-group", "name": "Write Rates", "page": "cse_ui_page", "width": "12", "height": "1", "order": 2, "showTitle": true, "className": "" }, { "id": "cse_measure_flow", "type": "measurement", "z": "cse_tab", "name": "FROST Flow Sensor FT-101", "scaling": false, "i_min": 0, "i_max": 120, "i_offset": 0, "o_min": 0, "o_max": 120, "simulator": false, "smooth_method": "none", "count": 1, "processOutputFormat": "process", "dbaseOutputFormat": "frost", "uuid": "", "assetTagCode": "FT-101", "tagCode": "FT-101", "supplier": "demo", "category": "sensor", "assetType": "flow", "model": "demo-flow-transmitter", "unit": "m3/h", "enableLog": false, "logLevel": "error", "positionVsParent": "upstream", "positionIcon": "", "hasDistance": false, "distance": 0, "distanceUnit": "m", "distanceDescription": "", "x": 560, "y": 160, "wires": [ [ "cse_dbg_measure_process" ], [ "cse_fn_measure_to_coresync", "cse_fn_raw_influx" ], [ "cse_rm_pump" ] ] }, { "id": "cse_inj_measure_loop", "type": "inject", "z": "cse_tab", "name": "1 Hz directional flow", "props": [ { "p": "payload" } ], "repeat": "1", "crontab": "", "once": true, "onceDelay": "1", "topic": "", "payload": "", "payloadType": "date", "x": 160, "y": 160, "wires": [ [ "cse_fn_measure_triangle" ] ] }, { "id": "cse_fn_measure_triangle", "type": "function", "z": "cse_tab", "name": "triangle 0..100..0", "func": "let i = context.get('i') || 0;\nconst phase = i % 80;\nconst value = phase <= 40 ? phase * 2.5 : (80 - phase) * 2.5;\ncontext.set('i', i + 1);\nmsg.topic = 'measurement';\nmsg.payload = Math.round(value * 100) / 100;\nreturn msg;", "outputs": 1, "noerr": 0, "x": 360, "y": 160, "wires": [ [ "cse_measure_flow" ] ] }, { "id": "cse_fn_measure_to_coresync", "type": "function", "z": "cse_tab", "name": "mAbs -> flow.measured.upstream.FT-101", "func": "const p = msg.payload;\nif (!p || !p.fields) return null;\nconst value = Number(p.fields.mAbs);\nif (!Number.isFinite(value)) return null;\nmsg.payload = {\n measurement: 'FT-101',\n fields: { 'flow.measured.upstream.FT-101': value },\n tags: { ...(p.tags || {}), tagcode: 'P-101', sensorTag: 'FT-101', unit: 'm3/h' },\n timestamp: p.timestamp || new Date().toISOString(),\n source: { ...(p.source || {}), softwareType: 'measurement', unit: 'm3/h' }\n};\nmsg.topic = msg.payload.measurement;\nreturn msg;", "outputs": 1, "noerr": 0, "x": 850, "y": 140, "wires": [ [ "cse_coresync" ] ] }, { "id": "cse_rm_pump", "type": "rotatingMachine", "z": "cse_tab", "name": "FROST Pump P-101", "speed": "25", "startup": "1", "warmup": "1", "shutdown": "1", "cooldown": "1", "movementMode": "staticspeed", "machineCurve": "", "processOutputFormat": "process", "dbaseOutputFormat": "frost", "uuid": "", "assetTagCode": "P-101", "assetTagNumber": "P-101", "supplier": "", "category": "", "assetType": "", "model": "hidrostal-H05K-S03R", "unit": "m3/h", "enableLog": false, "logLevel": "error", "positionVsParent": "atEquipment", "positionIcon": "", "hasDistance": false, "distance": "", "distanceUnit": "m", "distanceDescription": "", "x": 560, "y": 360, "wires": [ [ "cse_dbg_rm_process" ], [ "cse_coresync", "cse_fn_raw_influx" ], [ "cse_dbg_rm_parent" ] ] }, { "id": "cse_inj_rm_mode", "type": "inject", "z": "cse_tab", "name": "RM mode virtualControl", "props": [ { "p": "topic", "vt": "str" }, { "p": "payload", "vt": "str" } ], "repeat": "", "crontab": "", "once": true, "onceDelay": "0.5", "topic": "setMode", "payload": "virtualControl", "payloadType": "str", "x": 180, "y": 280, "wires": [ [ "cse_rm_pump" ] ] }, { "id": "cse_inj_rm_start", "type": "inject", "z": "cse_tab", "name": "RM startup", "props": [ { "p": "payload", "vt": "str" } ], "repeat": "", "crontab": "", "once": true, "onceDelay": "1.5", "topic": "", "payload": "startup", "payloadType": "str", "x": 150, "y": 320, "wires": [ [ "cse_fn_rm_sequence" ] ] }, { "id": "cse_fn_rm_sequence", "type": "function", "z": "cse_tab", "name": "execSequence", "func": "msg.topic = 'execSequence';\nmsg.payload = { source: 'GUI', action: 'execSequence', parameter: msg.payload };\nreturn msg;", "outputs": 1, "noerr": 0, "x": 340, "y": 320, "wires": [ [ "cse_rm_pump" ] ] }, { "id": "cse_inj_rm_setpoint", "type": "inject", "z": "cse_tab", "name": "RM 5s setpoint wave", "props": [ { "p": "payload" } ], "repeat": "5", "crontab": "", "once": true, "onceDelay": "3", "topic": "", "payload": "", "payloadType": "date", "x": 170, "y": 380, "wires": [ [ "cse_fn_rm_setpoint" ] ] }, { "id": "cse_fn_rm_setpoint", "type": "function", "z": "cse_tab", "name": "execMovement wave", "func": "let i = context.get('i') || 0;\nconst values = [25, 55, 85, 60, 35];\nconst setpoint = values[i % values.length];\ncontext.set('i', i + 1);\nmsg.topic = 'execMovement';\nmsg.payload = { source: 'GUI', action: 'execMovement', setpoint };\nreturn msg;", "outputs": 1, "noerr": 0, "x": 360, "y": 380, "wires": [ [ "cse_rm_pump" ] ] }, { "id": "cse_inj_rm_pressure", "type": "inject", "z": "cse_tab", "name": "RM pressure cycle", "props": [ { "p": "payload" } ], "repeat": "2", "crontab": "", "once": true, "onceDelay": "1", "topic": "", "payload": "", "payloadType": "date", "x": 160, "y": 440, "wires": [ [ "cse_fn_rm_pressure" ] ] }, { "id": "cse_fn_rm_pressure", "type": "function", "z": "cse_tab", "name": "simulate upstream/downstream pressure", "func": "let i = context.get('i') || 0;\nconst up = 900 + ((i % 6) * 10);\nconst down = 1700 + ((i % 6) * 40);\ncontext.set('i', i + 1);\nreturn [\n { topic: 'simulateMeasurement', payload: { type: 'pressure', position: 'upstream', value: up, unit: 'mbar' } },\n { topic: 'simulateMeasurement', payload: { type: 'pressure', position: 'downstream', value: down, unit: 'mbar' } }\n];", "outputs": 2, "noerr": 0, "x": 390, "y": 440, "wires": [ [ "cse_rm_pump" ], [ "cse_rm_pump" ] ] }, { "id": "cse_coresync", "type": "coresync", "z": "cse_tab", "name": "CoreSync to WBD FROST", "frostBaseUrl": "https://sta.wbd-rd.nl/FROST-Server/", "serviceVersion": "v1.1", "dbaseFormat": "frost", "assetTagOverride": "", "sensorTagOverride": "", "comparisonMode": "angle", "angleToleranceDeg": 1, "timeScaleMs": 1000, "maxGapMs": 30000, "minDeltaTimeMs": 0, "minDeltaValue": 0, "burstWindowMs": 10, "maxQueuedObservationsPerStream": 2, "diagnosticsEnabled": false, "x": 1120, "y": 260, "wires": [ [ "cse_dbg_coresync_process" ], [ "cse_fn_frost_auth", "cse_fn_knot_influx" ], [ "cse_dbg_coresync_parent" ] ] }, { "id": "cse_inj_flush", "type": "inject", "z": "cse_tab", "name": "CoreSync flush 15s", "props": [ { "p": "topic", "vt": "str" } ], "repeat": "15", "crontab": "", "once": false, "onceDelay": "", "topic": "coresync.flush", "x": 860, "y": 300, "wires": [ [ "cse_coresync" ] ] }, { "id": "cse_fn_frost_auth", "type": "function", "z": "cse_tab", "name": "FROST basic auth from env", "func": "const user = env.get('FROST_USER');\nconst password = env.get('FROST_PASSWORD');\nif (!user || !password) {\n node.status({ fill: 'red', shape: 'ring', text: 'missing FROST_USER/FROST_PASSWORD' });\n return null;\n}\nmsg.headers = { ...(msg.headers || {}), Authorization: 'Basic ' + Buffer.from(`${user}:${password}`).toString('base64') };\nnode.status({ fill: 'green', shape: 'dot', text: msg.topic });\nreturn msg;", "outputs": 1, "noerr": 0, "x": 1420, "y": 220, "wires": [ [ "cse_http_frost" ] ] }, { "id": "cse_http_frost", "type": "http request", "z": "cse_tab", "name": "Send to FROST", "method": "use", "ret": "txt", "paytoqs": false, "url": "", "persist": false, "authType": "", "senderr": false, "x": 1630, "y": 220, "wires": [ [ "cse_fn_frost_response" ] ] }, { "id": "cse_fn_frost_response", "type": "function", "z": "cse_tab", "name": "topic=frost.response", "func": "if (typeof msg.payload === 'string') {\n try { msg.payload = JSON.parse(msg.payload); } catch (_) { /* keep raw */ }\n}\nconst metric = (msg._coreSync?.kind === 'observation' && msg.statusCode && msg.statusCode < 400)\n ? { topic: 'frostObservation', payload: 1 }\n : null;\nmsg.topic = 'frost.response';\nreturn [msg, metric];", "outputs": 2, "noerr": 0, "x": 1840, "y": 220, "wires": [ [ "cse_coresync", "cse_dbg_frost_response" ], [ "cse_fn_reduction_metrics" ] ] }, { "id": "cse_fn_raw_influx", "type": "function", "z": "cse_tab", "name": "raw EVOLV -> Influx line protocol", "func": "const p = msg.payload;\nif (!p || !p.measurement || !p.fields) return null;\nconst esc = (s) => String(s).replace(/,/g, '\\\\,').replace(/ /g, '\\\\ ').replace(/=/g, '\\\\=');\nconst escString = (s) => String(s).replace(/\"/g, '\\\\\"');\nconst tags = Object.entries(p.tags || {})\n .filter(([, v]) => v !== undefined && v !== null && v !== '')\n .map(([k, v]) => `${esc(k)}=${esc(v)}`)\n .join(',');\nconst fields = Object.entries(p.fields)\n .filter(([, v]) => v !== undefined && v !== null)\n .map(([k, v]) => {\n const n = Number(v);\n if (typeof v === 'number' && Number.isFinite(v)) return `${esc(k)}=${v}`;\n if (typeof v === 'boolean') return `${esc(k)}=${v}`;\n if (Number.isFinite(n) && String(v).trim() !== '') return `${esc(k)}=${n}`;\n return `${esc(k)}=\"${escString(v)}\"`;\n });\nif (!fields.length) return null;\nconst t = Date.parse(p.timestamp);\nconst ns = `${Number.isFinite(t) ? t : Date.now()}000000`;\nmsg._coreSyncDemo = { rawFields: fields.length };\nmsg.payload = `${esc(p.measurement)}${tags ? ',' + tags : ''} ${fields.join(',')} ${ns}`;\nmsg.headers = { Authorization: 'Token evolv-dev-token', 'Content-Type': 'text/plain' };\nmsg.url = 'http://influxdb:8086/api/v2/write?org=evolv&bucket=telemetry&precision=ns';\nmsg.method = 'POST';\nreturn msg;", "outputs": 1, "noerr": 0, "x": 880, "y": 560, "wires": [ [ "cse_http_influx_raw" ] ] }, { "id": "cse_http_influx_raw", "type": "http request", "z": "cse_tab", "name": "Write raw InfluxDB", "method": "use", "ret": "txt", "paytoqs": false, "url": "", "persist": false, "authType": "", "senderr": false, "x": 1140, "y": 560, "wires": [ [ "cse_fn_influx_raw_status" ] ] }, { "id": "cse_fn_knot_influx", "type": "function", "z": "cse_tab", "name": "CoreSync knot -> Influx line protocol", "func": "if (msg.topic !== 'frost.observation.create' || !msg.payload) return null;\nconst obs = msg.payload;\nconst params = obs.parameters || {};\nconst streamKey = params.evolvStreamKey || msg._coreSync?.streamKey || 'unknown';\nconst parts = streamKey.split(':');\nconst esc = (s) => String(s).replace(/,/g, '\\\\,').replace(/ /g, '\\\\ ').replace(/=/g, '\\\\=');\nconst tags = {\n streamKey,\n thing: parts[0] || 'unknown',\n type: parts[1] || 'unknown',\n variant: parts[2] || 'unknown',\n position: parts[3] || 'unknown',\n sensorTag: parts[4] || 'unknown',\n reason: params.reductionReason || 'unknown',\n direction: params.direction || 'unknown'\n};\nconst tagText = Object.entries(tags).map(([k, v]) => `${esc(k)}=${esc(v)}`).join(',');\nconst fields = [`result=${Number(obs.result)}`, 'knot=1i'];\nif (Number.isFinite(Number(params.slope))) fields.push(`slope=${Number(params.slope)}`);\nif (Number.isFinite(Number(params.previousValue))) fields.push(`previousValue=${Number(params.previousValue)}`);\nconst t = Date.parse(obs.phenomenonTime);\nconst ns = `${Number.isFinite(t) ? t : Date.now()}000000`;\nmsg._coreSyncDemo = { knotFields: 1 };\nmsg.payload = `coresync_knots,${tagText} ${fields.join(',')} ${ns}`;\nmsg.headers = { Authorization: 'Token evolv-dev-token', 'Content-Type': 'text/plain' };\nmsg.url = 'http://influxdb:8086/api/v2/write?org=evolv&bucket=telemetry&precision=ns';\nmsg.method = 'POST';\nreturn msg;", "outputs": 1, "noerr": 0, "x": 1430, "y": 300, "wires": [ [ "cse_http_influx_knot" ] ] }, { "id": "cse_http_influx_knot", "type": "http request", "z": "cse_tab", "name": "Write knot InfluxDB", "method": "use", "ret": "txt", "paytoqs": false, "url": "", "persist": false, "authType": "", "senderr": false, "x": 1680, "y": 300, "wires": [ [ "cse_fn_influx_knot_status" ] ] }, { "id": "cse_fn_influx_raw_status", "type": "function", "z": "cse_tab", "name": "raw write status", "func": "const count = (context.get('count') || 0) + 1;\nconst errors = context.get('errors') || 0;\nif (msg.statusCode && msg.statusCode >= 400) {\n context.set('errors', errors + 1);\n node.status({ fill: 'red', shape: 'ring', text: `raw ERR ${errors + 1}/${count}` });\n context.set('count', count);\n return null;\n}\nnode.status({ fill: 'green', shape: 'dot', text: `raw ${count} writes` });\ncontext.set('count', count);\nreturn { topic: 'rawFields', payload: msg._coreSyncDemo?.rawFields || 1 };", "outputs": 1, "noerr": 0, "x": 1360, "y": 560, "wires": [ [ "cse_fn_reduction_metrics" ] ] }, { "id": "cse_fn_influx_knot_status", "type": "function", "z": "cse_tab", "name": "knot write status", "func": "const count = (context.get('count') || 0) + 1;\nconst errors = context.get('errors') || 0;\nif (msg.statusCode && msg.statusCode >= 400) {\n context.set('errors', errors + 1);\n node.status({ fill: 'red', shape: 'ring', text: `knot ERR ${errors + 1}/${count}` });\n context.set('count', count);\n return null;\n}\nnode.status({ fill: 'green', shape: 'dot', text: `knot ${count} writes` });\ncontext.set('count', count);\nreturn { topic: 'knotFields', payload: msg._coreSyncDemo?.knotFields || 1 };", "outputs": 1, "noerr": 0, "x": 1900, "y": 300, "wires": [ [ "cse_fn_reduction_metrics" ] ] }, { "id": "cse_fn_reduction_metrics", "type": "function", "z": "cse_tab", "name": "Live reduction metrics", "func": "const stats = context.get('stats') || { rawFields: 0, knotFields: 0, frostObservations: 0, started: Date.now() };\nconst n = Number(msg.payload) || 0;\nif (msg.topic === 'rawFields') stats.rawFields += n;\nif (msg.topic === 'knotFields') stats.knotFields += n;\nif (msg.topic === 'frostObservation') stats.frostObservations += n;\ncontext.set('stats', stats);\n\nconst saved = stats.rawFields > 0 ? Math.max(0, 100 * (1 - (stats.knotFields / stats.rawFields))) : 0;\nconst elapsedSec = Math.max(1, (Date.now() - stats.started) / 1000);\nconst rawRate = stats.rawFields / elapsedSec;\nconst knotRate = stats.knotFields / elapsedSec;\n\nnode.status({ fill: 'green', shape: 'dot', text: `${stats.rawFields} raw -> ${stats.knotFields} knots (${saved.toFixed(1)}% saved)` });\n\nreturn [\n { payload: `Raw fields: ${stats.rawFields} | CoreSync knots: ${stats.knotFields} | FROST observations: ${stats.frostObservations} | Saved: ${saved.toFixed(1)}%` },\n { payload: Number(saved.toFixed(1)) },\n { topic: 'raw fields/s', payload: Number(rawRate.toFixed(3)), timestamp: Date.now() },\n { topic: 'knot fields/s', payload: Number(knotRate.toFixed(3)), timestamp: Date.now() },\n { payload: stats.frostObservations }\n];", "outputs": 5, "noerr": 0, "x": 1620, "y": 500, "wires": [ [ "cse_ui_text_summary" ], [ "cse_ui_gauge_saved" ], [ "cse_ui_chart_raw" ], [ "cse_ui_chart_knot" ], [ "cse_ui_text_frost" ] ] }, { "id": "cse_ui_text_summary", "type": "ui-text", "z": "cse_tab", "group": "cse_ui_group_kpi", "name": "Reduction summary", "label": "Reduction", "order": 1, "width": "8", "height": "1", "format": "{{msg.payload}}", "layout": "row-spread", "style": false, "font": "", "fontSize": "", "color": "", "x": 1890, "y": 460, "wires": [] }, { "id": "cse_ui_text_frost", "type": "ui-text", "z": "cse_tab", "group": "cse_ui_group_kpi", "name": "FROST observations", "label": "FROST observations", "order": 2, "width": "4", "height": "1", "format": "{{msg.payload}}", "layout": "row-spread", "style": false, "font": "", "fontSize": "", "color": "", "x": 1900, "y": 620, "wires": [] }, { "id": "cse_ui_gauge_saved", "type": "ui-gauge", "z": "cse_tab", "group": "cse_ui_group_kpi", "name": "Saved gauge", "gtype": "gauge-half", "gstyle": "Rounded", "title": "Data Saved", "units": "%", "prefix": "", "suffix": "%", "min": 0, "max": 100, "segments": [ { "color": "#d64545", "from": 0 }, { "color": "#e8a23a", "from": 40 }, { "color": "#2f9e44", "from": 70 } ], "width": "4", "height": "3", "order": 3, "x": 1880, "y": 500, "wires": [] }, { "id": "cse_ui_chart_raw", "type": "ui-chart", "z": "cse_tab", "group": "cse_ui_group_chart", "name": "Raw write rate", "label": "Raw field write rate", "order": 1, "width": 6, "height": 4, "chartType": "line", "category": "topic", "xAxisLabel": "time", "xAxisType": "time", "yAxisLabel": "fields/s", "removeOlder": "15", "removeOlderUnit": "60", "x": 1890, "y": 540, "wires": [], "showLegend": false, "categoryType": "msg", "xAxisProperty": "", "xAxisPropertyType": "timestamp", "yAxisProperty": "payload", "yAxisPropertyType": "msg", "action": "append", "interpolation": "linear", "colors": [ "#0095FF", "#FF0000", "#FF7F0E", "#2CA02C", "#A347E1", "#D62728", "#FF9896", "#9467BD", "#C5B0D5" ] }, { "id": "cse_ui_chart_knot", "type": "ui-chart", "z": "cse_tab", "group": "cse_ui_group_chart", "name": "Knot write rate", "label": "CoreSync knot write rate", "order": 2, "width": 6, "height": 4, "chartType": "line", "category": "topic", "xAxisLabel": "time", "xAxisType": "time", "yAxisLabel": "knots/s", "removeOlder": "15", "removeOlderUnit": "60", "x": 1890, "y": 580, "wires": [], "showLegend": false, "categoryType": "msg", "xAxisProperty": "", "xAxisPropertyType": "timestamp", "yAxisProperty": "payload", "yAxisPropertyType": "msg", "action": "append", "interpolation": "linear", "colors": [ "#0095FF", "#FF0000", "#FF7F0E", "#2CA02C", "#A347E1", "#D62728", "#FF9896", "#9467BD", "#C5B0D5" ] }, { "id": "cse_dbg_measure_process", "type": "debug", "z": "cse_tab", "name": "measurement process", "active": false, "tosidebar": true, "console": false, "tostatus": false, "complete": "true", "targetType": "full", "x": 850, "y": 100, "wires": [] }, { "id": "cse_dbg_rm_process", "type": "debug", "z": "cse_tab", "name": "rotatingMachine process", "active": false, "tosidebar": true, "console": false, "tostatus": false, "complete": "true", "targetType": "full", "x": 840, "y": 360, "wires": [] }, { "id": "cse_dbg_rm_parent", "type": "debug", "z": "cse_tab", "name": "rotatingMachine parent", "active": false, "tosidebar": true, "console": false, "tostatus": false, "complete": "true", "targetType": "full", "x": 840, "y": 400, "wires": [] }, { "id": "cse_dbg_coresync_process", "type": "debug", "z": "cse_tab", "name": "CoreSync diagnostics", "active": true, "tosidebar": true, "console": false, "tostatus": false, "complete": "true", "targetType": "full", "x": 1410, "y": 180, "wires": [] }, { "id": "cse_dbg_coresync_parent", "type": "debug", "z": "cse_tab", "name": "CoreSync parent", "active": false, "tosidebar": true, "console": false, "tostatus": false, "complete": "true", "targetType": "full", "x": 1400, "y": 340, "wires": [] }, { "id": "cse_dbg_frost_response", "type": "debug", "z": "cse_tab", "name": "FROST response", "active": false, "tosidebar": true, "console": false, "tostatus": false, "complete": "statusCode", "targetType": "msg", "x": 2070, "y": 220, "wires": [] } ]