- 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
652 lines
27 KiB
Python
652 lines
27 KiB
Python
#!/usr/bin/env python3
|
|
"""Bridge HTTP locale verso l'impianto Aira (per l'app Sailfish harbour-aira).
|
|
|
|
Perche' esiste: il cloud Aira parla **gRPC** (HTTP/2) e Qt 5.6, il toolkit di
|
|
Sailfish, non ha HTTP/2. Un client nativo non puo' quindi parlare direttamente
|
|
con Aira: questo servizio traduce HTTP/1.1 + JSON -> gRPC, e tiene le
|
|
credenziali/token SOLO sul server.
|
|
|
|
Avvio:
|
|
python aira_bridge.py # ascolta su 0.0.0.0:8790
|
|
python aira_bridge.py --port 8791 # porta diversa
|
|
|
|
Config: $AIRA_CONFIG_DIR/aira_bridge.json {"host","port","token"}
|
|
(il default di AIRA_CONFIG_DIR e' la cartella usata dall'autore; su un'altra
|
|
macchina conviene impostarla, es. AIRA_CONFIG_DIR=$HOME/.config/aira)
|
|
Il token viene generato al primo avvio se assente.
|
|
|
|
Endpoint (tutti richiedono `Authorization: Bearer <token>`, tranne /health):
|
|
GET /health -> {ok, version}
|
|
GET /summary -> valori leggibili per la dashboard
|
|
GET /state -> stato completo (raw)
|
|
GET /commands -> comandi disponibili con i loro parametri
|
|
POST /command -> {"id": "...", "value": ...}
|
|
|
|
Log su stderr. stdout NON e' usato per il protocollo (solo log di servizio).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import secrets
|
|
import stat
|
|
import sys
|
|
import threading
|
|
import traceback
|
|
import warnings
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from pathlib import Path
|
|
|
|
warnings.filterwarnings("ignore")
|
|
|
|
# Tutti i file di configurazione stanno in una cartella sola (permessi 600):
|
|
# aira.json credenziali Aira (email + password)
|
|
# aira_tokens.json token di sessione Cognito (si rinnovano da soli)
|
|
# aira_config.json indice del pool di utenti Cognito
|
|
# aira_bridge.json host/porta/token del bridge (token generato al primo avvio)
|
|
# Il default e' la cartella usata dall'autore: su un'altra macchina si imposta
|
|
# AIRA_CONFIG_DIR (es. AIRA_CONFIG_DIR=$HOME/.config/aira).
|
|
BASE = Path(os.environ.get("AIRA_CONFIG_DIR") or (Path.home() / ".hermes" / "profiles" / "carlo"))
|
|
BRIDGE_CFG = BASE / "aira_bridge.json"
|
|
CREDS = BASE / "aira.json"
|
|
TOKENS = BASE / "aira_tokens.json"
|
|
POOL_CFG = BASE / "aira_config.json"
|
|
|
|
VERSION = "0.4.0"
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# configurazione e credenziali
|
|
# --------------------------------------------------------------------------- #
|
|
def load_bridge_config() -> dict:
|
|
cfg = {"host": "0.0.0.0", "port": 8790, "token": ""}
|
|
if BRIDGE_CFG.exists():
|
|
cfg.update(json.loads(BRIDGE_CFG.read_text()))
|
|
if not cfg.get("token"):
|
|
cfg["token"] = secrets.token_urlsafe(32)
|
|
write_private(BRIDGE_CFG, cfg)
|
|
print(f"[bridge] token generato -> {BRIDGE_CFG} (600)", file=sys.stderr)
|
|
return cfg
|
|
|
|
|
|
def write_private(path: Path, data: dict) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
tmp.write_text(json.dumps(data, indent=2))
|
|
os.chmod(tmp, stat.S_IRUSR | stat.S_IWUSR)
|
|
tmp.replace(path)
|
|
|
|
|
|
def pool_id() -> str:
|
|
from pyairahome.config import Settings
|
|
|
|
idx = 0
|
|
if POOL_CFG.exists():
|
|
idx = json.loads(POOL_CFG.read_text()).get("user_pool_index", 0)
|
|
return Settings.USER_POOL_IDS[idx]
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# sessione Aira (una sola, riusata; il login si fa solo se i token non bastano)
|
|
# --------------------------------------------------------------------------- #
|
|
class AiraSession:
|
|
def __init__(self) -> None:
|
|
self._lock = threading.Lock()
|
|
self._client = None
|
|
# 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
|
|
|
|
aira = AiraHome(user_pool_id=pool_id())
|
|
|
|
if TOKENS.exists():
|
|
t = json.loads(TOKENS.read_text())
|
|
try:
|
|
aira.cloud.login_with_tokens(t["id_token"], t["access_token"], t["refresh_token"])
|
|
return aira
|
|
except Exception as exc:
|
|
print(f"[bridge] token non validi ({type(exc).__name__}), provo le credenziali", file=sys.stderr)
|
|
|
|
if not CREDS.exists():
|
|
raise RuntimeError(f"credenziali assenti: {CREDS}")
|
|
|
|
c = json.loads(CREDS.read_text())
|
|
aira.cloud.login_with_credentials(c["email"], c["password"])
|
|
try:
|
|
write_private(TOKENS, aira.cloud.get_tokens().dict())
|
|
except Exception as exc:
|
|
print(f"[bridge] token non salvati: {exc}", file=sys.stderr)
|
|
return aira
|
|
|
|
def _ensure(self):
|
|
if self._client is None:
|
|
self._client = self._login()
|
|
return self._client
|
|
|
|
def call(self, fn):
|
|
"""Esegue `fn(client)` sotto lock; se la sessione e' morta, rifa' il login una volta."""
|
|
from pyairahome.utils import TokenError
|
|
from pyairahome.utils.exceptions import AuthenticationError
|
|
|
|
with self._lock:
|
|
try:
|
|
return fn(self._ensure())
|
|
except (TokenError, AuthenticationError):
|
|
print("[bridge] sessione scaduta: nuovo login", file=sys.stderr)
|
|
self._client = None
|
|
return fn(self._ensure())
|
|
|
|
def device_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]["id"]["value"]
|
|
|
|
def states(self) -> dict:
|
|
did = self.device_id()
|
|
st = self.call(lambda a: a.cloud.get_states(did))
|
|
out = st["heat_pump_states"][0]
|
|
self._states = out
|
|
return out
|
|
|
|
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
|
|
# --------------------------------------------------------------------------- #
|
|
def summarize(st: dict) -> dict:
|
|
def g(*path, default=None):
|
|
d = st
|
|
for p in path:
|
|
if not isinstance(d, dict) or p not in d:
|
|
return default
|
|
d = d[p]
|
|
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"),
|
|
"dhw_current": g("current_hot_water_temperature"),
|
|
"dhw_target": g("target_hot_water_temperature"),
|
|
"operating_status": g("operating_status"),
|
|
"away_mode": bool(st.get("away_mode_enabled")),
|
|
"night_mode": bool(st.get("night_mode_enabled")),
|
|
"manual_mode": bool(st.get("manual_mode_enabled")),
|
|
# force_heating E' un oggetto {enabled, remaining_time}: leggerlo con
|
|
# bool() darebbe sempre vero, perche' un dict non vuoto e' truthy.
|
|
"force_heating": bool(g("force_heating", "enabled")),
|
|
"force_heating_remaining": g("force_heating", "remaining_time"),
|
|
"inline_heater": bool(st.get("inline_heater_active")),
|
|
# Stato delle funzioni, per la pagina dei comandi: senza questi l'utente
|
|
# preme alla cieca senza sapere cosa e' gia' attivo.
|
|
"hot_water_heating": bool(g("hot_water", "heating_enabled")),
|
|
"configured_modes": g("configured_pump_modes"),
|
|
"pump_active_state": g("pump_active_state"),
|
|
"outdoor_defrost": bool(st.get("outdoor_unit_defrost_enabled")),
|
|
"signature_lights": bool(g("signature_element", "enabled")),
|
|
"led_pattern": g("led_pattern"),
|
|
"zones": st.get("num_zones"),
|
|
# Riscaldamento/raffrescamento per zona: obiettivo, temperatura della
|
|
# stanza e stato della pompa (zona 2 a zero se non configurata).
|
|
"zone1_setpoint": g("zone_setpoints_heating", "zone_1"),
|
|
"zone1_temperature": g("zone_temperatures", "zone_1"),
|
|
"zone1_mode": g("current_pump_mode_state", "zone_1"),
|
|
"zone1_cooling_setpoint": g("zone_setpoints_cooling", "zone_1"),
|
|
"zone2_setpoint": g("zone_setpoints_heating", "zone_2"),
|
|
"zone2_temperature": g("zone_temperatures", "zone_2"),
|
|
"zone2_mode": g("current_pump_mode_state", "zone_2"),
|
|
"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)
|
|
# --------------------------------------------------------------------------- #
|
|
COMMANDS = {
|
|
"ping": {"help": "verifica che l'impianto risponda", "value": None},
|
|
"set_hot_water_target": {"help": "temperatura ACS obiettivo in °C", "value": "45-60"},
|
|
"set_heating_setpoint": {"help": "temperatura obiettivo della casa in °C", "value": "5-30", "zone": "1 o 2 (opzionale, default 1)"},
|
|
"set_cooling_setpoint": {"help": "temperatura obiettivo del raffrescamento in °C", "value": "5-30", "zone": "1 o 2 (opzionale, default 1)"},
|
|
"hot_water_boost_on": {"help": "avvia il boost ACS (una tantum)", "value": None},
|
|
"hot_water_boost_off": {"help": "ferma il boost ACS", "value": None},
|
|
"hot_water_heating_on": {"help": "abilita il riscaldamento ACS", "value": None},
|
|
"hot_water_heating_off": {"help": "disabilita il riscaldamento ACS", "value": None},
|
|
"away_mode_on": {"help": "modalita' assenza", "value": None},
|
|
"away_mode_off": {"help": "disattiva l'assenza", "value": None},
|
|
"night_mode_1h": {"help": "night mode per un'ora", "value": None},
|
|
"force_heating_on": {"help": "forza il riscaldamento", "value": None},
|
|
"force_heating_off": {"help": "disattiva il riscaldamento forzato", "value": None},
|
|
"heating_on": {"help": "abilita la funzione riscaldamento", "value": None},
|
|
"heating_off": {"help": "disabilita la funzione riscaldamento", "value": None},
|
|
"legionella_cycle": {"help": "avvia un ciclo antilegionella", "value": None},
|
|
}
|
|
|
|
|
|
def build_command(cmd_id: str, value=None, zone=1, context=None):
|
|
"""Costruisce l'oggetto comando della libreria. Importata solo qui per non
|
|
pagarne il costo all'avvio del bridge.
|
|
|
|
`context` e' l'ultimo stato letto: serve ai comandi per zona, che vanno
|
|
inviati completi (zona 1 E zona 2) per non azzerare quella non toccata.
|
|
"""
|
|
from pyairahome.commands import (
|
|
ActivateHotWaterBoosting,
|
|
ActivateNightModeForOneHour,
|
|
ClearAwayMode,
|
|
DeactivateHotWaterBoosting,
|
|
DisableForceHeating,
|
|
DisableHeatingFunction,
|
|
DisableHotWaterHeating,
|
|
EnableForceHeating,
|
|
EnableHeatingFunction,
|
|
EnableHotWaterHeating,
|
|
Ping,
|
|
RunLegionellaCycle,
|
|
SetAwayMode,
|
|
SetTargetHotWaterTemperature,
|
|
SetZoneSetpoints,
|
|
)
|
|
|
|
simple = {
|
|
"ping": Ping,
|
|
"hot_water_boost_on": ActivateHotWaterBoosting,
|
|
"hot_water_boost_off": DeactivateHotWaterBoosting,
|
|
"hot_water_heating_on": EnableHotWaterHeating,
|
|
"hot_water_heating_off": DisableHotWaterHeating,
|
|
"away_mode_on": SetAwayMode,
|
|
"away_mode_off": ClearAwayMode,
|
|
"night_mode_1h": ActivateNightModeForOneHour,
|
|
"force_heating_on": EnableForceHeating,
|
|
"force_heating_off": DisableForceHeating,
|
|
"heating_on": EnableHeatingFunction,
|
|
"heating_off": DisableHeatingFunction,
|
|
"legionella_cycle": RunLegionellaCycle,
|
|
}
|
|
|
|
if cmd_id == "set_hot_water_target":
|
|
try:
|
|
t = float(value)
|
|
except (TypeError, ValueError):
|
|
raise ValueError("set_hot_water_target richiede un numero in °C")
|
|
if not 45.0 <= t <= 60.0:
|
|
raise ValueError("temperatura ACS fuori intervallo ammesso (45-60 °C)")
|
|
return SetTargetHotWaterTemperature(t)
|
|
|
|
if cmd_id in ("set_heating_setpoint", "set_cooling_setpoint"):
|
|
heating = cmd_id == "set_heating_setpoint"
|
|
nome = "heating" if heating else "cooling"
|
|
try:
|
|
t = float(value)
|
|
except (TypeError, ValueError):
|
|
raise ValueError(f"{cmd_id} richiede un numero in °C")
|
|
if not 5.0 <= t <= 30.0:
|
|
raise ValueError("temperatura della casa fuori intervallo ammesso (5-30 °C)")
|
|
try:
|
|
z = int(zone)
|
|
except (TypeError, ValueError):
|
|
raise ValueError("zona non valida: usare 1 o 2")
|
|
if z not in (1, 2):
|
|
raise ValueError("zona ammessa: 1 o 2")
|
|
|
|
# Il messaggio va inviato completo: la zona non toccata viaggia col suo
|
|
# valore attuale, altrimenti a zero verrebbe spenta.
|
|
def attuale(zona: int) -> float:
|
|
if not isinstance(context, dict):
|
|
return 0.0
|
|
valori = context.get(f"zone_setpoints_{nome}") or {}
|
|
v = valori.get(f"zone_{zona}")
|
|
return float(v) if isinstance(v, (int, float)) else 0.0
|
|
|
|
from pyairahome.device.heat_pump.command.v1.set_zone_setpoints_pb2 import (
|
|
ZoneTemperatures,
|
|
)
|
|
|
|
zs = ZoneTemperatures(
|
|
zone_1=t if z == 1 else attuale(1),
|
|
zone_2=t if z == 2 else attuale(2),
|
|
)
|
|
# Kind: 1 = riscaldamento, 2 = raffrescamento (enum del protocollo).
|
|
return SetZoneSetpoints(zone_setpoints=zs, kind=1 if heating else 2)
|
|
|
|
cls = simple.get(cmd_id)
|
|
if cls is None:
|
|
raise ValueError(f"comando non ammesso: {cmd_id}")
|
|
return cls()
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# server HTTP
|
|
# --------------------------------------------------------------------------- #
|
|
class Handler(BaseHTTPRequestHandler):
|
|
server_version = "aira-bridge/" + VERSION
|
|
session: AiraSession = None # impostato in main()
|
|
token: str = ""
|
|
|
|
# ---- utilita' ---------------------------------------------------------- #
|
|
def log_message(self, fmt, *args): # log su stderr, non su stdout
|
|
print(f"[bridge] {self.address_string()} {fmt % args}", file=sys.stderr)
|
|
|
|
def _send(self, code: int, payload: dict) -> None:
|
|
body = json.dumps(payload, default=str).encode()
|
|
self.send_response(code)
|
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def _auth_ok(self) -> bool:
|
|
if self.path.split("?")[0] == "/health":
|
|
return True
|
|
hdr = self.headers.get("Authorization", "")
|
|
return hdr == f"Bearer {self.token}"
|
|
|
|
def _body(self) -> dict:
|
|
n = int(self.headers.get("Content-Length") or 0)
|
|
if not n:
|
|
return {}
|
|
return json.loads(self.rfile.read(n).decode() or "{}")
|
|
|
|
# ---- rotte ------------------------------------------------------------- #
|
|
def do_GET(self):
|
|
if not self._auth_ok():
|
|
return self._send(401, {"ok": False, "error": "token mancante o errato"})
|
|
path = self.path.split("?")[0]
|
|
try:
|
|
if path == "/health":
|
|
return self._send(200, {"ok": True, "version": VERSION})
|
|
if path == "/summary":
|
|
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"})
|
|
except Exception as exc:
|
|
traceback.print_exc(file=sys.stderr)
|
|
return self._send(502, {"ok": False, "error": f"{type(exc).__name__}: {exc}"})
|
|
|
|
def do_POST(self):
|
|
if not self._auth_ok():
|
|
return self._send(401, {"ok": False, "error": "token mancante o errato"})
|
|
if self.path.split("?")[0] != "/command":
|
|
return self._send(404, {"ok": False, "error": "rotta sconosciuta"})
|
|
try:
|
|
body = self._body()
|
|
cmd_id = body.get("id", "")
|
|
cmd = build_command(cmd_id, body.get("value"), body.get("zone") or 1,
|
|
self.session.last_states())
|
|
except ValueError as exc:
|
|
return self._send(400, {"ok": False, "error": str(exc)})
|
|
except Exception as exc:
|
|
return self._send(400, {"ok": False, "error": f"richiesta non valida: {exc}"})
|
|
|
|
try:
|
|
did = self.session.device_id()
|
|
|
|
def run(a):
|
|
out = []
|
|
for update in a.cloud.run_command(did, cmd):
|
|
out.append(update)
|
|
return out
|
|
|
|
result = self.session.call(run)
|
|
print(f"[bridge] comando {cmd_id} eseguito", file=sys.stderr)
|
|
return self._send(200, {"ok": True, "id": cmd_id, "result": result[-1] if result else None})
|
|
except Exception as exc:
|
|
traceback.print_exc(file=sys.stderr)
|
|
return self._send(502, {"ok": False, "error": f"{type(exc).__name__}: {exc}"})
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--host", default=None)
|
|
ap.add_argument("--port", type=int, default=None)
|
|
args = ap.parse_args()
|
|
|
|
cfg = load_bridge_config()
|
|
host = args.host or cfg["host"]
|
|
port = args.port or cfg["port"]
|
|
|
|
Handler.session = AiraSession()
|
|
Handler.token = cfg["token"]
|
|
|
|
srv = ThreadingHTTPServer((host, port), Handler)
|
|
print(f"[bridge] in ascolto su http://{host}:{port} (v{VERSION})", file=sys.stderr)
|
|
try:
|
|
srv.serve_forever()
|
|
except KeyboardInterrupt:
|
|
print("[bridge] arresto", file=sys.stderr)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|