#!/usr/bin/env node /** * Step 4: Manual Controls per PS Detail Page * - Creates 3 PS detail pages (/ps-west, /ps-north, /ps-south) with control groups * - Adds control widgets: mode switches, pump speed sliders * - Format functions to convert dashboard inputs to process node messages * - Link-in/out routing between dashboard tab and PS tabs * - Per-PS monitoring charts on detail pages */ const fs = require('fs'); const path = require('path'); const FLOW_PATH = path.join(__dirname, '..', 'docker', 'demo-flow.json'); const flow = JSON.parse(fs.readFileSync(FLOW_PATH, 'utf8')); const byId = (id) => flow.find(n => n.id === id); // ============================================= // Helper to create a standard set of controls for a PS // ============================================= function createPSDetailPage(config) { const { psKey, // 'west', 'north', 'south' psLabel, // 'PS West', 'PS North', 'PS South' pagePath, // '/ps-west' pageOrder, // 2, 3, 4 psNodeId, // 'demo_ps_west' pumps, // [{id: 'demo_pump_w1', label: 'W1'}, ...] controlModes, // ['levelbased','flowbased','manual'] defaultMode, // 'levelbased' maxFlow, // 300 basinHeight, // 4 tabId, // 'demo_tab_ps_west' } = config; const prefix = `demo_ctrl_${psKey}`; const nodes = []; // === Page === nodes.push({ id: `demo_ui_page_ps_${psKey}_detail`, type: "ui-page", name: `${psLabel} Detail`, ui: "demo_ui_base", path: pagePath, icon: "water_drop", layout: "grid", theme: "demo_ui_theme", breakpoints: [{ name: "Default", px: "0", cols: "12" }], order: pageOrder, className: "" }); // === Groups === nodes.push( { id: `${prefix}_grp_controls`, type: "ui-group", name: `${psLabel} Controls`, page: `demo_ui_page_ps_${psKey}_detail`, width: "6", height: "1", order: 1, showTitle: true, className: "" }, { id: `${prefix}_grp_monitoring`, type: "ui-group", name: `${psLabel} Monitoring`, page: `demo_ui_page_ps_${psKey}_detail`, width: "6", height: "1", order: 2, showTitle: true, className: "" }, { id: `${prefix}_grp_charts`, type: "ui-group", name: `${psLabel} Trends`, page: `demo_ui_page_ps_${psKey}_detail`, width: "12", height: "1", order: 3, showTitle: true, className: "" } ); // === PS Mode button group === const modeOptions = controlModes.map(m => ({ label: m === 'levelbased' ? 'Level' : m === 'flowbased' ? 'Flow' : m.charAt(0).toUpperCase() + m.slice(1), value: m, valueType: "str" })); nodes.push({ id: `${prefix}_mode`, type: "ui-button-group", z: "demo_tab_dashboard", group: `${prefix}_grp_controls`, name: `${psLabel} Mode`, label: "Station Mode", tooltip: "", order: 1, width: "6", height: "1", passthru: false, options: modeOptions, x: 120, y: 100 + pageOrder * 300, wires: [[`${prefix}_fn_mode`]] }); // Format: PS mode → setMode message nodes.push({ id: `${prefix}_fn_mode`, type: "function", z: "demo_tab_dashboard", name: `Fmt ${psLabel} Mode`, func: `msg.topic = 'setMode';\nmsg.payload = msg.payload;\nreturn msg;`, outputs: 1, x: 320, y: 100 + pageOrder * 300, wires: [[`${prefix}_link_cmd_out`]] }); // === Manual Flow slider === nodes.push({ id: `${prefix}_flow`, type: "ui-slider", z: "demo_tab_dashboard", group: `${prefix}_grp_controls`, name: `${psLabel} Flow`, label: "Manual Flow (m\u00b3/h)", tooltip: "", order: 2, width: "6", height: "1", passthru: false, outs: "end", min: 0, max: maxFlow, step: 1, x: 120, y: 140 + pageOrder * 300, wires: [[`${prefix}_fn_flow`]] }); // Format: flow slider → q_in message nodes.push({ id: `${prefix}_fn_flow`, type: "function", z: "demo_tab_dashboard", name: `Fmt ${psLabel} Flow`, func: `msg.topic = 'q_in';\nmsg.payload = { value: Number(msg.payload), unit: 'm3/h' };\nreturn msg;`, outputs: 1, x: 320, y: 140 + pageOrder * 300, wires: [[`${prefix}_link_cmd_out`]] }); // === Pump controls === pumps.forEach((pump, pIdx) => { const yOff = 180 + pageOrder * 300 + pIdx * 80; // Pump mode button group nodes.push({ id: `${prefix}_pump_${pump.label.toLowerCase()}_mode`, type: "ui-button-group", z: "demo_tab_dashboard", group: `${prefix}_grp_controls`, name: `${pump.label} Mode`, label: `${pump.label} Mode`, tooltip: "", order: 3 + pIdx * 2, width: "3", height: "1", passthru: false, options: [ { label: "Auto", value: "auto", valueType: "str" }, { label: "Virtual", value: "virtualControl", valueType: "str" }, { label: "Physical", value: "fysicalControl", valueType: "str" } ], x: 120, y: yOff, wires: [[`${prefix}_fn_pump_${pump.label.toLowerCase()}_mode`]] }); // Format: pump mode nodes.push({ id: `${prefix}_fn_pump_${pump.label.toLowerCase()}_mode`, type: "function", z: "demo_tab_dashboard", name: `Fmt ${pump.label} Mode`, func: `msg.topic = 'setMode';\nmsg.payload = msg.payload;\nmsg._targetNode = '${pump.id}';\nreturn msg;`, outputs: 1, x: 320, y: yOff, wires: [[`${prefix}_link_pump_${pump.label.toLowerCase()}_out`]] }); // Pump speed slider nodes.push({ id: `${prefix}_pump_${pump.label.toLowerCase()}_speed`, type: "ui-slider", z: "demo_tab_dashboard", group: `${prefix}_grp_controls`, name: `${pump.label} Speed`, label: `${pump.label} Speed (%)`, tooltip: "", order: 4 + pIdx * 2, width: "3", height: "1", passthru: false, outs: "end", min: 0, max: 100, step: 1, x: 120, y: yOff + 40, wires: [[`${prefix}_fn_pump_${pump.label.toLowerCase()}_speed`]] }); // Format: pump speed → execMovement nodes.push({ id: `${prefix}_fn_pump_${pump.label.toLowerCase()}_speed`, type: "function", z: "demo_tab_dashboard", name: `Fmt ${pump.label} Speed`, func: `msg.topic = 'execMovement';\nmsg.payload = { source: 'dashboard', action: 'setpoint', setpoint: Number(msg.payload) };\nmsg._targetNode = '${pump.id}';\nreturn msg;`, outputs: 1, x: 320, y: yOff + 40, wires: [[`${prefix}_link_pump_${pump.label.toLowerCase()}_out`]] }); // Link-out for pump commands (dashboard → PS tab) nodes.push({ id: `${prefix}_link_pump_${pump.label.toLowerCase()}_out`, type: "link out", z: "demo_tab_dashboard", name: `→ ${pump.label} Cmd`, mode: "link", links: [`${prefix}_link_pump_${pump.label.toLowerCase()}_in`], x: 520, y: yOff + 20 }); // Link-in on PS tab nodes.push({ id: `${prefix}_link_pump_${pump.label.toLowerCase()}_in`, type: "link in", z: tabId, name: `← ${pump.label} Cmd`, links: [`${prefix}_link_pump_${pump.label.toLowerCase()}_out`], x: 120, y: 540 + pIdx * 60, wires: [[pump.id]] }); }); // === PS command link-out (dashboard → PS tab) === nodes.push({ id: `${prefix}_link_cmd_out`, type: "link out", z: "demo_tab_dashboard", name: `→ ${psLabel} Cmd`, mode: "link", links: [`${prefix}_link_cmd_in`], x: 520, y: 120 + pageOrder * 300 }); // Link-in on PS tab for PS-level commands nodes.push({ id: `${prefix}_link_cmd_in`, type: "link in", z: tabId, name: `← ${psLabel} Cmd`, links: [`${prefix}_link_cmd_out`], x: 120, y: 480, wires: [[psNodeId]] }); // === Monitoring widgets on detail page === // Re-use existing data from the PS parse functions on dashboard tab // Create a link-in to receive PS data and parse for detail page nodes.push({ id: `${prefix}_link_detail_data_out`, type: "link out", z: tabId, name: `→ ${psLabel} Detail`, mode: "link", links: [`${prefix}_link_detail_data_in`], x: 1080, y: 400 }); // Add to PS node wires[0] const psNode = byId(psNodeId); if (psNode && psNode.wires && psNode.wires[0]) { psNode.wires[0].push(`${prefix}_link_detail_data_out`); } nodes.push({ id: `${prefix}_link_detail_data_in`, type: "link in", z: "demo_tab_dashboard", name: `← ${psLabel} Detail`, links: [`${prefix}_link_detail_data_out`], x: 75, y: 50 + pageOrder * 300, wires: [[`${prefix}_fn_detail_parse`]] }); // Parse function for detail monitoring nodes.push({ id: `${prefix}_fn_detail_parse`, type: "function", z: "demo_tab_dashboard", name: `Parse ${psLabel} Detail`, func: `const p = msg.payload || {}; const cache = context.get('c') || {}; const keys = Object.keys(p); const pick = (prefixes) => { for (const pfx of prefixes) { const k = keys.find(k => k.startsWith(pfx)); if (k) { const v = Number(p[k]); if (Number.isFinite(v)) return v; } } return null; }; const level = pick(['level.predicted.atequipment','level.measured.atequipment']); const volume = pick(['volume.predicted.atequipment']); const netFlow = pick(['netFlowRate.predicted.atequipment']); const fillPct = pick(['volumePercent.predicted.atequipment']); const direction = p.direction || cache.direction || '?'; if (level !== null) cache.level = level; if (volume !== null) cache.volume = volume; if (netFlow !== null) cache.netFlow = netFlow; if (fillPct !== null) cache.fillPct = fillPct; cache.direction = direction; context.set('c', cache); const now = Date.now(); const dirArrow = cache.direction === 'filling' ? '\\u2191' : cache.direction === 'emptying' ? '\\u2193' : '\\u2014'; const status = [ dirArrow + ' ' + (cache.direction || ''), cache.netFlow !== undefined ? Math.abs(cache.netFlow).toFixed(0) + ' m\\u00b3/h' : '', ].filter(s => s.trim()).join(' | '); return [ cache.level !== undefined ? {topic:'${psLabel} Level', payload: cache.level, timestamp: now} : null, cache.netFlow !== undefined ? {topic:'${psLabel} Flow', payload: cache.netFlow, timestamp: now} : null, {topic:'${psLabel} Status', payload: status}, cache.fillPct !== undefined ? {payload: Number(cache.fillPct.toFixed(1))} : null, cache.level !== undefined ? {payload: Number(cache.level.toFixed(2))} : null ];`, outputs: 5, x: 280, y: 50 + pageOrder * 300, wires: [ [`${prefix}_chart_level`], [`${prefix}_chart_flow`], [`${prefix}_text_status`], [`${prefix}_gauge_fill`], [`${prefix}_gauge_tank`] ] }); // Level chart nodes.push({ id: `${prefix}_chart_level`, type: "ui-chart", z: "demo_tab_dashboard", group: `${prefix}_grp_charts`, name: `${psLabel} Level`, label: "Basin Level (m)", order: 1, width: "6", height: "5", chartType: "line", category: "topic", categoryType: "msg", xAxisType: "time", yAxisLabel: "m", removeOlder: "10", removeOlderUnit: "60", action: "append", pointShape: "false", pointRadius: 0, interpolation: "linear", showLegend: true, xAxisProperty: "", xAxisPropertyType: "timestamp", yAxisProperty: "payload", yAxisPropertyType: "msg", colors: ["#0094ce", "#FF7F0E", "#2CA02C"], textColor: ["#aaaaaa"], textColorDefault: false, gridColor: ["#333333"], gridColorDefault: false, x: 510, y: 30 + pageOrder * 300, wires: [] }); // Flow chart nodes.push({ id: `${prefix}_chart_flow`, type: "ui-chart", z: "demo_tab_dashboard", group: `${prefix}_grp_charts`, name: `${psLabel} Flow`, label: "Net Flow (m\u00b3/h)", order: 2, width: "6", height: "5", chartType: "line", category: "topic", categoryType: "msg", xAxisType: "time", yAxisLabel: "m\u00b3/h", removeOlder: "10", removeOlderUnit: "60", action: "append", pointShape: "false", pointRadius: 0, interpolation: "linear", showLegend: true, xAxisProperty: "", xAxisPropertyType: "timestamp", yAxisProperty: "payload", yAxisPropertyType: "msg", colors: ["#4fc3f7", "#FF7F0E", "#2CA02C"], textColor: ["#aaaaaa"], textColorDefault: false, gridColor: ["#333333"], gridColorDefault: false, x: 510, y: 60 + pageOrder * 300, wires: [] }); // Status text nodes.push({ id: `${prefix}_text_status`, type: "ui-text", z: "demo_tab_dashboard", group: `${prefix}_grp_monitoring`, name: `${psLabel} Status`, label: "Status", order: 1, width: "6", height: "1", format: "{{msg.payload}}", layout: "row-spread", x: 510, y: 80 + pageOrder * 300, wires: [] }); // Fill % gauge nodes.push({ id: `${prefix}_gauge_fill`, type: "ui-gauge", z: "demo_tab_dashboard", group: `${prefix}_grp_monitoring`, name: `${psLabel} Fill`, gtype: "gauge-34", gstyle: "Rounded", title: "Fill", units: "%", prefix: "", suffix: "%", min: 0, max: 100, segments: [ { color: "#f44336", from: 0 }, { color: "#ff9800", from: 10 }, { color: "#4caf50", from: 25 }, { color: "#ff9800", from: 75 }, { color: "#f44336", from: 90 } ], width: 3, height: 3, order: 2, className: "", x: 700, y: 80 + pageOrder * 300, wires: [] }); // Tank gauge nodes.push({ id: `${prefix}_gauge_tank`, type: "ui-gauge", z: "demo_tab_dashboard", group: `${prefix}_grp_monitoring`, name: `${psLabel} Tank`, gtype: "gauge-tank", gstyle: "Rounded", title: "Level", units: "m", prefix: "", suffix: "m", min: 0, max: basinHeight, segments: [ { color: "#f44336", from: 0 }, { color: "#ff9800", from: basinHeight * 0.08 }, { color: "#2196f3", from: basinHeight * 0.25 }, { color: "#ff9800", from: basinHeight * 0.62 }, { color: "#f44336", from: basinHeight * 0.8 } ], width: 3, height: 4, order: 3, className: "", x: 700, y: 40 + pageOrder * 300, wires: [] }); return nodes; } // ============================================= // Create detail pages for each PS // ============================================= const westNodes = createPSDetailPage({ psKey: 'west', psLabel: 'PS West', pagePath: '/ps-west', pageOrder: 2, psNodeId: 'demo_ps_west', pumps: [ { id: 'demo_pump_w1', label: 'W1' }, { id: 'demo_pump_w2', label: 'W2' } ], controlModes: ['levelbased', 'flowbased', 'manual'], defaultMode: 'levelbased', maxFlow: 300, basinHeight: 4, tabId: 'demo_tab_ps_west', }); const northNodes = createPSDetailPage({ psKey: 'north', psLabel: 'PS North', pagePath: '/ps-north', pageOrder: 3, psNodeId: 'demo_ps_north', pumps: [ { id: 'demo_pump_n1', label: 'N1' } ], controlModes: ['levelbased', 'flowbased', 'manual'], defaultMode: 'flowbased', maxFlow: 200, basinHeight: 3, tabId: 'demo_tab_ps_north', }); const southNodes = createPSDetailPage({ psKey: 'south', psLabel: 'PS South', pagePath: '/ps-south', pageOrder: 4, psNodeId: 'demo_ps_south', pumps: [ { id: 'demo_pump_s1', label: 'S1' } ], controlModes: ['levelbased', 'flowbased', 'manual'], defaultMode: 'manual', maxFlow: 100, basinHeight: 2.5, tabId: 'demo_tab_ps_south', }); flow.push(...westNodes, ...northNodes, ...southNodes); // ============================================= // Validate // ============================================= const allIds = new Set(flow.map(n => n.id)); let issues = 0; // Check for duplicate IDs const idCounts = {}; flow.forEach(n => { idCounts[n.id] = (idCounts[n.id] || 0) + 1; }); for (const [id, count] of Object.entries(idCounts)) { if (count > 1) { console.warn(`DUPLICATE ID: ${id} (${count} instances)`); issues++; } } for (const n of flow) { if (!n.wires) continue; for (const port of n.wires) { for (const target of port) { if (!allIds.has(target)) { console.warn(`BROKEN WIRE: ${n.id} → ${target}`); issues++; } } } if (n.type === 'link out' && n.links) { for (const lt of n.links) { if (!allIds.has(lt)) { console.warn(`BROKEN LINK OUT: ${n.id} → ${lt}`); issues++; } } } if (n.type === 'link in' && n.links) { for (const ls of n.links) { if (!allIds.has(ls)) { console.warn(`BROKEN LINK IN: ${n.id} ← ${ls}`); issues++; } } } } if (issues === 0) console.log('All references valid ✓'); else console.log(`Found ${issues} issues`); // Count nodes per tab const tabCounts = {}; for (const n of flow) { if (n.z) tabCounts[n.z] = (tabCounts[n.z] || 0) + 1; } console.log('Nodes per tab:', JSON.stringify(tabCounts, null, 2)); console.log('Total nodes:', flow.length); // Count new nodes added const newNodeCount = westNodes.length + northNodes.length + southNodes.length; console.log(`Added ${newNodeCount} new nodes (${westNodes.length} west + ${northNodes.length} north + ${southNodes.length} south)`); // Write fs.writeFileSync(FLOW_PATH, JSON.stringify(flow, null, 2) + '\n'); console.log(`Wrote ${FLOW_PATH}`);