bridge nel repo: sorgenti, unit systemd e istruzioni per i tester
- bridge/ con aira_bridge.py, setup_creds.py, aira_probe.py, test offline - percorsi di configurazione via AIRA_CONFIG_DIR (default invariato) - README del bridge in inglese: installazione, servizio, firewall, sicurezza - README principale aggiornato (il bridge non è più 'fuori dal repo')
This commit is contained in:
@@ -0,0 +1,432 @@
|
||||
#!/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.3.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
|
||||
|
||||
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
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 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 []
|
||||
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"),
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 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 == "/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())
|
||||
Reference in New Issue
Block a user