Performance & Resources on the Pi 1 Node
This document records the node’s resource analysis: how much CPU and RAM the individual services actually need, how they were measured, and which optimizations follow from that.
Measurement: 2026-08-29, live node (Raspberry Pi 1 Model B Rev 2, ARMv6, single-core, 512 MB — of which 427 MB usable, the rest GPU reserve). Uptime at the time of measurement: 11 days. Raw logs are not in the repo; the numbers below are the distilled state.
This is a point-in-time snapshot, not a figure that is kept in sync with the code. Everything below (service list, rule counts, CPU/RAM shares, the open items in §5) describes the node on that date; treat it as a baseline to re-measure against, not as the current state.
1. Measurement method
| Metric | Source | Why |
|---|---|---|
| CPU per service | systemctl show <unit> -p CPUUsageNSec divided by runtime since ActiveEnterTimestamp |
cgroup-accurate, incl. all child processes — more honest than ps TIME |
| RAM per process | Pss: from /proc/<pid>/smaps_rollup |
PSS counts shared pages proportionally — the only fair per-process figure |
| Idle baseline | vmstat 2 over several seconds |
averages out short polling spikes |
| Live spikes | top -b -d 3 -n N |
shows which service is currently burning |
Values are normalized to ”% of one core” (single-core, so = % of the whole system) and extrapolated linearly to 7 days, so that freshly restarted services become comparable.
2. CPU — 7-day extrapolation
| Unit | Ø % / 1 core | Projection 7 d | Measurement basis | Assessment |
|---|---|---|---|---|
| dashboard | ~20–30 % | ~2000–3000 min | 75 min, NRestarts=0 | By far the largest consumer. Re-measured 2026-08-29 22:35 (see 2.1) — not a startup effect, real sustained load |
| tailscaled | 3.19 % | ~321 min | 11 d | Fixed cost (WireGuard crypto, possibly a DERP relay) — hardly reducible |
| shelly-rpc | 2.83 % | ~285 min | 7.5 d | Largest real consumer among the bridges. Spikes up to ~22 % when all devices are polled at once |
nodeagent (measured as a standalone service; now internal/nodeagent in the dashboard) |
1.12 % | ~113 min | 8 d | Contains the expensive apt list --upgradable call on the diagnostic cycle |
| trucki-http | 0.86 % | ~86 min | 8 d | |
| mosquitto | 0.78 % | ~78 min | 11 d | Inconspicuous at ~3–10 msg/s |
| battery-soc | 0.36 % | ~37 min | 28 h | |
| caddy | 0.30 % | ~30 min | 11 d | |
| automation | 0.28 % | ~29 min | 3.9 h | Third-lightest service. Only 3 rules (2× balance_threshold, 1× time_window), evaluated per balance message. Not an optimization target. |
| apsystems-ez1 | 0.14 % | ~15 min | 8 d | |
| tuya | 0.11 % | ~11 min | 8 d |
The 1094 lines of the automation rule engine
(services/automation/automation_mqtt.py)
are large in the code but irrelevant to load, as long as only a handful of
rules are configured.
2.1 dashboard — re-measurement 2026-08-29 22:35
The first value (18 %, 19 min uptime) had been dismissed as a startup artifact.
The re-measurement after 75 min of uninterrupted runtime (NRestarts=0)
disproves that:
| Method | CPU | 7-day projection |
|---|---|---|
| Ø since start (868.8 CPU-s / 4509 s) | 19.3 % / 1 core | ~1940 min |
| Delta rate over a 150 s window | 29.8 % / 1 core | ~3005 min |
In that window top shows the dashboard process as the clear main CPU user
(us 57–82 %). This is not burst load but baseline load — the dashboard is
the most expensive single process on the node, well ahead of tailscaled and
shelly.
Observed drivers:
live_update_interval_seconds: 3(default) — every connected client polls the registry state every 3 s; on a change, a fullGET /api/v1/devicesfollows (SSE itself only transmits{version: N}, seedata-flow.md§4).- 18 open connections on
:8080at the time of measurement. Whether these are several real clients or an EventSource reconnect leak from a long-lived tab is open — 18 × full serialization of the device list every 3 s explains the order of magnitude. - Fan-out amplification: every retained state publication of a bridge
(shelly ~20 topics / 10 s, balance / 10 s, …) bumps
registry.version, whereupon every client re-fetches the complete device list.
3. RAM — PSS per process
| Process | PSS (MB) | RSS (MB) |
|---|---|---|
| dashboard (Go) | 20.2 | 20.2 |
| automation.py | 12.4 | 18.7 |
| shelly.py | 11.9 | 17.1 |
| battery_soc.py | 9.9 | 15.8 |
| trucki.py | 6.4 | 11.8 |
| apsystems.py | 5.1 | 10.5 |
| node.py | 3.6 | 9.0 |
| tuya.py | 2.7 | 7.9 |
| Σ 7 Python bridges | ~52 | ~91 |
| caddy | ~25 | 29.6 |
| tailscaled | — | 27.0 |
System state at the time of measurement: 116 MB free, 153 MB buff/cache
(reclaimable), 74 MB swap used — but stable (vmstat si/so = 0, nothing is
being swapped right now).
4. Interpretation
- The sustained-load driver is the dashboard (~20–30 % of one core,
re-measurement 2.1) — ahead of
tailscaled(~3 %) and all bridges combined. Originally misjudged as a startup artifact. - The rest is bursts, not baseline load:
- shelly poll — all 6 devices are queried simultaneously per cycle via
asyncio.gather→ short spike up to ~22 %. apt list --upgradablein the node service — parses the entire APT package cache every time, ran on the diagnostic cycle (every ~10 min) and was caught at 40–72 % CPU.
- shelly poll — all 6 devices are queried simultaneously per cycle via
- Without the dashboard, the sum of all remaining services except
tailscaledis ~6.6 % of one core;vmstatthen shows 93–98 % idle. With the dashboard, idle time drops to 30–50 %. - RAM is tight, but not critical. The swap level is old and stable.
tailscaled, at ~321 min/7 d, is the second-largest item after the dashboard, but essentially a fixed cost — a poor cost/benefit ratio for tackling it.
5. Optimization options (sorted by impact)
5.1 shelly: reuse HTTP connections — implemented 2026-08-29
shelly_rpc_mqtt.py opened a new TCP
connection on every poll (requests.get/requests.post directly). On ARMv6,
establishing the connection is the most expensive part of a poll.
Implementation: a module-wide requests.Session with HTTPAdapter +
Retry. Keep-alive holds the TCP connection open per device; if a pooled
connection drops (device restarted, Wi-Fi gone), urllib3 discards it and builds
a new one on demand, and Retry(connect≥1, allowed_methods=None) covers the
case where the drop is only noticed on send — including for the Gen2 RPC POSTs.
Not changed: the poll intervals. The 10 s for the fastest channel is
explicitly wanted by the operator and lives in shelly_devices.json /
config.json anyway, not in the code.
Open (later, optional): stagger the 6 device polls within a cycle instead of firing them simultaneously → the 22 % spike would become several small ones.
5.2 node: decouple apt list --upgradable — implemented 2026-08-29
read_apt_updates_pending() in the then-standalone energy_node_mqtt.py
(since folded into the dashboard as
internal/nodeagent)
ran on the diagnostic cycle (every ~10 min).
Implementation: a TTL cache around the call, APT_UPDATES_TTL_S = 86400
(once per day). Between two real calls, the last determined value is returned. A
failed call is not cached (the next cycle tries again); if a later call fails,
the last good value is passed on instead of flickering to None. The
diagnostic cycle still calls the function every ~10 min, but it is now usually
just a dict lookup.
5.3 dashboard: reduce live-update load — now the biggest lever
Per re-measurement 2.1, the dashboard is the most expensive process on the node (~20–30 % of one core, sustained). Starting points, roughly by expected impact:
live_update_interval_secondsfrom 3 s to 10 s. It lives indata/settings.jsonand takes effect without a reconnect (data-flow.md§4). Cuts the poll/serialization rate by a factor of ~3.- Clarify the 18 connections on
:8080. If this is one client with an EventSource reconnect leak (each reconnect leaves the old SSE connection open), a fix in the browser JS or a server-side timeout on orphaned SSE streams is enough. If there really are that many clients, point 1 applies all the more strongly. - Decouple the fan-out. Currently every client re-fetches the complete
device list on every
registry.versionbump. Options: coalesce balance and state updates in the server into a single tick (e.g. at most one version bump every 2–3 s instead of one per MQTT message), or have the SSE message carry the changed entities directly, so that theGET /api/v1/devicesround trip is eliminated. - Check restarts:
systemctl show energy-node-dashboard -p NRestarts(0 at the time of measurement). Frequent deploys add up to ~180 CPU-s each.
5.4 tailscaled
tailscale status shows whether the connection is direct or via DERP (relay =
permanently more crypto + copy). Disable unused features (SSH/serve/funnel,
--accept-routes). Overall low ROI.
5.5 Merge the bridges into one process
7 interpreters → 1 saves roughly 25–35 MB by PSS and removes 6 idle-polling paho clients. This is a RAM gain, not a meaningful CPU gain. Two ways:
- One Python process (threads/asyncio, one paho client): almost zero
rewrite risk,
tinytuyaand the pytest suites stay. It costs the per-service systemd fault isolation (in-process supervision as a replacement). - One Go central service with a central scheduler: net ~30–35 MB PSS,
enables a staggered poll fan-out and global rate limiting in one place.
Counter-cost: porting ~7,700 lines of tested Python domain logic, plus
rebuilding the local protocol for Tuya without a
tinytuyaequivalent. Only worthwhile if there is also a wish to standardize on Go long-term. Automation would remain a separate process in any case (dashboard-read-only property).
A Go central service is not urgently justified by the current state: the ~30 MB alone do not carry the porting effort.
6. Conclusion
- 5.1 + 5.2 (shelly keep-alive, apt cache) — small, low-risk, one file each, done 2026-08-29. Addresses the burst load.
- 5.3 (dashboard) is now the actual lever for more idle time: the sustained
load is there, not with the bridges. Next step:
live_update_interval_secondsto 10 s and clarify the 18 open:8080connections. - 5.5 (merge the bridges) remains a RAM topic, to be decided separately.