From 6ef6f5bfd8f7136afd50dafb7972c8a565a41fb2 Mon Sep 17 00:00:00 2001 From: Carlo Date: Sun, 11 Oct 2026 11:49:39 +0200 Subject: [PATCH] 0.6.9: il bridge corretto (boost con la durata) e i comandi con argomento in PROTOCOL La copia del bridge dentro l'app va tenuta allineata: contiene la correzione del boost, dell'assenza, di ACS off e del riscaldamento forzato, che la libreria accetta solo con un argomento. Il pacchetto Sailfish non cambia (il bridge non entra nell'RPM), quindi non serve ricostruire l'RPM. --- bridge/aira_bridge.py | 87 +++++++++++++++++++++++++++++---- bridge/test_bridge_commands.py | 56 +++++++++++++++++++++ docs/PIANO.md | 22 ++++++++- docs/PROTOCOL.md | 8 +-- translations/harbour-aira-it.ts | 4 +- translations/harbour-aira.ts | 30 ++++++------ 6 files changed, 175 insertions(+), 32 deletions(-) diff --git a/bridge/aira_bridge.py b/bridge/aira_bridge.py index 10a4b4f..4b8ae0c 100644 --- a/bridge/aira_bridge.py +++ b/bridge/aira_bridge.py @@ -35,9 +35,13 @@ import sys import threading import traceback import warnings +from datetime import datetime, timedelta, timezone from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path +from google.protobuf.duration_pb2 import Duration +from google.protobuf.timestamp_pb2 import Timestamp + warnings.filterwarnings("ignore") # Tutti i file di configurazione stanno in una cartella sola (permessi 600): @@ -53,7 +57,7 @@ CREDS = BASE / "aira.json" TOKENS = BASE / "aira_tokens.json" POOL_CFG = BASE / "aira_config.json" -VERSION = "0.6.2" +VERSION = "0.6.9" # --------------------------------------------------------------------------- # @@ -451,19 +455,32 @@ def build_stats(aira, heat_pump_id: str, household_id: str, # --------------------------------------------------------------------------- # # comandi ammessi (whitelist: mai esporre l'intera libreria su HTTP) # --------------------------------------------------------------------------- # +# Quattro comandi NON si possono inviare senza un argomento: la libreria vuole +# una durata (Duration) o, per l'assenza, inizio/fine (Timestamp) e la +# temperatura da tenere in casa. Senza, solleva TypeError e il bridge risponde +# "richiesta non valida" — e' quello che e' successo al primo boost, perche' +# nemmeno l'app mandava la durata. +# I valori qui sotto sono i default del bridge: sono scelte, non misure. L'app +# puo' mandarne altri in `value` (minuti, o giorni per l'assenza). +BOOST_MIN_DEFAULT = 60 # boost ACS +ACS_OFF_MIN_DEFAULT = 60 # ACS spento +FORCE_MIN_DEFAULT = 60 # riscaldamento forzato +AWAY_DAYS_DEFAULT = 7 # assenza +AWAY_TARGET_TEMP = 15.0 # casa tenuta a questa temperatura in assenza, °C + 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_on": {"help": "avvia il boost ACS (una tantum)", "value": "minuti, 15-240 (opzionale, default 60)"}, "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}, + "hot_water_heating_off": {"help": "disabilita il riscaldamento ACS", "value": "minuti, 15-1440 (opzionale, default 60)"}, + "away_mode_on": {"help": "modalita' assenza", "value": "giorni, 1-60 (opzionale, default 7)"}, "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_on": {"help": "forza il riscaldamento", "value": "minuti, 15-240 (opzionale, default 60)"}, "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}, @@ -496,16 +513,14 @@ def build_command(cmd_id: str, value=None, zone=1, context=None): SetZoneSetpoints, ) + # Solo i comandi che la libreria accetta senza argomenti: gli altri hanno un + # ramo loro qui sotto, perche' senza la durata sollevano TypeError. 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, @@ -557,12 +572,66 @@ def build_command(cmd_id: str, value=None, zone=1, context=None): # Kind: 1 = riscaldamento, 2 = raffrescamento (enum del protocollo). return SetZoneSetpoints(zone_setpoints=zs, kind=1 if heating else 2) + if cmd_id == "hot_water_boost_on": + return ActivateHotWaterBoosting( + hot_water_boost_duration=_durata(value, BOOST_MIN_DEFAULT, 15, 240, cmd_id)) + + if cmd_id == "hot_water_heating_off": + return DisableHotWaterHeating( + time=_durata(value, ACS_OFF_MIN_DEFAULT, 15, 1440, cmd_id)) + + if cmd_id == "force_heating_on": + return EnableForceHeating( + duration=_durata(value, FORCE_MIN_DEFAULT, 15, 240, cmd_id)) + + if cmd_id == "away_mode_on": + return SetAwayMode( + current_time=_adesso(), + end_time=_adesso(giorni=_giorni(value)), + target_room_temperature=AWAY_TARGET_TEMP, + ) + cls = simple.get(cmd_id) if cls is None: raise ValueError(f"comando non ammesso: {cmd_id}") return cls() +def _durata(value, default_min: int, min_min: int, max_min: int, nome: str) -> Duration: + """Durata in minuti: `value` se l'app lo manda, altrimenti il default.""" + if value in (None, ""): + minuti = default_min + else: + try: + minuti = int(float(value)) + except (TypeError, ValueError): + raise ValueError(f"{nome} richiede i minuti come numero") + if not min_min <= minuti <= max_min: + raise ValueError( + f"{nome}: durata fuori intervallo ({min_min}-{max_min} minuti)") + return Duration(seconds=minuti * 60) + + +def _giorni(value) -> int: + """Giorni di assenza: `value` se l'app lo manda, altrimenti il default.""" + if value in (None, ""): + return AWAY_DAYS_DEFAULT + try: + g = int(float(value)) + except (TypeError, ValueError): + raise ValueError("away_mode_on richiede i giorni come numero") + if not 1 <= g <= 60: + raise ValueError("away_mode_on: durata fuori intervallo (1-60 giorni)") + return g + + +def _adesso(giorni: int = 0) -> Timestamp: + """Timestamp UTC di adesso, oppure di adesso + N giorni.""" + t = Timestamp() + t.FromDatetime(datetime.now(timezone.utc) + timedelta(days=giorni)) + return t + + # --------------------------------------------------------------------------- # # server HTTP # --------------------------------------------------------------------------- # diff --git a/bridge/test_bridge_commands.py b/bridge/test_bridge_commands.py index 74013a9..7543a49 100644 --- a/bridge/test_bridge_commands.py +++ b/bridge/test_bridge_commands.py @@ -143,6 +143,62 @@ check("termostato: batteria non scarica", d.get("thermostat_battery_low") is Fal check("senza termostato: nessun errore", b.summarize({k: v for k, v in finto.items() if k != "thermostats"}).get("thermostat_zone") is None) +# --- 5) comandi che la libreria accetta solo con i loro argomenti ---------- # +# Qui sta la ragione del blocco: il boost senza durata solleva TypeError e il +# bridge risponde "richiesta non valida" all'app. +from pyairahome.commands import CommandBase # noqa: E402 + +boost = b.build_command("hot_water_boost_on") +check("boost: un'ora di default", + boost._message.hot_water_boost_duration.seconds == 3600, + f"{boost._message.hot_water_boost_duration.seconds}s") +check("boost: durata scelta in minuti (90 -> 5400s)", + b.build_command("hot_water_boost_on", 90) + ._message.hot_water_boost_duration.seconds == 5400) +rifiuta("boost: durata fuori intervallo", "hot_water_boost_on", 5) +rifiuta("boost: durata non numerica", "hot_water_boost_on", "presto") + +acs = b.build_command("hot_water_heating_off") +check("ACS spento: un'ora di default", acs._message.time.seconds == 3600, + f"{acs._message.time.seconds}s") + +forzato = b.build_command("force_heating_on") +check("forzato: un'ora di default", forzato._message.duration.seconds == 3600, + f"{forzato._message.duration.seconds}s") + +assenza = b.build_command("away_mode_on") +m = assenza._message +check("assenza: da adesso a +7 giorni", + abs((m.end_time.seconds - m.current_time.seconds) - 7 * 86400) <= 1, + f"{m.end_time.seconds - m.current_time.seconds}s") +check("assenza: fine dopo l'inizio", m.end_time.seconds > m.current_time.seconds) +check("assenza: casa tenuta a 15 °C", abs(m.target_room_temperature - 15.0) < 1e-6, + f"{m.target_room_temperature}") +rifiuta("assenza: zero giorni", "away_mode_on", 0) + +# Nessun comando della whitelist deve restare senza un argomento obbligatorio: +# e' il difetto che ha fermato il boost, e la whitelist e' esattamente la lista +# che l'app puo' chiamare, quindi va provata tutta. +campione = {"set_hot_water_target": 55, "set_heating_setpoint": 20, + "set_cooling_setpoint": 20, "hot_water_boost_on": 60, + "hot_water_heating_off": 60, "away_mode_on": 7, + "force_heating_on": 60} +for cid in b.COMMANDS: + try: + cmd = b.build_command(cid, campione.get(cid), 1, ctx) + # La busta e' quello che la libreria manda davvero: se il comando non + # avesse un nome di campo valido, si romperebbe qui. (I comandi senza + # parametri, come ping, hanno il messaggio interno vuoto: controllare + # quello sarebbe sbagliato.) + busta = CommandBase.build_command(cmd) + byte = len(busta.SerializeToString()) + check(f"whitelist: {cid} si costruisce e si incarta", + bool(cmd.get_field()) and byte > 0, + f"campo {cmd.get_field()}, {byte} byte") + except Exception as exc: + check(f"whitelist: {cid} si costruisce e si incarta", False, + f"{type(exc).__name__}: {exc}") + # --- esito ---------------------------------------------------------------- # falliti = [n for n, ok, _ in esiti if not ok] print(f"\n{len(esiti) - len(falliti)}/{len(esiti)} controlli superati") diff --git a/docs/PIANO.md b/docs/PIANO.md index 9fbef6f..75d1d89 100644 --- a/docs/PIANO.md +++ b/docs/PIANO.md @@ -382,6 +382,24 @@ la copre; se l'area non c'è la riga resta dov'era. Nuovo controllo statico esiste, e l'id del comando confrontato con la tabella del bridge — un id sbagliato sarebbe un 400 scoperto solo sul telefono. -**Stato attuale (0.6.9).** 85/85 verifiche del nucleo, 48/48 del bridge, +**Il boost non partiva: mancava un argomento (correzione del bridge).** Il primo +tocco sul boost ha mostrato in rosso, in fondo alla Home, *«richiesta non +valida: ActivateHotWaterBoosting.__init__() missing 1 required positional +argument: 'hot_water_boost_duration'»*. Non era l'impianto a rifiutare: il +comando non veniva nemmeno costruito. La libreria pretende un argomento per +quattro comandi della whitelist — `hot_water_boost_on`, `hot_water_heating_off` +e `force_heating_on` una durata, `away_mode_on` inizio, fine e la temperatura da +tenere in casa — e il bridge li chiamava senza. Il boost è solo il primo che è +stato toccato: anche l'assenza, il tasto accanto, sarebbe fallita così. Ora +ognuno ha il suo ramo in `build_command`, con i default in cima al file (60 +minuti per le tre durate, 7 giorni e 15 °C per l'assenza) e un `value` +opzionale che l'app può mandare. `test_bridge_commands.py` ora costruisce +**tutti** i comandi della whitelist e li incarta come li manda la libreria: se +uno resta senza argomento obbligatorio si vede lì (74/74). Provato dal vivo con +un `ping` (200, con la ricezione dall'impianto) e con un boost fuori intervallo, +respinto con 400 **prima** di partire. + +**Stato attuale (0.6.9).** 85/85 verifiche del nucleo, 74/74 del bridge, 191/191 stringhe tradotte, `qmllint` pulito, contratto UI↔bridge rispettato. -Nessun comando è stato inviato all'impianto: tutto quanto sopra è a sola lettura. +All'impianto è stato inviato un solo comando, di prova e innocuo: un `ping` +diagnostico, che non cambia nulla. Nessun comando di regolazione è mai partito. diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 93140ff..273006b 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -153,11 +153,11 @@ Risposta: `{"ok": true, "id": "...", "result": {...}}`. | `set_hot_water_target` | 45–60 | temperatura ACS obiettivo | | `set_heating_setpoint` | 5–30 | temperatura obiettivo della casa (zona, default 1) | | `set_cooling_setpoint` | 5–30 | temperatura obiettivo del raffrescamento (zona, default 1) | -| `hot_water_boost_on` / `hot_water_boost_off` | — | boost ACS una tantum | -| `hot_water_heating_on` / `hot_water_heating_off` | — | riscaldamento ACS | -| `away_mode_on` / `away_mode_off` | — | modalità assenza | +| `hot_water_boost_on` / `hot_water_boost_off` | minuti, 15–240 (default 60) | boost ACS una tantum | +| `hot_water_heating_on` / `hot_water_heating_off` | minuti, 15–1440 (default 60) | riscaldamento ACS | +| `away_mode_on` / `away_mode_off` | giorni, 1–60 (default 7) | modalità assenza | | `night_mode_1h` | — | night mode per un'ora | -| `force_heating_on` / `force_heating_off` | — | riscaldamento forzato | +| `force_heating_on` / `force_heating_off` | minuti, 15–240 (default 60) | riscaldamento forzato | | `heating_on` / `heating_off` | — | funzione riscaldamento | | `legionella_cycle` | — | ciclo antilegionella | diff --git a/translations/harbour-aira-it.ts b/translations/harbour-aira-it.ts index bdc9b89..a40cfa2 100644 --- a/translations/harbour-aira-it.ts +++ b/translations/harbour-aira-it.ts @@ -347,12 +347,12 @@ Acqua calda - + Not updated · last %1 Non aggiornato · ultimo %1 - + outside %1 °C esterna %1 °C diff --git a/translations/harbour-aira.ts b/translations/harbour-aira.ts index c0e7ca7..3b16f6c 100644 --- a/translations/harbour-aira.ts +++ b/translations/harbour-aira.ts @@ -347,12 +347,12 @@ - + Not updated · last %1 - + outside %1 °C @@ -560,37 +560,37 @@ - + Starting DHW boost - - Back home + + Home - - Away mode + + Away - + + More + + + + Away mode off - + Away mode on - - Commands - - - - + Outside %1 °C · Thermostat %2 °C · Zones %3