diff --git a/README.md b/README.md index db3d900..9ea0e04 100644 --- a/README.md +++ b/README.md @@ -76,9 +76,11 @@ src/apiclient.{h,cpp} QNetworkAccessManager + watchdog di timeout src/settings.{h,cpp} QSettings su AppConfigLocation (sandbox SailJail) src/main.cpp context properties: appSettings, api qml/harbour-aira.qml ApplicationWindow + copertina -qml/pages/MainPage.qml dashboard e comandi +qml/pages/MainPage.qml dashboard e comandi acqua/riscaldamento +qml/pages/CommandsPage.qml gli altri comandi, con lo stato attuale in cima qml/pages/SettingsPage.qml qml/components/InfoRow.qml +qml/components/ActionButton.qml pulsante di comando, con conferma opzionale tests/core_test.cpp verifiche del nucleo (headless) docs/PIANO.md piano di progetto e scelte docs/PROTOCOL.md API del bridge diff --git a/docs/PIANO.md b/docs/PIANO.md index ce77ca1..34ea1bf 100644 --- a/docs/PIANO.md +++ b/docs/PIANO.md @@ -100,6 +100,14 @@ Non verificabile qui (serve il suo PC + il telefono): build RPM, istallazione, U - **App 0.2.0**: sezione "Riscaldamento" nella dashboard (temperatura in casa, obiettivo, stato zona, slider 5–30 °C a passi di 0,5 con conferma) e copertina con la temperatura di casa. Aggiunte le etichette per gli stati `PUMP_MODE_STATE_*`. Nucleo: **29/29**. +- **Bridge 0.3.0 + App 0.3.0**: pagina **"Altri comandi"** nell'app (dalla tendina in + alto o dal pulsante in fondo alla home) con assenza, night mode un'ora, riscaldamento + forzato on/off, funzione riscaldamento on/off, ciclo antilegionella, riscaldamento ACS + on/off e verifica impianto; i comandi che durano (forzato, legionella, disabilitazioni) + chiedono conferma. In cima alla pagina lo **stato attuale**, così non si preme alla + cieca. **Bug corretto nello stesso giro**: `force_heating` nello stato è un oggetto + `{enabled, remaining_time}`, e il bridge lo leggeva con `bool()` — sempre vero. Ora si + legge il campo `enabled`; il test lo copre (27/27). Nucleo app: **31/31**. - **App 0.2.1** (richiesta dell'utente): la temperatura della casa diventa il dato principale **in cima** alla home (valore grande, barra, obiettivo, slider e pulsante), l'acqua calda sanitaria scende in una propria sezione sotto, lo stato impianto va in diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 3996fb2..44c9017 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -35,8 +35,14 @@ Valori pronti per la dashboard. "away_mode": false, "night_mode": false, "manual_mode": false, - "force_heating": true, + "force_heating": false, + "force_heating_remaining": "0s", "inline_heater": false, + "hot_water_heating": true, + "configured_modes": "PUMP_MODE_STATE_HEATING", + "pump_active_state": "PUMP_ACTIVE_STATE_IDLE", + "outdoor_defrost": false, + "signature_lights": true, "led_pattern": "LED_PATTERN_NORMAL", "zones": 1, "zone1_setpoint": 19.0, diff --git a/docs/anteprima/comandi.png b/docs/anteprima/comandi.png new file mode 100644 index 0000000..8177449 Binary files /dev/null and b/docs/anteprima/comandi.png differ diff --git a/qml/components/ActionButton.qml b/qml/components/ActionButton.qml new file mode 100644 index 0000000..368e065 --- /dev/null +++ b/qml/components/ActionButton.qml @@ -0,0 +1,37 @@ +import QtQuick 2.0 +import Sailfish.Silica 1.0 + +// Pulsante a tutta larghezza per un comando del bridge. +// Se `confirmText` e' valorizzato, l'azione parte solo dopo la conferma +// (RemorseItem): la usano i comandi che cambiano lo stato in modo duraturo. +Item { + id: root + + width: parent ? parent.width : 0 + height: btn.height + + property string text: "" + property string confirmText: "" + signal triggered() + + Button { + id: btn + anchors { + left: parent.left + right: parent.right + leftMargin: Theme.horizontalPageMargin + rightMargin: Theme.horizontalPageMargin + } + text: root.text + onClicked: { + if (root.confirmText.length > 0) { + remorse.execute(btn, root.confirmText, function() { root.triggered() }) + } else { + root.triggered() + } + } + } + + // Fuori dal Button: dentro il pulsante i figli finiscono nel contentItem. + RemorseItem { id: remorse } +} diff --git a/qml/pages/CommandsPage.qml b/qml/pages/CommandsPage.qml new file mode 100644 index 0000000..5440eb6 --- /dev/null +++ b/qml/pages/CommandsPage.qml @@ -0,0 +1,166 @@ +import QtQuick 2.2 +import Sailfish.Silica 1.0 +import "../components" + +// Comandi secondari: quelli che non stanno nella home. In cima lo stato +// attuale, cosi' non si preme alla cieca. +Page { + id: page + + allowedOrientations: Orientation.All + + property string notice: "" + property bool noticeIsError: false + + function raw(key) { + return api.summary[key] + } + + function flag(key) { + return page.raw(key) ? qsTr("sì") : qsTr("no") + } + + function stato(key) { + return api.modeLabel(page.raw(key)) + } + + function showNotice(msg, isError) { + page.notice = msg + page.noticeIsError = isError === true + noticeTimer.restart() + } + + Timer { + id: noticeTimer + interval: 4000 + onTriggered: page.notice = "" + } + + Connections { + target: api + onErrorOccurred: page.showNotice(message, true) + onCommandFinished: page.showNotice(message, !ok) + } + + SilicaFlickable { + anchors.fill: parent + contentHeight: col.height + Theme.paddingLarge + + Column { + id: col + width: parent.width + spacing: Theme.paddingSmall + + PageHeader { title: qsTr("Altri comandi") } + + Label { + visible: page.notice.length > 0 + x: Theme.horizontalPageMargin + width: parent.width - 2 * Theme.horizontalPageMargin + text: page.notice + color: page.noticeIsError ? Theme.errorColor : Theme.highlightColor + font.pixelSize: Theme.fontSizeSmall + wrapMode: Text.Wrap + } + + // ---- stato attuale -------------------------------------------- // + SectionHeader { text: qsTr("Stato attuale") } + + InfoRow { label: qsTr("Assenza"); value: page.flag("away_mode") } + InfoRow { label: qsTr("Night mode"); value: page.flag("night_mode") } + InfoRow { label: qsTr("Riscaldamento forzato"); value: page.flag("force_heating") } + InfoRow { label: qsTr("Acqua calda attiva"); value: page.flag("hot_water_heating") } + InfoRow { label: qsTr("Pompa"); value: page.stato("pump_active_state") } + InfoRow { label: qsTr("Modalità consentite"); value: page.stato("configured_modes") } + + // ---- assenza --------------------------------------------------- // + SectionHeader { text: qsTr("Assenza") } + + ActionButton { + text: qsTr("Attiva assenza") + onTriggered: api.sendCommand("away_mode_on") + } + ActionButton { + text: qsTr("Disattiva assenza") + onTriggered: api.sendCommand("away_mode_off") + } + + // ---- rumore ---------------------------------------------------- // + SectionHeader { text: qsTr("Rumore") } + + ActionButton { + text: qsTr("Night mode per un'ora") + onTriggered: api.sendCommand("night_mode_1h") + } + + // ---- riscaldamento --------------------------------------------- // + SectionHeader { text: qsTr("Riscaldamento") } + + ActionButton { + text: qsTr("Forza riscaldamento") + confirmText: qsTr("Avvio del riscaldamento forzato") + onTriggered: api.sendCommand("force_heating_on") + } + ActionButton { + text: qsTr("Ferma riscaldamento forzato") + onTriggered: api.sendCommand("force_heating_off") + } + ActionButton { + text: qsTr("Abilita funzione riscaldamento") + onTriggered: api.sendCommand("heating_on") + } + ActionButton { + text: qsTr("Disabilita funzione riscaldamento") + confirmText: qsTr("Il riscaldamento verrà disabilitato") + onTriggered: api.sendCommand("heating_off") + } + + // ---- acqua calda ----------------------------------------------- // + SectionHeader { text: qsTr("Acqua calda sanitaria") } + + ActionButton { + text: qsTr("Ciclo antilegionella") + confirmText: qsTr("Avvio del ciclo antilegionella: richiede tempo") + onTriggered: api.sendCommand("legionella_cycle") + } + ActionButton { + text: qsTr("Abilita riscaldamento ACS") + onTriggered: api.sendCommand("hot_water_heating_on") + } + ActionButton { + text: qsTr("Disabilita riscaldamento ACS") + confirmText: qsTr("L'acqua calda smetterà di essere riscaldata") + onTriggered: api.sendCommand("hot_water_heating_off") + } + + // ---- diagnostica ----------------------------------------------- // + SectionHeader { text: qsTr("Diagnostica") } + + ActionButton { + text: qsTr("Verifica impianto") + onTriggered: api.sendCommand("ping") + } + + BusyIndicator { + visible: api.busy + anchors.horizontalCenter: parent.horizontalCenter + size: BusyIndicatorSize.Small + running: true + } + + Label { + x: Theme.horizontalPageMargin + width: parent.width - 2 * Theme.horizontalPageMargin + text: qsTr("I comandi passano dal bridge e possono richiedere qualche secondo. Dopo l'invio lo stato si aggiorna da solo.") + color: Theme.secondaryColor + font.pixelSize: Theme.fontSizeExtraSmall + wrapMode: Text.Wrap + } + } + } + + Component.onCompleted: { + if (api.configured) + api.refresh() + } +} diff --git a/qml/pages/MainPage.qml b/qml/pages/MainPage.qml index 22dbb7f..042262d 100644 --- a/qml/pages/MainPage.qml +++ b/qml/pages/MainPage.qml @@ -24,6 +24,10 @@ Page { pageStack.push(Qt.resolvedUrl("SettingsPage.qml")) } + function openCommands() { + pageStack.push(Qt.resolvedUrl("CommandsPage.qml")) + } + function showNotice(msg, isError) { page.notice = msg page.noticeIsError = isError === true @@ -101,6 +105,10 @@ Page { text: qsTr("Impostazioni") onClicked: page.openSettings() } + MenuItem { + text: qsTr("Altri comandi") + onClicked: page.openCommands() + } } Column { @@ -308,6 +316,18 @@ Page { InfoRow { visible: api.configured; label: qsTr("Errori"); value: page.count("errors_count") } InfoRow { visible: api.configured; label: qsTr("Aggiornato alle"); value: page.shortTime(page.raw("pump_updated")) } + Button { + visible: api.configured + anchors { + left: parent.left + right: parent.right + leftMargin: Theme.horizontalPageMargin + rightMargin: Theme.horizontalPageMargin + } + text: qsTr("Altri comandi") + onClicked: page.openCommands() + } + BusyIndicator { visible: api.busy anchors.horizontalCenter: parent.horizontalCenter diff --git a/rpm/harbour-aira.spec b/rpm/harbour-aira.spec index c9b7f5c..cee42ff 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.2.1 +Version: 0.3.0 Release: 1 Group: Qt/Qt License: MIT diff --git a/scripts/render_mockups.py b/scripts/render_mockups.py index 76171b8..9a14d62 100644 --- a/scripts/render_mockups.py +++ b/scripts/render_mockups.py @@ -216,10 +216,36 @@ def cover_screen() -> Image.Image: return img +def commands_screen() -> Image.Image: + img = background() + d = ImageDraw.Draw(img) + status_bar(d) + page_header(d, "Altri comandi") + + y = section_header(d, 156, "Stato attuale") + for lab, val in (("Assenza", "no"), ("Night mode", "no"), + ("Riscaldamento forzato", "no"), ("Acqua calda attiva", "sì"), + ("Pompa", "Ferma"), ("Modalità consentite", "Riscaldamento")): + y = label_pair(d, y, lab, val) + + y = section_header(d, y + 24, "Assenza") + y = button(d, y, "Attiva assenza") + y = button(d, y, "Disattiva assenza") + + y = section_header(d, y + 24, "Rumore") + y = button(d, y, "Night mode per un'ora") + + y = section_header(d, y + 24, "Riscaldamento") + y = button(d, y, "Forza riscaldamento", primary=True) + d.text(((W - d.textlength("(chiede conferma prima di partire)", font=font(22))) / 2, y - 4), + "(chiede conferma prima di partire)", font=font(22), fill=SECONDARY) + return img + + if __name__ == "__main__": OUT.mkdir(parents=True, exist_ok=True) - for name, fn in (("dashboard", main_screen), ("impostazioni", settings_screen), - ("copertina", cover_screen)): + for name, fn in (("dashboard", main_screen), ("comandi", commands_screen), + ("impostazioni", settings_screen), ("copertina", cover_screen)): path = OUT / f"{name}.png" fn().save(path) print(f"scritto {path} ({path.stat().st_size} byte)") diff --git a/src/airajson.cpp b/src/airajson.cpp index 8a60b85..476da6c 100644 --- a/src/airajson.cpp +++ b/src/airajson.cpp @@ -73,12 +73,19 @@ QString modeLabel(const QString &operatingStatus) return QStringLiteral("Stand-by"); if (s == QLatin1String("PUMP_MODE_STATE_OFF")) return QStringLiteral("Spento"); + // Stato operativo della pompa (state.pump_active_state). + if (s == QLatin1String("PUMP_ACTIVE_STATE_IDLE")) + return QStringLiteral("Ferma"); + if (s == QLatin1String("PUMP_ACTIVE_STATE_RUNNING")) + return QStringLiteral("In funzione"); if (s.isEmpty()) return QStringLiteral("—"); // Sconosciuto: togliamo il prefisso e mostriamo il resto, minuscolo. QString rest = s; rest.remove(QLatin1String("OPERATING_STATUS_")); rest.remove(QLatin1String("PUMP_MODE_STATE_")); + rest.remove(QLatin1String("PUMP_ACTIVE_STATE_")); + rest.remove(QLatin1String("POWER_PREFERENCE_")); return rest.toLower(); } diff --git a/tests/core_test.cpp b/tests/core_test.cpp index 9fb3b32..146ddba 100644 --- a/tests/core_test.cpp +++ b/tests/core_test.cpp @@ -68,6 +68,10 @@ int main(int argc, char *argv[]) "stato zona: in attesa"); check(AiraJson::modeLabel("PUMP_MODE_STATE_IGNOTO") == "ignoto", "stato zona sconosciuto -> senza prefisso"); + check(AiraJson::modeLabel("PUMP_ACTIVE_STATE_IDLE") == "Ferma", + "pompa ferma"); + check(AiraJson::modeLabel("PUMP_ACTIVE_STATE_RUNNING") == "In funzione", + "pompa in funzione"); check(AiraJson::modeLabel("OPERATING_STATUS_DEFROST") == "defrost", "stato sconosciuto: prefisso tolto, minuscolo");