Data flows in the Energy Node system
This page describes which data is produced where, which channels it travels through and who reads it. It covers the whole system, so the Python bridges, the MQTT broker, the Go dashboard and the browser all appear together.
Details of the individual parts are documented elsewhere: the dashboard’s HTTP
interface in api.md, the Python infrastructure package in
libs/energy_node_common/, and the Mosquitto bridge to the main site in
section 5 of INSTALLATION.md.
1. The whole picture
Device traffic goes through one local MQTT broker (Mosquitto on the Raspberry Pi). The device bridges and the dashboard have no direct HTTP path between them.
Home Assistant reaches the node in two ways. It gets the MQTT topics through the Mosquitto bridge, and with the Energy Node Companion it also talks HTTP to the dashboard, for the history exchange and for the sidebar page (§14).
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)"]
EDISC["energydiscovery<br/>(own HA device<br/>Energy Node)"]
STATS[("energystats<br/>hourly kWh")]
end
BR["Mosquitto bridge"]
subgraph HA["Main site (Home Assistant)"]
HAMQTT["MQTT integration<br/>+ recorder"]
COMP["Energy Node Companion<br/>(history peer, panel proxy)"]
end
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
EDISC -->|"homeassistant/sensor/energy_node/…/config"| BROKER
REG --> STATS
STATS <--> API
WEB -->|HTML| BROWSER
API <-->|"JSON / SSE"| BROWSER
BROKER <--> BR
BR <--> HAMQTT
HAMQTT -->|"entity registry,<br/>states, statistics"| COMP
COMP <-->|"HTTP / SSE<br/>/api/v1/history/exchange/*"| API
COMP <-->|"panel proxy<br/>(whole dashboard)"| WEB
Device state flows up (device → bridge → broker → dashboard → browser). Commands and configuration flow down (browser → dashboard → broker → bridge → device). Home Assistant closes a loop: it records what the bridge mirrors and the companion hands parts of that back to the dashboard.
2. Topic conventions
There are two namespaces with separate jobs:
| Namespace | Purpose | Retained | Who writes |
|---|---|---|---|
homeassistant/{component}/{device_id}/{object_id}/config |
Discovery: which entity exists and where its value is | yes | Python bridges, and the dashboard for its own device energy_node |
outstation/{device_id}/... |
Payload data: measurements, status, commands | partly | bridges and dashboard |
The main topics below outstation/:
| Topic | Direction | Content |
|---|---|---|
outstation/{device_id}/{group}/{field} |
bridge → broker | Measurement (for example 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 | The bridge reloads its JSON configuration |
outstation/energy_node/energy/balance |
dashboard → broker | Energy balance, every 10 s, retained |
outstation/energy_node/status/online |
dashboard → broker | Availability of the dashboard (1/0), retained, also the last will |
homeassistant/sensor/energy_node/{object_id}/config |
dashboard → broker | Discovery of the dashboard’s own energy sensors (§3) |
outstation/automation/test/set |
dashboard → automation | Run one rule action as a test |
outstation/automation/test/result |
automation → broker | Result of the test |
$SYS/broker/connection/{remote_client_id}/state |
broker → dashboard | Whether the bridge to the main site is up (1/0) |
The automation service refuses to publish to wildcards, to homeassistant/...
(no forged Discovery), to $SYS/... and to the dashboard’s own topics
outstation/energy_node/energy/balance and
outstation/energy_node/status/online.
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. After a restart of the dashboard the broker delivers the retained messages again.
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
The dashboard only subscribes to topics it has discovered, never to
outstation/#. If a Discovery field is empty, the dashboard does not see that
value.
An empty payload on a config topic deletes the entity, and topics nobody
needs any more are unsubscribed.
Parse errors are collected in reg.DiscoveryErrors() and shown by
GET /api/v1/discovery, so they do not get lost.
The discovery_prefix is configurable (default homeassistant).
The dashboard as a Discovery publisher
The dashboard also announces itself. On every connect and reconnect it
publishes retained Discovery for the Home Assistant device “Energy Node”. Two
parts share that device through device.identifiers = ["energy_node"]:
| Part | Entities | Values from |
|---|---|---|
internal/energydiscovery |
seven read only sensors: pv_power, grid_import, grid_export, battery_charge, battery_discharge, house_load (the load_total field), battery_soc |
the balance topic outstation/energy_node/energy/balance, one field each through value_template |
internal/nodeagent |
Pi diagnostics, poll rate, simulation switch | outstation/energy_node/… |
The unique IDs are energy_node_<object_id>, for example
energy_node_pv_power. The Energy Node Companion finds these sensors in Home
Assistant by exactly these IDs (§14), so they must not change.
Three more sensors are optional: energy_node_wallbox, energy_node_heat_pump
and energy_node_custom_<id> for each custom category. They are announced only
once something is assigned to the wallbox, the heat pump or the category. The
setting energy_sensors decides which of them are announced, and a missing
setting counts as on. A category that is deleted or unassigned is retracted
with an empty retained config. The announced sensors are remembered in
energydiscovery/announced.json, so the retraction also works after a restart.
All energy sensors point to outstation/energy_node/status/online as their
availability. When Tailscale reports a MagicDNS name, the device block carries
it as configuration_url, and the configs are published again when the name
changes.
The switch publish_energy_device in mqtt.json (default on) turns the
energy sensors off. The dashboard then publishes empty retained payloads on the
same topics, which removes the entities in Home Assistant. Without these
sensors the companion has no source for the energy roles.
The dashboard subscribes to its own Discovery like to any other, so the “Energy Node” device also shows up in its own registry.
4. Live values all the way to the browser
The browser never talks MQTT. The registry counts every change in a version number, and one SSE stream carries the version together with the topics the page subscribed to.
flowchart LR
M{{Mosquitto}} -->|"state / availability"| C[mqttclient]
C --> R[("registry<br/>version: uint64")]
R -->|"ObserveChanges"| RC[("runtime.json<br/>runtime cache")]
R --> HUB["event hub<br/>builds each subscribed<br/>topic once per change"]
ES[("energystats<br/>revision")] --> HUB
HUB -->|"event: registry<br/>version + topics"| S["GET /api/v1/events?topics=…&devices=…"]
S --> JS["browser JS<br/>patches tiles in place"]
R -->|"ETag: registry-N"| D["GET /api/v1/devices"]
D -.->|"full load,<br/>pages without topics"| JS
Each event carries version and one branch per subscribed topic:
| Topic | Content | Version source |
|---|---|---|
energy |
energy snapshot with balance | registry |
entities |
entity values, complete on connect, then only changes (entities_delta) |
registry |
diagnostics |
summary of the diagnostics | registry |
devices |
structure, availability, status and with :auto the compact rows of the devices chosen with ?devices=, as a delta after the first event |
registry and device preferences |
energy_stats |
only {"rev": N} of the energy statistics |
statistics store, so a backfilled hour from Home Assistant triggers an event while the registry is idle |
Without ?topics= a client gets all topics except devices. With an empty
?topics= it gets only the version and fetches GET /api/v1/devices itself
when the version changes. The exact contract is in api.md.
The hub builds a topic only while someone subscribes to it, and only once per change for all connections. A connection that falls four events behind is dropped and starts again with a complete event after the browser reconnects.
The send interval of the hub comes from settings.json
(live_update_interval_seconds, default 3 s) and takes effect without a
reconnect.
GET /api/v1/devices returns ETag: "registry-N". With a matching
If-None-Match the server answers 304.
The runtime cache (runtime.json) survives restarts. It remembers the last
values and restores them when an entity comes back, 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 only publishes to command_topics from Discovery. No API path
writes to an arbitrary topic, except the fixed automation test on
outstation/automation/test/set.
Each command is recorded per device in an in-memory history (at most 12
entries) and appears in GET /api/v1/devices/{id} as command_actions.
6. Configuration: files, not a database
There are two directories with different owners.
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
Writes are atomic (temp file and rename) and store a revision first. Every
configuration can be viewed under .../revisions and restored with
.../restore.
The reload goes over MQTT and not through systemd. The target service swaps its
configuration inside the running process. automation_rules maps to the
service ID automation, otherwise {name}_devices maps to {name}.
Passwords always live in their own files (*_credentials.json), so they stay
out of the revision copies. The API never returns them in clear text and only
reports password_configured: true/false.
7. Energy balance
The balance is derived data. Entities get roles, and the roles produce a balance object that is available 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, retained<br/>outstation/energy_node/energy/balance"]
MQTT --> AUTO["automation_mqtt.py<br/>balance_threshold condition"]
MQTT --> HAS["Home Assistant sensors<br/>of the device Energy Node"]
HTTP --> UI["energy-flow chart"]
BAL --> EST["energystats<br/>hourly kWh (§13)"]
The payload is {"at": <unix s>, "balance": {…}, "interpretation": {…}}.
balance.categories holds one value in watts per custom category, keyed by the
category ID. Consumers are never negative, while producers and storage keep
their raw sign. The seven energy sensors from §3 each pick one
field of balance, and Home Assistant records them. These recorded values are what the companion later
supplies back (§14).
The automation service takes the balance as finished numbers and computes
nothing itself. If the balance is older than balance_max_age_s (default
30 s), rules based on it 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
The condition types are balance_threshold, battery_soc, topic_value,
time_window, sun_window and entity_value. The action types are publish
and notification.
9. Path to the main site
The Mosquitto bridge mirrors both namespaces in both directions to the main
site (topic outstation/# both 0, topic homeassistant/# both 0), so the main
site sees the same Discovery and measurement topics as the dashboard. These are
the defaults, the topic list is editable per connection in bridge.json.
Home Assistant on the main site creates its MQTT entities from the mirrored Discovery, the bridges’ devices as well as the dashboard’s own “Energy Node” device. The companion depends on this path (§14): it finds the dashboard’s series in Home Assistant only through these MQTT entities.
The dashboard manages the bridge but is not part of its 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 to /etc itself. It puts a file into its own data
directory and lets a root helper do the rest. The trust boundary is the helper,
not the dashboard process: it reads exactly one compiled-in staging path, requires
a regular file owned by the dashboard user of at most 8 KB and 200 lines, and
accepts only lines that match a fixed directive allowlist (connection,
address, topic and the like). A compromised dashboard process therefore
cannot slip in log_dest, a new listener or any other directive. The helper keeps
the previous file as a backup and restores it if Mosquitto does not restart. The status combines three
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 in each browser’s IndexedDB
(energy-node-dashboard, store version 2). The Pi stores nothing.
internal/history on the server only defines the data contract and the
retention window and 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
The browser records the energy roles (role:pv, role:grid_import, …) from
the balance. It also records the entities listed in settings.json under
history_extra_entities and polls their values from
GET /api/v1/history/entities. That endpoint is an allowlist on the server, so
the browser cannot ask for arbitrary IDs. The sampling interval comes from the
dashboard settings.
Each browser has one recorder. The writing tab holds a Web Locks lease
(energy-node-historizer). The other tabs only read and are notified over a
BroadcastChannel. Two tabs writing slightly different timestamps would break
the composite key and bloat the store.
There are three tiers with limited retention: samples_raw (~6 h), samples_1m
(~7 days) and samples_5m (~30 days). history-maintenance.js compacts raw to
1 m and 1 m to 5 m in the leading tab. It moves a watermark forward in the
meta store, so each run only touches the newest span.
The hourly resolution is not kept in the browser. The history page reads it from the node’s energy statistics (§13).
For the exchange between devices (/api/v1/history/exchange/*) the server only
relays and stores nothing. Offers go to all peers. Requests and deliveries go to
exactly one peer over an SSE stream, and nothing is written to disk. A 24 h ring
buffer in memory joins as the pseudo-peer server, so a single browser can
still fill gaps after a reload. Peers announce their coverage as coarse rasters
(counts per time bucket), compare them and request only the missing ranges.
Only the 1m and 5m tiers are exchanged. Raw samples are not, because their
offset timestamps would not deduplicate. Protocol version 1 allows 500 rows per
delivery, 20 000 per request and 1 MiB per body.
Each browser runs the exchange in one of two modes, stored per device in
localStorage (history-exchange-mode). always offers its own coverage and
requests every gap of the full raster window, as described above. on-demand
is the default. The device keeps its SSE stream open, feeds the ring buffer
and answers requests, but it sends no offer of its own. It only plans requests
for the ranges the history page or a history tile currently shows
(HistoryExchange.watchRange()). Tabs that do not lead forward these ranges to
the leading tab over the recorder’s BroadcastChannel and get a message back
when rows arrive. In always mode a device always offers its own coverage. In on-demand mode
it offers it only if the per-device choice history-exchange-serve is on,
and is otherwise a source only through the ring buffer.
Minute values are only exchanged up to 30 minutes before now (SETTLE_MS in
history-coverage.js). Until then devices can hold different numbers of samples
for the same minute, so the newest half hour would not match. Each watched range
also asks for the last 30 minutes of the minute tier once, and again at most
every five minutes while the range stays on screen. That request only goes out in
on-demand mode and while the exchange stream is connected.
Home Assistant can join as a permanent peer that only supplies data, through the Energy Node Companion (labelled “Home Assistant”). Which series it maps and from which recorder tables it answers is described in §14.
11. What the system does not do on purpose
Several design decisions above follow from these limits.
There is no time series database on the server. The Pi’s registry only holds the current state, and the only thing the Pi keeps over time is the hourly energy per role (§13). The rolling measurement history lives in the browser (§10), and a long term archive is the main site’s job. With the companion the main site feeds parts of that archive back (§14).
There is no event history on the server either. Command history, the last bridge apply and the last Tailscale action live in memory and are lost on a restart, like the ring buffer of the history exchange (§10).
There is no wildcard subscription. The dashboard does not see what was not announced through Discovery.
The dashboard does not access devices directly. The only exception is the TinyTuya helper, which calls a Python script for the initial setup.
12. Data flow on the device map
The data flow layer of the device map
is drawn from what the services declare in their JSON schemas. Every field with
"format": "mqtt-topic" needs an x-dataflow annotation:
| Annotation | Meaning |
|---|---|
direction |
input or output |
label |
text on the edge |
json_key_field |
field that holds the JSON key, when the payload is JSON |
unit_field |
field that holds the unit |
x-dataflow-label-key |
translation key for the label, on the field |
x-dataflow: false |
leave the field out of the data flow (true is not allowed) |
A service that lists entries, such as several banks, puts x-dataflow-node on
the schema of one item with id_field, label_field and device_id_template.
Each entry becomes its own node. It is linked to a device when the template
gives the ID of a known device, otherwise it stays a service node.
The contract test TestEveryServiceTopicFieldDeclaresDataflow in
dashboard/internal/dataflow/contract_test.go walks the schema of every
service and fails for a topic field without an annotation, so a new service has
to decide for each topic whether it appears in the data flow.
13. Energy statistics
The dashboard records energy per hour and role on its own, whether or not a browser is open. Every 10 seconds internal/energystats takes the energy snapshot with its derived balance and integrates the balance fields (pv, grid_import, grid_export, battery_charge, battery_discharge, load_total, base, wallbox, heat_pump and consumer or producer categories as custom:<id>) into kWh. A value counts until the next snapshot, for at most 5 minutes. A snapshot with a stale power role is skipped. Recording waits until the system clock is synchronised, and a clock jump discards the running hour.
Hours are stored per UTC month in <data dir>/energystats/<yyyy-mm>.json. Each hour carries its source: own recording or Home Assistant. Home Assistant always wins over own recording.
GET /api/v1/energy/stats?period=day|week|month|year&at=<date> sums the hours into hours, days or months in the local time zone and adds autarky and self consumption for each bucket and for the whole period. Weeks run Monday to Sunday and are summed into days.
GET /api/v1/energy/stats/hours?from=<ms>&to=<ms> returns the hourly means that the history page shows for spans of a week or longer (see api.md). Own hours with less than 50 minutes of recording are left out, Home Assistant hours always count.
Hours from Home Assistant
Where the companion takes these hours from is described in §14. The Energy Node Companion offers a third exchange tier 1h next to 1m and 5m. Its rows carry the mean power of one hour in W, which equals the energy of that hour in Wh. Its coverage counts complete hours per UTC day, starting at the oldest day Home Assistant has, at most ten years back. The protocol version stays 1. A companion only offers 1h when the announcement lists it, and it marks its offer with kind: "companion".
Inside the dashboard internal/energystats acts as the pseudo peer energystats, like the ring buffer acts as server. It reads every companion offer, compares it with the hours it already holds from Home Assistant and requests what is missing, newest days first, at most 31 days per request and one request at a time. Deliveries addressed to energystats are handled by the server and never relayed. A day that Home Assistant offers but cannot fill is requested again only when the offered hour count changes.
The statistics API reports companion.connected, companion.current (the last or second to last complete hour came from Home Assistant for every offered role) and companion.missing_days.
The setting energy_stats_recording (always, pause_with_companion, off) controls the own recording. In pause mode the recorder keeps integrating but holds completed hours in memory (at most three) while the companion reports current. When that ends it writes them, AddOwn never touches hours from Home Assistant. Switching to off stores the started hour first.
The response also carries at, prev and next for navigation, local per bucket (start in the node’s local time, used for labels so a browser in another time zone shows the node’s hours), categories for custom role labels, status.state (recording, paused, off, waiting_for_clock), status.corrupt_months and companion.backfill_from.
revision is the statistics revision at the time of the query. Compare it with energy_stats.rev from the event stream to decide whether to reload.
14. Home Assistant and the Energy Node Companion
The integration energy_node_companion runs inside Home Assistant and connects
it with one dashboard per config entry. It has three data paths, and each can be
switched on its own in the options: history (history), energy statistics
(stats) and the sidebar page (panel: all users, administrators only or off).
flowchart LR
subgraph Node["Energy Node (Pi)"]
BAL["balance topic +<br/>own Discovery (§3)"]
HX["history exchange<br/>/api/v1/history/exchange/*"]
ESY["energystats<br/>pseudo peer"]
DASH["dashboard pages<br/>and API"]
end
subgraph HA["Home Assistant"]
MQ["MQTT integration<br/>entity registry"]
RECO[("recorder<br/>states, statistics")]
PEER["HistoryPeer"]
PROXY["DashboardProxyView"]
end
BR["Mosquitto bridge"]
B1["Browser on the node"]
B2["Browser in HA<br/>(sidebar iframe)"]
BAL --> BR --> MQ --> RECO
MQ -->|"unique_id → entity_id"| PEER
RECO -->|"rows"| PEER
PEER <-->|"offer / request / deliver"| HX
HX <--> B1
HX -->|"1h"| ESY
B2 <--> PROXY <-->|"guest session or<br/>the user's own session"| DASH
Where the companion’s values come from
The companion creates no entities and stores no values of its own. Everything it supplies comes from the Home Assistant recorder, about MQTT entities that exist only because the dashboard and the bridges announced them through Discovery and the bridge mirrored them (§3, §9).
It maps the dashboard’s series to Home Assistant entities through the entity
registry, platform mqtt, by unique_id:
| Dashboard series | Home Assistant source |
|---|---|
role:pv |
energy_node_pv_power |
role:grid |
energy_node_grid_import minus energy_node_grid_export |
role:battery |
energy_node_battery_charge minus energy_node_battery_discharge |
role:battery_soc |
energy_node_battery_soc |
| any extra history entity | the sensor or number with the same unique_id |
role:load, role:wallbox and role:heat_pump are never supplied in the
1m and 5m tiers. house_load carries load_total and not the measured
load role, and the dashboard publishes no sensor for wallbox and heat pump.
| Tier | Source in the recorder | Fallback |
|---|---|---|
1m |
state changes, weighted by how long each value held | none |
5m |
5-minute statistics (mean, min, max) |
state changes, for entities without statistics and for difference series |
1h |
hourly long term statistics, per role | none |
For 1h each role takes either an energy meter picked in the options (the
hourly change in Wh, a negative change from a meter reset drops the hour) or
the power sensor from §3 (the hourly mean in W). Wallbox and heat pump are
only available with a meter. Values are converted to the dashboard’s unit, and
a series whose unit cannot be converted is left out instead of supplying wrong
numbers. Unavailable states stay gaps.
Exchange with the dashboard
sequenceDiagram
participant P as Companion (HistoryPeer)
participant D as dashboard
participant S as energystats
participant B as Browser
P->>D: POST /api/v1/auth/guest (once, kept until a 401)
P->>D: GET /api/v1/history/exchange (series, tiers, limits)
P->>D: GET /api/v1/history/exchange/stream (SSE)
D-->>P: hello (peer_id)
P->>D: POST /offer (label "Home Assistant", kind "companion",<br/>coverage 1m, 5m and 1h)
D-->>B: offer (browsers use 1m and 5m)
D->>S: OnOffer (1h coverage)
B->>D: POST /request (missing ranges)
D-->>P: request
P->>D: POST /deliver (chunks of at most max_rows_per_deliver)
D-->>B: deliver
S->>D: request for missing days (1h)
D-->>P: request
P->>D: POST /deliver to "energystats"
D->>S: OnDeliver (handled on the server, never relayed)
The companion is a pure source. It never asks for rows, keeps no store of its
own and does not feed the server’s ring buffer. It sends 1h only when the
announcement lists that tier, so an older dashboard is not sent an offer it
would reject. kind: "companion" is passed on only for this peer, and only then
does the server hand the 1h coverage to energystats. Browsers ignore 1h.
The companion recomputes its coverage every ten minutes and sends a new offer
only when it changed. When another peer joins it sends the current offer again.
On a lost connection it reconnects with a pause growing from 2 to 60 seconds.
The guest session is kept across reconnects, because every guest login writes
users.json on the node’s SD card.
The sidebar page
The page in the Home Assistant sidebar is the real dashboard, passed through by Home Assistant. The browser only talks to Home Assistant, the Home Assistant host is the only one that has to reach the node.
sequenceDiagram
participant B as Browser (HA frontend)
participant H as Home Assistant
participant D as dashboard
B->>H: POST /api/energy_node_companion/panel_session/{entry}<br/>(HA token)
H-->>B: cookie energy_node_panel, path = proxy prefix, 2 h
B->>H: iframe GET /api/energy_node_companion/proxy/{entry}/
H->>H: check panel cookie, user active, panel mode
H->>D: same request with X-Forwarded-Prefix/Proto/Host/For<br/>and one dashboard session cookie
D-->>H: page, links under the proxy prefix
H-->>B: response, streamed without buffering (SSE)
The proxy sets the forwarding headers itself and drops whatever the browser
sent in them, because the dashboard trusts X-Forwarded-Proto for its HTTPS
check. The dashboard resolves its base path per request from
X-Forwarded-Prefix (internal/basepath).
Home Assistant’s cookies, the panel cookie and the HA token from
Authorization stay in Home Assistant. Upstream the request carries exactly
one dashboard session and at most the language cookie lang. If the browser
has signed in to the dashboard itself, its own session is used. Otherwise the
proxy uses the guest session it shares with the history peer. A logout through
the panel does not end that shared session, and a GET or HEAD on the guest
session that hits a 401 is repeated once after a new guest login.
The panel cookie lives only in Home Assistant’s memory, so after a restart of Home Assistant the page fetches a new one. The page renews it every 30 minutes while it is open.