Data flows in the Energy Node system
This document describes which data is produced where, which channels it travels through, and who consumes it. It is deliberately system-wide: the Python bridges, the MQTT broker, the Go dashboard and the browser all appear together.
Details about individual building blocks live elsewhere:
- The dashboard’s HTTP interface → dashboard/api-documentation.md
- The Python infrastructure package →
libs/energy_node_common/ - The Mosquitto bridge to the main site → see INSTALLATION.md section 5
1. The whole picture
Everything runs through exactly one local MQTT broker (Mosquitto on the Raspberry Pi). There is no direct HTTP path between the device bridges and the dashboard — the broker is the only coupling.
flowchart TB
subgraph Physik["Devices / field level"]
AP["AP Systems EZ1<br/>(local HTTP API)"]
TR["Trucki stick<br/>(HTTP)"]
SH["Shelly devices<br/>(RPC / HTTP)"]
TY["Tuya devices<br/>(local, tinytuya)"]
end
subgraph Bridges["Python services (systemd, one process each)"]
APS["apsystems_ez1_mqtt.py"]
TRS["trucki_http_mqtt.py"]
SHS["shelly_rpc_mqtt.py"]
TYS["tuya_mqtt.py"]
BAT["battery_soc_mqtt.py<br/>(computes, polls nothing)"]
AUT["automation_mqtt.py<br/>(rules)"]
end
BROKER{{"Mosquitto<br/>localhost:1883"}}
subgraph Dash["Dashboard (Go, one binary)"]
MC["mqttclient<br/>Discovery + state"]
REG[("registry<br/>in-memory state")]
API["httpapi<br/>/api/v1/*"]
WEB["webui<br/>server-side HTML"]
FILES[("data directory<br/>*.json")]
NODE["nodeagent<br/>(node identity,<br/>Pi diagnostics)"]
end
BR["Mosquitto bridge"]
HS["Main site<br/>(Home Assistant)"]
BROWSER["Browser"]
AP --> APS
TR --> TRS
SH --> SHS
TY --> TYS
APS -->|publish| BROKER
TRS -->|publish| BROKER
SHS -->|publish| BROKER
TYS -->|publish| BROKER
BAT -->|publish| BROKER
AUT -->|publish| BROKER
NODE -->|"outstation/energy_node/… (diagnostics, poll rate, simulation)"| BROKER
BROKER -->|"subscribe: raw values"| BAT
BROKER -->|"subscribe: balance, topics"| AUT
BROKER -->|"config/reload, */set"| APS
BROKER -->|"config/reload, */set"| SHS
BROKER -->|"config/reload, */set"| TYS
BROKER -->|"homeassistant/+/+/+/config<br/>state and availability topics"| MC
MC --> REG
REG --> API
REG --> WEB
API <--> FILES
API -->|"publish: command_topic"| BROKER
API -->|"outstation/energy_node/energy/balance"| BROKER
WEB -->|HTML| BROWSER
API <-->|"JSON / SSE"| BROWSER
BROKER <--> BR
BR <--> HS
Core statement: device state flows upward (device → bridge → broker → dashboard → browser), commands and configuration flow downward (browser → dashboard → broker → bridge → device).
2. Topic conventions
Two namespaces, with strictly separated jobs:
| Namespace | Purpose | Retained | Who writes |
|---|---|---|---|
homeassistant/{component}/{device_id}/{object_id}/config |
Discovery — describes which entity exists and where its value is | yes | Python bridges |
outstation/{device_id}/... |
Payload data — measurements, status, commands | partly | bridges + dashboard |
Important sub-structures below outstation/:
| Topic | Direction | Content |
|---|---|---|
outstation/{device_id}/{group}/{field} |
bridge → broker | measurement (e.g. outstation/t2mg81a4e9/field/acpower) |
outstation/{device_id}/status/online |
bridge → broker | availability; also set as the MQTT last will |
outstation/{device_id}/.../set |
broker → bridge | switch command (command_topic from Discovery) |
outstation/{service_id}/config/reload |
dashboard → bridge | bridge reloads its JSON configuration |
outstation/energy_node/energy/balance |
dashboard → broker | energy balance, every 10 s |
outstation/automation/test/set |
dashboard → automation | run a single rule action as a test |
outstation/automation/test/result |
automation → broker | result of the test |
$SYS/broker/connection/{remote_client_id}/state |
broker → dashboard | is the bridge to the main site up? (1/0) |
When publishing, the automation service enforces guard rules: no wildcards, no
homeassistant/... (no forged Discovery), no $SYS/..., and no writes to the
dashboard’s own outstation/energy_node/energy/balance and
outstation/energy_node/status/online topics.
3. Discovery — how the dashboard learns what exists
The dashboard has no device list in a configuration file. It learns everything at runtime from retained Discovery messages. A restart of the dashboard is enough, because the broker re-delivers the retained messages.
sequenceDiagram
participant B as Python bridge
participant M as Mosquitto
participant C as mqttclient
participant R as registry
participant U as Browser
Note over B,M: When the bridge starts
B->>M: publish retained<br/>homeassistant/sensor/x/y/config
B->>M: publish outstation/x/status/online = "online"
Note over C,M: When the dashboard starts / reconnects
C->>M: subscribe homeassistant/+/+/+/config
M-->>C: all retained config messages
C->>R: UpsertEntity(Discovery)
R-->>C: new state_topic / availability_topic
C->>M: subscribe to exactly those topics
M-->>C: retained values
C->>R: UpdateState / UpdateAvailability
R->>R: version++
U->>R: GET /api/v1/events (SSE) sees the new version
Details that matter day to day:
- The dashboard subscribes only to concretely discovered topics, never to
outstation/#. An empty Discovery field means: the dashboard does not see the value. - An empty payload on a
configtopic is the deletion: the entity disappears, orphaned topics are unsubscribed. - Parse errors land in
reg.DiscoveryErrors()and are visible viaGET /api/v1/discovery— they are not silently dropped. - The
discovery_prefixis configurable (defaulthomeassistant).
4. Live values all the way to the browser
The browser does not get values pushed from MQTT. Between the registry and the browser sits a deliberately simple version mechanism.
flowchart LR
M{{Mosquitto}} -->|"state / availability"| C[mqttclient]
C --> R[("registry<br/>version: uint64")]
R -->|"ObserveChanges"| RC[("runtime.json<br/>runtime cache")]
R -->|"ETag: registry-N"| D["GET /api/v1/devices"]
R -->|"event: registry<br/>data: version"| S["GET /api/v1/events (SSE)"]
S -->|"version changed?"| JS["browser JS"]
JS -->|"only then"| D
D --> JS
- SSE carries no payload data, only
{"version": N}. On a change the client fetches the devices viaGET /api/v1/devices— that saves a diff implementation on both sides. - The poll interval of the SSE loop comes from
settings.json(live_update_interval_seconds, default 3 s) and takes effect without a reconnect. GET /api/v1/devicesreturnsETag: "registry-N"; with a matchingIf-None-Matchthe server answers304.- The runtime cache (
runtime.json) survives restarts: it remembers the last seen values and restores them when an entity reappears, so tiles are not empty after a restart.
5. Commands — from the click to the device
sequenceDiagram
participant U as Browser
participant A as httpapi
participant R as registry
participant M as Mosquitto
participant B as Bridge
participant D as Device
U->>A: POST /api/v1/entities/UNIQUE_ID/command<br/>body with payload or value
A->>R: Command(unique_id)
R-->>A: command_topic, payload_on/off, min/max/step
A->>A: validate payload (range, step size, allowed values)
A->>M: publish command_topic
A->>R: ApplyCommandState (optimistic)
A-->>U: status published
M->>B: command on .../set
B->>D: device access (HTTP/RPC/local)
B->>M: publish new actual value
M->>A: state update → registry → version++
The dashboard publishes only to command_topics that come from Discovery —
there is no API path to write an arbitrary topic. The one exception is the
hard-wired automation test on outstation/automation/test/set.
Every command is recorded per device in an in-memory history (max. 12 entries)
and appears in GET /api/v1/devices/{id} as command_actions.
6. Configuration — files, not a database
There are two separate directories with different ownership.
flowchart TB
subgraph DEV["Devices directory (paths.devices_dir)"]
D1["apsystems_devices.json"]
D2["shelly_devices.json"]
D3["tuya_devices.json"]
D4["battery_soc_devices.json"]
D5["automation_rules.json"]
DS["*.schema.json<br/>(read-only)"]
DR["revisions/ per configuration"]
end
subgraph DAT["Data directory (paths.data_dir)"]
S1["settings.json"]
S2["layout.json"]
S3["energy.json"]
S4["mqtt.json / bridge.json"]
S5["users.json"]
S6["runtime.json"]
S7["ignored_devices.json"]
S8["*_credentials.json<br/>(passwords, separate)"]
end
UI["configuration UI"] -->|"PUT /api/v1/configurations/NAME"| D1
UI --> D2
UI --> D3
UI --> D5
D1 -->|"schema validation"| OK{"valid?"}
OK -->|no| REJ["400, file left unchanged"]
OK -->|yes| WRITE["write atomically<br/>+ store revision"]
WRITE --> DR
WRITE --> RELOAD["publish outstation/SERVICE/config/reload"]
RELOAD --> SVC["bridge reloads<br/>(no process restart)"]
UI2["dashboard settings"] -->|"PUT /api/v1/settings, /layout, ..."| S1
Characteristics of the storage path:
- Writing is atomic (temp file +
rename) and stores a revision first — every configuration is viewable via.../revisionsand restorable via.../restore. - The reload runs over MQTT, not over systemd: the target service swaps its
configuration in the running process.
automation_rulesmaps to the service IDautomation, otherwise{name}_devices→{name}applies. - Passwords always live in their own files (
*_credentials.json) and thus outside the revision copies. They are never returned in clear text — the API reports onlypassword_configured: true/false.
7. Energy balance
The balance is a derived data stream: roles are assigned to entities, and from the roles a balance object is produced that is available both over HTTP and over MQTT.
flowchart LR
REG[("registry<br/>entity values")] --> RES["energy.Resolver<br/>role per entity"]
EJ[("energy.json<br/>assignments + interpretation")] --> RES
RES --> AGG["energy.Aggregate"]
AGG --> BAL["Balance:<br/>pv, grid_import/export,<br/>battery_charge/discharge,<br/>load_total, wallbox, heat_pump,<br/>battery_soc, self-sufficiency"]
BAL --> HTTP["GET /api/v1/energy"]
BAL --> MQTT["publish every 10 s<br/>outstation/energy_node/energy/balance"]
MQTT --> AUTO["automation_mqtt.py<br/>balance_threshold condition"]
HTTP --> UI["energy-flow chart"]
The automation service consumes the balance as finished numbers — it computes
nothing itself. If the balance goes stale (older than balance_max_age_s,
default 30 s), balance-based rules stop firing.
8. Automations
sequenceDiagram
participant UI as automations editor
participant A as httpapi
participant F as automation_rules.json
participant M as Mosquitto
participant S as automation_mqtt.py
UI->>A: PUT /api/v1/configurations/automation_rules<br/>(role automations + CSRF)
A->>F: validate, revision, write atomically
A->>M: publish outstation/automation/config/reload
M->>S: reload
S->>S: reload rules, re-subscribe topics
Note over S,M: Running operation
M-->>S: balance + subscribed topics
S->>S: check conditions (with hold times)
S->>M: publish action (allowed prefixes only)
Note over UI,S: Single test
UI->>A: POST /api/v1/automations/test<br/>with rule_id and action_index
A->>M: publish outstation/automation/test/set
M->>S: test job
S->>M: publish outstation/automation/test/result
Condition types: balance_threshold, battery_soc, topic_value,
time_window, entity_value. Action types: publish, notification.
9. Path to the main site
The Mosquitto bridge mirrors both namespaces bidirectionally to the main site
(topic outstation/# both 0, topic homeassistant/# both 0). This way the main
site sees the same Discovery and measurement topics as the dashboard.
The dashboard manages the bridge but is not itself part of the bridge data path:
flowchart LR
UI["bridge UI"] -->|"PUT /api/v1/mqtt/bridge"| BJ[("bridge.json")]
UI -->|"POST .../apply<br/>(mqtt_config + system_actions)"| REN["mqttbridge.Render"]
BJ --> REN
CRED[("mqtt_bridge_credentials.json")] --> REN
REN --> STG["staged conf in the data directory"]
STG --> HLP["root helper<br/>energy-node-dashboard-system-action"]
HLP --> TGT["/etc/mosquitto/conf.d/bridge.conf"]
HLP --> RST["restart mosquitto<br/>(rollback on error)"]
TGT -.->|"drift comparison via checksum"| ST["GET /api/v1/mqtt/bridge/status"]
SYS["$SYS connection state<br/>of the bridge client"] --> ST
The dashboard never writes directly to /etc — it drops a file in its own data
directory and lets a root helper do the rest. The status combines three
independent sources: the systemd service state, the $SYS connection state, and
a checksum comparison between the stored and the installed configuration.
10. Browser history (IndexedDB, not on the Pi)
The dashboard keeps a rolling measurement history, but it lives in each
browser’s IndexedDB (energy-node-dashboard, store version 2) — the Pi
stores nothing. internal/history on the server side only defines the data
contract and the retention window; it holds no samples.
flowchart LR
subgraph Browser["Browser (one leading tab records)"]
REC["history-recorder.js<br/>Web Locks leader"]
IDB[("IndexedDB<br/>samples_raw / _1m / _5m")]
MNT["history-maintenance.js<br/>compaction"]
CHART["history.js<br/>chart panel"]
end
API1["GET /api/v1/energy"] --> REC
API2["GET /api/v1/history/entities"] --> REC
REC --> IDB
IDB --> MNT --> IDB
IDB --> CHART
EX["history-exchange.js"] <-->|"/api/v1/history/exchange/*"| SRV{{"dashboard<br/>relay + 24 h ring buffer"}}
IDB <--> EX
- What is recorded: the energy roles (
role:pv,role:grid_import, …) taken from the balance, plus any individual entities listed insettings.jsonunderhistory_extra_entities, whose current values the browser polls fromGET /api/v1/history/entities— a server-side allowlist, so the browser cannot ask for arbitrary IDs. The sampling interval comes from the dashboard settings, not from code. - One recorder per browser. The writing tab holds a Web Locks lease
(
energy-node-historizer); the other tabs read only and are notified over aBroadcastChannel. Two tabs writing slightly offset timestamps would defeat the composite key and bloat the store. - Three tiers, bounded retention:
samples_raw(~6 h),samples_1m(~7 days),samples_5m(~30 days).history-maintenance.jsrolls raw → 1 m → 5 m in the leading tab, advancing a watermark in themetastore so a run only ever touches the newest span. - Device-to-device exchange (
/api/v1/history/exchange/*): the server is a relay, not a store — offers are broadcast, requests and deliveries are passed to exactly one peer over an SSE stream, nothing is written to disk. A 24 h in-memory ring buffer joins as the pseudo-peerserver, so a single browser can still backfill after a reload. Peers announce coverage as coarse rasters (counts per time bucket), diff them, and request only the missing ranges. Only the1mand5mtiers are exchanged — never raw, whose offset timestamps would not deduplicate. Protocol version 1; limits are 500 rows per delivery, 20 000 per request, 1 MiB per body.
11. What the system deliberately does not do
These non-features explain many of the design decisions above:
- No server-side time-series database. The Pi’s registry holds only the current state; the rolling history lives in the browser (§10), and any long-term archive is the main site’s job.
- No server-side event history. Command history, the last bridge apply and the last Tailscale action live in memory only and are gone after a restart — as is the history-exchange ring buffer (§10).
- No wildcard subscription. What was not announced via Discovery, the dashboard does not see.
- No direct device access from the dashboard. The exception is the TinyTuya helper, which calls a Python script for the initial setup.