0.7.0: assenza con date, conto alla rovescia del boost, catalogo rifatto

- AwayDialog.qml: popup con inizio e fine (Dialog, ValueButton,
  DatePickerDialog: componenti e proprieta' verificati nella guida Silica
  dell'SDK). Il tasto Assenza apre il popup invece di mandare il comando;
- conto alla rovescia 30:00 -> 00:00 in Home e in copertina, con i secondi
  dell'impianto (AiraJson::countdown + timer che si riallinea a ogni lettura);
- commandPayload accetta campi extra (start/end);
- catalogo: lupdate leggeva solo i C++ e marcava 151 voci QML come obsolete.
  Ora src e qml espliciti: 197 stringhe, 0 non tradotte;
- versione 0.7.0 (spec, main.cpp), PROTOCOL, README/PIANO, post per il forum.
This commit is contained in:
Carlo
2026-10-11 12:05:59 +02:00
parent 6ef6f5bfd8
commit d2cb9d692f
17 changed files with 481 additions and 80 deletions
+75 -11
View File
@@ -35,9 +35,10 @@ import sys
import threading
import traceback
import warnings
from datetime import datetime, timedelta, timezone
from datetime import datetime, time, timedelta, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from zoneinfo import ZoneInfo
from google.protobuf.duration_pb2 import Duration
from google.protobuf.timestamp_pb2 import Timestamp
@@ -236,6 +237,8 @@ def summarize(st: dict) -> dict:
return None
return round(float(valore) / 10.0, 1)
boost_restano = boost_restanti(st)
return {
"outdoor_temperature": g("current_outdoor_temperature"),
"indoor_temperature": g("deprecated_current_indoor_temperature"),
@@ -243,6 +246,8 @@ def summarize(st: dict) -> dict:
"dhw_target": g("target_hot_water_temperature"),
"operating_status": g("operating_status"),
"away_mode": bool(st.get("away_mode_enabled")),
"boost_active": boost_restano > 0,
"boost_remaining_seconds": boost_restano,
"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
@@ -462,22 +467,26 @@ def build_stats(aira, heat_pump_id: str, household_id: str,
# 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
BOOST_MIN_DEFAULT = 30 # boost ACS (richiesta dell'utente: mezz'ora)
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
# Il fuso dell'impianto, NON quello della macchina: il server puo' stare in UTC
# e nei giorni di cambio ora si sbaglia di un'ora (e' successo nei test).
# Lo stato lo dichiara in `time_zone.time_zone_identifier`.
PLANT_TZ = ZoneInfo("Europe/Rome")
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": "minuti, 15-240 (opzionale, default 60)"},
"hot_water_boost_on": {"help": "avvia il boost ACS (una tantum)", "value": "minuti, 15-240 (opzionale, default 30)"},
"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": "minuti, 15-1440 (opzionale, default 60)"},
"away_mode_on": {"help": "modalita' assenza", "value": "giorni, 1-60 (opzionale, default 7)"},
"away_mode_on": {"help": "assenza con date di inizio e fine", "value": "start ed end (AAAA-MM-GG); in alternativa giorni, 1-60"},
"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": "minuti, 15-240 (opzionale, default 60)"},
@@ -488,7 +497,7 @@ COMMANDS = {
}
def build_command(cmd_id: str, value=None, zone=1, context=None):
def build_command(cmd_id: str, value=None, zone=1, context=None, params=None):
"""Costruisce l'oggetto comando della libreria. Importata solo qui per non
pagarne il costo all'avvio del bridge.
@@ -585,9 +594,10 @@ def build_command(cmd_id: str, value=None, zone=1, context=None):
duration=_durata(value, FORCE_MIN_DEFAULT, 15, 240, cmd_id))
if cmd_id == "away_mode_on":
inizio, fine = _periodo_assenza(params, value)
return SetAwayMode(
current_time=_adesso(),
end_time=_adesso(giorni=_giorni(value)),
current_time=inizio,
end_time=fine,
target_room_temperature=AWAY_TARGET_TEMP,
)
@@ -625,13 +635,67 @@ def _giorni(value) -> int:
return g
def _adesso(giorni: int = 0) -> Timestamp:
"""Timestamp UTC di adesso, oppure di adesso + N giorni."""
def _ts(quando: datetime) -> Timestamp:
t = Timestamp()
t.FromDatetime(datetime.now(timezone.utc) + timedelta(days=giorni))
t.FromDatetime(quando.astimezone(timezone.utc))
return t
def _periodo_assenza(params, value):
"""Inizio e fine dell'assenza.
Date in `start`/`end` (AAAA-MM-GG, ora locale); se mancano, `value` = giorni
da adesso. La fine e' la mezzanotte locale del giorno indicato: da li'
l'impianto riprende il programma normale e la casa risale dai 15 °C.
"""
params = params or {}
oggi = datetime.now(PLANT_TZ).date()
if not str(params.get("start") or "").strip() and not str(params.get("end") or "").strip():
# Compatibilita': senza date, solo i giorni da adesso (nessun giorno di
# calendario): e' la forma che usava il bridge prima del popup.
adesso = datetime.now(PLANT_TZ)
return _ts(adesso), _ts(adesso + timedelta(days=_giorni(value)))
def giorno(chiave, default):
testo = str(params.get(chiave) or "").strip()
if not testo:
return default
try:
return datetime.strptime(testo[:10], "%Y-%m-%d").date()
except ValueError:
raise ValueError(f"away_mode_on: data non valida in {chiave}: {testo}")
inizio = giorno("start", oggi)
fine = giorno("end", oggi + timedelta(days=_giorni(value)))
if fine <= inizio:
raise ValueError("away_mode_on: la fine deve venire dopo l'inizio")
if (fine - inizio).days > 180:
raise ValueError("away_mode_on: periodo troppo lungo (max 180 giorni)")
adesso = datetime.now(PLANT_TZ)
partenza = datetime.combine(inizio, time.min, tzinfo=PLANT_TZ)
if partenza < adesso:
partenza = adesso
return _ts(partenza), _ts(datetime.combine(fine, time.min, tzinfo=PLANT_TZ))
def boost_restanti(st: dict) -> int:
"""Secondi che mancano alla fine del boost ACS (0 se non e' in corso).
L'impianto espone solo start_time/end_time: il conto alla rovescia dell'app
si basa su end_time, non sul momento in cui l'app ha visto partire il boost.
"""
fine = str((st.get("hot_water_boost") or {}).get("end_time") or "")
if not fine:
return 0
try:
quando = datetime.fromisoformat(fine.replace("Z", "+00:00"))
except ValueError:
return 0
return max(0, int((quando - datetime.now(timezone.utc)).total_seconds()))
# --------------------------------------------------------------------------- #
# server HTTP
# --------------------------------------------------------------------------- #
@@ -695,7 +759,7 @@ class Handler(BaseHTTPRequestHandler):
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())
self.session.last_states(), body)
except ValueError as exc:
return self._send(400, {"ok": False, "error": str(exc)})
except Exception as exc: