diff --git a/README.md b/README.md index 9975517..624483e 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ # harbour-aira App nativa Sailfish OS per una pompa di calore **Aira Home**: mostra lo stato -dell'impianto e invia i comandi dell'acqua calda sanitaria. +dell'impianto e invia i comandi dell'acqua calda sanitaria e del riscaldamento +(temperatura obiettivo della casa, per zona). ![architettura](docs/architettura.svg) @@ -52,7 +53,7 @@ sfdk build # RPM in RPMS/ sfdk deploy # sul dispositivo collegato ``` -Sorgenti dal tarball: `tar xzf harbour-aira-0.1.0.tar.gz` e compilare in una +Sorgenti dal tarball: `tar xzf harbour-aira-0.2.0.tar.gz` e compilare in una cartella nuova (mai sopra una estrazione precedente: i Makefile restano stale). ### Verifica locale, senza SDK diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 6444859..3996fb2 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -16,7 +16,7 @@ Nessuna autenticazione: serve a distinguere "server irraggiungibile" da "token sbagliato". ```json -{"ok": true, "version": "0.1.0"} +{"ok": true, "version": "0.2.0"} ``` ## GET /summary @@ -39,6 +39,14 @@ Valori pronti per la dashboard. "inline_heater": false, "led_pattern": "LED_PATTERN_NORMAL", "zones": 1, + "zone1_setpoint": 19.0, + "zone1_temperature": 23.0, + "zone1_mode": "PUMP_MODE_STATE_HEATING", + "zone1_cooling_setpoint": 22.0, + "zone2_setpoint": 0.0, + "zone2_temperature": 0.0, + "zone2_mode": "PUMP_MODE_STATE_IDLE", + "zone2_cooling_setpoint": 0.0, "errors_count": 0, "pump_updated": "2026-10-09 18:52:17.199340" } @@ -68,6 +76,8 @@ Risposta: `{"ok": true, "id": "...", "result": {...}}`. |---|---|---| | `ping` | — | verifica che l'impianto risponda | | `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 | @@ -76,6 +86,21 @@ Risposta: `{"ok": true, "id": "...", "result": {...}}`. | `heating_on` / `heating_off` | — | funzione riscaldamento | | `legionella_cycle` | — | ciclo antilegionella | +### Riscaldamento: come si invia il comando per zona + +Il messaggio `ZoneTemperatures` contiene **sempre entrambe** le zone: + +```json +{"id": "set_heating_setpoint", "value": 21.5, "zone": 1} +``` + +`zone` è opzionale (default 1) e accetta 1 o 2. La zona che non si sta +modificando viene rimandata con il **suo valore attuale**, letto dall'ultimo +stato disponibile: inviarla a zero la spegnerebbe. Per questo il bridge tiene +in memoria l'ultima lettura, e il comando va costruito con quel contesto. + +Il campo `kind` distingue riscaldamento (`1`) da raffrescamento (`2`). + ### Perché una whitelist La libreria espone ~90 comandi, fra cui `FactoryReset`, `InstallFirmware`, diff --git a/docs/anteprima/copertina.png b/docs/anteprima/copertina.png index 0790885..bd5ccd9 100644 Binary files a/docs/anteprima/copertina.png and b/docs/anteprima/copertina.png differ diff --git a/docs/anteprima/dashboard.png b/docs/anteprima/dashboard.png index 7462a64..33f0500 100644 Binary files a/docs/anteprima/dashboard.png and b/docs/anteprima/dashboard.png differ diff --git a/qml/cover/CoverPage.qml b/qml/cover/CoverPage.qml index cd5905d..4aead36 100644 --- a/qml/cover/CoverPage.qml +++ b/qml/cover/CoverPage.qml @@ -42,6 +42,15 @@ CoverBackground { font.pixelSize: Theme.fontSizeExtraSmall truncationMode: TruncationMode.Fade } + + Label { + width: parent.width + horizontalAlignment: Text.AlignHCenter + text: qsTr("casa %1 °C · obiettivo %2 °C").arg(cover.t("zone1_temperature")).arg(cover.t("zone1_setpoint")) + color: Theme.secondaryColor + font.pixelSize: Theme.fontSizeExtraSmall + truncationMode: TruncationMode.Fade + } } Label { diff --git a/qml/pages/MainPage.qml b/qml/pages/MainPage.qml index cb1a3ac..75bc29e 100644 --- a/qml/pages/MainPage.qml +++ b/qml/pages/MainPage.qml @@ -13,6 +13,10 @@ Page { // non lo tocca, altrimenti lo slider "salta" a ogni lettura) property int targetAcs: 55 property bool targetAcsTouched: false + // obiettivo della casa (zona 1); parte dalla temperatura attuale e non + // viene sovrascritto dal refresh finche' l'utente non lo tocca + property real targetCasa: 20 + property bool targetCasaTouched: false property string notice: "" property bool noticeIsError: false @@ -73,6 +77,11 @@ Page { if (v !== undefined && v !== null) page.targetAcs = Math.round(Number(v)) } + if (!page.targetCasaTouched) { + var c = page.raw("zone1_setpoint") + if (c !== undefined && c !== null && Number(c) > 0) + page.targetCasa = Math.round(Number(c) * 2) / 2 + } } } @@ -167,11 +176,72 @@ Page { font.pixelSize: Theme.fontSizeExtraSmall } + // ---- riscaldamento: temperatura della casa --------------------- // + SectionHeader { visible: api.configured; text: qsTr("Riscaldamento") } + + Label { + visible: api.configured + anchors.horizontalCenter: parent.horizontalCenter + text: qsTr("Temperatura in casa") + color: Theme.secondaryColor + font.pixelSize: Theme.fontSizeSmall + } + + Label { + visible: api.configured + anchors.horizontalCenter: parent.horizontalCenter + text: page.temp("zone1_temperature") + " °C" + color: Theme.primaryColor + font.pixelSize: Theme.fontSizeLarge + } + + InfoRow { + visible: api.configured + label: qsTr("Obiettivo casa") + value: page.temp("zone1_setpoint") + " °C" + } + InfoRow { + visible: api.configured + label: qsTr("Stato riscaldamento") + value: api.modeLabel(page.raw("zone1_mode")) + } + + Slider { + id: casaSlider + visible: api.configured + width: parent.width + label: qsTr("Nuovo obiettivo") + minimumValue: 5 + maximumValue: 30 + stepSize: 0.5 + value: page.targetCasa + valueText: value.toFixed(1) + " °C" + onValueChanged: { + page.targetCasaTouched = true + page.targetCasa = Math.round(value * 2) / 2 + } + } + + Button { + id: setCasaButton + visible: api.configured + anchors { + left: parent.left + right: parent.right + leftMargin: Theme.horizontalPageMargin + rightMargin: Theme.horizontalPageMargin + } + text: qsTr("Imposta casa a %1 °C").arg(page.targetCasa.toFixed(1)) + onClicked: remorse.execute(setCasaButton, + qsTr("Temperatura della casa a %1 °C").arg(page.targetCasa.toFixed(1)), + function() { api.sendCommand("set_heating_setpoint", page.targetCasa) }) + } + // ---- stato impianto ------------------------------------------- // SectionHeader { visible: api.configured; text: qsTr("Impianto") } InfoRow { visible: api.configured; label: qsTr("Temperatura esterna"); value: page.temp("outdoor_temperature") + " °C" } - InfoRow { visible: api.configured; label: qsTr("Temperatura interna"); value: page.temp("indoor_temperature") + " °C" } + InfoRow { visible: api.configured; label: qsTr("Temperatura interna (pompa)"); value: page.temp("indoor_temperature") + " °C" } InfoRow { visible: api.configured; label: qsTr("Modalità"); value: api.modeLabel(page.raw("operating_status")) } InfoRow { visible: api.configured; label: qsTr("Assenza"); value: page.flag("away_mode") } InfoRow { visible: api.configured; label: qsTr("Night mode"); value: page.flag("night_mode") } @@ -182,7 +252,7 @@ Page { InfoRow { visible: api.configured; label: qsTr("Aggiornato alle"); value: page.shortTime(page.raw("pump_updated")) } // ---- comandi --------------------------------------------------- // - SectionHeader { visible: api.configured; text: qsTr("Comandi") } + SectionHeader { visible: api.configured; text: qsTr("Comandi acqua calda") } Slider { visible: api.configured diff --git a/rpm/harbour-aira.spec b/rpm/harbour-aira.spec index 1c08a93..ce03b83 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.1.0 +Version: 0.2.0 Release: 1 Group: Qt/Qt License: MIT diff --git a/scripts/render_mockups.py b/scripts/render_mockups.py index 9f50747..dbfd21f 100644 --- a/scripts/render_mockups.py +++ b/scripts/render_mockups.py @@ -87,8 +87,9 @@ def progress(d: ImageDraw.ImageDraw, y: int, frac: float) -> None: d.rounded_rectangle([M, y, end, y + 3], radius=2, fill=ACCENT) -def slider(d: ImageDraw.ImageDraw, y: int, frac: float, value_text: str) -> int: - d.text((M, y), "Obiettivo ACS", font=font(24), fill=SECONDARY) +def slider(d: ImageDraw.ImageDraw, y: int, frac: float, value_text: str, + label: str = "Obiettivo ACS") -> int: + d.text((M, y), label, font=font(24), fill=SECONDARY) vw = d.textlength(value_text, font=font(24)) d.text((W - M - vw, y), value_text, font=font(24), fill=PRIMARY) ty = y + 58 @@ -129,15 +130,27 @@ def main_screen() -> Image.Image: t = "obiettivo 55,0 °C" d.text(((W - d.textlength(t, font=font(24))) / 2, y), t, font=font(24), fill=SECONDARY) - y = section_header(d, y + 44, "Impianto") - y = label_pair(d, y, "Esterna", "18,0 °C") - y = label_pair(d, y, "Interna", "22,5 °C") - y = label_pair(d, y, "Modalità", "Auto") - y = label_pair(d, y, "Zone", "1") - y = label_pair(d, y, "Errori", "0") - y = label_pair(d, y, "Aggiornato", "19:07") + # ---- riscaldamento: temperatura della casa -------------------------- # + y = section_header(d, y + 44, "Riscaldamento") + c = "Temperatura in casa" + d.text(((W - d.textlength(c, font=font(24))) / 2, y), c, font=font(24), fill=SECONDARY) + y += 34 + big2 = "23,0 °C" + d.text(((W - d.textlength(big2, font=font(64, bold=True))) / 2, y), + big2, font=font(64, bold=True), fill=PRIMARY) + y += 88 + y = label_pair(d, y, "Obiettivo casa", "19,0 °C") + y = label_pair(d, y, "Stato riscaldamento", "Riscaldamento") + y = slider(d, y + 14, (19.0 - 5) / (30 - 5), "19,0 °C", "Nuovo obiettivo") + y = button(d, y, "Imposta casa a 19,0 °C", primary=True) - y = section_header(d, y + 24, "Comandi") + y = section_header(d, y + 24, "Impianto") + y = label_pair(d, y, "Esterna", "18,0 °C") + y = label_pair(d, y, "Interna (pompa)", "22,5 °C") + y = label_pair(d, y, "Modalità", "Automatico") + y = label_pair(d, y, "Errori", "0") + + y = section_header(d, y + 24, "Comandi acqua calda") y = slider(d, y, (55.0 - 45) / (60 - 45), "55 °C") y = button(d, y, "Imposta obiettivo ACS", primary=True) y = button(d, y, "Avvia boost ACS") @@ -202,6 +215,9 @@ def cover_screen() -> Image.Image: vw = d.textlength(val, font=font(24)) d.text((card[2] - 34 - vw, y), val, font=font(24), fill=PRIMARY) y += 52 + vw = d.textlength("23,0 °C", font=font(24)) + d.text((card[0] + 34, y), "casa", font=font(24), fill=SECONDARY) + d.text((card[2] - 34 - vw, y), "23,0 °C", font=font(24), fill=PRIMARY) d.text((card[0] + 34, card[3] - 60), "acqua calda sanitaria", font=font(22), fill=SECONDARY) return img diff --git a/src/airajson.cpp b/src/airajson.cpp index d6f912d..8a60b85 100644 --- a/src/airajson.cpp +++ b/src/airajson.cpp @@ -60,11 +60,25 @@ QString modeLabel(const QString &operatingStatus) return QStringLiteral("Stand-by"); if (s == QLatin1String("OPERATING_STATUS_OFF")) return QStringLiteral("Spento"); + // Stato della pompa per zona (state.zoneN_mode). + if (s == QLatin1String("PUMP_MODE_STATE_HEATING")) + return QStringLiteral("Riscaldamento"); + if (s == QLatin1String("PUMP_MODE_STATE_COOLING")) + return QStringLiteral("Raffrescamento"); + if (s == QLatin1String("PUMP_MODE_STATE_HOT_WATER")) + return QStringLiteral("Acqua calda"); + if (s == QLatin1String("PUMP_MODE_STATE_IDLE")) + return QStringLiteral("In attesa"); + if (s == QLatin1String("PUMP_MODE_STATE_STANDBY")) + return QStringLiteral("Stand-by"); + if (s == QLatin1String("PUMP_MODE_STATE_OFF")) + return QStringLiteral("Spento"); 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_")); return rest.toLower(); } diff --git a/tests/core_test.cpp b/tests/core_test.cpp index 6624b0c..9fb3b32 100644 --- a/tests/core_test.cpp +++ b/tests/core_test.cpp @@ -62,6 +62,12 @@ int main(int argc, char *argv[]) check(AiraJson::modeLabel("OPERATING_STATUS_AUTO") == "Automatico", "AUTO -> Automatico"); check(AiraJson::modeLabel("OPERATING_STATUS_HOT_WATER") == "Acqua calda", "acqua calda"); check(AiraJson::modeLabel("") == "—", "stato vuoto -> trattino"); + check(AiraJson::modeLabel("PUMP_MODE_STATE_HEATING") == "Riscaldamento", + "stato zona: riscaldamento"); + check(AiraJson::modeLabel("PUMP_MODE_STATE_IDLE") == "In attesa", + "stato zona: in attesa"); + check(AiraJson::modeLabel("PUMP_MODE_STATE_IGNOTO") == "ignoto", + "stato zona sconosciuto -> senza prefisso"); check(AiraJson::modeLabel("OPERATING_STATUS_DEFROST") == "defrost", "stato sconosciuto: prefisso tolto, minuscolo");