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.
This commit is contained in:
+78
-9
@@ -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
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@@ -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")
|
||||
|
||||
+20
-2
@@ -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.
|
||||
|
||||
+4
-4
@@ -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 |
|
||||
|
||||
|
||||
@@ -347,12 +347,12 @@
|
||||
<translation>Acqua calda</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../qml/cover/CoverPage.qml" line="156"/>
|
||||
<location filename="../qml/cover/CoverPage.qml" line="159"/>
|
||||
<source>Not updated · last %1</source>
|
||||
<translation>Non aggiornato · ultimo %1</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../qml/cover/CoverPage.qml" line="157"/>
|
||||
<location filename="../qml/cover/CoverPage.qml" line="160"/>
|
||||
<source>outside %1 °C</source>
|
||||
<translation>esterna %1 °C</translation>
|
||||
</message>
|
||||
|
||||
@@ -347,12 +347,12 @@
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../qml/cover/CoverPage.qml" line="156"/>
|
||||
<location filename="../qml/cover/CoverPage.qml" line="159"/>
|
||||
<source>Not updated · last %1</source>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../qml/cover/CoverPage.qml" line="157"/>
|
||||
<location filename="../qml/cover/CoverPage.qml" line="160"/>
|
||||
<source>outside %1 °C</source>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
@@ -560,37 +560,37 @@
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../qml/pages/MainPage.qml" line="252"/>
|
||||
<location filename="../qml/pages/MainPage.qml" line="256"/>
|
||||
<source>Starting DHW boost</source>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../qml/pages/MainPage.qml" line="260"/>
|
||||
<source>Back home</source>
|
||||
<location filename="../qml/pages/MainPage.qml" line="266"/>
|
||||
<source>Home</source>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../qml/pages/MainPage.qml" line="260"/>
|
||||
<source>Away mode</source>
|
||||
<location filename="../qml/pages/MainPage.qml" line="266"/>
|
||||
<source>Away</source>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../qml/pages/MainPage.qml" line="264"/>
|
||||
<location filename="../qml/pages/MainPage.qml" line="285"/>
|
||||
<source>More</source>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../qml/pages/MainPage.qml" line="272"/>
|
||||
<source>Away mode off</source>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../qml/pages/MainPage.qml" line="267"/>
|
||||
<location filename="../qml/pages/MainPage.qml" line="275"/>
|
||||
<source>Away mode on</source>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../qml/pages/MainPage.qml" line="275"/>
|
||||
<source>Commands</source>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../qml/pages/MainPage.qml" line="296"/>
|
||||
<location filename="../qml/pages/MainPage.qml" line="308"/>
|
||||
<source>Outside %1 °C · Thermostat %2 °C · Zones %3</source>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
|
||||
Reference in New Issue
Block a user