diff options
| author | Danilo M. <danix@danix.xyz> | 2026-08-31 09:49:50 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-08-31 09:49:50 +0200 |
| commit | 135115b27ad415865458f20965b0e14253eba0ec (patch) | |
| tree | 1503f933c342f134d6b4db8808dcf0ebcbc09a84 | |
| download | tasmota-dash-master.tar.gz tasmota-dash-master.zip | |
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.
| -rw-r--r-- | .gitignore | 3 | ||||
| -rw-r--r-- | AGENTS.md | 126 | ||||
| -rw-r--r-- | CLAUDE.md | 8 | ||||
| -rw-r--r-- | LICENSE | 338 | ||||
| -rw-r--r-- | README.md | 85 | ||||
| -rw-r--r-- | homepage-services.yaml | 126 | ||||
| -rw-r--r-- | homepage-settings.yaml | 21 | ||||
| -rw-r--r-- | tasmota-proxy.service | 23 | ||||
| -rwxr-xr-x | tasmota_proxy.py | 269 | ||||
| -rw-r--r-- | test_proxy.py | 119 |
10 files changed, 1118 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5aa0679 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.pyc +*.db 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. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..520fddc --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,8 @@ +# tasmota-dash + +See @AGENTS.md for everything: architecture, endpoints, deployment, plug +calibration and the Tasmota command gotchas. + +This file is intentionally thin. AGENTS.md is the single source of truth, +shared across every agent tool. Do not duplicate content here, edit AGENTS.md +instead. @@ -0,0 +1,338 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + <https://fsf.org/> + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + <one line to give the program's name and a brief idea of what it does.> + Copyright (C) <year> <name of author> + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see <https://www.gnu.org/licenses/>. + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + <signature of Moe Ghoul>, 1 April 1989 + Moe Ghoul, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..85491d6 --- /dev/null +++ b/README.md @@ -0,0 +1,85 @@ +# tasmota-dash + +Aggregates five [Tasmota](https://tasmota.github.io/docs/) smart plugs into a +[Homepage](https://gethomepage.dev) dashboard: live power, per-day and +lifetime energy, and what it costs in euro. + +Homepage's `customapi` widget can render JSON but cannot do arithmetic, so +this proxy sits between the plugs and the dashboard, applies the tariff, sums +across plugs and records a daily history that the plugs themselves do not keep. + +Stdlib Python, no dependencies, one file. + +## Features + +- Per-plug and aggregate readings on one endpoint each +- Cost in euro at two tariffs: marginal (consumption quota) and all-in (total bill / total kWh) +- Daily kWh history in SQLite, with rolling 7 and 30 day figures +- A bar-chart page for the last 30 days +- An offline plug is reported, not silently counted as zero + +## Endpoints + +| Path | Returns | +|---|---| +| `/<name>` | One plug: power, today/yesterday/lifetime kWh, cost | +| `/total` | Sum across plugs, plus rolling 7/30 day figures | +| `/history` | Per-day kWh, last 30 days, JSON | +| `/graph` | HTML bar chart of the last 30 days | +| `/` | Plug list and available paths | + +## Install + +Runs on the machine hosting Homepage, listening on `127.0.0.1:8099`. + +```bash +install -Dm755 tasmota_proxy.py /opt/tasmota-proxy/tasmota_proxy.py +install -Dm644 tasmota-proxy.service /etc/systemd/system/tasmota-proxy.service +# edit the unit: plug addresses and tariff rates +systemctl daemon-reload && systemctl enable --now tasmota-proxy +curl -s localhost:8099/total +``` + +Then merge `homepage-services.yaml` into Homepage's `services.yaml` and the +`layout:` block from `homepage-settings.yaml` into its `settings.yaml`, and +restart Homepage. + +## Configuration + +Environment variables, set in the unit file: + +| Variable | Default | Meaning | +|---|---|---| +| `TASMOTA_PLUGS` | five LAN addresses | `name=host,name=host,...` — also defines the URL paths | +| `PORT` | `8099` | Listen port, bound to localhost | +| `RATE_MARGINAL` | `0.17598` | €/kWh, consumption quota only | +| `RATE_ALLIN` | `0.2754` | €/kWh, total bill divided by total kWh | +| `HISTORY_DB` | `/var/lib/tasmota-proxy/history.db` | Daily history | + +## History + +Tasmota keeps only today, yesterday and a lifetime total. Each poll records +yesterday's finished kWh per plug, keyed by day so repeated polls overwrite +rather than accumulate. History therefore starts from first run, and only +covers days the proxy was running for. Today is excluded from rolling sums +because it is unfinished. + +## Tests + +```bash +python3 test_proxy.py +``` + +Fakes the plugs, so it needs no LAN access. Prints `ok`. + +## License + +GPLv2. See [LICENSE](LICENSE). + +## Development Approach + +This project is developed using AI-assisted tools. Code is generated with the help of AI based on human-provided specifications, design decisions, and iterative feedback. + +All contributions are reviewed, tested, and curated by the maintainer before being included in the codebase. AI is used as a productivity and exploration tool, while human oversight remains central to all decisions. + +The goal is to combine the flexibility of AI-assisted development with standard open-source practices such as transparency, review, and accountability. diff --git a/homepage-services.yaml b/homepage-services.yaml new file mode 100644 index 0000000..2ee00e6 --- /dev/null +++ b/homepage-services.yaml @@ -0,0 +1,126 @@ +# Append to Homepage's services.yaml. Replaces the old single "Presa Tasmota" entry +# under "Other Local Services" - delete that one. +# +# Layout (total on its own row, the 5 plugs on the row below) is set in +# settings.yaml, not here. See homepage-settings.yaml. + +- Consumi: + - Totale: + icon: mdi-flash + href: http://127.0.0.1:8099/graph + description: Tutte le prese - consumo e costo + widget: + type: customapi + url: http://127.0.0.1:8099/total + refreshInterval: 10000 + display: list + mappings: + - field: power_fmt + label: Potenza attuale + - field: today_fmt + label: Oggi + - field: yesterday_fmt + label: Ieri + - field: last7_fmt + label: Ultimi 7 giorni + - field: last30_fmt + label: Ultimi 30 giorni + - field: total_fmt + label: Storico + - field: plugs_ok + label: Prese online + format: number + +- Prese: + - Condizionatore: + icon: mdi-air-conditioner + href: http://172.16.34.176/ + #description: NOUS A1T + widget: + type: customapi + url: http://127.0.0.1:8099/ac + refreshInterval: 10000 + display: list + mappings: + - field: power_fmt + label: Ora + - field: today_fmt + label: Oggi + - field: yesterday_fmt + label: Ieri + - field: total_fmt + label: Storico + + - Lavatrice: + icon: mdi-washing-machine + href: http://172.16.34.127/ + widget: + type: customapi + url: http://127.0.0.1:8099/washer + refreshInterval: 10000 + display: list + mappings: + - field: power_fmt + label: Ora + - field: today_fmt + label: Oggi + - field: yesterday_fmt + label: Ieri + - field: total_fmt + label: Storico + + - Lavastoviglie: + icon: mdi-dishwasher + href: http://172.16.34.149/ + widget: + type: customapi + url: http://127.0.0.1:8099/dishwasher + refreshInterval: 10000 + display: list + mappings: + - field: power_fmt + label: Ora + - field: today_fmt + label: Oggi + - field: yesterday_fmt + label: Ieri + - field: total_fmt + label: Storico + + - PC e NAS: + icon: mdi-server + href: http://172.16.34.118/ + description: UPS - PC, nodi Proxmox, NAS + widget: + type: customapi + url: http://127.0.0.1:8099/pc + refreshInterval: 10000 + display: list + mappings: + - field: power_fmt + label: Ora + - field: today_fmt + label: Oggi + - field: yesterday_fmt + label: Ieri + - field: total_fmt + label: Storico + + - Varie: + icon: mdi-power-socket-eu + href: http://172.16.34.186/ + description: friggitrice, forno pizza, ecc + widget: + type: customapi + url: http://127.0.0.1:8099/spare + refreshInterval: 10000 + display: list + mappings: + - field: power_fmt + label: Ora + - field: today_fmt + label: Oggi + - field: yesterday_fmt + label: Ieri + - field: total_fmt + label: Storico diff --git a/homepage-settings.yaml b/homepage-settings.yaml new file mode 100644 index 0000000..095b03b --- /dev/null +++ b/homepage-settings.yaml @@ -0,0 +1,21 @@ +# Merge this `layout:` block into Homepage's settings.yaml. +# +# The layout block controls rows. `style: row` + `columns:` puts a group's +# services side by side on one row; without it they stack in a column. +# Listing every group also fixes their order on the page, so groups you +# already have must appear here too, or they fall to the bottom. + +layout: + Consumi: + style: row + columns: 1 # single full-width card: the total gets its own row + Prese: + style: row + columns: 5 # the 5 plugs side by side on the row below + + # Your other groups go here too. Any group omitted from this block falls to + # the bottom of the page, so list them all in the order you want them shown: + # + # Nome Gruppo: + # style: row + # columns: 3 diff --git a/tasmota-proxy.service b/tasmota-proxy.service new file mode 100644 index 0000000..1776c42 --- /dev/null +++ b/tasmota-proxy.service @@ -0,0 +1,23 @@ +[Unit] +Description=Tasmota energy proxy for Homepage +After=network-online.target +Wants=network-online.target + +[Service] +ExecStart=/usr/bin/python3 /opt/tasmota-proxy/tasmota_proxy.py +Environment=TASMOTA_PLUGS=ac=172.16.34.176,washer=172.16.34.127,dishwasher=172.16.34.149,pc=172.16.34.118,spare=172.16.34.186 +Environment=PORT=8099 +Environment=RATE_MARGINAL=0.17598 +Environment=RATE_ALLIN=0.2754 +Environment=HISTORY_DB=/var/lib/tasmota-proxy/history.db +Restart=always +RestartSec=5 +DynamicUser=yes +StateDirectory=tasmota-proxy +NoNewPrivileges=yes +PrivateTmp=yes +ProtectSystem=strict +ProtectHome=yes + +[Install] +WantedBy=multi-user.target diff --git a/tasmota_proxy.py b/tasmota_proxy.py new file mode 100755 index 0000000..e1c40d3 --- /dev/null +++ b/tasmota_proxy.py @@ -0,0 +1,269 @@ +#!/usr/bin/env python3 +# Copyright (C) 2026 Danilo M. <danix@danix.xyz> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +"""Fetch Tasmota energy JSON from several plugs, add cost fields, serve for Homepage customapi. + +Paths: /<name> for one plug, /total for the sum, /history for per-day kWh, +/graph for a bar chart page, / for the plug list. + +Daily history: Tasmota keeps only Today/Yesterday/Total, so each poll records +yesterday's finished kWh per plug into SQLite, keyed (day, plug) so repeated +polls and restarts overwrite rather than accumulate. +""" + +import datetime as dt +import json +import os +import sqlite3 +import urllib.request +from concurrent.futures import ThreadPoolExecutor +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +# name -> host. Override wholesale with TASMOTA_PLUGS="name=host,name=host". +PLUGS = dict( + p.split("=", 1) + for p in os.environ.get( + "TASMOTA_PLUGS", + "ac=172.16.34.176,washer=172.16.34.127,dishwasher=172.16.34.149," + "pc=172.16.34.118,spare=172.16.34.186", + ).split(",") +) +PORT = int(os.environ.get("PORT", "8099")) +# Rates from invoice: marginal = consumption quota only, all-in = total bill / total kWh +RATE_MARGINAL = float(os.environ.get("RATE_MARGINAL", "0.17598")) +RATE_ALLIN = float(os.environ.get("RATE_ALLIN", "0.2754")) +DB = os.environ.get("HISTORY_DB", "/var/lib/tasmota-proxy/history.db") + + +def db(): + conn = sqlite3.connect(DB, timeout=5) + conn.execute( + "CREATE TABLE IF NOT EXISTS daily (" + "day TEXT, plug TEXT, kwh REAL, PRIMARY KEY (day, plug))" + ) + return conn + + +def record(plugs): + """Store yesterday's finished kWh per plug. Idempotent: same day+plug overwrites.""" + day = (dt.date.today() - dt.timedelta(days=1)).isoformat() + rows = [ + (day, name, p["yesterday_kwh"]) + for name, p in plugs.items() + if "error" not in p + ] + if not rows: + return + with db() as conn: + conn.executemany("INSERT OR REPLACE INTO daily VALUES (?, ?, ?)", rows) + + +def history(days=30): + """Per-day totals across all plugs, oldest first.""" + since = (dt.date.today() - dt.timedelta(days=days)).isoformat() + with db() as conn: + rows = conn.execute( + "SELECT day, ROUND(SUM(kwh), 3) FROM daily WHERE day >= ? " + "GROUP BY day ORDER BY day", + (since,), + ).fetchall() + return [ + {"day": d, "kwh": k, "cost_allin": round(k * RATE_ALLIN, 2)} for d, k in rows + ] + + +def rolling(days): + """kWh summed over the last N recorded days (excludes today, which is unfinished).""" + since = (dt.date.today() - dt.timedelta(days=days)).isoformat() + with db() as conn: + (kwh,) = conn.execute( + "SELECT COALESCE(SUM(kwh), 0) FROM daily WHERE day >= ?", (since,) + ).fetchone() + return round(kwh, 3) + + +def watts(power_w): + """Human-readable power: kW above 1000 W, W below. Preformatted so no locale + separator can turn 1585 W into an ambiguous "1,585".""" + if power_w >= 1000: + return f"{power_w / 1000:.2f} kW" + return f"{power_w:g} W" + + +def kwh_eur(kwh): + """One row's worth of text: "0.87 kWh · 0.24 €". Homepage's customapi has no + columns, so pairing the two numbers here is what keeps the card two-up.""" + return f"{kwh:g} kWh · {kwh * RATE_ALLIN:.2f} €" + + +def costs(power_w, today, total): + return { + "now_hourly_marginal": round(power_w / 1000 * RATE_MARGINAL, 4), + "now_hourly_allin": round(power_w / 1000 * RATE_ALLIN, 4), + "today_marginal": round(today * RATE_MARGINAL, 2), + "today_allin": round(today * RATE_ALLIN, 2), + "total_marginal": round(total * RATE_MARGINAL, 2), + "total_allin": round(total * RATE_ALLIN, 2), + } + + +def fetch(host): + url = f"http://{host}/cm?cmnd=Status%2010" + with urllib.request.urlopen(url, timeout=5) as r: + e = json.load(r)["StatusSNS"]["ENERGY"] + return { + "power": e["Power"], + "power_fmt": watts(e["Power"]), + "voltage": e["Voltage"], + "current": e["Current"], + "today_kwh": e["Today"], + "yesterday_kwh": e["Yesterday"], + "total_kwh": e["Total"], + "today_fmt": kwh_eur(e["Today"]), + "yesterday_fmt": kwh_eur(e["Yesterday"]), + "total_fmt": kwh_eur(e["Total"]), + "cost": costs(e["Power"], e["Today"], e["Total"]), + } + + +def fetch_all(): + """All plugs in parallel; an offline plug yields {"error": ...} instead of failing the batch.""" + def one(item): + name, host = item + try: + return name, fetch(host) + except Exception as exc: # plug offline, bad JSON, timeout + return name, {"error": str(exc)} + + with ThreadPoolExecutor(max_workers=len(PLUGS)) as pool: + return dict(pool.map(one, PLUGS.items())) + + +def total(): + plugs = fetch_all() + record(plugs) + ok = {n: p for n, p in plugs.items() if "error" not in p} + power = sum(p["power"] for p in ok.values()) + today = sum(p["today_kwh"] for p in ok.values()) + tot = sum(p["total_kwh"] for p in ok.values()) + last7, last30 = rolling(7), rolling(30) + return { + "today_fmt": kwh_eur(round(today, 3)), + "yesterday_fmt": kwh_eur(round(sum(p["yesterday_kwh"] for p in ok.values()), 3)), + "last7_fmt": kwh_eur(last7), + "last30_fmt": kwh_eur(last30), + "total_fmt": kwh_eur(round(tot, 3)), + "power": round(power, 1), + "power_fmt": watts(power), + "today_kwh": round(today, 3), + "yesterday_kwh": round(sum(p["yesterday_kwh"] for p in ok.values()), 3), + "total_kwh": round(tot, 3), + "cost": costs(power, today, tot), + "plugs_ok": len(ok), + "plugs_total": len(plugs), + "offline": sorted(set(plugs) - set(ok)), + "last7_kwh": last7, + "last30_kwh": last30, + "last7_allin": round(last7 * RATE_ALLIN, 2), + "last30_allin": round(last30 * RATE_ALLIN, 2), + } + + +GRAPH_PAGE = """<!doctype html><meta charset=utf-8> +<title>Consumi giornalieri</title> +<style> + body{{background:#1a1c1e;color:#e8eaed;font:14px system-ui,sans-serif;margin:0;padding:24px}} + h1{{font-size:16px;font-weight:600;margin:0 0 4px}} + p{{color:#9aa0a6;margin:0 0 24px}} + .bars{{display:flex;align-items:flex-end;gap:4px;height:260px; + border-bottom:1px solid #3c4043;padding-bottom:2px}} + .bar{{flex:1;background:#8ab4f8;border-radius:2px 2px 0 0;min-height:1px}} + .bar:hover{{background:#aecbfa}} + .labels{{display:flex;gap:4px;margin-top:6px;color:#9aa0a6;font-size:11px}} + .labels span{{flex:1;text-align:center;overflow:hidden}} + .empty{{color:#9aa0a6;padding:40px 0}} +</style> +<h1>Consumi giornalieri</h1> +<p>{subtitle}</p> +{body} +""" + + +def graph_html(): + rows = history(30) + if not rows: + return GRAPH_PAGE.format( + subtitle="Nessun dato ancora. Una riga per giorno viene registrata " + "dal giorno successivo al primo avvio.", + body='<div class=empty>In attesa del primo giorno completo.</div>', + ) + peak = max(r["kwh"] for r in rows) or 1 + bars = "".join( + '<div class=bar style="height:{h:.1f}%" title="{day}: {kwh} kWh - {cost} EUR"></div>'.format( + h=r["kwh"] / peak * 100, day=r["day"], kwh=r["kwh"], cost=r["cost_allin"] + ) + for r in rows + ) + labels = "".join("<span>{}</span>".format(r["day"][8:]) for r in rows) + tot = round(sum(r["kwh"] for r in rows), 2) + return GRAPH_PAGE.format( + subtitle="Ultimi {n} giorni - {tot} kWh - {eur} EUR".format( + n=len(rows), tot=tot, eur=round(tot * RATE_ALLIN, 2) + ), + body='<div class=bars>{}</div><div class=labels>{}</div>'.format(bars, labels), + ) + + +class Handler(BaseHTTPRequestHandler): + def do_GET(self): + name = self.path.strip("/").split("?")[0] + try: + if name == "graph": + page = graph_html().encode() + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(page))) + self.end_headers() + self.wfile.write(page) + return + if name == "total": + body, code = total(), 200 + elif name == "history": + body, code = {"days": history(30)}, 200 + elif name in PLUGS: + body, code = fetch(PLUGS[name]), 200 + elif not name: + body, code = { + "plugs": sorted(PLUGS), + "paths": ["/total", "/history", "/graph"], + }, 200 + else: + body, code = {"error": f"unknown plug {name!r}"}, 404 + except Exception as exc: # plug offline, bad JSON, timeout + body, code = {"error": str(exc)}, 502 + body = json.dumps(body).encode() + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *a): + pass # ponytail: no access log, journald has the unit status + + +if __name__ == "__main__": + ThreadingHTTPServer(("127.0.0.1", PORT), Handler).serve_forever() diff --git a/test_proxy.py b/test_proxy.py new file mode 100644 index 0000000..e1f1e51 --- /dev/null +++ b/test_proxy.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +# Copyright (C) 2026 Danilo M. <danix@danix.xyz> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +"""Self-check: run `python3 test_proxy.py`. Fakes the plugs, exercises routing and totals.""" + +import datetime as dt +import json +import os +import tempfile +import urllib.request + +os.environ["HISTORY_DB"] = os.path.join(tempfile.mkdtemp(), "test.db") +os.environ["TASMOTA_PLUGS"] = "ac=1.1.1.1,washer=2.2.2.2,dead=3.3.3.3" +os.environ["RATE_MARGINAL"] = "0.1" +os.environ["RATE_ALLIN"] = "0.2" + +import tasmota_proxy as p # noqa: E402 + +SAMPLE = { + "1.1.1.1": {"Power": 1000, "Voltage": 230, "Current": 4.3, + "Today": 2.0, "Yesterday": 1.0, "Total": 10.0}, + "2.2.2.2": {"Power": 500, "Voltage": 231, "Current": 2.1, + "Today": 1.0, "Yesterday": 0.5, "Total": 5.0}, +} + + +class FakeResponse: + def __init__(self, host): + if host not in SAMPLE: + raise OSError("plug offline") + self._body = json.dumps({"StatusSNS": {"ENERGY": SAMPLE[host]}}) + + def read(self): + return self._body.encode() + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + +p.urllib.request.urlopen = lambda url, timeout=None: FakeResponse( + urllib.parse.urlparse(url).hostname +) + +one = p.fetch("1.1.1.1") +assert one["power"] == 1000, one +assert one["cost"]["today_allin"] == 0.4, one # 2.0 kWh * 0.2 +assert one["cost"]["now_hourly_marginal"] == 0.1, one # 1 kW * 0.1 + +allp = p.fetch_all() +assert set(allp) == {"ac", "washer", "dead"}, allp +assert "error" in allp["dead"], allp # offline plug isolated +assert "error" not in allp["ac"], allp + +t = p.total() +assert t["power"] == 1500, t # dead plug excluded, not zero-filled +assert t["today_kwh"] == 3.0, t +assert t["cost"]["today_allin"] == 0.6, t +assert t["plugs_ok"] == 2 and t["plugs_total"] == 3, t +assert t["offline"] == ["dead"], t + +# --- power formatting --- +# 1585 W -> "1.58 kW": banker's rounding on the exact half, not a typo. +for w, want in [(1585, "1.58 kW"), (79.7, "79.7 W"), (0, "0 W"), + (999, "999 W"), (1000, "1.00 kW"), (2340.5, "2.34 kW")]: + assert p.watts(w) == want, (w, p.watts(w), want) +assert one["power_fmt"] == "1.00 kW", one # the 1000 W fake plug +assert p.total()["power_fmt"] == "1.50 kW", p.total() + +# --- paired kWh + cost rows --- +assert p.kwh_eur(2.0) == "2 kWh · 0.40 €", p.kwh_eur(2.0) # RATE_ALLIN=0.2 here +assert p.kwh_eur(0) == "0 kWh · 0.00 €", p.kwh_eur(0) +t = p.total() +assert one["today_fmt"] == "2 kWh · 0.40 €", one # per-plug rows +assert one["total_fmt"] == "10 kWh · 2.00 €", one +t = p.total() +assert t["today_fmt"] == "3 kWh · 0.60 €", t # 2.0 + 1.0, dead skipped +assert t["total_fmt"] == "15 kWh · 3.00 €", t + +# --- daily history --- +yday = (dt.date.today() - dt.timedelta(days=1)).isoformat() + +h = p.history() +assert h == [{"day": yday, "kwh": 1.5, "cost_allin": 0.3}], h # 1.0 + 0.5, dead skipped + +p.total() # poll again same day +h = p.history() +assert len(h) == 1 and h[0]["kwh"] == 1.5, h # idempotent, not doubled + +# an older day coexists rather than replacing +with p.db() as c: + c.execute("INSERT OR REPLACE INTO daily VALUES (?, ?, ?)", + ((dt.date.today() - dt.timedelta(days=3)).isoformat(), "ac", 4.0)) +assert [r["kwh"] for r in p.history()] == [4.0, 1.5], p.history() # oldest first +assert p.rolling(7) == 5.5, p.rolling(7) +assert p.rolling(2) == 1.5, p.rolling(2) # window excludes the 3-day-old row + +t = p.total() +assert t["last7_kwh"] == 5.5 and t["last7_allin"] == 1.1, t + +assert "<div class=bar" in p.graph_html() +assert "5.5 kWh" in p.graph_html() + +print("ok") |
