0.2.0: riscaldamento - obiettivo temperatura casa per zona, stato zone in copertina

This commit is contained in:
Carlo
2026-10-10 07:12:17 +02:00
parent d25da7a91f
commit 7ef9d1e999
10 changed files with 157 additions and 16 deletions
+3 -2
View File
@@ -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
+26 -1
View File
@@ -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`,
Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 78 KiB

+9
View File
@@ -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 {
+72 -2
View File
@@ -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
+1 -1
View File
@@ -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
+26 -10
View File
@@ -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
+14
View File
@@ -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();
}
+6
View File
@@ -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");