fix(dashboard): resolve [object Object] in ui-text widgets + use dispatcher pattern
Some checks failed
CI / lint-and-test (push) Has been cancelled

FlowFuse ui-text only supports {{msg.payload}} — not nested paths
like {{msg.payload.state}}. Every ui-text was showing [object Object]
because the formatter sent a fat object as msg.payload and the format
template tried to access sub-fields.

Fix: per-pump (and per-MGC, per-PS) "dispatcher" function on the
Dashboard UI tab. The dispatcher receives the fat object via one
link-in, then returns 7-9 plain-string outputs — one per ui-text
widget — each with msg.payload set to the formatted string value.
Outputs 8+9 carry numeric values (flowNum/powerNum) tagged with
msg.topic for the trend charts, wired directly to both short-term
and long-term chart nodes.

Pattern documented as the recommended approach in the rule set:
"FlowFuse ui-text receives plain strings only — use a dispatcher
function to split a fat object into per-widget outputs."

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
znetsixe
2026-04-14 07:54:02 +02:00
parent d439b048f2
commit 82db2953e9
2 changed files with 339 additions and 217 deletions

View File

@@ -402,8 +402,9 @@ def build_process_tab():
],
})
# Per-pump output formatter: builds the structured event used by the
# dashboard widgets and trend feeders.
# Per-pump output formatter: builds a fat object with all fields.
# The dashboard dispatcher (on the UI tab) then splits it into
# plain-string payloads per ui-text widget. One link-out per pump.
nodes.append(function_node(
f"format_{pump}", TAB_PROCESS, LANE_X[4], y_section + 80,
f"format {label} port 0",
@@ -423,18 +424,18 @@ def build_process_tab():
" state: c.state || 'idle',\n"
" mode: c.mode || 'auto',\n"
" ctrl: c.ctrl != null ? Number(c.ctrl).toFixed(1) + '%' : 'n/a',\n"
" flow: flow != null ? Number(flow).toFixed(1) + ' m³/h' : 'n/a',\n"
" flow: flow != null ? Number(flow).toFixed(1) + ' m³/h' : 'n/a',\n"
" power: power != null ? Number(power).toFixed(2) + ' kW' : 'n/a',\n"
" pUp: pU != null ? Number(pU).toFixed(0) : 'n/a',\n"
" pDn: pD != null ? Number(pD).toFixed(0) : 'n/a',\n"
" flowNum: flow != null ? Number(flow) : null,\n"
" pUp: pU != null ? Number(pU).toFixed(0) + ' mbar' : 'n/a',\n"
" pDn: pD != null ? Number(pD).toFixed(0) + ' mbar' : 'n/a',\n"
" flowNum: flow != null ? Number(flow) : null,\n"
" powerNum: power != null ? Number(power) : null,\n"
"};\n"
"return msg;",
outputs=1, wires=[[f"lout_evt_{pump}"]],
))
# link-out: per-pump event stream → dashboard
# link-out: one per pump → dashboard dispatcher
nodes.append(link_out(
f"lout_evt_{pump}", TAB_PROCESS, LANE_X[5], y_section + 80,
CH_PUMP_EVT[pump],
@@ -797,30 +798,56 @@ def build_ui_tab():
nodes.append(link_in(
"lin_evt_mgc_dash", TAB_UI, LANE_X[0], y + 40,
CH_MGC_EVT, source_out_ids=["lout_evt_mgc"],
downstream=["ui_mgc_total_flow", "ui_mgc_total_power", "ui_mgc_eff"]
downstream=["dispatch_mgc"]
))
nodes.append(function_node(
"dispatch_mgc", TAB_UI, LANE_X[1], y + 40,
"dispatch MGC",
"const p = msg.payload || {};\n"
"return [\n"
" {payload: String(p.totalFlow || 'n/a')},\n"
" {payload: String(p.totalPower || 'n/a')},\n"
" {payload: String(p.efficiency || 'n/a')},\n"
"];",
outputs=3,
wires=[["ui_mgc_total_flow"], ["ui_mgc_total_power"], ["ui_mgc_eff"]],
))
nodes.append(ui_text("ui_mgc_total_flow", TAB_UI, LANE_X[2], y + 40, g_mgc,
"MGC total flow", "Total flow", "{{msg.payload.totalFlow}}"))
"MGC total flow", "Total flow", "{{msg.payload}}"))
nodes.append(ui_text("ui_mgc_total_power", TAB_UI, LANE_X[2], y + 70, g_mgc,
"MGC total power", "Total power", "{{msg.payload.totalPower}}"))
"MGC total power", "Total power", "{{msg.payload}}"))
nodes.append(ui_text("ui_mgc_eff", TAB_UI, LANE_X[2], y + 100, g_mgc,
"MGC efficiency", "Group efficiency", "{{msg.payload.efficiency}}"))
"MGC efficiency", "Group efficiency", "{{msg.payload}}"))
nodes.append(link_in(
"lin_evt_ps_dash", TAB_UI, LANE_X[0], y + 160,
CH_PS_EVT, source_out_ids=["lout_evt_ps"],
downstream=["ui_ps_state", "ui_ps_level", "ui_ps_volume", "ui_ps_qin", "ui_ps_qout"]
downstream=["dispatch_ps"]
))
nodes.append(function_node(
"dispatch_ps", TAB_UI, LANE_X[1], y + 160,
"dispatch PS",
"const p = msg.payload || {};\n"
"return [\n"
" {payload: String(p.state || 'idle')},\n"
" {payload: String(p.level || 'n/a')},\n"
" {payload: String(p.volume || 'n/a')},\n"
" {payload: String(p.qIn || 'n/a')},\n"
" {payload: String(p.qOut || 'n/a')},\n"
"];",
outputs=5,
wires=[["ui_ps_state"], ["ui_ps_level"], ["ui_ps_volume"], ["ui_ps_qin"], ["ui_ps_qout"]],
))
nodes.append(ui_text("ui_ps_state", TAB_UI, LANE_X[2], y + 160, g_ps,
"PS state", "Basin state", "{{msg.payload.state}}"))
nodes.append(ui_text("ui_ps_level", TAB_UI, LANE_X[2], y + 190, g_ps,
"PS level", "Basin level", "{{msg.payload.level}}"))
nodes.append(ui_text("ui_ps_volume", TAB_UI, LANE_X[2], y + 220, g_ps,
"PS volume","Basin volume", "{{msg.payload.volume}}"))
nodes.append(ui_text("ui_ps_qin", TAB_UI, LANE_X[2], y + 250, g_ps,
"PS Qin", "Inflow", "{{msg.payload.qIn}}"))
nodes.append(ui_text("ui_ps_qout", TAB_UI, LANE_X[2], y + 280, g_ps,
"PS Qout", "Pumped out", "{{msg.payload.qOut}}"))
"PS state", "Basin state", "{{msg.payload}}"))
nodes.append(ui_text("ui_ps_level", TAB_UI, LANE_X[2], y + 200, g_ps,
"PS level", "Basin level", "{{msg.payload}}"))
nodes.append(ui_text("ui_ps_volume", TAB_UI, LANE_X[2], y + 240, g_ps,
"PS volume","Basin volume", "{{msg.payload}}"))
nodes.append(ui_text("ui_ps_qin", TAB_UI, LANE_X[2], y + 280, g_ps,
"PS Qin", "Inflow", "{{msg.payload}}"))
nodes.append(ui_text("ui_ps_qout", TAB_UI, LANE_X[2], y + 320, g_ps,
"PS Qout", "Pumped out", "{{msg.payload}}"))
# ===== SECTION: Per-pump panels =====
y_pumps_start = 1000
@@ -832,36 +859,54 @@ def build_ui_tab():
nodes.append(comment(f"c_ui_{pump}", TAB_UI, LANE_X[2], y_p,
f"── {label} ──", ""))
# link-in for this pump's events
# link-in: one fat object per pump → dispatcher splits into
# plain-string payloads per ui-text widget + numeric payloads
# for trend charts. 9 outputs total.
DISPLAY_FIELDS = [
("State", "state"),
("Mode", "mode"),
("Controller %", "ctrl"),
("Flow", "flow"),
("Power", "power"),
("p Upstream", "pUp"),
("p Downstream", "pDn"),
]
nodes.append(link_in(
f"lin_evt_{pump}_dash", TAB_UI, LANE_X[0], y_p + 40,
CH_PUMP_EVT[pump], source_out_ids=[f"lout_evt_{pump}"],
downstream=[
f"ui_{pump}_state",
f"ui_{pump}_mode",
f"ui_{pump}_ctrl",
f"ui_{pump}_flow",
f"ui_{pump}_power",
f"ui_{pump}_pUp",
f"ui_{pump}_pDn",
f"trend_split_{pump}",
downstream=[f"dispatch_{pump}"],
))
# Dispatcher: takes the fat object and returns 9 outputs, each
# with a plain payload ready for a ui-text or trend chart.
nodes.append(function_node(
f"dispatch_{pump}", TAB_UI, LANE_X[1], y_p + 40,
f"dispatch {label}",
"const p = msg.payload || {};\n"
"return [\n"
" {payload: String(p.state || 'idle')},\n"
" {payload: String(p.mode || 'auto')},\n"
" {payload: String(p.ctrl || 'n/a')},\n"
" {payload: String(p.flow || 'n/a')},\n"
" {payload: String(p.power || 'n/a')},\n"
" {payload: String(p.pUp || 'n/a')},\n"
" {payload: String(p.pDn || 'n/a')},\n"
" p.flowNum != null ? {topic: '" + label + "', payload: p.flowNum} : null,\n"
" p.powerNum != null ? {topic: '" + label + "', payload: p.powerNum} : null,\n"
"];",
outputs=9,
wires=[
[f"ui_{pump}_{f}"] for _, f in DISPLAY_FIELDS
] + [
["trend_short_flow", "trend_long_flow"], # output 7: flowNum → both flow charts
["trend_short_power", "trend_long_power"], # output 8: powerNum → both power charts
],
))
# Status text widgets — text-only, fed by the link-in
for k, (label_txt, fmt_field) in enumerate([
("State", "state"),
("Mode", "mode"),
("Controller %", "ctrl"),
("Flow", "flow"),
("Power", "power"),
("p Upstream", "pUp"),
("p Downstream", "pDn"),
]):
# ui-text widgets
for k, (label_txt, field) in enumerate(DISPLAY_FIELDS):
nodes.append(ui_text(
f"ui_{pump}_{fmt_field}", TAB_UI, LANE_X[2], y_p + 40 + k * 30, g,
f"ui_{pump}_{field}", TAB_UI, LANE_X[2], y_p + 40 + k * 40, g,
f"{label} {label_txt}", label_txt,
"{{msg.payload." + fmt_field + "}}"
"{{msg.payload}}" # plain string — FlowFuse-safe
))
# Setpoint slider → wrapper → link-out → process pump (cmd:setpoint-X)
@@ -914,23 +959,8 @@ def build_ui_tab():
target_in_ids=[f"lin_seq_{pump}"]
))
# Trend feeder — 2 outputs (flow / power), each wired to BOTH
# the short-term and long-term chart on their respective pages.
nodes.append(function_node(
f"trend_split_{pump}", TAB_UI, LANE_X[3], y_p + 80,
f"trend split ({label})",
"const p = msg.payload || {};\n"
"const flowMsg = p.flowNum != null ? "
"{ topic: '" + label + "', payload: Number(p.flowNum) } : null;\n"
"const powerMsg = p.powerNum != null ? "
"{ topic: '" + label + "', payload: Number(p.powerNum) } : null;\n"
"return [flowMsg, powerMsg];",
outputs=2,
wires=[
["trend_short_flow", "trend_long_flow"],
["trend_short_power", "trend_long_power"],
]
))
# (Trend feed is handled by dispatcher outputs 7+8 above — no separate
# trend_split function needed.)
# ===== Trend charts — two pages, two charts per page =====
# Short-term (10 min rolling window) and long-term (1 hour).