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 %1Non aggiornato · ultimo %1
-
+ outside %1 °Cesterna %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