Dashboard API documentation
This is the HTTP interface of the Energy Node dashboard (Go binary
energy-node-dashboard). All endpoints live under /api/v1/.
Related pages:
- data-flow.md: data flows across the whole system
- ../operating/reverse-proxy.md: running under a sub-path behind a reverse proxy
- ../operating/secrets.md: secrets, passwords and credential files
- The cache-busting notes in the repository cover frontend assets.
When in doubt, the routing table in dashboard/internal/httpapi/httpapi.go
(NewRouterWithDependencies) is authoritative.
Fundamentals
Base URL and subpath
The default is http://<host>:8080/. Behind a reverse proxy under a subpath,
basepath.Middleware strips the subpath before the router sees the request, so
the router only knows root-relative paths. Clients put the subpath in front of
/api/v1/....
Response format
All responses are application/json. Errors always take this form:
{ "code": "device_not_found", "message": "Gerät wurde nicht gefunden" }
code is stable and meant for programs. message is a German fallback text.
The dashboard shows the catalog text for code or message_key and only uses
message for codes it does not know. On 405 the server also sets the Allow
header.
Some errors carry additional fields, all optional:
{ "code": "bridge_forbidden", "message": "Für Bridge-Revisionen fehlt die Berechtigung",
"message_key": "error.bridge_forbidden.revisions" }
{ "code": "settings_rejected", "message": "layout: page id is empty",
"detail": "layout: page id is empty" }
message_keyandparamsname a catalog text for a code with several variants, with the values for its placeholders.detailcarries a passed-through technical error, usually in English. The dashboard appends it to the translated text ofcode.tool_output(TinyTuya only) is the output of the helper tool.
Common error codes
| Code | Status | Meaning |
|---|---|---|
authentication_required |
401 | No session, or the session has expired |
secure_login_required |
403 | Admin operation attempted without HTTPS |
secure_connection_required |
403 | Endpoint requires HTTPS |
csrf_failed |
403 | X-CSRF-Token missing or does not match |
method_not_allowed |
405 | Method not supported |
*_forbidden |
403 | Role missing (e.g. mqtt_config_forbidden) |
*_busy |
409 | A system action is already running |
*_rejected |
400 | Input rejected (validation, schema) |
Authentication and permissions
Sessions
A middleware (authMiddleware) protects everything except /static/* and
/api/v1/auth/*. Without a valid session, GET / returns the login page and
any /api/* call returns 401.
There are two cookies, depending on the transport:
| Cookie | When set/read | Session lifetime |
|---|---|---|
energy_node_session |
HTTPS requests | 24 h (password login) |
energy_node_guest_session |
HTTP requests | 7 days (guest session) |
Both are HttpOnly and SameSite=Lax. A request counts as HTTPS if the server
terminates TLS itself or if X-Forwarded-Proto: https is set.
Sessions survive a restart of the dashboard, so an update from the
Re-Deploy screen keeps the operator logged in. They live in sessions.json
next to users.json (mode 0600). The file stores a SHA-256 hash of each
session token, never the token itself. Expired sessions and sessions of
deleted users are dropped when the dashboard starts.
In practice the transport decides the session type. Password login only works over HTTPS, guest access also over HTTP.
A session with the system_actions role is useless over plain HTTP. The
middleware discards it and asks for authentication again, so admin work needs
HTTPS.
Roles
| Role | Allows |
|---|---|
system_actions |
Restart/reboot/poweroff, apply bridge, Tailscale actions |
mqtt_config |
Configure the MQTT connection and the Mosquitto bridge |
automations |
Write and test automation rules |
delete_device_discovery |
Delete a device’s discovery entries |
tune_live_updates |
Change live_update_interval_seconds |
edit_layout |
Edit mode of the overview and writes to /api/v1/device/prefs/{id}. PUT /api/v1/layout itself has no gate yet |
edit_energy |
Write the energy configuration: roles, interpretation, groups, categories and restoring their revisions |
check_updates |
Ask whether a newer version is available on GitHub (/api/v1/updates/status and /api/v1/updates/check). Read only, it starts no update |
A session without a role can read everything and change the layout, switch commands and the remaining settings.
Guest sessions get automations, edit_layout, edit_energy and
check_updates for now, because there is no per user role management yet. A
guest can therefore write and test automation rules, which may publish to MQTT
within publish_allowed_prefixes. Keep this in mind before you open guest
access to people you do not trust.
CSRF
Privileged write endpoints need the X-CSRF-Token header with the value from
GET /api/v1/auth/session (field csrf_token). It is checked against the
cookie’s session.
Three-stage pattern
The security critical endpoints (bridge, MQTT configuration, Tailscale) always check in this order: role, then HTTPS, then CSRF.
Endpoint overview
In the Gate column, – means a session is enough. Otherwise the column names
the role or other requirements.
Auth
| Method | Path | Gate | Purpose |
|---|---|---|---|
| POST | /api/v1/auth/login |
HTTPS | Log in with username/password |
| POST | /api/v1/auth/guest |
– | Guest session without a password |
| GET | /api/v1/auth/session |
– | Current session, roles, CSRF token |
| POST | /api/v1/auth/logout |
– | Log out, delete cookie |
GET /api/v1/auth/session responds with:
{
"username": "admin",
"guest": false,
"roles": ["system_actions", "mqtt_config"],
"system_actions": true,
"delete_device_discovery": true,
"tune_live_updates": true,
"mqtt_config": true,
"automations": true,
"edit_layout": false,
"csrf_token": "…",
"expires_at": "2026-08-12T09:00:00Z"
}
Devices and entities
| Method | Path | Gate | Purpose |
|---|---|---|---|
| GET | /api/v1/devices |
– | All devices with entities and live values |
| GET | /api/v1/devices/{id} |
– | A single device including warnings and command_actions |
| GET | /api/v1/devices/{id}/diagnostics |
– | The device’s diagnostic warnings |
| POST | /api/v1/devices/{id}/reload |
– | Rebuild the registry completely |
| POST | /api/v1/devices/{id}/ignore |
CSRF | Hide device |
| POST | /api/v1/devices/{id}/unignore |
CSRF | Undo hiding |
| GET | /api/v1/devices/{id}/discovery-delete/preview |
delete_device_discovery |
What would be deleted? |
| POST | /api/v1/devices/{id}/discovery-delete |
delete_device_discovery + CSRF |
Delete discovery topics |
| GET | /api/v1/devices/ignored |
– | List of hidden devices |
| GET | /api/v1/discovery |
– | Devices + parse errors + duplicate IDs |
| GET | /api/v1/discovery/summary |
– | device_count, entity_count, discovery_errors, duplicate_ids; carries ETag: "registry-N" |
| GET | /api/v1/topics |
– | All known topics |
| GET | /api/v1/topics/samples |
– | Last payload per topic |
| GET | /api/v1/automation/notification |
– | Last {at, message} event from the automation service’s last_event topic, parsed. 404 when no automation service, 204 before its first event. Polled by notifications.js for toasts. |
| GET | /api/v1/automation/state |
– | The automation service’s full state document (at, online, rules, events, counters) from its state topic. 404 when no automation service, 204 before the first usable document. Read by the automations page instead of last_message, which is capped at 4096 bytes. |
| GET | /api/v1/automation/history |
– | The live trigger history {at, rules, rule_count} from the history topic, also when history_persist is off. Same 404 and 204 rules as the state route. |
| GET | /api/v1/events |
topics, devices |
SSE stream with the registry version and the subscribed topics |
| POST | /api/v1/entities/{unique_id}/command |
– | Switch an entity or set a value |
GET /api/v1/devices returns ETag: "registry-N". With a matching
If-None-Match the server answers 304 Not Modified without a body. Use this
when you poll repeatedly.
GET /api/v1/events is a Server-Sent Events stream. Each event carries the
registry version and one branch per subscribed topic:
event: registry
data: {"version":42,"energy":{...},"entities":{...},"entities_delta":true}
topics is a comma-separated list. Known topics are energy (energy
snapshot), entities (entity values, full on connect, then only changes with
entities_delta: true), diagnostics (summary), devices (see below) and energy_stats ({"rev":N}, the revision of the
energy statistics, present when the dashboard has a data directory). An event
arrives when rev changes, even if the registry is idle. Unknown names are ignored. Without the parameter you get all topics except devices. With an empty parameter (?topics=) you get only
{"version":N} whenever the registry changes, which is enough if you fetch
GET /api/v1/devices yourself.
devices (per subscribed device: structure, availability, status and,
with :auto, the automatic compact rows). Choose the devices with
devices=a,b:auto, or * / *:auto for all of them. A delta carries
devices_delta: true and only changed devices, a removed one as null. With
* the event also carries devices_list, the device IDs in display order.
devices is not part of the default set.
The server builds a topic only while someone subscribes to it, once per change
for all connections. To change the topics, close the stream and open it again.
The send interval comes from settings.json and takes effect without a
reconnect.
POST /api/v1/entities/{unique_id}/command, with {unique_id} URL-encoded.
The body depends on the component:
// switch / light: without a body the server toggles, otherwise explicit
{ "payload": "ON" }
// number: the value is checked against min/max/step from discovery
{ "value": 42 }
Response: {"entity_id":"…","payload":"ON","status":"published"}.
Error codes: command_not_supported (404), invalid_command_value,
command_value_out_of_range, invalid_command_step,
invalid_command_payload (all 400), command_publish_failed (502).
The dashboard only publishes to command_topics from discovery. The API cannot
reach arbitrary topics.
Energy
| Method | Path | Gate | Purpose |
|---|---|---|---|
| GET | /api/v1/energy |
– | Current balance including interpretation |
| GET/PUT | /api/v1/energy/roles |
– | Role assignment per entity |
| GET/PUT | /api/v1/energy/interpretation |
– | Interpretation parameters |
| GET | /api/v1/energy/revisions |
– | Revision list for energy.json (roles + interpretation) |
| GET | /api/v1/energy/revisions/{revision} |
– | A single revision |
| POST | /api/v1/energy/restore |
– | Restore a revision, body {"revision":"…"} |
| GET | /api/v1/energy/stats/hours |
– | Hourly means in W for the history page |
Available roles: pv, battery, battery_charge, battery_discharge,
battery_soc, grid, grid_import, grid_export, load, wallbox,
heat_pump.
PUT /api/v1/energy/roles replaces all of assignments. If the body has no
interpretation, the stored one is kept, so saving only the roles does not
reset the interpretation.
The balance is also published every 10 s to
outstation/energy_node/energy/balance, which the automations use.
GET /api/v1/energy/stats/hours?from=<ms>&to=<ms> returns hourly means for
the history page. from and to are Unix times in milliseconds. from is
rounded down to the full hour, to must be later than from, and the span may
be at most 400 days.
{
"rows": [
{ "series": "role:pv", "ts": 1785999600000, "min": 1520.4, "max": 1520.4, "avg": 1520.4, "n": 1, "u": "W" }
]
}
Each row is one hour. ts is the start of the hour in milliseconds. min,
max and avg are the mean power of that hour in W, so they are equal, and n
is 1. The series are:
role:pv,role:wallbox,role:heat_pumprole:grid(import minus export) androle:battery(charge minus discharge)berechnet:hausverbrauch, the house consumption fromload_totalrole:custom:<base>:<id>for custom categories, except storage categories
An own hour counts only if the node recorded at least 50 minutes of it. Hours from Home Assistant always count. An hour without data is missing from the rows, and a series appears only if at least one of its hours is not zero. Rows are sorted by series and then by time.
| Status | Code | When |
|---|---|---|
| 400 | energy_stats_invalid_range |
from or to is missing or not an integer, to is not after from, or the span is longer than 400 days |
| 501 | energy_stats_unavailable |
The node runs without a data directory, so there is no statistics store |
| 500 | energy_stats_failed |
Reading the monthly statistics failed |
History
| Method | Path | Gate | Purpose |
|---|---|---|---|
| GET | /api/v1/history/entities |
– | Current value of the entities on the history allowlist |
| GET | /api/v1/history/exchange |
– | Self-describing announcement: protocol version + limits |
| GET | /api/v1/history/exchange/stream |
– | SSE stream: peer join/leave, offers, requests, deliveries |
| POST | /api/v1/history/exchange/offer |
– | Broadcast a coverage offer to the other peers |
| POST | /api/v1/history/exchange/request |
– | Ask one peer (or the ring buffer) for specific ranges |
| POST | /api/v1/history/exchange/deliver |
– | Hand rows to one peer, answering a request |
| POST | /api/v1/history/exchange/buffer |
– | Feed rows into the server’s 24 h ring buffer |
The dashboard keeps no history on disk. Samples live in each browser’s IndexedDB (see data-flow.md §10), and these endpoints only serve the recorder in the browser.
GET /api/v1/history/entities returns the current value of the entities
named in settings.json under history_extra_entities. That list is an
allowlist on the server, so the browser cannot poll arbitrary IDs:
{
"at": "2026-09-06T09:00:00Z",
"samples": [
{ "entity_id": "…", "value": 42.0, "unit": "W", "stale": false }
]
}
value is null when the entity currently has no numeric value.
/api/v1/history/exchange/* transfers history between devices. The server
only relays and stores nothing. Browsers on the same broker exchange their
recorded 1m and 5m tiers (never raw), so a dashboard that was just opened
can fill its gaps. GET /api/v1/history/exchange describes the protocol
(version 1, 500 rows per delivery, 20 000 per request, 1 MiB per body). The
.../stream SSE carries the messages. A peer answers with the POST endpoints
offer, request and deliver. buffer feeds a 24 h ring buffer in memory
that takes part as the pseudo-peer server. Every POST must carry a peer
field that matches a connected stream, otherwise the answer is peer_unknown
(403). Other codes are peer_gone (404, the target left), tier_unknown
(400), label_too_long (400) and body_too_large / too_many_rows (413).
The announcement also lists series, the series this dashboard records
([{ "id": "role:pv", "unit": "W" }, …]: the energy roles that currently have a
value, plus history_extra_entities with their unit). Other peers only offer
these series. An offer can carry an optional label of at most 64
characters (otherwise label_too_long, 400). The server relays it, and the
settings page shows it as the source of added rows. The Home Assistant
integration energy_node_companion (Energy Node Companion) is such a peer. It
supplies 1m and 5m rows from the HA recorder and never requests anything.
Diagnostics and health
| Method | Path | Gate | Purpose |
|---|---|---|---|
| GET | /api/v1/health |
– | Overall status: ok or degraded |
| GET | /api/v1/health/storage |
– | State of the storage medium |
| GET | /api/v1/runtime-cache |
– | Status of the runtime cache |
| GET | /api/v1/diagnostics |
– | All warnings |
| GET | /api/v1/diagnostics/rules |
– | IDs of the diagnostic rules |
| GET | /api/v1/diagnostics/health |
– | Health score |
Each warning names its rule and the text variant it uses:
{
"rule_id": "Offline",
"key": "offline",
"severity": "critical",
"device_id": "node",
"entity_id": "temp",
"message": "Gerät meldet sich als offline.",
"hint": "Stromversorgung, Netzwerk und Bridge prüfen"
}
key selects the catalog texts diagnostics.rule.<key>.message and
.hint, which the dashboard shows in the page language. message and hint
are the German fallback for clients that do not know the key. key is not
rule_id, because one rule can produce more than one text.
GET /api/v1/health combines several sources:
{
"status": "ok",
"uptime_seconds": 86400,
"runtime_cache": { "…": "…" },
"mqtt": { "connected": true, "broker_address": "localhost:1883" },
"storage": { "available": true, "medium": "…", "mode": "…", "confidence": "…" },
"node": {
"telemetry": { "cpu_temp_c": 47.8, "ram_used_pct": 63.1, "undervoltage_now": false },
"services": [
{ "id": "shelly", "state": "active" },
{ "id": "trucki", "state": "configured" }
]
}
}
status becomes degraded when the runtime cache is degraded, the MQTT
connection is missing or the storage check fails.
node reports what the dashboard’s node agent (internal/nodeagent) publishes
about the Pi. It is only present when the agent is wired in. node.telemetry
is missing until the first reading arrives and then carries cpu_temp_c and
ram_used_pct (each a number or null) and the undervoltage_now flag.
node.services has one entry per configured device service,
{ "id": "<service_id>", "state": … }. state is active when the service’s
outstation/<id>/status/online is 1 and the last_update in its
outstation/<id>/settings/status is no older than poll_interval_s *
diagnostic_poll_multiplier + 60 s. Otherwise it is configured.
Configurations (device JSONs)
| Method | Path | Gate | Purpose |
|---|---|---|---|
| GET | /api/v1/configurations |
– | All configuration documents, each with checksum |
| GET/PUT | /api/v1/configurations/{name} |
PUT for automation_rules: automations + CSRF |
Read/write content |
| GET | /api/v1/configurations/{name}/schema |
– | JSON schema |
| GET | /api/v1/configurations/{name}/revisions |
– | Revision list |
| GET | /api/v1/configurations/{name}/revisions/{revision} |
– | A single revision |
| POST | /api/v1/configurations/{name}/restore |
– | Restore a revision |
| POST | /api/v1/configurations/{name}/reload |
– | Trigger a reload via MQTT |
| GET | /api/v1/configurations/{name}/status |
– | Reload result of the owning service: runtime_status, error, error_code, config_revision, applied_revision |
| POST | /api/v1/automations/test |
automations + CSRF |
Test a single rule action |
| GET | /api/v1/automations/history/{rule_id} |
– | Recorded fire events for one rule, read from automation_history.json in the devices directory (empty array if the file or rule is absent) |
PUT validates against the schema, creates a revision, writes atomically and
then sends a reload command over MQTT to the service. If the reload fails, the
endpoint still answers with the saved document plus reload_failed and
reload_error. The file is written in that case, but the service has not
loaded it yet.
Body limit for PUT: 2 MiB.
POST /api/v1/automations/test:
{ "rule_id": "pv-ueberschuss", "action_index": 0 }
The dashboard then publishes to the fixed topic
outstation/automation/test/set. The automation service does all checks
(rule exists, index valid, topic allowed), not the dashboard.
Settings, layout, device card
| Method | Path | Gate | Purpose |
|---|---|---|---|
| GET/PUT | /api/v1/settings |
tune_live_updates only for interval changes |
Dashboard settings |
| GET | /api/v1/settings/revisions |
– | Settings revisions |
| GET | /api/v1/settings/revisions/{revision} |
– | A single settings revision |
| POST | /api/v1/settings/restore |
– | Restore settings, body {"revision":"…"} |
| GET/PUT | /api/v1/layout |
– | Tile layout |
| GET | /api/v1/layout/revisions |
– | Layout revisions |
| GET | /api/v1/layout/revisions/{revision} |
– | A single layout revision |
| POST | /api/v1/layout/restore |
– | Restore layout |
| GET | /api/v1/device/icons |
– | Icon catalogue (name, label, markup) |
| GET | /api/v1/device/prefs |
– | Device preferences document |
| PUT | /api/v1/device/prefs/{device_id} |
edit_layout + CSRF |
Save icon, favourites, and pinning |
| GET/PUT | /api/v1/device/map |
– | Device card including manual edges |
| GET | /api/v1/device/map/revisions |
– | Revisions |
| POST | /api/v1/device/map/restore |
– | Restore |
| POST | /api/v1/device/map/relations |
– | Create a relation (201) |
| DELETE | /api/v1/device/map/relations/{id} |
– | Delete a relation (204) |
| GET | /api/v1/shelly/presets |
– | Shelly presets (read-only) |
PUT /api/v1/settings only checks the tune_live_updates role when
live_update_interval_seconds differs from the stored value. Any logged-in
session can change every other field.
Since layout v3 (Spec F), GET /api/v1/layout also returns card_types. It
maps each item type to {min_span, min_width, min_height, fills_height,
default_span} from internal/settings/cardcatalog.go, the one place that
defines how much space each card type needs. Instead of coordinates, an item
carries span (size class "1"–"6" or "full") and optionally height (a
fixed height of 1–12 units of 7 rem). card_types is only output, and PUT
ignores it.
GET /api/v1/device/icons returns the full icon catalogue as an array:
[
{
"name": "energy-node:solar-panel",
"mdi": "mdi:solar-panel",
"category": "generation",
"categoryKey": "device_icon_category.generation",
"label": "Solarpanel",
"labelKey": "device_icon.solar_panel",
"markup": "…"
},
…
]
markup is the inner SVG of a 24×24 stroked icon. You can use it directly in a
<symbol> or draw it on a canvas. name is the value device-prefs.json
stores, mdi is the matching Material Design icon for Home Assistant
discovery. Entries of one category are contiguous.
GET /api/v1/device/prefs returns the complete device-prefs.json with the
icon, favorite_refs and pin_favorites of every device. All browser
sessions share it.
PUT /api/v1/device/prefs/{device_id}, with {device_id} URL-encoded.
The body contains the fields to change:
{
"icon": "mdi:solar-panel",
"favorite_refs": ["entity-1", "entity-2"],
"pin_favorites": true
}
The server merges the changes into the saved document and returns the updated
device-prefs.json. It validates:
iconmust match^mdi:[a-z0-9-]+$(names from the icon catalogue).nullor a missing field clears the custom icon.favorite_refsis an array of at most 3 entity IDs. Leave it out to clear it.pin_favoritesis a boolean. Leave it out to clear it.
A validation error returns 400 with device_prefs_rejected. With
authentication active, the endpoint needs the edit_layout role and a CSRF
token, like every endpoint that changes the layout.
POST /api/v1/device/map/relations expects
{"child_id":"…","parent_id":"…","kind":"via_device"} and rejects
self-references (relation_self_reference), unknown IDs (unknown_device,
404), and cycles (relation_cycle).
MQTT connection
| Method | Path | Gate | Purpose |
|---|---|---|---|
| GET | /api/v1/mqtt |
– | Effective configuration (masked) |
| PUT | /api/v1/mqtt |
mqtt_config + HTTPS + CSRF |
Save configuration |
| POST | /api/v1/mqtt/credentials |
mqtt_config + HTTPS + CSRF |
Set password |
| DELETE | /api/v1/mqtt/credentials |
mqtt_config + HTTPS + CSRF |
Delete password |
| POST | /api/v1/mqtt/test |
mqtt_config + HTTPS + CSRF |
Test connection |
| POST | /api/v1/mqtt/reconnect |
mqtt_config + HTTPS + CSRF |
Rebuild the running connection |
| PUT | /api/v1/mqtt/energy-device |
mqtt_config + HTTPS + CSRF |
Toggle publish_energy_device in mqtt.json, body {"publish_energy_device": true} |
| GET | /api/v1/mqtt/status |
– | Connection status |
A saved and activated mqtt.json replaces the central configuration
(config.json) completely. Fields are not merged. GET /api/v1/mqtt returns
the configuration in effect and names its origin in source: settings or
config.
Saving, testing and reconnecting are three separate operations on purpose.
PUT /api/v1/mqtt only saves and leaves the running connection alone.
POST /api/v1/mqtt/test opens a short, separate connection.
POST /api/v1/mqtt/reconnect switches to the saved configuration and falls
back to the previous one if that fails. The response always contains the
resulting status, also after a failure.
The password lives in mqtt_credentials.json, separate from mqtt.json, so it
does not end up in revision copies. The API never returns it and only reports
password_configured.
Mosquitto bridge
| Method | Path | Gate | Purpose |
|---|---|---|---|
| GET | /api/v1/mqtt/bridge |
mqtt_config + HTTPS |
Configuration including preview |
| PUT | /api/v1/mqtt/bridge |
mqtt_config + HTTPS + CSRF |
Save configuration |
| POST | /api/v1/mqtt/bridge/credentials |
mqtt_config + HTTPS + CSRF |
Set remote password |
| DELETE | /api/v1/mqtt/bridge/credentials |
mqtt_config + HTTPS + CSRF |
Delete remote password |
| POST | /api/v1/mqtt/bridge/apply |
mqtt_config + system_actions + HTTPS + CSRF |
Write to /etc, restart mosquitto |
| POST | /api/v1/mqtt/bridge/restart |
system_actions + HTTPS + CSRF |
Just restart mosquitto |
| GET | /api/v1/mqtt/bridge/status |
– | Service, $SYS state, drift |
| GET | /api/v1/mqtt/bridge/revisions |
mqtt_config + HTTPS |
Revisions |
| POST | /api/v1/mqtt/bridge/restore |
mqtt_config + HTTPS + CSRF |
Restore a revision |
The API works with one connection. bridge.json stores a list internally, and
the HTTP layer hides that. GET and PUT return a preview with a masked
password.
POST .../apply requires {"confirm": true} and is the only privileged way to
/etc/mosquitto/conf.d/bridge.conf. The dashboard renders the configuration,
puts it into its own data directory and calls the root helper, which installs
it and rolls back on failure. Error codes: bridge_helper_failed (file
rejected), bridge_restart_failed (restart failed, rolled back), bridge_busy
(409).
GET .../status combines three sources:
{
"service_state": "active",
"bridge": { "configured": true, "connected": true },
"drift": { "known": true, "matches": true, "checked_at": "…" },
"last_apply": { "at": "…", "user": "admin", "ok": true }
}
drift.matches compares checksums over the directives and ignores comments,
including the timestamp in the header. last_apply only exists in memory and is
gone after a restart.
System configuration
| Method | Path | Gate | Purpose |
|---|---|---|---|
| GET | /api/v1/system/config |
– | Central configuration file (config.json) + revision list |
| PUT | /api/v1/system/config |
system_actions + HTTPS + CSRF |
Validate and save configuration |
| GET | /api/v1/system/config/schema |
– | The embedded config.schema.json, composed by dashboard/cmd/schemagen from the per-service fragments (the settings form is built from it) |
The central config has no /revisions or /restore routes. GET returns the
revision list inline, and there is no restore endpoint yet.
GET /api/v1/system/config returns
{"config": {…}, "revisions": ["<UTC timestamp>.json", …]}. All *_file
fields contain only the path, never the password itself. Not role-gated.
PUT /api/v1/system/config (body limit 1 MiB):
- schema-validates the document against
config.schema.json; - checks every
*_filevalue against the allowlist (only paths under/etc/energy-node/and/etc/energy-node-dashboard/are accepted); - writes a revision of the previous file (kept under
data_dir/revisions/system-config/, max 20); - writes the new file atomically.
Response: {"config": {…}, "restart_required": [...], "reloaded": {…}}.
restart_required lists the changed fields that need a unit restart (mqtt,
paths, dashboard.node_device_id, any services.*.service_id,
dashboard.port, .bind_address, .tls, .admin_username,
.admin_password_file). The services pick up everything else over
outstation/<id>/config/reload. reloaded maps a service ID to "ok" or an
error string. It is only filled when a reload dispatcher is wired into the
router. The current build passes nil, so reloaded comes back empty and no
reload command is sent.
Error codes: config_unreadable, invalid_body, schema_violation,
path_not_allowed, revision_failed, config_not_writable.
System actions
| Method | Path | Gate | Purpose |
|---|---|---|---|
| POST | /api/v1/system/restart-dashboard |
system_actions + CSRF |
Restart the dashboard service |
| POST | /api/v1/system/reboot |
system_actions + CSRF |
Reboot the Pi |
| POST | /api/v1/system/poweroff |
system_actions + CSRF |
Shut down the Pi |
The response is 202 Accepted with {"action":"…","status":"accepted"}. If a
system action is already running, the answer is 409 with
system_action_busy.
Versions and changelog
| Method | Path | Gate | Purpose |
|---|---|---|---|
| GET | /api/v1/versions |
signed-in session | The installed bundle and the version of every component |
| GET | /api/v1/changelog |
signed-in session | The installed changelog.json, ?component=<id> (repeatable) keeps only those components |
Both read the installer’s state directory (/var/lib/energy-node-installer:
installed-manifest.json, selection.json, changelog.json) and never write.
On a node the installer never touched, versions answers 200 with
"bundle": null and "components": []. changelog answers 404 with code
no_changelog (also for a bundle from before changelog.json existed).
versions response:
{
"bundle": {"version": "v0.7.5", "built_at": "2026-09-21T10:00:00+02:00", "arch": "armv6"},
"running": {"dashboard": "v0.7.5"},
"has_changelog": true,
"components": [
{"id": "dashboard", "label": "Dashboard", "kind": "app", "version": "v0.7.5", "installed": true},
{"id": "service:shelly", "label": "Shelly", "kind": "service", "version": "v0.4.2", "installed": false}
]
}
installed is false for an optional service the operator deselected
(selection.json). running.dashboard is the build version of the running
binary, which can differ from bundle.version after the dashboard updated
itself. Without a changelog.json, components falls back to what the manifest
knows, with labels and kinds from a table in internal/versions that mirrors
scripts/version/components.json (a test keeps them in step).
ENERGY_NODE_INSTALLER_STATE_DIR overrides the directory. Only the local smoke
test uses it.
Tailscale
| Method | Path | Gate | Purpose |
|---|---|---|---|
| GET | /api/v1/tailscale/status |
– | tailscale status --json + last action |
| GET | /api/v1/tailscale/prereqs |
– | Installed? Service active? |
| POST | /api/v1/tailscale/login |
system_actions + HTTPS + CSRF |
tailscale up (202) |
| POST | /api/v1/tailscale/logout |
system_actions + HTTPS + CSRF |
tailscale logout |
| POST | /api/v1/tailscale/restart |
system_actions + HTTPS + CSRF |
Restart tailscaled |
login and logout require {"confirm": true}. login answers right away with
202 and starts the CLI in the background. The auth URL then appears in
GET /api/v1/tailscale/status, so the client has to poll.
TinyTuya
| Method | Path | Gate | Purpose |
|---|---|---|---|
| POST | /api/v1/tiny-tuya/devices |
– | Fetch devices from the Tuya cloud |
| POST | /api/v1/tiny-tuya/status |
– | Status of a local device |
| POST | /api/v1/tiny-tuya/configure |
– | Add a device to tuya_devices.json |
| GET/POST | /api/v1/tiny-tuya/credentials |
– | Read/set cloud credentials |
Error responses in this group also contain tool_output with the output of the
Python helper. GET .../credentials returns metadata only:
{"configured":true,"region":"eu","access_id_hint":"****abcd"}.
POST .../devices uses stored credentials if the body contains no
access_secret. If a new access_id is sent without an access_secret, the
server rejects it.
POST .../configure validates all fields, merges the entry into
tuya_devices.json (matching by id or device_id, otherwise appended) and
saves it the same way as PUT /api/v1/configurations/tuya_devices, with
revision and reload.
Not under /api/v1/
| Path | Purpose |
|---|---|
/ |
Server-side rendered overview or login page |
/static/* |
Embedded assets (CSS, JS, icons) |
Operational notes
Configuration
The dashboard is configured only through the central configuration file
/etc/energy-node/config.json. It reads no environment variables. Start it
with:
./dashboard -config /etc/energy-node/config.json
For tests, -config <path> points to a different file.
Files in the data directory
settings.json, layout.json, energy.json, mqtt.json, bridge.json,
users.json, sessions.json, runtime.json, ignored_devices.json and the separate secret
files mqtt_credentials.json, mqtt_bridge_credentials.json, and
tinytuya_credentials.json.
Deliberate limits
- The server stores no history. The command history per device (at most 12 entries) and the ring buffer of the history exchange (24 h) only exist in memory. The rolling measurement history lives in each browser’s IndexedDB, not on the Pi (see History above). There is no time series and no persistent event list on the server.
- Write endpoints limit the body to 2 MiB, commands to 4 KiB.
- There is no rate limiting. The dashboard is built for a trusted network and Tailscale and should not be exposed openly.