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:
@@ -11,6 +11,11 @@ tests/build/
|
|||||||
# tarball di consegna (rigenerabili con git archive)
|
# tarball di consegna (rigenerabili con git archive)
|
||||||
*.tar.gz
|
*.tar.gz
|
||||||
|
|
||||||
|
# bridge: ambiente virtuale e cache di Python
|
||||||
|
bridge/.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
||||||
# temporanei
|
# temporanei
|
||||||
*~
|
*~
|
||||||
*.bak
|
*.bak
|
||||||
|
|||||||
@@ -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.
|
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
|
Jolla (QML/C++ Qt 5.6) --HTTP/1.1 + JSON--> bridge (Linux in LAN) --gRPC--> cloud Aira
|
||||||
(~/workspace/Carlo/Aira/
|
bridge/aira_bridge.py
|
||||||
aira_bridge.py)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Il bridge
|
## Il bridge
|
||||||
|
|
||||||
Vive in `~/workspace/Carlo/Aira/` (fuori da questo repo: contiene dati
|
Il codice sta in [`bridge/`](bridge/): istruzioni complete in
|
||||||
personali e non va pubblicato).
|
[bridge/README.md](bridge/README.md) (in inglese, perché è la parte che serve a
|
||||||
|
chi prova l'app su un altro impianto).
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd ~/workspace/Carlo/Aira
|
cd bridge
|
||||||
.venv/bin/python aira_bridge.py --port 8790 # ascolta su 0.0.0.0:8790
|
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.
|
(permessi 600): è il valore da incollare nelle impostazioni dell'app.
|
||||||
API completa in [docs/PROTOCOL.md](docs/PROTOCOL.md).
|
API completa in [docs/PROTOCOL.md](docs/PROTOCOL.md).
|
||||||
|
|
||||||
@@ -53,8 +57,10 @@ sfdk build # RPM in RPMS/
|
|||||||
sfdk deploy # sul dispositivo collegato
|
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).
|
cartella nuova (mai sopra una estrazione precedente: i Makefile restano stale).
|
||||||
|
Il log dell'app inizia con `harbour-aira v<versione> build <data>`: serve a
|
||||||
|
distinguere una build nuova da una stale senza indovinare.
|
||||||
|
|
||||||
### Verifica locale, senza SDK
|
### Verifica locale, senza SDK
|
||||||
|
|
||||||
@@ -82,6 +88,12 @@ qml/pages/SettingsPage.qml
|
|||||||
qml/components/InfoRow.qml
|
qml/components/InfoRow.qml
|
||||||
qml/components/ActionButton.qml pulsante di comando, con conferma opzionale
|
qml/components/ActionButton.qml pulsante di comando, con conferma opzionale
|
||||||
tests/core_test.cpp verifiche del nucleo (headless)
|
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/PIANO.md piano di progetto e scelte
|
||||||
docs/PROTOCOL.md API del bridge
|
docs/PROTOCOL.md API del bridge
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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