aboutsummaryrefslogtreecommitdiffstats
path: root/AGENTS.md
blob: 07fcde097b01083db57feccaa5fb7dd9e91c8e32 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
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.