From d2cb9d692f752a424ab1cf821981a5962fc4fffe Mon Sep 17 00:00:00 2001 From: Carlo Date: Sun, 11 Oct 2026 12:05:59 +0200 Subject: [PATCH] 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. --- README.it.md | 8 ++ README.md | 8 ++ bridge/aira_bridge.py | 86 ++++++++++++++++++--- bridge/test_bridge_commands.py | 45 ++++++++++- docs/PIANO.md | 51 +++++++++++- docs/PROTOCOL.md | 6 +- qml/cover/CoverPage.qml | 8 +- qml/pages/AwayDialog.qml | 55 +++++++++++++ qml/pages/MainPage.qml | 24 +++++- rpm/harbour-aira.spec | 12 ++- src/airajson.cpp | 18 ++++- src/airajson.h | 10 ++- src/apiclient.cpp | 53 ++++++++++++- src/apiclient.h | 20 ++++- src/main.cpp | 4 +- tests/core_test.cpp | 20 +++++ translations/harbour-aira-it.ts | 133 ++++++++++++++++++++------------ 17 files changed, 481 insertions(+), 80 deletions(-) create mode 100644 qml/pages/AwayDialog.qml diff --git a/README.it.md b/README.it.md index 9ea1be3..0482b90 100644 --- a/README.it.md +++ b/README.it.md @@ -23,6 +23,14 @@ e ripiega sull'inglese; la si può scegliere anche nelle impostazioni dell'app. affiancate e **toccabili**, e tre azioni rapide — boost, assenza, comandi impianto — ognuna con la sua icona. Sta in una schermata, senza scorrere: i comandi hanno pagine loro +- **Conto alla rovescia del boost**: mentre il boost dell'acqua calda è in corso, + in Home scorre da 30:00 a 00:00 e in copertina prende il posto dell'obiettivo + dell'acqua. I secondi vengono dalla fine del boost letta dall'impianto: il + conto è giusto anche se il boost l'ha avviato qualcun altro +- **Assenza con le date**: toccando *Assenza* si apre una finestra dove si + scelgono il primo e l'ultimo giorno (calendario, un tocco ciascuno). In quel + periodo la casa resta a 15 °C e riprende il programma normale il giorno scelto + come ultimo - **Regolazioni**: obiettivo casa e obiettivo acqua calda, ognuno con il valore che l'impianto sta tenendo adesso, lo slider (la proposta: il valore vero non si muove), una fila di valori rapidi per le dita fredde e il pulsante di conferma, diff --git a/README.md b/README.md index 67d0858..e9aab5c 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,14 @@ status, domestic hot water, and the house target temperature per zone. target) side by side and **tappable**, and three quick actions — boost, away mode and the plant commands, each with its own icon. It fits without scrolling — the controls live in their own pages +- **Boost countdown**: while a hot water boost is running the home screen shows + it ticking down from 30:00 to 00:00, and the cover page shows it in place of + the hot water target. The seconds come from the plant's own boost end time, so + the count is right even if the boost was started from somewhere else +- **Away mode with dates**: tapping *Away* opens a dialog where you pick the + first and the last day (calendar, one tap each). The house is kept at 15 °C + for that period and resumes its normal programme on the day you chose as the + last one - **Setpoints** page: house target and hot water target, each with the value the plant is keeping right now, a slider (the proposal, the plant value does not move), a row of quick values for cold fingers and a confirmation button that diff --git a/bridge/aira_bridge.py b/bridge/aira_bridge.py index 4b8ae0c..b1f38af 100644 --- a/bridge/aira_bridge.py +++ b/bridge/aira_bridge.py @@ -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: diff --git a/bridge/test_bridge_commands.py b/bridge/test_bridge_commands.py index 7543a49..57e126b 100644 --- a/bridge/test_bridge_commands.py +++ b/bridge/test_bridge_commands.py @@ -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") diff --git a/docs/PIANO.md b/docs/PIANO.md index 75d1d89..7015127 100644 --- a/docs/PIANO.md +++ b/docs/PIANO.md @@ -399,7 +399,50 @@ 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. -All'impianto è stato inviato un solo comando, di prova e innocuo: un `ping` -diagnostico, che non cambia nulla. Nessun comando di regolazione è mai partito. +## 11. Assenza con le date e conto alla rovescia del boost — v0.7.0 + +**Richieste dell'utente (11/10/2026), da applicare come dati:** + +1. il **boost** deve essere di **30 minuti**; +2. l'**assenza** si imposta da un **popup con data di inizio e fine**; +3. durante l'assenza l'impianto **tiene la casa a 15 °C** e **riprende + normalmente il giorno prima del rientro**; +4. mentre il boost è in corso si deve vedere un **conto alla rovescia da 30:00 a + 00:00**. + +## 12. Conseguenze dell'uso reale (0.7.0) + +Deciso con l'uso, non in anticipo: + +- il **boost** è di **mezz'ora** (era un'ora); +- l'**assenza** non è più "N giorni": si scelgono **due date** in un popup, e la + casa resta a **15 °C**; +- mentre il boost corre, **conto alla rovescia** in Home e in copertina. + +Resta aperto: il popup chiede *inizio* e *fine*, dove "fine" è il giorno in cui +l'impianto ricomincia a scaldare (nell'uso di Carlo: il giorno prima del rientro). +Se preferisce indicare il *rientro* e far togliere un giorno all'app, è una riga. + +**Come è stato fatto (0.7.0).** +- Bridge: i comandi con durata hanno il loro ramo e i default in cima al file; + per l'assenza `_periodo_assenza` legge `start` ed `end` (AAAA-MM-GG) e li + traduce in `current_time`/`end_time`. La fine è la **mezzanotte del giorno + indicato nel fuso dell'impianto** (`PLANT_TZ = Europe/Rome`, dichiarato dallo + stato): da lì la pompa riprende il programma. Il fuso della *macchina* non si + usa: alle date di cambio ora sbaglia di un'ora (lo hanno visto i test). +- Bridge: `/summary` espone `boost_active` e `boost_remaining_seconds`, calcolati + dalla fine del boost letta dall'impianto (`hot_water_boost.end_time`). Il conto + alla rovescia dell'app si basa su quelli, non su quando l'app ha visto partire + il comando: se il boost l'ha avviato il telefono di casa o l'app ufficiale, il + numero è comunque giusto. +- App: `AwayDialog.qml` (Dialog + due `ValueButton` + `DatePickerDialog`, come + nell'esempio della guida Silica), il conto alla rovescia in Home sotto le + letture e in copertina al posto dell'obiettivo dell'acqua calda. La formattazione + `mm:ss` sta in `AiraJson::countdown` (C++, verificata in `tests/core_test.cpp`), + i secondi li fa scorrere un timer del client, che si riallinea a ogni rilettura. +- Catalogo: `lupdate harbour-aira.pro` leggeva solo i C++ e marcava 151 voci QML + come obsolete (l'italiano sarebbe rimasto a metà senza accorgersene). Ora: + `lupdate src qml -ts translations/harbour-aira-it.ts`. 197 stringhe, 0 non + tradotte. + + diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 273006b..d098a59 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -37,6 +37,8 @@ Valori pronti per la dashboard. "manual_mode": false, "force_heating": false, "force_heating_remaining": "0s", + "boost_active": false, + "boost_remaining_seconds": 0, "inline_heater": false, "hot_water_heating": true, "configured_modes": "PUMP_MODE_STATE_HEATING", @@ -153,9 +155,9 @@ 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` | minuti, 15–240 (default 60) | boost ACS una tantum | +| `hot_water_boost_on` / `hot_water_boost_off` | minuti, 15–240 (default 30) | 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 | +| `away_mode_on` / `away_mode_off` | `start` ed `end`, AAAA-MM-GG | assenza con date; la casa resta a 15 °C | | `night_mode_1h` | — | night mode per un'ora | | `force_heating_on` / `force_heating_off` | minuti, 15–240 (default 60) | riscaldamento forzato | | `heating_on` / `heating_off` | — | funzione riscaldamento | diff --git a/qml/cover/CoverPage.qml b/qml/cover/CoverPage.qml index 550b7f6..e0f3549 100644 --- a/qml/cover/CoverPage.qml +++ b/qml/cover/CoverPage.qml @@ -136,8 +136,12 @@ CoverBackground { } Label { - text: cover.obiettivo("dhw_target") - color: Theme.secondaryColor + // Col boost in corso questo posto lo prende il conto alla + // rovescia: piu' utile del valore obiettivo. + text: api.boostActive + ? qsTr("boost %1").arg(api.boostCountdown) + : cover.obiettivo("dhw_target") + color: api.boostActive ? Theme.highlightColor : Theme.secondaryColor font.pixelSize: Theme.fontSizeExtraSmall width: parent.width - acquaOra.width elide: Text.ElideRight diff --git a/qml/pages/AwayDialog.qml b/qml/pages/AwayDialog.qml new file mode 100644 index 0000000..a8c1fcc --- /dev/null +++ b/qml/pages/AwayDialog.qml @@ -0,0 +1,55 @@ +import QtQuick 2.6 +import Sailfish.Silica 1.0 + +// Assenza con le date (richiesta dell'utente, 11/10/2026): si scelgono inizio e +// fine e il comando parte solo quando si accetta. La "fine" e' il giorno in cui +// l'impianto ricomincia a scaldare: nell'uso di Carlo, il giorno prima del +// rientro. Durante l'assenza la casa resta a 15 °C. +Dialog { + id: away + + property date inizio: new Date() + property date fine: new Date(Date.now() + 7 * 24 * 3600000) + + // Non si accetta una fine che non viene dopo l'inizio: il bridge la + // respingerebbe comunque, ed e' meglio non far partire il comando. + canAccept: away.fine > away.inizio + + Column { + width: parent.width + + DialogHeader { + title: qsTr("Away mode") + acceptText: qsTr("Activate") + } + + ValueButton { + label: qsTr("From") + value: Qt.formatDate(away.inizio, Qt.SystemLocaleShortDate) + onClicked: { + var scelta = pageStack.push("Sailfish.Silica.DatePickerDialog", + { "date": away.inizio }) + scelta.accepted.connect(function() { away.inizio = scelta.date }) + } + } + + ValueButton { + label: qsTr("To") + value: Qt.formatDate(away.fine, Qt.SystemLocaleShortDate) + onClicked: { + var scelta = pageStack.push("Sailfish.Silica.DatePickerDialog", + { "date": away.fine }) + scelta.accepted.connect(function() { away.fine = scelta.date }) + } + } + + Label { + x: Theme.horizontalPageMargin + width: parent.width - 2 * Theme.horizontalPageMargin + text: qsTr("The house stays at 15 °C and starts heating again on the day chosen as \"To\".") + wrapMode: Text.WordWrap + color: Theme.secondaryColor + font.pixelSize: Theme.fontSizeExtraSmall + } + } +} diff --git a/qml/pages/MainPage.qml b/qml/pages/MainPage.qml index 16f4740..0870a5a 100644 --- a/qml/pages/MainPage.qml +++ b/qml/pages/MainPage.qml @@ -223,6 +223,18 @@ Page { } } + // Conto alla rovescia del boost, da 30:00 a 00:00 (richiesta + // dell'utente): si vede solo mentre il boost e' in corso. + Label { + visible: api.configured && api.boostActive + x: Theme.horizontalPageMargin + width: parent.width - 2 * Theme.horizontalPageMargin + text: qsTr("Boost · %1").arg(api.boostCountdown) + color: Theme.highlightColor + font.pixelSize: Theme.fontSizeSmall + font.bold: true + } + Label { visible: api.configured x: Theme.horizontalPageMargin @@ -272,8 +284,16 @@ Page { remorse.execute(assenzaBtn, qsTr("Away mode off"), function() { api.sendCommand("away_mode_off") }) } else { - remorse.execute(assenzaBtn, qsTr("Away mode on"), - function() { api.sendCommand("away_mode_on") }) + // L'assenza si imposta da un popup con le date + // (richiesta dell'utente): si apre qui e il comando + // parte solo se si accetta. + var scelta = pageStack.push(Qt.resolvedUrl("AwayDialog.qml")) + scelta.accepted.connect(function() { + api.sendCommand("away_mode_on", null, { + "start": Qt.formatDate(scelta.inizio, "yyyy-MM-dd"), + "end": Qt.formatDate(scelta.fine, "yyyy-MM-dd") + }) + }) } } } diff --git a/rpm/harbour-aira.spec b/rpm/harbour-aira.spec index e20f136..fbad7d7 100644 --- a/rpm/harbour-aira.spec +++ b/rpm/harbour-aira.spec @@ -1,6 +1,6 @@ Name: harbour-aira Summary: Control your Aira heat pump through a local bridge -Version: 0.6.9 +Version: 0.7.0 Release: 1 Group: Qt/Qt License: MIT @@ -37,6 +37,16 @@ Address and token of the bridge live in the app settings (AppConfigLocation); no personal data is stored in the binary. %changelog +* Sun Oct 11 2026 Carlo - 0.7.0-1 +- assenza con le date: popup con inizio e fine (DatePickerDialog, componenti + verificati nella guida Silica dell'SDK). Il bridge manda inizio e fine come + orari veri, l'impianto tiene la casa a 15 °C e riprende il giorno scelto + come fine (per Carlo: il giorno prima del rientro). +- conto alla rovescia del boost da 30:00 a 00:00, in Home e in copertina: i + secondi vengono dalla fine del boost letta dall'impianto, l'app li fa scorrere. +- boost a mezz'ora (era un'ora): richiesta dell'utente. +- catalogo italiano rifatto: lupdate leggeva solo i C++ e marcava 151 voci dei + QML come obsolete. Ora si passano src e qml espliciti: 197 stringhe tradotte. * Sun Oct 11 2026 Carlo - 0.6.9-1 - copertina: azione rapida (P8 del piano). Sulla copertina compare l'icona del boost: si avvia il boost dell'acqua calda senza aprire l'app. Una sola azione, diff --git a/src/airajson.cpp b/src/airajson.cpp index ff642bf..a3f9673 100644 --- a/src/airajson.cpp +++ b/src/airajson.cpp @@ -175,15 +175,31 @@ QString monthLabel(const QString &yearMonth) return QStringLiteral("%1 %2").arg(QLocale().monthName(mese, QLocale::LongFormat)).arg(anno); } -QByteArray commandPayload(const QString &id, const QVariant &value) +QByteArray commandPayload(const QString &id, const QVariant &value, const QVariantMap &extra) { QJsonObject o; o.insert(QStringLiteral("id"), id); if (value.isValid() && !value.isNull()) o.insert(QStringLiteral("value"), QJsonValue::fromVariant(value)); + // Campi in piu' (per esempio start ed end dell'assenza): li passa al bridge + // cosi' come sono, saltando quelli vuoti. + for (QVariantMap::const_iterator it = extra.constBegin(); it != extra.constEnd(); ++it) { + const QVariant v = it.value(); + if (v.isValid() && !v.isNull() && !v.toString().isEmpty()) + o.insert(it.key(), QJsonValue::fromVariant(v)); + } return QJsonDocument(o).toJson(QJsonDocument::Compact); } +QString countdown(int seconds) +{ + if (seconds < 0) + seconds = 0; + return QStringLiteral("%1:%2") + .arg(seconds / 60, 2, 10, QLatin1Char('0')) + .arg(seconds % 60, 2, 10, QLatin1Char('0')); +} + QString errorMessage(const QByteArray &body, int httpStatus) { QJsonParseError err; diff --git a/src/airajson.h b/src/airajson.h index 2dc044a..c6d071c 100644 --- a/src/airajson.h +++ b/src/airajson.h @@ -59,8 +59,14 @@ QString formatMoney(const QVariant &value, const QString ¤cyCode); QString monthLabel(const QString &yearMonth); // Corpo JSON per POST /command. value viene omesso se nullo (i comandi -// semplici non hanno parametri). -QByteArray commandPayload(const QString &id, const QVariant &value); +// semplici non hanno parametri). `extra` aggiunge campi in piu' (per esempio +// start/end dell'assenza): passano al bridge saltando i vuoti. +QByteArray commandPayload(const QString &id, const QVariant &value, + const QVariantMap &extra = QVariantMap()); + +// Conto alla rovescia leggibile da secondi: 1800 -> "30:00", 65 -> "01:05", +// 0 -> "00:00". Oltre l'ora i minuti continuano (240 min -> "240:00"). +QString countdown(int seconds); // Messaggio d'errore leggibile da una risposta del bridge // ({"ok":false,"error":"..."}), con fallback sullo stato HTTP. diff --git a/src/apiclient.cpp b/src/apiclient.cpp index 4145463..7f5eced 100644 --- a/src/apiclient.cpp +++ b/src/apiclient.cpp @@ -53,6 +53,14 @@ ApiClient::ApiClient(Settings *settings, QObject *parent) m_refreshTimer->start(); }); + // Un secondo alla volta mentre il boost e' in corso: i secondi veri li da' + // il bridge a ogni rilettura (ogni 30 s), qui si scorre in mezzo. + m_boostActive = false; + m_boostRemaining = 0; + m_boostTimer = new QTimer(this); + m_boostTimer->setInterval(1000); + connect(m_boostTimer, &QTimer::timeout, this, &ApiClient::tickBoost); + if (m_settings) { // La UI deve riflettere subito il passaggio da "non configurato" a // "configurato" quando si salvano indirizzo o token. @@ -186,6 +194,7 @@ void ApiClient::refresh() return; } m_summary = summary; + setBoostFromSummary(summary); if (m_stale) { m_stale = false; emit staleChanged(); @@ -295,14 +304,14 @@ void ApiClient::refreshStats() }); } -void ApiClient::sendCommand(const QString &id, const QVariant &value) +void ApiClient::sendCommand(const QString &id, const QVariant &value, const QVariantMap &extra) { if (!configured()) { emit commandFinished(false, tr("Set the bridge address and token")); return; } - const QByteArray payload = AiraJson::commandPayload(id, value); + const QByteArray payload = AiraJson::commandPayload(id, value, extra); qDebug() << "[api] comando:" << id; // mai i valori: qui potrebbe passare altro QNetworkReply *reply = send("POST", QStringLiteral("/command"), payload, 30); @@ -332,3 +341,43 @@ void ApiClient::sendCommand(const QString &id, const QVariant &value) refresh(); // i valori letti cambiano: rileggo }); } + +QString ApiClient::boostCountdown() const +{ + return AiraJson::countdown(m_boostRemaining); +} + +// I secondi veri arrivano dal riassunto: il bridge li ricava dalla FINE del +// boost letta dall'impianto. Il timer li fa solo scorrere fra una lettura e +// l'altra, cosi' il conto non riparte da capo quando si riapre la app (e non +// sbaglia se il boost e' stato avviato da un'altra parte). +void ApiClient::setBoostFromSummary(const QVariantMap &summary) +{ + const int restanti = summary.value(QStringLiteral("boost_remaining_seconds")).toInt(); + const bool attivo = restanti > 0; + + if (!attivo && !m_boostActive && m_boostRemaining == 0) { + m_boostTimer->stop(); // niente da mostrare: non disturbo + return; + } + + m_boostRemaining = restanti; + m_boostActive = attivo; + if (attivo) + m_boostTimer->start(); + else + m_boostTimer->stop(); + emit boostChanged(); +} + +void ApiClient::tickBoost() +{ + if (m_boostRemaining > 0) + --m_boostRemaining; + if (m_boostRemaining <= 0) { + m_boostRemaining = 0; + m_boostActive = false; + m_boostTimer->stop(); // arrivato a 00:00 + } + emit boostChanged(); +} diff --git a/src/apiclient.h b/src/apiclient.h index d05c16c..6a0fe37 100644 --- a/src/apiclient.h +++ b/src/apiclient.h @@ -35,6 +35,13 @@ class ApiClient : public QObject // messaggio e' lo stesso ovunque si stia. Q_PROPERTY(QString notice READ notice NOTIFY noticeChanged) Q_PROPERTY(bool noticeIsError READ noticeIsError NOTIFY noticeChanged) + // Conto alla rovescia del boost ACS (richiesta dell'utente: da 30:00 a + // 00:00). I secondi arrivano dal bridge, che li calcola dalla FINE del + // boost letta dall'impianto: cosi' il numero non dipende dal momento in cui + // l'app ha visto partire il comando. Qui scorrono di secondo in secondo. + Q_PROPERTY(bool boostActive READ boostActive NOTIFY boostChanged) + Q_PROPERTY(int boostRemaining READ boostRemaining NOTIFY boostChanged) + Q_PROPERTY(QString boostCountdown READ boostCountdown NOTIFY boostChanged) public: explicit ApiClient(Settings *settings, QObject *parent = 0); @@ -48,6 +55,9 @@ public: bool hasData() const { return !m_summary.isEmpty(); } QString notice() const { return m_notice; } bool noticeIsError() const { return m_noticeIsError; } + bool boostActive() const { return m_boostActive; } + int boostRemaining() const { return m_boostRemaining; } + QString boostCountdown() const; // Stato leggibile in una riga (stateShort) e per esteso (stateLong), piu' // il livello da cui la UI prende il colore. Stanno in C++ perche' sono @@ -66,7 +76,9 @@ public: Q_INVOKABLE void testConnection(); // Invia un comando: sendCommand("set_hot_water_target", 55). - Q_INVOKABLE void sendCommand(const QString &id, const QVariant &value = QVariant()); + // `extra` aggiunge campi al corpo JSON (start/end dell'assenza). + Q_INVOKABLE void sendCommand(const QString &id, const QVariant &value = QVariant(), + const QVariantMap &extra = QVariantMap()); // Formattazione per la UI: unica fonte di verita' in C++ (testata in // tests/core_test.cpp), niente duplicazione di logica nel QML. @@ -82,6 +94,7 @@ signals: void staleChanged(); void lastUpdateChanged(); void noticeChanged(); + void boostChanged(); void summaryChanged(const QVariantMap &summary); void statsChanged(const QVariantMap &stats); void statsRefreshed(bool ok, const QString &message); @@ -96,6 +109,8 @@ private: void setBusy(bool value); // Mostra un messaggio in fondo alla pagina per qualche secondo. void setNotice(const QString &message, bool isError); + void setBoostFromSummary(const QVariantMap &summary); + void tickBoost(); Settings *m_settings; QNetworkAccessManager m_nam; @@ -108,6 +123,9 @@ private: QTimer *m_refreshTimer; QVariantMap m_summary; QVariantMap m_stats; + bool m_boostActive; + int m_boostRemaining; + QTimer *m_boostTimer; }; #endif // APICLIENT_H diff --git a/src/main.cpp b/src/main.cpp index 1a03981..36a9792 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -21,7 +21,7 @@ Q_DECL_EXPORT int main(int argc, char *argv[]) app->setOrganizationName(QStringLiteral("harbour")); app->setApplicationName(QStringLiteral("aira")); - app->setApplicationVersion(QStringLiteral("0.6.9")); + app->setApplicationVersion(QStringLiteral("0.7.0")); Settings settings; ApiClient api(&settings); @@ -36,7 +36,7 @@ Q_DECL_EXPORT int main(int argc, char *argv[]) // Prima riga del log: dice subito se il device sta girando la build nuova // o una stale (trappola nota del build engine). - qDebug() << "harbour-aira v0.6.9 build" << __DATE__ << __TIME__; + qDebug() << "harbour-aira v0.7.0 build" << __DATE__ << __TIME__; view->rootContext()->setContextProperty(QStringLiteral("appSettings"), &settings); view->rootContext()->setContextProperty(QStringLiteral("api"), &api); diff --git a/tests/core_test.cpp b/tests/core_test.cpp index ad3368d..3df7d99 100644 --- a/tests/core_test.cpp +++ b/tests/core_test.cpp @@ -114,6 +114,26 @@ int main(int argc, char *argv[]) AiraJson::commandPayload("hot_water_boost_on", QVariant())).object(); check(c2.contains("value") == false, "comando senza parametri: nessun campo value"); + // Il popup dell'assenza manda start ed end: devono arrivare nel corpo, e i + // campi vuoti non devono entrarci. + const QJsonObject c3 = QJsonDocument::fromJson(AiraJson::commandPayload( + "away_mode_on", QVariant(), QVariantMap{ + { QStringLiteral("start"), QStringLiteral("2026-11-01") }, + { QStringLiteral("end"), QStringLiteral("2026-11-08") }, + { QStringLiteral("zone"), QString() } })).object(); + check(c3.value("start").toString() == "2026-11-01", "campo start nel corpo"); + check(c3.value("end").toString() == "2026-11-08", "campo end nel corpo"); + check(c3.contains("value") == false, "assenza con date: nessun campo value"); + check(c3.contains("zone") == false, "campo vuoto: non entra nel corpo"); + + // ---- countdown (conto alla rovescia del boost) ------------------------ // + check(AiraJson::countdown(1800) == "30:00", "mezz'ora -> 30:00"); + check(AiraJson::countdown(65) == "01:05", "65 secondi -> 01:05"); + check(AiraJson::countdown(59) == "00:59", "59 secondi -> 00:59"); + check(AiraJson::countdown(0) == "00:00", "zero -> 00:00"); + check(AiraJson::countdown(-5) == "00:00", "negativo -> 00:00"); + check(AiraJson::countdown(240 * 60) == "240:00", "quattro ore -> 240:00"); + // ---- errorMessage ----------------------------------------------------- // check(AiraJson::errorMessage(R"({"ok":false,"error":"token mancante o errato"})", 401) == "token mancante o errato", "messaggio d'errore dal corpo"); diff --git a/translations/harbour-aira-it.ts b/translations/harbour-aira-it.ts index a40cfa2..e653437 100644 --- a/translations/harbour-aira-it.ts +++ b/translations/harbour-aira-it.ts @@ -54,97 +54,97 @@ In funzione - + server unreachable server non raggiungibile - + HTTP error %1 errore HTTP %1 - + No data Nessun dato - + Attention: one error Attenzione: un errore - + Attention: %1 errors Attenzione: %1 errori - + Away mode Assenza - + Night mode Modalità notte - + Heating at full power Riscaldamento al massimo - + Heating the hot water Sta scaldando l'acqua - + Working In funzione - + Everything is fine Tutto regolare - + Attention: one error on the plant Attenzione: un errore sull'impianto - + Attention: %1 errors on the plant Attenzione: %1 errori sull'impianto - + Away mode: the house is saving energy. Modalità assenza: la casa sta risparmiando. - + Night mode is on. Modalità notte attiva. - + Boost: the house is heating at full power. Boost: la casa sta scaldando a pieno regime. - + The heat pump is heating the hot water. La pompa di calore sta scaldando l'acqua calda. - + The heat pump is working. La pompa di calore sta lavorando. - + Relax: the heat pump is running smoothly. Relax: la pompa di calore funziona regolarmente. @@ -152,70 +152,98 @@ ApiClient - + Bridge address not configured Indirizzo del bridge non configurato - - - + + + Set the bridge address and token Configura indirizzo e token del bridge - + Server unreachable (timeout) Server non raggiungibile (timeout) - - + + Unreadable response from the bridge Risposta del bridge non interpretabile - + Enter the bridge address Inserisci l'indirizzo del bridge - + Timeout: no response Timeout: nessuna risposta - + Unreachable (HTTP %1) Irraggiungibile (HTTP %1) - + Bridge reachable Bridge raggiungibile - - + + Bridge not configured Bridge non configurato - + Timeout: no statistics Timeout: statistiche non disponibili - + Timeout: command not sent Timeout: comando non inviato - + Command sent Comando inviato + + AwayDialog + + + Away mode + Assenza + + + + Activate + Attiva + + + + From + Dal + + + + To + Al + + + + The house stays at 15 °C and starts heating again on the day chosen as "To". + La casa resta a 15 °C e riprende a scaldare il giorno scelto come "Al". + + CommandsPage @@ -347,12 +375,17 @@ Acqua calda - + + boost %1 + boost %1 + + + Not updated · last %1 Non aggiornato · ultimo %1 - + outside %1 °C esterna %1 °C @@ -530,47 +563,51 @@ Acqua calda - + + Boost · %1 + Boost · %1 + + + Tap a value to adjust it Tocca un valore per regolarlo - + Quick actions Azioni rapide - + Boost Boost - + Home Torna - + Away Assenza - + Away mode off Assenza disattivata - Away mode on - Assenza attivata + Assenza attivata - + More Altro - + Outside %1 °C · Thermostat %2 °C · Zones %3 Esterno %1 °C · Termostato %2 °C · Zone %3 @@ -585,7 +622,7 @@ Bridge non configurato. Apri le impostazioni e inserisci l'indirizzo del server e il token. - + Starting DHW boost Avvio del boost ACS