aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.gitignore3
-rw-r--r--AGENTS.md126
-rw-r--r--CLAUDE.md8
-rw-r--r--LICENSE338
-rw-r--r--README.md85
-rw-r--r--homepage-services.yaml126
-rw-r--r--homepage-settings.yaml21
-rw-r--r--tasmota-proxy.service23
-rwxr-xr-xtasmota_proxy.py269
-rw-r--r--test_proxy.py119
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.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..9efa6fb
--- /dev/null
+++ b/LICENSE
@@ -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")