0.5.0: riordino come l'app ufficiale (home, Dati, Impianto)
- home: temperatura esterna e in casa/assente in alto, messaggio di stato, scheda impianto con temperatura stanza e acqua calda; comandi sotto - pagina Dati: calore prodotto, elettricita' usata, COP acqua calda e riscaldamento, risparmio, CO2 evitata, Smart Tariff Control (mese per mese) - pagina Impianto: stato, termostato a parete, versioni software, errori - bridge 0.4.0: rotta /stats (servizio statistiche del cloud) e blocco dispositivo in /summary (ID breve, versioni, termostato) - C++: parseStats, formatNumber, formatMoney, monthLabel (separatore e valuta secondo la lingua); 61/61 verifiche del nucleo - anteprime aggiornate (dati, impianto) in inglese e italiano
This commit is contained in:
+6
-3
@@ -109,8 +109,9 @@ not traverse the input chain, so it always works).
|
||||
| Route | Auth | Purpose |
|
||||
|---|---|---|
|
||||
| `GET /health` | none | `{ok, version}` — liveness, used above all for debugging |
|
||||
| `GET /summary` | token | dashboard values (house/DHW temperatures, targets, modes, errors) |
|
||||
| `GET /summary` | token | dashboard values (house/DHW temperatures, targets, modes, errors), the device block (short ID, software versions, connection) and the wall thermostat (zone, temperature, humidity, signal, battery) |
|
||||
| `GET /state` | token | full raw plant state, exactly as the cloud reports it |
|
||||
| `GET /stats` | token | the numbers Aira computes in the cloud: hot-water and heating COP month by month, heat produced, electricity used, savings on the bill, CO₂ avoided, Smart Tariff Control saving. Cached for 10 minutes |
|
||||
| `GET /commands` | token | allowed commands with their parameters and ranges |
|
||||
| `POST /command` | token | `{"id": "...", "value": 21.5, "zone": 1}` |
|
||||
|
||||
@@ -134,7 +135,8 @@ Two things learned on a real pump, both worth knowing:
|
||||
python test_bridge_commands.py
|
||||
```
|
||||
|
||||
27 checks on the protobuf payloads and on the summary parsing — no network, and
|
||||
48 checks on the protobuf payloads, the summary parsing (including the device
|
||||
block) and the statistics conversions — no network, and
|
||||
**no command is sent to the pump**.
|
||||
|
||||
## If login fails
|
||||
@@ -156,7 +158,8 @@ Try one pool at a time: repeated failures trigger a temporary lockout.
|
||||
| `aira_bridge.py` | the service: HTTP/1.1 + JSON in, gRPC to Aira out |
|
||||
| `setup_creds.py` | asks for the Aira credentials and saves them (600) |
|
||||
| `aira_probe.py` | read-only check that login and the cloud channel work |
|
||||
| `test_bridge_commands.py` | offline tests of the command payloads |
|
||||
| `aira_stats_probe.py` | read-only dump of the cloud statistics (COP, savings, insights), no command sent |
|
||||
| `test_bridge_commands.py` | offline tests of the command payloads, the summary parsing and the statistics conversions |
|
||||
| `harbour-aira-bridge.service` | systemd user unit |
|
||||
| `requirements.txt` | `pyairahome`, pinned to the tested version |
|
||||
|
||||
|
||||
+220
-1
@@ -53,7 +53,7 @@ CREDS = BASE / "aira.json"
|
||||
TOKENS = BASE / "aira_tokens.json"
|
||||
POOL_CFG = BASE / "aira_config.json"
|
||||
|
||||
VERSION = "0.3.0"
|
||||
VERSION = "0.4.0"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -97,6 +97,10 @@ class AiraSession:
|
||||
# Ultimo stato letto: serve a rimandare alla pompa il valore attuale
|
||||
# della zona che NON si sta modificando (mandarla a zero la azzererebbe).
|
||||
self._states = None
|
||||
# Statistiche: sono chiamate piu' lente e cambiano lentamente, quindi si
|
||||
# tengono in cache (il telefono puo' chiederle a ogni apertura di pagina).
|
||||
self._stats = None
|
||||
self._stats_at = 0.0
|
||||
|
||||
def _login(self):
|
||||
from pyairahome import AiraHome
|
||||
@@ -156,6 +160,26 @@ class AiraSession:
|
||||
def last_states(self):
|
||||
return self._states
|
||||
|
||||
def household_id(self) -> str:
|
||||
devs = self.call(lambda a: a.cloud.get_devices())["devices"]
|
||||
if not devs:
|
||||
raise RuntimeError("nessun impianto associato all'account")
|
||||
return (devs[0].get("device_id") or {}).get("household_id", {}).get("value", "")
|
||||
|
||||
def stats(self, ttl: int = 600) -> dict:
|
||||
"""Statistiche dal servizio di sola lettura, con cache (default 10 minuti)."""
|
||||
import time
|
||||
|
||||
now = time.time()
|
||||
if self._stats is not None and (now - self._stats_at) < ttl:
|
||||
return self._stats
|
||||
|
||||
did = self.device_id()
|
||||
hid = self.household_id()
|
||||
out = self.call(lambda a: build_stats(a, did, hid))
|
||||
self._stats, self._stats_at = out, now
|
||||
return out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# riassunto leggibile
|
||||
@@ -170,6 +194,27 @@ def summarize(st: dict) -> dict:
|
||||
return d
|
||||
|
||||
errs = st.get("errors") or []
|
||||
|
||||
# ---- dati del dispositivo (per la pagina "Impianto": accessori + riepilogo,
|
||||
# come la scheda "Accessori" e il "Riepilogo impostazioni" dell'app ufficiale)
|
||||
hp_id = g("heat_pump_id", "value") or ""
|
||||
versions = st.get("versions") or {}
|
||||
thermos = st.get("thermostats") or []
|
||||
t0 = thermos[0] if thermos and isinstance(thermos[0], dict) else {}
|
||||
t_upd = t0.get("last_update") or {}
|
||||
t_zone = str(t0.get("zone") or "")
|
||||
|
||||
def zona_numero(nome: str) -> int:
|
||||
"""'ZONE_1' -> 1 (0 se non riconosciuta)."""
|
||||
coda = nome.rsplit("_", 1)[-1]
|
||||
return int(coda) if coda.isdigit() else 0
|
||||
|
||||
def decimo(valore):
|
||||
"""Il termostato manda i decimali come interi: 228 -> 22.8, 734 -> 73.4."""
|
||||
if not isinstance(valore, (int, float)):
|
||||
return None
|
||||
return round(float(valore) / 10.0, 1)
|
||||
|
||||
return {
|
||||
"outdoor_temperature": g("current_outdoor_temperature"),
|
||||
"indoor_temperature": g("deprecated_current_indoor_temperature"),
|
||||
@@ -205,9 +250,180 @@ def summarize(st: dict) -> dict:
|
||||
"zone2_cooling_setpoint": g("zone_setpoints_cooling", "zone_2"),
|
||||
"errors_count": len(errs) if isinstance(errs, list) else 0,
|
||||
"pump_updated": g("aws_iot_received_time") or st.get("time"),
|
||||
# Identificativo e versioni: l'app ufficiale mostra le prime 6 cifre
|
||||
# dell'UUID come "Dispositivo ID".
|
||||
"device_id": hp_id[:6] if hp_id else None,
|
||||
"device_uuid": hp_id or None,
|
||||
"version_platform": versions.get("linux_build_id"),
|
||||
"version_connectivity": versions.get("connectivity_manager"),
|
||||
"version_climate_control": versions.get("climate_control_villa"),
|
||||
"version_outdoor_unit": versions.get("outdoor_unit_application"),
|
||||
"version_outdoor_eeprom": versions.get("outdoor_unit_eeprom"),
|
||||
"connected_via": "Cloud",
|
||||
"timezone": g("time_zone", "time_zone_identifier"),
|
||||
# Termostato a parete (se presente).
|
||||
"thermostats_count": len(thermos) if isinstance(thermos, list) else 0,
|
||||
"thermostat_zone": zona_numero(t_zone) if t0 else None,
|
||||
"thermostat_article": t0.get("article_number"),
|
||||
"thermostat_temperature": decimo(t_upd.get("actual_temperature")),
|
||||
"thermostat_humidity": decimo(t_upd.get("humidity")),
|
||||
"thermostat_rssi": t0.get("rssi"),
|
||||
"thermostat_battery_low": bool(t_upd.get("warning_low_battery_level")),
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# statistiche (HeatPumpStatisticsService: sola lettura, nessun comando)
|
||||
# --------------------------------------------------------------------------- #
|
||||
def money_value(units, scale) -> float:
|
||||
"""Money del protocollo: units / 10^scale. (2710000000, 9) -> 2.71."""
|
||||
try:
|
||||
return round(float(units) / (10 ** int(scale)), 2)
|
||||
except (TypeError, ValueError, ZeroDivisionError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def wh_to_kwh(wh):
|
||||
"""Wh -> kWh con un decimale; None se il dato manca o e' zero."""
|
||||
if not isinstance(wh, (int, float)) or not wh:
|
||||
return None
|
||||
return round(float(wh) / 1000.0, 1)
|
||||
|
||||
|
||||
def _ym(chiave) -> str:
|
||||
"""YearMonth -> '2026-09'."""
|
||||
return f"{getattr(chiave, 'year', 0) or 0:04d}-{getattr(chiave, 'month', 0) or 0:02d}"
|
||||
|
||||
|
||||
def _data(chiave) -> str:
|
||||
"""LocalDateTime -> '2026-10-01'."""
|
||||
return (f"{getattr(chiave, 'year', 0) or 0:04d}-"
|
||||
f"{getattr(chiave, 'month', 0) or 0:02d}-"
|
||||
f"{getattr(chiave, 'day', 0) or 0:02d}")
|
||||
|
||||
|
||||
def build_stats(aira, heat_pump_id: str, household_id: str,
|
||||
months: int = 6, days: int = 30) -> dict:
|
||||
"""Statistiche delle stesse voci che l'app ufficiale mostra in "Dati":
|
||||
calore prodotto, elettricita' usata, efficienza (COP) di acqua calda e
|
||||
riscaldamento, risparmi e risparmio Smart Tariff Control.
|
||||
|
||||
Tutto in sola lettura: non invia nessun comando all'impianto.
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from google.protobuf.timestamp_pb2 import Timestamp
|
||||
from pyairahome.device.heat_pump.statistics.v1 import service_pb2 as S
|
||||
|
||||
# _stats_service_stub non e' esposto dalla libreria: serve per GetCop,
|
||||
# GetSavings e GetOptimisationSavings (get_insights copre solo gli insight).
|
||||
stub = aira.cloud._stats_service_stub # noqa: SLF001
|
||||
call = aira.cloud.call_service
|
||||
now = datetime.now()
|
||||
|
||||
out = {
|
||||
"period_days": days,
|
||||
"currency": "EUR",
|
||||
"month": {"label": None, "heat_kwh": None, "energy_kwh": None,
|
||||
"cop_heating": None, "cop_dhw": None},
|
||||
"cop_monthly": [],
|
||||
"insights": [],
|
||||
"savings": None,
|
||||
"smart_tariff": None,
|
||||
"monthly_savings": [],
|
||||
}
|
||||
|
||||
# ---- COP mensile (l'"Efficienza energetica" dell'app ufficiale) ---------- #
|
||||
req = S.GetCopRequest()
|
||||
req.heat_pump_id.value = heat_pump_id
|
||||
req.granularity = S.COP_GRANULARITY_MONTHLY
|
||||
inizio = now - timedelta(days=31 * months)
|
||||
req.start_date.year, req.start_date.month = inizio.year, inizio.month
|
||||
if now.month == 12:
|
||||
req.end_date_exclusive.year, req.end_date_exclusive.month = now.year + 1, 1
|
||||
else:
|
||||
req.end_date_exclusive.year, req.end_date_exclusive.month = now.year, now.month + 1
|
||||
for e in call(stub, "GetCop", req, timeout=25, raw=True).entries:
|
||||
out["cop_monthly"].append({
|
||||
"month": _ym(e.start_date),
|
||||
"heating_cop": round(e.room_heating_cop_value, 2) if e.room_heating_cop_value else None,
|
||||
"dhw_cop": round(e.dhw_cop_value, 2) if e.dhw_cop_value else None,
|
||||
"avg_outdoor": round(e.average_outdoor_temperature, 1) if e.average_outdoor_temperature else None,
|
||||
})
|
||||
# L'efficienza da mostrare e' quella dell'ultimo mese con un dato: se il
|
||||
# riscaldamento non ha ancora dati (come nell'app ufficiale: "Nessun dato")
|
||||
# resta il COP dell'acqua calda, che invece c'e'.
|
||||
for voce in reversed(out["cop_monthly"]):
|
||||
if voce["dhw_cop"] or voce["heating_cop"]:
|
||||
out["month"]["cop_heating"] = voce["heating_cop"]
|
||||
out["month"]["cop_dhw"] = voce["dhw_cop"]
|
||||
break
|
||||
|
||||
# ---- Calore prodotto ed elettricita' usata ------------------------------ #
|
||||
req = S.GetHeatPumpInsightsRequest()
|
||||
req.heat_pump_id.value = heat_pump_id
|
||||
req.granularity = S.GRANULARITY_DAILY
|
||||
da = now - timedelta(days=days)
|
||||
req.start_time.year, req.start_time.month, req.start_time.day = da.year, da.month, da.day
|
||||
req.end_time_exclusive.year = now.year
|
||||
req.end_time_exclusive.month = now.month
|
||||
req.end_time_exclusive.day = now.day
|
||||
per_mese: dict = {}
|
||||
for e in call(stub, "GetHeatPumpInsights", req, timeout=25, raw=True).insights:
|
||||
giorno = _data(e.start_time)
|
||||
out["insights"].append({
|
||||
"date": giorno,
|
||||
"energy_kwh": wh_to_kwh(e.energy_consumption_wh),
|
||||
"heat_kwh": wh_to_kwh(e.delivered_heat_wh),
|
||||
})
|
||||
voce = per_mese.setdefault(giorno[:7], {"heat": 0.0, "energy": 0.0})
|
||||
voce["heat"] += float(e.delivered_heat_wh or 0)
|
||||
voce["energy"] += float(e.energy_consumption_wh or 0)
|
||||
if per_mese:
|
||||
ultimo = max(per_mese)
|
||||
out["month"]["label"] = ultimo
|
||||
out["month"]["heat_kwh"] = wh_to_kwh(per_mese[ultimo]["heat"])
|
||||
out["month"]["energy_kwh"] = wh_to_kwh(per_mese[ultimo]["energy"])
|
||||
|
||||
# ---- Risparmi del periodo ----------------------------------------------- #
|
||||
req = S.GetSavingsRequest()
|
||||
req.heat_pump_id.value = heat_pump_id
|
||||
ts = Timestamp()
|
||||
ts.FromDatetime(da)
|
||||
req.start_time.CopyFrom(ts)
|
||||
ts = Timestamp()
|
||||
ts.FromDatetime(now)
|
||||
req.end_time.CopyFrom(ts)
|
||||
sav = call(stub, "GetSavings", req, timeout=25, raw=True)
|
||||
out["currency"] = sav.currency_code or "EUR"
|
||||
out["savings"] = {
|
||||
"amount": round(sav.cost_savings, 2),
|
||||
# Attenzione al nome: il campo e' co_2_savings_kg (con l'underscore),
|
||||
# non "co2_savings_kg".
|
||||
"co2_kg": round(sav.co_2_savings_kg, 1),
|
||||
"show": bool(sav.show),
|
||||
"points": int(sav.number_of_data_points or 0),
|
||||
}
|
||||
|
||||
# ---- Risparmio Smart Tariff Control (mese mobile) ----------------------- #
|
||||
if household_id:
|
||||
req = S.GetOptimisationSavingsRequest()
|
||||
req.household_id.value = household_id
|
||||
opt = call(stub, "GetOptimisationSavings", req, timeout=25, raw=True)
|
||||
rm = opt.rolling_month_optimisation
|
||||
out["smart_tariff"] = {
|
||||
"amount": money_value(rm.savings.units, rm.savings.scale),
|
||||
"hours": int(rm.weather_optimisation_hours or 0),
|
||||
}
|
||||
for m in opt.monthly_savings:
|
||||
out["monthly_savings"].append({
|
||||
"month": _ym(m.month),
|
||||
"amount": money_value(m.savings.units, m.savings.scale),
|
||||
})
|
||||
|
||||
return out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# comandi ammessi (whitelist: mai esporre l'intera libreria su HTTP)
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -367,6 +583,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
return self._send(200, {"ok": True, "summary": summarize(self.session.states())})
|
||||
if path == "/state":
|
||||
return self._send(200, {"ok": True, "state": self.session.states()})
|
||||
if path == "/stats":
|
||||
# Statistiche (sola lettura, con cache breve lato bridge).
|
||||
return self._send(200, {"ok": True, "stats": self.session.stats()})
|
||||
if path == "/commands":
|
||||
return self._send(200, {"ok": True, "commands": COMMANDS})
|
||||
return self._send(404, {"ok": False, "error": "rotta sconosciuta"})
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Sonda di sola lettura sui dati statistici del cloud Aira (v2).
|
||||
NON invia comandi all'impianto. Obiettivo: capire cosa si puo' mostrare in una
|
||||
pagina "Dati" come quella dell'app ufficiale (calore prodotto, elettricita',
|
||||
efficienza/COP, risparmi).
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
CFG = Path(os.environ.get("AIRA_CONFIG_DIR", str(Path.home() / ".hermes/profiles/carlo")))
|
||||
sys.path.insert(0, str(CFG))
|
||||
|
||||
from pyairahome import AiraHome
|
||||
from pyairahome.device.heat_pump.statistics.v1 import service_pb2 as S
|
||||
|
||||
def section(t):
|
||||
print("\n" + "=" * 72)
|
||||
print(t)
|
||||
print("=" * 72)
|
||||
|
||||
section("messaggi annidati e risposte")
|
||||
for name in ("Uuid", "YearMonth", "LocalDateTime", "CopEntry", "GetCopResponse",
|
||||
"MonthlySavings", "GetSavingsResponse", "RollingMonthOptimisation",
|
||||
"GetOptimisationSavingsResponse", "InsightEntry", "GetHeatPumpInsightsResponse"):
|
||||
cls = getattr(S, name, None)
|
||||
if cls is None:
|
||||
print(f"{name}: assente")
|
||||
continue
|
||||
try:
|
||||
fs = cls.DESCRIPTOR.fields
|
||||
print(f"{name}: " + ", ".join(
|
||||
f"{f.name}(type={f.type}, msg={f.message_type and f.message_type.name}, "
|
||||
f"enum={f.enum_type and f.enum_type.name})" for f in fs))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"{name}: introspezione fallita ({type(exc).__name__}: {exc})")
|
||||
|
||||
# --- login ----------------------------------------------------------------- #
|
||||
tokens = json.loads((CFG / "aira_tokens.json").read_text())
|
||||
state = json.loads(Path("/tmp/aira_state.json").read_text())["state"]
|
||||
hp = state["heat_pump_id"]["value"]
|
||||
|
||||
aira = AiraHome()
|
||||
aira.cloud.login_with_tokens(tokens["id_token"], tokens["access_token"], tokens["refresh_token"])
|
||||
stub = aira.cloud._stats_service_stub # noqa: SLF001
|
||||
call = aira.cloud.call_service
|
||||
print(f"\nheat_pump_id: {hp}")
|
||||
|
||||
now = datetime.now()
|
||||
|
||||
def show(label, method, req):
|
||||
section(label)
|
||||
try:
|
||||
out = call(stub, method, req, timeout=25, raw=True)
|
||||
print(str(out).replace("\n", " | ")[:1500] or "(risposta vuota)")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"ERRORE {type(exc).__name__}: {str(exc)[:400]}")
|
||||
|
||||
# COP mensile, ultimi 6 mesi
|
||||
req = S.GetCopRequest()
|
||||
req.heat_pump_id.value = hp
|
||||
req.granularity = S.COP_GRANULARITY_MONTHLY
|
||||
start = now - timedelta(days=180)
|
||||
req.start_date.year, req.start_date.month = start.year, start.month
|
||||
req.end_date_exclusive.year, req.end_date_exclusive.month = now.year, now.month + 1 if now.month < 12 else 1
|
||||
if now.month == 12:
|
||||
req.end_date_exclusive.year = now.year + 1
|
||||
show("GetCop mensile (6 mesi) -> efficienza", "GetCop", req)
|
||||
|
||||
# Risparmi: periodo 180 giorni
|
||||
from google.protobuf.timestamp_pb2 import Timestamp
|
||||
req = S.GetSavingsRequest()
|
||||
req.heat_pump_id.value = hp
|
||||
ts = Timestamp(); ts.FromDatetime(start); req.start_time.CopyFrom(ts)
|
||||
te = Timestamp(); te.FromDatetime(now); req.end_time.CopyFrom(te)
|
||||
show("GetSavings (180 giorni) -> risparmi", "GetSavings", req)
|
||||
|
||||
# Risparmi Smart Tariff Control (per nucleo familiare)
|
||||
household = None
|
||||
try:
|
||||
devs = aira.cloud.get_devices()
|
||||
print("\nget_devices ->", str(devs)[:400])
|
||||
def find_hh(o):
|
||||
if isinstance(o, dict):
|
||||
for k, v in o.items():
|
||||
if k == "household_id" and v:
|
||||
return v if isinstance(v, str) else v.get("value")
|
||||
r = find_hh(v)
|
||||
if r:
|
||||
return r
|
||||
elif isinstance(o, list):
|
||||
for v in o:
|
||||
r = find_hh(v)
|
||||
if r:
|
||||
return r
|
||||
return None
|
||||
household = find_hh(devs)
|
||||
print("household_id:", household)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print("get_devices fallita:", type(exc).__name__, str(exc)[:200])
|
||||
|
||||
if household:
|
||||
req = S.GetOptimisationSavingsRequest()
|
||||
req.household_id.value = household
|
||||
show("GetOptimisationSavings -> risparmio Smart Tariff Control", "GetOptimisationSavings", req)
|
||||
|
||||
# Insight giornalieri, ultimi 7 giorni (LocalDateTime)
|
||||
req = S.GetHeatPumpInsightsRequest()
|
||||
req.heat_pump_id.value = hp
|
||||
req.granularity = S.GRANULARITY_DAILY
|
||||
s = now - timedelta(days=7)
|
||||
for m in (req.start_time, req.end_time_exclusive):
|
||||
pass
|
||||
req.start_time.year, req.start_time.month, req.start_time.day = s.year, s.month, s.day
|
||||
req.end_time_exclusive.year, req.end_time_exclusive.month, req.end_time_exclusive.day = now.year, now.month, now.day
|
||||
show("GetHeatPumpInsights giornaliero (7 giorni)", "GetHeatPumpInsights", req)
|
||||
|
||||
aira.cloud.close()
|
||||
print("\nsonda conclusa: nessun comando inviato all'impianto.")
|
||||
@@ -94,6 +94,55 @@ if stato_file.exists():
|
||||
else:
|
||||
print("(salto il controllo del riassunto: /tmp/state.json assente)")
|
||||
|
||||
# --- 6) statistiche: conversioni pure ------------------------------------- #
|
||||
check("money_value: 2710000000 / 10^9 = 2.71", b.money_value(2710000000, 9) == 2.71,
|
||||
repr(b.money_value(2710000000, 9)))
|
||||
check("money_value: valori assenti -> 0.0", b.money_value(None, None) == 0.0)
|
||||
check("wh_to_kwh: 29300 Wh = 29.3 kWh", b.wh_to_kwh(29300) == 29.3, repr(b.wh_to_kwh(29300)))
|
||||
check("wh_to_kwh: 70844.5 Wh = 70.8 kWh", b.wh_to_kwh(70844.53878839477) == 70.8,
|
||||
repr(b.wh_to_kwh(70844.53878839477)))
|
||||
check("wh_to_kwh: zero -> None (nessun dato)", b.wh_to_kwh(0) is None)
|
||||
check("wh_to_kwh: valore assente -> None", b.wh_to_kwh(None) is None)
|
||||
|
||||
class _YM: # finto YearMonth / LocalDateTime
|
||||
def __init__(self, y, m, d=0):
|
||||
self.year, self.month, self.day = y, m, d
|
||||
|
||||
check("_ym: 2026-09", b._ym(_YM(2026, 9)) == "2026-09", b._ym(_YM(2026, 9)))
|
||||
check("_data: 2026-10-01", b._data(_YM(2026, 10, 1)) == "2026-10-01", b._data(_YM(2026, 10, 1)))
|
||||
|
||||
# --- 7) riassunto: blocco dispositivo (stato sintetico, nessun file) ------- #
|
||||
# I valori del termostato arrivano con i decimali come interi (228 -> 22.8).
|
||||
finto = {
|
||||
"heat_pump_id": {"value": "1f4b26c4-1800-4b21-8ea2-236925a92aeb"},
|
||||
"versions": {"linux_build_id": "6.10.0", "connectivity_manager": "3.30.1",
|
||||
"climate_control_villa": "3.9.12", "outdoor_unit_application": "1.30.0",
|
||||
"outdoor_unit_eeprom": "0.114.0"},
|
||||
"thermostats": [{"article_number": "201884", "zone": "ZONE_1", "rssi": -79,
|
||||
"last_update": {"actual_temperature": 228, "humidity": 734,
|
||||
"warning_low_battery_level": False}}],
|
||||
"time_zone": {"time_zone_identifier": "Europe/Rome"},
|
||||
"zone_setpoints_heating": {"zone_1": 19.0, "zone_2": 0.0},
|
||||
"zone_temperatures": {"zone_1": 22.8, "zone_2": 0.0},
|
||||
}
|
||||
d = b.summarize(finto)
|
||||
check("dispositivo: id breve di 6 cifre", d.get("device_id") == "1f4b26", repr(d.get("device_id")))
|
||||
check("dispositivo: uuid completo", d.get("device_uuid") == "1f4b26c4-1800-4b21-8ea2-236925a92aeb")
|
||||
check("dispositivo: versione piattaforma", d.get("version_platform") == "6.10.0")
|
||||
check("dispositivo: versione connettività", d.get("version_connectivity") == "3.30.1")
|
||||
check("dispositivo: versione unità esterna", d.get("version_outdoor_unit") == "1.30.0")
|
||||
check("dispositivo: connessione", d.get("connected_via") == "Cloud")
|
||||
check("dispositivo: fuso orario", d.get("timezone") == "Europe/Rome")
|
||||
check("termostato: zona 1 da 'ZONE_1'", d.get("thermostat_zone") == 1, repr(d.get("thermostat_zone")))
|
||||
check("termostato: 228 -> 22.8 °C", d.get("thermostat_temperature") == 22.8,
|
||||
repr(d.get("thermostat_temperature")))
|
||||
check("termostato: 734 -> 73.4 %", d.get("thermostat_humidity") == 73.4,
|
||||
repr(d.get("thermostat_humidity")))
|
||||
check("termostato: segnale riportato", d.get("thermostat_rssi") == -79)
|
||||
check("termostato: batteria non scarica", d.get("thermostat_battery_low") is False)
|
||||
check("senza termostato: nessun errore",
|
||||
b.summarize({k: v for k, v in finto.items() if k != "thermostats"}).get("thermostat_zone") is None)
|
||||
|
||||
# --- esito ---------------------------------------------------------------- #
|
||||
falliti = [n for n, ok, _ in esiti if not ok]
|
||||
print(f"\n{len(esiti) - len(falliti)}/{len(esiti)} controlli superati")
|
||||
|
||||
Reference in New Issue
Block a user