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:
Carlo
2026-10-10 07:44:41 +02:00
parent 071684da15
commit f6e670b408
9 changed files with 941 additions and 9 deletions
+169
View File
@@ -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://<LAN-IP-of-this-machine>: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.
+432
View File
@@ -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())
+145
View File
@@ -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())
+20
View File
@@ -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
+4
View File
@@ -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
+45
View File
@@ -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())
+100
View File
@@ -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)