diff options
Diffstat (limited to 'AGENTS.md')
| -rw-r--r-- | AGENTS.md | 126 |
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. |
