aboutsummaryrefslogtreecommitdiffstats
path: root/AGENTS.md
diff options
context:
space:
mode:
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.