aboutsummaryrefslogtreecommitdiffstats
path: root/AGENTS.md
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-31 09:49:50 +0200
committerDanilo M. <danix@danix.xyz>2026-08-31 09:49:50 +0200
commit135115b27ad415865458f20965b0e14253eba0ec (patch)
tree1503f933c342f134d6b4db8808dcf0ebcbc09a84 /AGENTS.md
downloadtasmota-dash-135115b27ad415865458f20965b0e14253eba0ec.tar.gz
tasmota-dash-135115b27ad415865458f20965b0e14253eba0ec.zip
feat: aggregate five Tasmota plugs with cost and daily historyHEADmaster
Proxy Tasmota energy JSON into Homepage's customapi widget, which can render JSON but cannot do arithmetic. Applies the tariff, sums across plugs, and records the per-day history the plugs do not keep. Plugs are fetched in parallel; sequential polling would stack five timeouts into a 25s hang. An offline plug is reported rather than counted as zero, so a dead sensor cannot read as "consumed nothing". Tasmota exposes only Today/Yesterday/Total, so each poll writes yesterday's finished kWh into SQLite keyed (day, plug), making repeat polls and restarts idempotent. Display strings are preformatted server-side: Homepage's locale formatting renders 1593 W as an ambiguous "1,593", and its widget has no columns, so pairing kWh with its cost in one value is what keeps the cards compact.
Diffstat (limited to 'AGENTS.md')
-rw-r--r--AGENTS.md126
1 files changed, 126 insertions, 0 deletions
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..07fcde0
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,126 @@
+# tasmota-dash
+
+Energy proxy between five Tasmota smart plugs on the LAN and a Homepage
+dashboard. Homepage's `customapi` widget cannot do arithmetic, cannot format
+numbers unambiguously, and has no table layout, so everything derived is
+computed and preformatted here and the widget only maps field names to labels.
+
+## Files
+
+| File | Role |
+|---|---|
+| `tasmota_proxy.py` | The whole service. Stdlib only, no dependencies. |
+| `test_proxy.py` | Self-check: `python3 test_proxy.py`, prints `ok`. Fakes the plugs, no LAN needed. |
+| `tasmota-proxy.service` | systemd unit. Deployed to `/etc/systemd/system/`. |
+| `homepage-services.yaml` | Snippet for Homepage's `services.yaml`. |
+| `homepage-settings.yaml` | `layout:` block for Homepage's `settings.yaml`, controls the rows. |
+
+Underscore in `tasmota_proxy.py` is deliberate: a dash is not importable, and
+the test imports the module. The unit file's `ExecStart` must match.
+
+## Endpoints
+
+- `/<name>` one plug: `ac`, `washer`, `dishwasher`, `pc`, `spare`
+- `/total` sum across plugs, plus rolling 7/30-day figures
+- `/history` per-day kWh, 30 days, JSON
+- `/graph` self-contained HTML bar chart, linked from the totals widget
+- `/` plug list and available paths
+
+Unknown plug name returns 404, an upstream failure 502.
+
+## Deployment
+
+Runs on the Homepage host, listening on `127.0.0.1:8099`.
+
+```bash
+scp tasmota_proxy.py <homepage-host>:/opt/tasmota-proxy/
+scp tasmota-proxy.service <homepage-host>:/etc/systemd/system/
+ssh <homepage-host> 'systemctl daemon-reload && systemctl restart tasmota-proxy'
+```
+
+`tasmota_proxy.py` and `homepage-services.yaml` are a matched pair. Deploying
+one without the other renders blank widget rows, because the YAML maps fields
+(`power_fmt`, `today_fmt`, ...) that only the newer proxy emits.
+
+Homepage may run in a container. If it does, `127.0.0.1:8099` is the
+container's loopback, not the host's, and the widget URLs need the host IP
+with the proxy bound accordingly.
+
+## Configuration
+
+All through environment variables in the unit file:
+
+- `TASMOTA_PLUGS` — `name=host,name=host,...`, defines both the plugs and their URL paths
+- `PORT` — default 8099
+- `RATE_MARGINAL` / `RATE_ALLIN` — €/kWh from the invoice. Marginal is the consumption quota alone; all-in is total bill divided by total kWh. Widgets show all-in.
+- `HISTORY_DB` — default `/var/lib/tasmota-proxy/history.db`
+
+`StateDirectory=tasmota-proxy` in the unit is load-bearing. `ProtectSystem=strict`
+makes the filesystem read-only, so without it the first history write crashes
+the service.
+
+## Daily history
+
+Tasmota exposes only `Today`, `Yesterday` and `Total`; there is no per-day
+series to fetch and no peak/maximum field of any kind. So each `/total` poll
+writes yesterday's finished kWh per plug into SQLite, keyed `(day, plug)` so
+repeat polls and restarts overwrite instead of accumulating. Homepage polls
+every 10s, so a day is captured as long as the proxy runs at some point during
+it.
+
+Consequences worth knowing:
+
+- Today is deliberately excluded from rolling sums; it is unfinished.
+- An offline plug is skipped, so its day-row is short. A later poll *the same
+ day* fills it in; after midnight that day is lost, because Tasmota cannot be
+ asked about a day before yesterday.
+- Extended downtime leaves permanent gaps.
+
+## Working on the plugs
+
+Read state before writing it. Tasmota's HTTP API is `http://<host>/cm?cmnd=<Command>`,
+with `%20` before an argument to set it, no argument to query.
+
+**Firmware varies, probe before assuming.** On these plugs:
+
+- `EnergyReset1/2/3` do **not** exist, they return `{"Command":"Unknown"}`.
+ The working commands are `EnergyTotal`, `EnergyToday`, `EnergyYesterday`.
+- `VoltSet` / `PowerSet` do not exist either. Calibration goes through
+ `VoltageCal`, `CurrentCal`, `PowerCal`.
+- `cmnd=Cmd=0` is not the argument syntax; it arrives as `CMD =0` and fails.
+ Use `cmnd=Cmd%200`.
+
+Counter resets are irreversible. Confirm scope with the user first, and name
+which plugs are excluded.
+
+### Calibration
+
+All five are NOUS A1T, same template, and should share calibration values:
+`VoltageCal 1550`, `CurrentCal 3500`, `PowerCal 12530`.
+
+Stock `VoltageCal` is 220 and is wrong for this hardware: voltage reads ~34 V
+instead of ~239. Because power factor and apparent power are derived from
+voltage, one wrong value cascades into physically impossible readings, PF
+above 1.0, apparent power below real power, ReactivePower pinned at 3276.
+
+Diagnosing a suspect plug: compare against a known-good one on the same mains,
+and sanity-check the physics. Mains is ~230-240 V, PF cannot exceed 1.0,
+apparent power cannot be below real power. A plug failing those is
+miscalibrated, and its kWh totals are wrong too, not just its instantaneous
+reading.
+
+Voltage needs no meter, since nominal mains is known. Current and power need a
+known resistive load with a nameplate rating. PF a few percent above 1.0 is
+residual per-unit variation and is not worth chasing for cost tracking.
+
+## Conventions
+
+- Stdlib only. No dependencies, and no reason to add one.
+- Preformatted display strings (`power_fmt`, `today_fmt`, ...) exist because
+ Homepage's locale formatting renders 1593 W as an ambiguous "1,593". Numeric
+ fields stay in the JSON alongside them for any other consumer.
+- `1585 W` formats as `1.58 kW`, not 1.59. Python rounds the exact half to
+ even. Verified in the test, not a bug.
+- Widget labels are Italian, matching the rest of the dashboard.
+- Non-trivial logic keeps its assertion in `test_proxy.py`. Run it before
+ committing.