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,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.
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user