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:
+75
-11
@@ -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:
|
||||
|
||||
@@ -149,8 +149,8 @@ check("senza termostato: nessun errore",
|
||||
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,
|
||||
check("boost: mezz'ora di default (richiesta dell'utente)",
|
||||
boost._message.hot_water_boost_duration.seconds == 1800,
|
||||
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)
|
||||
@@ -199,6 +199,47 @@ for cid in b.COMMANDS:
|
||||
check(f"whitelist: {cid} si costruisce e si incarta", False,
|
||||
f"{type(exc).__name__}: {exc}")
|
||||
|
||||
# --- 6) assenza con date, e conto alla rovescia del boost ----------------- #
|
||||
from datetime import datetime, timedelta, timezone # noqa: E402
|
||||
from zoneinfo import ZoneInfo # noqa: E402
|
||||
|
||||
ROMA = ZoneInfo("Europe/Rome")
|
||||
|
||||
|
||||
def a_roma(t):
|
||||
return t.ToDatetime(tzinfo=timezone.utc).astimezone(ROMA).strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
|
||||
date_di = b.build_command("away_mode_on", None, 1, ctx,
|
||||
{"start": "2026-11-01", "end": "2026-11-08"})
|
||||
m3 = date_di._message
|
||||
check("assenza con date: inizio = 1 novembre a mezzanotte",
|
||||
a_roma(m3.current_time) == "2026-11-01 00:00", a_roma(m3.current_time))
|
||||
check("assenza con date: fine = mezzanotte dell'8 (da li' riparte)",
|
||||
a_roma(m3.end_time) == "2026-11-08 00:00", a_roma(m3.end_time))
|
||||
check("assenza con date: casa a 15 °C", abs(m3.target_room_temperature - 15.0) < 1e-6)
|
||||
rifiuta("assenza: fine prima dell'inizio", "away_mode_on", None, 1, ctx,
|
||||
{"start": "2026-11-08", "end": "2026-11-01"})
|
||||
rifiuta("assenza: data illeggibile", "away_mode_on", None, 1, ctx,
|
||||
{"start": "domani", "end": "2026-11-08"})
|
||||
rifiuta("assenza: oltre 180 giorni", "away_mode_on", None, 1, ctx,
|
||||
{"start": "2026-11-01", "end": "2028-11-08"})
|
||||
|
||||
fra = (datetime.now(timezone.utc) + timedelta(minutes=20)).isoformat().replace("+00:00", "Z")
|
||||
check("boost in corso: restano ~1200 secondi",
|
||||
abs(b.boost_restanti({"hot_water_boost": {"end_time": fra}}) - 1200) <= 3)
|
||||
check("boost finito: zero secondi",
|
||||
b.boost_restanti({"hot_water_boost": {"end_time": "2026-01-01T00:00:00Z"}}) == 0)
|
||||
check("boost mai avviato: zero secondi", b.boost_restanti({}) == 0)
|
||||
riassunto = b.summarize({"hot_water_boost": {"end_time": fra}})
|
||||
check("riassunto: dice che il boost e' in corso", riassunto.get("boost_active") is True)
|
||||
check("riassunto: porta i secondi del boost",
|
||||
abs(riassunto.get("boost_remaining_seconds", 0) - 1200) <= 3,
|
||||
str(riassunto.get("boost_remaining_seconds")))
|
||||
check("riassunto senza boost: spento e zero",
|
||||
b.summarize({}).get("boost_active") is False
|
||||
and b.summarize({}).get("boost_remaining_seconds") == 0)
|
||||
|
||||
# --- esito ---------------------------------------------------------------- #
|
||||
falliti = [n for n, ok, _ in esiti if not ok]
|
||||
print(f"\n{len(esiti) - len(falliti)}/{len(esiti)} controlli superati")
|
||||
|
||||
Reference in New Issue
Block a user