La copia del bridge dentro l'app va tenuta allineata: contiene la correzione del boost, dell'assenza, di ACS off e del riscaldamento forzato, che la libreria accetta solo con un argomento. Il pacchetto Sailfish non cambia (il bridge non entra nell'RPM), quindi non serve ricostruire l'RPM.
745 lines
31 KiB
Python
745 lines
31 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 datetime import datetime, timedelta, timezone
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from pathlib import Path
|
|
|
|
from google.protobuf.duration_pb2 import Duration
|
|
from google.protobuf.timestamp_pb2 import Timestamp
|
|
|
|
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.6.9"
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# 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 []
|
|
|
|
# ---- gravita' degli errori -------------------------------------------
|
|
# La severita' arriva come stringa ("SEVERITY_WARNING") e le voci possono
|
|
# essere piu' di una: si normalizza al nome nudo e si tiene la peggiore.
|
|
# "allarme che ferma" NON si deduce dalla severita': sta nei metadati
|
|
# (compreso quello del compressore).
|
|
_GRAVITA = {"WARNING": 1, "ERROR": 2, "CRITICAL": 3}
|
|
_meta = st.get("error_metadata") or {}
|
|
alarm_stopping = bool(_meta.get("hp_has_stopping_alarms")) or bool(
|
|
_meta.get("compressor_has_stopping_alarm"))
|
|
severita = ""
|
|
for _voce in errs if isinstance(errs, list) else []:
|
|
_nome = str((_voce or {}).get("severity") or "")
|
|
if _nome.startswith("SEVERITY_"):
|
|
_nome = _nome[len("SEVERITY_"):]
|
|
if _GRAVITA.get(_nome, 0) > _GRAVITA.get(severita, 0):
|
|
severita = _nome
|
|
|
|
# ---- 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,
|
|
# Gravita' degli errori, per il pallino di stato dell'app: un semplice
|
|
# avviso (WARNING) non deve diventare rosso come un allarme che ferma la
|
|
# macchina. "error_metadata" dice se c'e' un allarme che ferma; le voci
|
|
# di "errors" portano la severita' di ognuna (normalizzata senza il
|
|
# prefisso SEVERITY_, cosi' la UI non dipende dal protobuf).
|
|
"alarm_stopping": alarm_stopping,
|
|
"error_severity": severita,
|
|
"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)
|
|
# --------------------------------------------------------------------------- #
|
|
# Quattro comandi NON si possono inviare senza un argomento: la libreria vuole
|
|
# una durata (Duration) o, per l'assenza, inizio/fine (Timestamp) e la
|
|
# temperatura da tenere in casa. Senza, solleva TypeError e il bridge risponde
|
|
# "richiesta non valida" — e' quello che e' successo al primo boost, perche'
|
|
# nemmeno l'app mandava la durata.
|
|
# I valori qui sotto sono i default del bridge: sono scelte, non misure. L'app
|
|
# puo' mandarne altri in `value` (minuti, o giorni per l'assenza).
|
|
BOOST_MIN_DEFAULT = 60 # boost ACS
|
|
ACS_OFF_MIN_DEFAULT = 60 # ACS spento
|
|
FORCE_MIN_DEFAULT = 60 # riscaldamento forzato
|
|
AWAY_DAYS_DEFAULT = 7 # assenza
|
|
AWAY_TARGET_TEMP = 15.0 # casa tenuta a questa temperatura in assenza, °C
|
|
|
|
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": "minuti, 15-240 (opzionale, default 60)"},
|
|
"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": "minuti, 15-1440 (opzionale, default 60)"},
|
|
"away_mode_on": {"help": "modalita' assenza", "value": "giorni, 1-60 (opzionale, default 7)"},
|
|
"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": "minuti, 15-240 (opzionale, default 60)"},
|
|
"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,
|
|
)
|
|
|
|
# Solo i comandi che la libreria accetta senza argomenti: gli altri hanno un
|
|
# ramo loro qui sotto, perche' senza la durata sollevano TypeError.
|
|
simple = {
|
|
"ping": Ping,
|
|
"hot_water_boost_off": DeactivateHotWaterBoosting,
|
|
"hot_water_heating_on": EnableHotWaterHeating,
|
|
"away_mode_off": ClearAwayMode,
|
|
"night_mode_1h": ActivateNightModeForOneHour,
|
|
"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)
|
|
|
|
if cmd_id == "hot_water_boost_on":
|
|
return ActivateHotWaterBoosting(
|
|
hot_water_boost_duration=_durata(value, BOOST_MIN_DEFAULT, 15, 240, cmd_id))
|
|
|
|
if cmd_id == "hot_water_heating_off":
|
|
return DisableHotWaterHeating(
|
|
time=_durata(value, ACS_OFF_MIN_DEFAULT, 15, 1440, cmd_id))
|
|
|
|
if cmd_id == "force_heating_on":
|
|
return EnableForceHeating(
|
|
duration=_durata(value, FORCE_MIN_DEFAULT, 15, 240, cmd_id))
|
|
|
|
if cmd_id == "away_mode_on":
|
|
return SetAwayMode(
|
|
current_time=_adesso(),
|
|
end_time=_adesso(giorni=_giorni(value)),
|
|
target_room_temperature=AWAY_TARGET_TEMP,
|
|
)
|
|
|
|
cls = simple.get(cmd_id)
|
|
if cls is None:
|
|
raise ValueError(f"comando non ammesso: {cmd_id}")
|
|
return cls()
|
|
|
|
|
|
def _durata(value, default_min: int, min_min: int, max_min: int, nome: str) -> Duration:
|
|
"""Durata in minuti: `value` se l'app lo manda, altrimenti il default."""
|
|
if value in (None, ""):
|
|
minuti = default_min
|
|
else:
|
|
try:
|
|
minuti = int(float(value))
|
|
except (TypeError, ValueError):
|
|
raise ValueError(f"{nome} richiede i minuti come numero")
|
|
if not min_min <= minuti <= max_min:
|
|
raise ValueError(
|
|
f"{nome}: durata fuori intervallo ({min_min}-{max_min} minuti)")
|
|
return Duration(seconds=minuti * 60)
|
|
|
|
|
|
def _giorni(value) -> int:
|
|
"""Giorni di assenza: `value` se l'app lo manda, altrimenti il default."""
|
|
if value in (None, ""):
|
|
return AWAY_DAYS_DEFAULT
|
|
try:
|
|
g = int(float(value))
|
|
except (TypeError, ValueError):
|
|
raise ValueError("away_mode_on richiede i giorni come numero")
|
|
if not 1 <= g <= 60:
|
|
raise ValueError("away_mode_on: durata fuori intervallo (1-60 giorni)")
|
|
return g
|
|
|
|
|
|
def _adesso(giorni: int = 0) -> Timestamp:
|
|
"""Timestamp UTC di adesso, oppure di adesso + N giorni."""
|
|
t = Timestamp()
|
|
t.FromDatetime(datetime.now(timezone.utc) + timedelta(days=giorni))
|
|
return t
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# 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())
|