diff --git a/.gitignore b/.gitignore index 2fa5967..8cad61b 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,11 @@ tests/build/ # tarball di consegna (rigenerabili con git archive) *.tar.gz +# bridge: ambiente virtuale e cache di Python +bridge/.venv/ +__pycache__/ +*.pyc + # temporanei *~ *.bak diff --git a/README.md b/README.md index 9ea0e04..ee0acb1 100644 --- a/README.md +++ b/README.md @@ -18,22 +18,26 @@ l'app è un client sottile che parla HTTP/1.1 + JSON. Effetto collaterale gradito: credenziali e token non stanno mai sul telefono. ``` - Jolla (QML/C++ Qt 5.6) --HTTP/1.1 + JSON--> bridge sul server --gRPC--> cloud Aira - (~/workspace/Carlo/Aira/ - aira_bridge.py) + Jolla (QML/C++ Qt 5.6) --HTTP/1.1 + JSON--> bridge (Linux in LAN) --gRPC--> cloud Aira + bridge/aira_bridge.py ``` ## Il bridge -Vive in `~/workspace/Carlo/Aira/` (fuori da questo repo: contiene dati -personali e non va pubblicato). +Il codice sta in [`bridge/`](bridge/): istruzioni complete in +[bridge/README.md](bridge/README.md) (in inglese, perché è la parte che serve a +chi prova l'app su un altro impianto). ```sh -cd ~/workspace/Carlo/Aira -.venv/bin/python aira_bridge.py --port 8790 # ascolta su 0.0.0.0:8790 +cd bridge +python3 -m venv .venv && . .venv/bin/activate +pip install -r requirements.txt +export AIRA_CONFIG_DIR="$HOME/.config/aira" +python setup_creds.py # email e password Aira: salvate in un file 600 +python aira_bridge.py --port 8790 ``` -Al primo avvio genera il token in `~/.hermes/profiles/carlo/aira_bridge.json` +Al primo avvio il bridge genera un token in `$AIRA_CONFIG_DIR/aira_bridge.json` (permessi 600): è il valore da incollare nelle impostazioni dell'app. API completa in [docs/PROTOCOL.md](docs/PROTOCOL.md). @@ -53,8 +57,10 @@ sfdk build # RPM in RPMS/ sfdk deploy # sul dispositivo collegato ``` -Sorgenti dal tarball: `tar xzf harbour-aira-0.2.1.tar.gz` e compilare in una +Sorgenti dal tarball: `tar xzf harbour-aira-0.3.0.tar.gz` e compilare in una cartella nuova (mai sopra una estrazione precedente: i Makefile restano stale). +Il log dell'app inizia con `harbour-aira v build `: serve a +distinguere una build nuova da una stale senza indovinare. ### Verifica locale, senza SDK @@ -82,6 +88,12 @@ qml/pages/SettingsPage.qml qml/components/InfoRow.qml qml/components/ActionButton.qml pulsante di comando, con conferma opzionale tests/core_test.cpp verifiche del nucleo (headless) +bridge/aira_bridge.py servizio HTTP/1.1 + JSON verso il cloud (gRPC) +bridge/setup_creds.py salva le credenziali Aira (file 600) +bridge/aira_probe.py sonda di sola lettura: login + stato impianto +bridge/test_bridge_commands.py verifiche offline dei comandi (27 controlli) +bridge/harbour-aira-bridge.service unit systemd utente +bridge/README.md istruzioni di installazione del bridge (inglese) docs/PIANO.md piano di progetto e scelte docs/PROTOCOL.md API del bridge ``` diff --git a/bridge/README.md b/bridge/README.md new file mode 100644 index 0000000..89246f1 --- /dev/null +++ b/bridge/README.md @@ -0,0 +1,169 @@ +# Aira bridge — let the Sailfish app talk to your heat pump + +The app cannot reach Aira directly: Aira's cloud API is **gRPC over HTTP/2**, and +Sailfish's Qt 5.6 speaks HTTP/1.1 only. This small service sits on a Linux box in +your LAN, answers the phone in plain HTTP/1.1 + JSON and speaks gRPC to Aira. +A side effect worth having: your Aira credentials and session tokens never leave +this machine, so nothing sensitive is stored on the phone. + +Tested against an Aira Home heat pump (Europe). Python 3.9+. + +## What you need + +- a machine that stays on, on the same LAN as the phone (a Raspberry Pi is plenty) +- an Aira account — the same one you use in the Aira app +- outbound internet access to the Aira cloud +- **no** port forwarding, no public exposure: this is a LAN service + +## Install + +```bash +git clone https://git.hackatoniclife.com/kaneda/harbour-aira.git +cd harbour-aira/bridge + +python3 -m venv .venv +. .venv/bin/activate +pip install -r requirements.txt + +# where credentials and bridge configuration will live +export AIRA_CONFIG_DIR="$HOME/.config/aira" +mkdir -p "$AIRA_CONFIG_DIR" + +# your Aira email + password: typed without echo, saved in a file readable +# only by your user (600). Nothing is printed, nothing is logged. +python setup_creds.py + +# sanity check: logs in and prints your plant state (read-only) +python aira_probe.py +``` + +If `aira_probe.py` prints your temperatures and modes, the hard part is done. + +## Run + +```bash +python aira_bridge.py --host 0.0.0.0 --port 8790 +``` + +On first start the bridge creates `$AIRA_CONFIG_DIR/aira_bridge.json` with a +random token (600). To read it: + +```bash +python -c "import json,pathlib,os; print(json.load(open(pathlib.Path(os.environ['AIRA_CONFIG_DIR'])/'aira_bridge.json'))['token'])" +``` + +Then in the app: pull the menu down, *Impostazioni*, and enter +`http://:8790` plus that token. + +## Run as a service (recommended) + +```bash +mkdir -p ~/.config/systemd/user +cp harbour-aira-bridge.service ~/.config/systemd/user/ +systemctl --user daemon-reload +systemctl --user enable --now harbour-aira-bridge.service +loginctl enable-linger "$USER" # keeps it up after logout and at boot +journalctl --user -u harbour-aira-bridge.service -f +``` + +The unit uses `%h` and expects the repo in `~/harbour-aira`: +edit `WorkingDirectory` / `ExecStart` if you cloned it elsewhere, and make sure +`Environment=AIRA_CONFIG_DIR=...` matches what you used with `setup_creds.py`. +Verified resilience: killing the process brings it back in about ten seconds. + +## Opening the port (LAN only) + +The bridge listens on 8790 and checks a bearer token on every route except +`/health`, but it is meant for a trusted network. Do **not** forward this port +from the internet. + +```bash +# ufw +sudo ufw allow from 192.168.1.0/24 to any port 8790 proto tcp +``` + +```nftables +# nftables: inside your input chain +ip saddr 192.168.1.0/24 tcp dport 8790 accept +``` + +On a host with a default-drop firewall the bridge looks dead from the phone even +though it is running: check the firewall before debugging anything else, and +test from the phone, not from the bridge machine itself (loopback traffic does +not traverse the input chain, so it always works). + +## Security + +- Credentials (`aira.json`) and Cognito session tokens (`aira_tokens.json`) live + in `$AIRA_CONFIG_DIR` with mode 600, on this machine only. +- `aira_bridge.json` holds the bearer token, which is the only credential stored + on the phone. To rotate it: stop the bridge, delete the file, start it again, + and paste the new token in the app. +- Traffic goes to Aira's own API and nowhere else. Nothing is collected, nothing + is sent to the author. +- Only a whitelist of commands is exposed (see below). Factory reset, firmware + updates, reboots and Wi-Fi provisioning are deliberately not reachable. + +## Endpoints + +| 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 /state` | token | full raw plant state, exactly as the cloud reports it | +| `GET /commands` | token | allowed commands with their parameters and ranges | +| `POST /command` | token | `{"id": "...", "value": 21.5, "zone": 1}` | + +Commands exposed: hot water target and boost, heating/cooling setpoint per zone, +away mode, night mode for one hour, force heating, heating function, legionella +cycle, plant check. Protocol details and field names: `../docs/PROTOCOL.md`. + +Two things learned on a real pump, both worth knowing: + +- **A zone command always carries every zone.** `SetZoneSetpoints` takes the full + set of zones, so sending only the zone you want to change would zero the other + one. The bridge remembers the last states it read and sends the untouched zone + back with its current value. +- **`force_heating` is not a boolean** in the state but an object + `{enabled, remaining_time}`. Reading it as a truthy value makes "forced heating: + yes" appear while the pump is idle. Same shape for `hot_water.heating_enabled`. + +## Offline tests + +```bash +python test_bridge_commands.py +``` + +27 checks on the protobuf payloads and on the summary parsing — no network, and +**no command is sent to the pump**. + +## If login fails + +Your account may live in a different Cognito user pool: a wrong pool answers +"credentials not valid" even with the right password. Try: + +```bash +python aira_probe.py --pool 0 # then 1, then 2 +``` + +or write `{"user_pool_index": N}` into `$AIRA_CONFIG_DIR/aira_config.json`. +Try one pool at a time: repeated failures trigger a temporary lockout. + +## Files + +| File | What it is | +|---|---| +| `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 | +| `harbour-aira-bridge.service` | systemd user unit | +| `requirements.txt` | `pyairahome`, pinned to the tested version | + +## Reporting your pump + +If you are testing this on your own unit, the useful things to send me are: pump +model and firmware versions, number of heating zones (1 or 2), whether cooling is +configured, the output of `python aira_probe.py --json`, and anything that looks +wrong next to what the Aira app shows at that moment. The probe output contains +no credentials. diff --git a/bridge/aira_bridge.py b/bridge/aira_bridge.py new file mode 100644 index 0000000..d537512 --- /dev/null +++ b/bridge/aira_bridge.py @@ -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 `, 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()) diff --git a/bridge/aira_probe.py b/bridge/aira_probe.py new file mode 100644 index 0000000..c0d468e --- /dev/null +++ b/bridge/aira_probe.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +"""Sonda Aira Home: verifica che il canale cloud funzioni e legge lo stato dell'impianto. + +Credenziali (in ordine di priorita'): + 1. variabili d'ambiente AIRA_EMAIL / AIRA_PASSWORD + 2. file JSON $AIRA_CONFIG_DIR/aira.json {"email": "...", "password": "..."} + 3. token salvati in $AIRA_CONFIG_DIR/aira_tokens.json (refresh automatico) + +I token vengono risalvati (permessi 600) a ogni login riuscito, cosi' le volte +successive non serve piu' la password. + +Uso: + .venv/bin/python aira_probe.py # stato impianto + .venv/bin/python aira_probe.py --json # dump completo +""" +from __future__ import annotations + +import argparse +import json +import os +import stat +import sys +from pathlib import Path + +BASE = Path(os.environ.get("AIRA_CONFIG_DIR") or (Path.home() / ".hermes" / "profiles" / "carlo")) +CREDS_FILE = BASE / "aira.json" +TOKENS_FILE = BASE / "aira_tokens.json" + + +def _write_private(path: Path, data: dict) -> None: + """Scrive JSON con permessi 600 (mai credenziali leggibili da altri).""" + 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 load_credentials() -> tuple[str, str] | None: + email = os.environ.get("AIRA_EMAIL") + password = os.environ.get("AIRA_PASSWORD") + if email and password: + return email, password + if CREDS_FILE.exists(): + data = json.loads(CREDS_FILE.read_text()) + if data.get("email") and data.get("password"): + return data["email"], data["password"] + return None + + +def load_tokens() -> dict | None: + if TOKENS_FILE.exists(): + data = json.loads(TOKENS_FILE.read_text()) + if all(data.get(k) for k in ("id_token", "access_token", "refresh_token")): + return data + return None + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--json", action="store_true", help="dump completo dello stato") + ap.add_argument("--pool", type=int, default=None, + help="indice user pool (default: da aira_config.json, altrimenti 0)") + args = ap.parse_args() + + from pyairahome import AiraHome + from pyairahome.config import Settings + + # Il pool giusto va letto dalla config, mai indovinato: un pool sbagliato + # risponde "credenziali non valide" anche con la password corretta. + cfg = BASE / "aira_config.json" + pool_index = args.pool + if pool_index is None and cfg.exists(): + pool_index = json.loads(cfg.read_text()).get("user_pool_index") + if pool_index is None: + pool_index = 0 + print(f"[auth] user pool [{pool_index}] {Settings.USER_POOL_IDS[pool_index]}") + + aira = AiraHome(user_pool_id=Settings.USER_POOL_IDS[pool_index]) + logged = False + + tokens = load_tokens() + if tokens: + try: + aira.cloud.login_with_tokens( + tokens["id_token"], tokens["access_token"], tokens["refresh_token"] + ) + logged = True + print("[auth] sessione ripristinata dai token salvati") + except Exception as exc: # token scaduti/revocati + print(f"[auth] token non piu' validi ({type(exc).__name__}), serve il login") + + if not logged: + creds = load_credentials() + if not creds: + print( + "[auth] credenziali assenti.\n" + f" Esegui prima: .venv/bin/python setup_creds.py\n" + f" (scrive {CREDS_FILE})", + file=sys.stderr, + ) + return 2 + email, password = creds + aira.cloud.login_with_credentials(email, password) + logged = True + print(f"[auth] login cloud riuscito come {email}") + + # Salva i token per i prossimi avvii. + try: + _write_private(TOKENS_FILE, aira.cloud.get_tokens().dict()) + print(f"[auth] token salvati in {TOKENS_FILE} (600)") + except Exception as exc: + print(f"[auth] impossibile salvare i token: {exc}") + + devices = aira.cloud.get_devices() + dev_list = devices.get("devices", []) + print(f"\n[impianti] trovati: {len(dev_list)}") + + for i, dev in enumerate(dev_list): + device_id = dev["id"]["value"] + household_id = dev.get("device_id", {}).get("household_id", {}).get("value") + name = dev.get("name", {}).get("value", f"impianto {i}") + print(f" - [{i}] {name} id={device_id} household={household_id}") + + if not dev_list: + print("[!] nessun impianto associato all'account") + aira.close() + return 1 + + device_id = dev_list[0]["id"]["value"] + states = aira.cloud.get_states(device_id) + + if args.json: + print("\n=== STATO (dump completo) ===") + print(json.dumps(states, indent=2, default=str)) + else: + print("\n=== STATO ===") + print(json.dumps(states, indent=2, default=str)[:6000]) + + aira.close() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/bridge/harbour-aira-bridge.service b/bridge/harbour-aira-bridge.service new file mode 100644 index 0000000..f458067 --- /dev/null +++ b/bridge/harbour-aira-bridge.service @@ -0,0 +1,20 @@ +# Local HTTP bridge for the harbour-aira Sailfish app. +# Install as a systemd *user* unit: see README.md in the same folder. +[Unit] +Description=Aira bridge for the harbour-aira app (local heat pump HTTP API) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +# %h expands to the home of the user running the unit, so the file works as-is +# for any user, provided the repo was cloned in ~/harbour-aira. +WorkingDirectory=%h/harbour-aira/bridge +# Must match the AIRA_CONFIG_DIR used when running setup_creds.py. +Environment=AIRA_CONFIG_DIR=%h/.config/aira +ExecStart=%h/harbour-aira/bridge/.venv/bin/python %h/harbour-aira/bridge/aira_bridge.py --host 0.0.0.0 --port 8790 +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=default.target diff --git a/bridge/requirements.txt b/bridge/requirements.txt new file mode 100644 index 0000000..4183078 --- /dev/null +++ b/bridge/requirements.txt @@ -0,0 +1,4 @@ +# Dipendenze del bridge. La libreria parla gRPC con il cloud Aira e porta con +# se' il resto (grpcio, protobuf, pycognito, boto3, bleak...). +# Versione fissata: e' quella su cui il bridge e' stato provato (27/27). +pyairahome==2.3.0 diff --git a/bridge/setup_creds.py b/bridge/setup_creds.py new file mode 100644 index 0000000..8a8d342 --- /dev/null +++ b/bridge/setup_creds.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""Salva le credenziali Aira in $AIRA_CONFIG_DIR/aira.json (permessi 600). + +Va eseguito da te, nel tuo terminale: la password viene digitata senza eco e non +compare ne' nei log ne' nella cronologia della shell. + +Uso: + cd bridge + .venv/bin/python setup_creds.py +""" +import getpass +import json +import os +import stat +import sys +from pathlib import Path + +BASE = Path(os.environ.get("AIRA_CONFIG_DIR") or (Path.home() / ".hermes" / "profiles" / "carlo")) +TARGET = BASE / "aira.json" + + +def main() -> int: + email = input("Email account Aira: ").strip() + if not email: + print("email vuota, annullo", file=sys.stderr) + return 1 + + password = getpass.getpass("Password (non verra' mostrata): ") + if not password: + print("password vuota, annullo", file=sys.stderr) + return 1 + + TARGET.parent.mkdir(parents=True, exist_ok=True) + tmp = TARGET.with_suffix(".tmp") + tmp.write_text(json.dumps({"email": email, "password": password}, indent=2)) + os.chmod(tmp, stat.S_IRUSR | stat.S_IWUSR) + tmp.replace(TARGET) + + print(f"\nOK: credenziali salvate in {TARGET} (permessi 600)") + print("Ora lancia: .venv/bin/python aira_probe.py") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/bridge/test_bridge_commands.py b/bridge/test_bridge_commands.py new file mode 100644 index 0000000..70a89eb --- /dev/null +++ b/bridge/test_bridge_commands.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Verifica offline dei comandi del bridge. NON invia nulla all'impianto: +costruisce i messaggi protobuf e ne controlla il contenuto. + + .venv/bin/python test_bridge_commands.py +""" +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +import aira_bridge as b # noqa: E402 + +esiti = [] + + +def check(nome, cond, extra=""): + esiti.append((nome, bool(cond), extra)) + print(f"{'OK ' if cond else 'FALLITO'} {nome}{(' -> ' + extra) if extra else ''}") + + +# --- contesto: come lo stato reale dell'impianto --------------------------- # +ctx = {"zone_setpoints_heating": {"zone_1": 19.0, "zone_2": 0.0}, + "zone_setpoints_cooling": {"zone_1": 22.0, "zone_2": 0.0}} + +# --- 1) riscaldamento, zona 1 --------------------------------------------- # +cmd = b.build_command("set_heating_setpoint", 21.5, 1, ctx) +msg = cmd._message +check("zona 1: obiettivo applicato", abs(msg.zone_setpoints.zone_1 - 21.5) < 1e-6, + f"zone_1={msg.zone_setpoints.zone_1}") +check("zona 1: zona 2 preservata", msg.zone_setpoints.zone_2 == 0.0, + f"zone_2={msg.zone_setpoints.zone_2}") +check("zona 1: kind = riscaldamento (1)", msg.kind == 1, f"kind={msg.kind}") +check("messaggio serializzabile", len(msg.SerializeToString()) > 0, + f"{len(msg.SerializeToString())} byte") + +# --- 2) raffrescamento, zona 2 (con zona 1 da preservare) ------------------ # +ctx2 = {"zone_setpoints_cooling": {"zone_1": 22.0, "zone_2": 24.0}, + "zone_setpoints_heating": {"zone_1": 19.0, "zone_2": 0.0}} +cmd = b.build_command("set_cooling_setpoint", 23.0, 2, ctx2) +msg = cmd._message +check("zona 2: obiettivo applicato", abs(msg.zone_setpoints.zone_2 - 23.0) < 1e-6, + f"zone_2={msg.zone_setpoints.zone_2}") +check("zona 2: zona 1 preservata (22.0)", msg.zone_setpoints.zone_1 == 22.0, + f"zone_1={msg.zone_setpoints.zone_1}") +check("raffrescamento: kind = 2", msg.kind == 2, f"kind={msg.kind}") + +# --- 3) senza contesto: non deve rompersi -------------------------------- # +cmd = b.build_command("set_heating_setpoint", 20.0) +check("senza contesto: nessuna eccezione", cmd._message.zone_setpoints.zone_1 == 20.0) + +# --- 4) valori rifiutati -------------------------------------------------- # +def rifiuta(nome, *args, **kw): + try: + b.build_command(*args, **kw) + except ValueError as exc: + check(nome, True, str(exc)) + except Exception as exc: # inatteso + check(nome, False, f"eccezione sbagliata: {type(exc).__name__}: {exc}") + else: + check(nome, False, "accettato, doveva essere rifiutato") + + +rifiuta("rifiuta 40 °C (troppo alta)", "set_heating_setpoint", 40.0, 1, ctx) +rifiuta("rifiuta 3 °C (troppo bassa)", "set_heating_setpoint", 3.0, 1, ctx) +rifiuta("rifiuta zona 3", "set_heating_setpoint", 20.0, 3, ctx) +rifiuta("rifiuta valore non numerico", "set_heating_setpoint", "caldo", 1, ctx) +rifiuta("rifiuta comando sconosciuto", "factory_reset", None, 1, ctx) + +# --- 5) il riassunto espone i nuovi campi --------------------------------- # +stato_file = Path("/tmp/state.json") +if stato_file.exists(): + st = json.loads(stato_file.read_text())["state"] + s = b.summarize(st) + for k in ("zone1_setpoint", "zone1_temperature", "zone1_mode", + "zone2_setpoint", "zones"): + check(f"riassunto contiene {k}", k in s, repr(s.get(k))) + check("zona 1: obiettivo presente", s.get("zone1_setpoint") is not None, + f"{s.get('zone1_setpoint')} °C") + + # force_heating e' un oggetto {enabled, remaining_time}: leggerlo con bool() + # darebbe sempre vero (era il bug). Qui si verifica la lettura del campo. + spento = b.summarize(dict(st, force_heating={"enabled": False, "remaining_time": "0s"})) + acceso = b.summarize(dict(st, force_heating={"enabled": True, "remaining_time": "1h"})) + check("riassunto: forzato spento -> falso", spento.get("force_heating") is False, + repr(spento.get("force_heating"))) + check("riassunto: forzato acceso -> vero", acceso.get("force_heating") is True, + repr(acceso.get("force_heating"))) + check("riassunto: durata forzato riportata", acceso.get("force_heating_remaining") == "1h", + repr(acceso.get("force_heating_remaining"))) + for k in ("hot_water_heating", "configured_modes", "pump_active_state", + "outdoor_defrost", "signature_lights"): + check(f"riassunto contiene {k}", k in s, repr(s.get(k))) +else: + print("(salto il controllo del riassunto: /tmp/state.json assente)") + +# --- esito ---------------------------------------------------------------- # +falliti = [n for n, ok, _ in esiti if not ok] +print(f"\n{len(esiti) - len(falliti)}/{len(esiti)} controlli superati") +sys.exit(1 if falliti else 0)