aboutsummaryrefslogtreecommitdiffstats
path: root/test_proxy.py
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-31 09:49:50 +0200
committerDanilo M. <danix@danix.xyz>2026-08-31 09:49:50 +0200
commit135115b27ad415865458f20965b0e14253eba0ec (patch)
tree1503f933c342f134d6b4db8808dcf0ebcbc09a84 /test_proxy.py
downloadtasmota-dash-master.tar.gz
tasmota-dash-master.zip
feat: aggregate five Tasmota plugs with cost and daily historyHEADmaster
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.
Diffstat (limited to 'test_proxy.py')
-rw-r--r--test_proxy.py119
1 files changed, 119 insertions, 0 deletions
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")