0.6.2: pallino rosso solo per un allarme che ferma la macchina (avviso = ambra)

Il pallino era rosso per qualunque voce nell'elenco errori: un semplice avviso
(compressore limitato dalla temperatura esterna, caso reale del 10/10) sembrava
un guasto. Ora il ponte espone in /summary anche error_severity e
alarm_stopping, e il livello 3 (rosso) e' riservato agli allarmi che fermano la
macchina o di severita' CRITICAL/ERROR; avviso = ambra. Copertina allineata.

6 verifiche nuove nell'harness del nucleo (avviso/stop/critical/error/
precedenza sul modo attivo/gravita' ignota = ambra).
This commit is contained in:
2026-10-10 18:16:04 +02:00
parent 4050b3b5d7
commit a281a7e112
9 changed files with 108 additions and 13 deletions
+25 -1
View File
@@ -53,7 +53,7 @@ CREDS = BASE / "aira.json"
TOKENS = BASE / "aira_tokens.json" TOKENS = BASE / "aira_tokens.json"
POOL_CFG = BASE / "aira_config.json" POOL_CFG = BASE / "aira_config.json"
VERSION = "0.4.0" VERSION = "0.6.2"
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -195,6 +195,23 @@ def summarize(st: dict) -> dict:
errs = st.get("errors") or [] errs = st.get("errors") or []
# ---- gravita' degli errori -------------------------------------------
# La severita' arriva come stringa ("SEVERITY_WARNING") e le voci possono
# essere piu' di una: si normalizza al nome nudo e si tiene la peggiore.
# "allarme che ferma" NON si deduce dalla severita': sta nei metadati
# (compreso quello del compressore).
_GRAVITA = {"WARNING": 1, "ERROR": 2, "CRITICAL": 3}
_meta = st.get("error_metadata") or {}
alarm_stopping = bool(_meta.get("hp_has_stopping_alarms")) or bool(
_meta.get("compressor_has_stopping_alarm"))
severita = ""
for _voce in errs if isinstance(errs, list) else []:
_nome = str((_voce or {}).get("severity") or "")
if _nome.startswith("SEVERITY_"):
_nome = _nome[len("SEVERITY_"):]
if _GRAVITA.get(_nome, 0) > _GRAVITA.get(severita, 0):
severita = _nome
# ---- dati del dispositivo (per la pagina "Impianto": accessori + riepilogo, # ---- dati del dispositivo (per la pagina "Impianto": accessori + riepilogo,
# come la scheda "Accessori" e il "Riepilogo impostazioni" dell'app ufficiale) # come la scheda "Accessori" e il "Riepilogo impostazioni" dell'app ufficiale)
hp_id = g("heat_pump_id", "value") or "" hp_id = g("heat_pump_id", "value") or ""
@@ -249,6 +266,13 @@ def summarize(st: dict) -> dict:
"zone2_mode": g("current_pump_mode_state", "zone_2"), "zone2_mode": g("current_pump_mode_state", "zone_2"),
"zone2_cooling_setpoint": g("zone_setpoints_cooling", "zone_2"), "zone2_cooling_setpoint": g("zone_setpoints_cooling", "zone_2"),
"errors_count": len(errs) if isinstance(errs, list) else 0, "errors_count": len(errs) if isinstance(errs, list) else 0,
# Gravita' degli errori, per il pallino di stato dell'app: un semplice
# avviso (WARNING) non deve diventare rosso come un allarme che ferma la
# macchina. "error_metadata" dice se c'e' un allarme che ferma; le voci
# di "errors" portano la severita' di ognuna (normalizzata senza il
# prefisso SEVERITY_, cosi' la UI non dipende dal protobuf).
"alarm_stopping": alarm_stopping,
"error_severity": severita,
"pump_updated": g("aws_iot_received_time") or st.get("time"), "pump_updated": g("aws_iot_received_time") or st.get("time"),
# Identificativo e versioni: l'app ufficiale mostra le prime 6 cifre # Identificativo e versioni: l'app ufficiale mostra le prime 6 cifre
# dell'UUID come "Dispositivo ID". # dell'UUID come "Dispositivo ID".
+3 -2
View File
@@ -245,8 +245,9 @@ rapide sono pulsanti visibili, le azioni sulla copertina restano per dopo la
prova di `CoverAction` sul kit. prova di `CoverAction` sul kit.
**Home** (`MainPage.qml`, riscritta): una schermata da guardare, non un modulo. **Home** (`MainPage.qml`, riscritta): una schermata da guardare, non un modulo.
Stato in una riga con il pallino colorato (verde regolare, ambra modo attivo o Stato in una riga con il pallino colorato (verde regolare, ambra modo attivo,
numeri vecchi, rosso errore), le due letture che contano — casa e acqua calda, numeri vecchi o avviso, rosso solo allarme che ferma la macchina), le due
letture che contano — casa e acqua calda,
con obiettivo e barra di carica — affiancate e **toccabili**, e tre azioni con obiettivo e barra di carica — affiancate e **toccabili**, e tre azioni
rapide: boost, assenza, comandi. Escono dalla Home i due slider, i sette rapide: boost, assenza, comandi. Escono dalla Home i due slider, i sette
pulsanti e i tre pulsanti di navigazione ridondanti col menù a tendina. Sotto, pulsanti e i tre pulsanti di navigazione ridondanti col menù a tendina. Sotto,
+5 -2
View File
@@ -169,8 +169,11 @@ I richiami numerati dell'immagine:
Solo navigazione fra pagine, una voce per pagina: i pulsanti in fondo alla Solo navigazione fra pagine, una voce per pagina: i pulsanti in fondo alla
home non ci sono più (risolve P4). home non ci sono più (risolve P4).
2. **Stato in una riga**: pallino colorato + "Tutto regolare · Automatico" + 2. **Stato in una riga**: pallino colorato + "Tutto regolare · Automatico" +
ora dell'ultimo aggiornamento. Verde regolare, ambra attenzione, rosso ora dell'ultimo aggiornamento. Verde regolare, ambra attenzione (modo
errore o non raggiungibile (risolve P6). La frase estesa dell'app ufficiale attivo, numeri vecchi o avviso della pompa), rosso solo un allarme che
ferma la macchina o non raggiungibile (risolve P6 — la distinzione fra
avviso e allarme è arrivata il 10/10: prima qualunque voce nell'elenco
errori accendeva il rosso). La frase estesa dell'app ufficiale
("Relax: la pompa funziona regolarmente") è bella ma occupa 2 righe in cima: ("Relax: la pompa funziona regolarmente") è bella ma occupa 2 righe in cima:
sta meglio nella pagina Impianto (decisione 1). sta meglio nella pagina Impianto (decisione 1).
3. **Due letture grandi, affiancate**: Casa 22,8 °C (obiettivo 19,0) e Acqua 3. **Due letture grandi, affiancate**: Casa 22,8 °C (obiettivo 19,0) e Acqua
+5 -1
View File
@@ -14,12 +14,16 @@ CoverBackground {
// Colore del pallino: gli stessi criteri della Home (vedi MainPage.qml). // Colore del pallino: gli stessi criteri della Home (vedi MainPage.qml).
// Un dato vecchio e' ambra come un modo attivo: un dato vecchio non e' un // Un dato vecchio e' ambra come un modo attivo: un dato vecchio non e' un
// dato buono, e la copertina e' il posto dove si guarda di sfuggita. // dato buono, e la copertina e' il posto dove si guarda di sfuggita.
// Rosso solo per un allarme che ferma la macchina (livello 3): un avviso
// (livello 2) e' ambra.
function statusColor() { function statusColor() {
if (!api.hasData || api.stale) if (!api.hasData || api.stale)
return "orange" return "orange"
var livello = api.statusLevel() var livello = api.statusLevel()
if (livello === 2) if (livello === 3)
return Theme.errorColor return Theme.errorColor
if (livello === 2)
return "#ffb300"
if (livello === 1) if (livello === 1)
return Theme.highlightColor return Theme.highlightColor
return "#4caf50" return "#4caf50"
+8 -3
View File
@@ -29,14 +29,19 @@ Page {
} }
// Colore del pallino di stato. Il livello (0 regolare, 1 modo attivo, // Colore del pallino di stato. Il livello (0 regolare, 1 modo attivo,
// 2 errore) lo calcola il C++; qui si sceglie il colore, e l'ambra quando // 2 avviso, 3 allarme serio) lo calcola il C++; qui si sceglie il colore,
// i numeri sono vecchi: un dato vecchio non e' un dato buono. // e l'ambra quando i numeri sono vecchi: un dato vecchio non e' un dato
// buono. Rosso solo quando e' la macchina a essere ferma o guasta: un
// avviso (per esempio il compressore limitato dalla temperatura esterna)
// e' ambra, come nell'app ufficiale.
function statusColor() { function statusColor() {
if (!api.hasData || api.stale) if (!api.hasData || api.stale)
return "orange" return "orange"
var livello = api.statusLevel() var livello = api.statusLevel()
if (livello === 2) if (livello === 3)
return Theme.errorColor return Theme.errorColor
if (livello === 2)
return "#ffb300"
if (livello === 1) if (livello === 1)
return Theme.highlightColor return Theme.highlightColor
return "#4caf50" return "#4caf50"
+8 -1
View File
@@ -1,6 +1,6 @@
Name: harbour-aira Name: harbour-aira
Summary: Control your Aira heat pump through a local bridge Summary: Control your Aira heat pump through a local bridge
Version: 0.6.1 Version: 0.6.2
Release: 1 Release: 1
Group: Qt/Qt Group: Qt/Qt
License: MIT License: MIT
@@ -37,6 +37,13 @@ Address and token of the bridge live in the app settings (AppConfigLocation);
no personal data is stored in the binary. no personal data is stored in the binary.
%changelog %changelog
* Sat Oct 10 2026 Carlo <carlo@carlobaratto.it> - 0.6.2-1
- pallino di stato: rosso solo per un allarme che ferma la macchina (o di
severita' CRITICAL/ERROR); un avviso ora e' ambra. Prima qualunque voce
nell'elenco errori accendeva il rosso, e un semplice avviso (compressore
limitato dalla temperatura esterna, caso reale del 10/10) sembrava un guasto.
Il bridge espone error_severity e alarm_stopping in /summary; l'esito ignoto
resta ambra, il rosso si accende solo su un guasto che sappiamo essere serio
* Sat Oct 10 2026 Carlo <carlo@carlobaratto.it> - 0.6.1-1 * Sat Oct 10 2026 Carlo <carlo@carlobaratto.it> - 0.6.1-1
- correzione: la Home non si apriva ("Type MainPage unavailable", app senza - correzione: la Home non si apriva ("Type MainPage unavailable", app senza
pagina iniziale). Causa: due assegnazioni di Component.onCompleted nello pagina iniziale). Causa: due assegnazioni di Component.onCompleted nello
+14 -1
View File
@@ -211,6 +211,19 @@ int errorCount(const QVariantMap &summary)
return summary.value(QStringLiteral("errors_count")).toInt(); return summary.value(QStringLiteral("errors_count")).toInt();
} }
// Allarme "serio": quello che ferma la macchina (flag del bridge, dai metadati
// della pompa) oppure una voce di severita' CRITICAL/ERROR. Un semplice avviso
// (WARNING: per esempio il compressore limitato dalla temperatura esterna) NON
// e' serio, e non deve accendere il pallino rosso. Senza i campi (bridge piu'
// vecchio) si resta prudenti sul colore: ambra, non rosso.
bool alarmSerious(const QVariantMap &summary)
{
if (flagValue(summary, QStringLiteral("alarm_stopping")))
return true;
const QString severita = summary.value(QStringLiteral("error_severity")).toString();
return severita == QStringLiteral("CRITICAL") || severita == QStringLiteral("ERROR");
}
bool pumpRunning(const QVariantMap &summary) bool pumpRunning(const QVariantMap &summary)
{ {
return summary.value(QStringLiteral("pump_active_state")).toString() return summary.value(QStringLiteral("pump_active_state")).toString()
@@ -268,7 +281,7 @@ int statusLevel(const QVariantMap &summary)
if (summary.isEmpty()) if (summary.isEmpty())
return 1; return 1;
if (errorCount(summary) > 0) if (errorCount(summary) > 0)
return 2; return alarmSerious(summary) ? 3 : 2;
if (flagValue(summary, QStringLiteral("away_mode")) if (flagValue(summary, QStringLiteral("away_mode"))
|| flagValue(summary, QStringLiteral("night_mode")) || flagValue(summary, QStringLiteral("night_mode"))
|| flagValue(summary, QStringLiteral("force_heating"))) || flagValue(summary, QStringLiteral("force_heating")))
+4 -2
View File
@@ -38,8 +38,10 @@ QString stateShort(const QVariantMap &summary);
// "Relax: la pompa di calore funziona regolarmente"). // "Relax: la pompa di calore funziona regolarmente").
QString stateLong(const QVariantMap &summary); QString stateLong(const QVariantMap &summary);
// Livello dello stato, per il colore della riga: 0 regolare, 1 modo attivo // Livello dello stato, per il colore del pallino in Home:
// (assenza, notte, riscaldamento forzato), 2 errore sull'impianto. // 0 regolare, 1 modo attivo (assenza, notte, riscaldamento forzato),
// 2 avviso (errori di severita' WARNING: la macchina lavora),
// 3 allarme serio (allarme che ferma la macchina, o severita' CRITICAL/ERROR).
int statusLevel(const QVariantMap &summary); int statusLevel(const QVariantMap &summary);
// Temperatura leggibile: "51,0" oppure "—" se il valore manca. // Temperatura leggibile: "51,0" oppure "—" se il valore manca.
+36
View File
@@ -245,6 +245,42 @@ int main(int argc, char *argv[])
"stato breve: l'errore vince sul modo attivo"); "stato breve: l'errore vince sul modo attivo");
check(AiraJson::statusLevel(misto) == 2, "livello: errore prima di tutto"); check(AiraJson::statusLevel(misto) == 2, "livello: errore prima di tutto");
// Un avviso NON e' un allarme: pallino ambra (livello 2), non rosso.
// Caso reale 10/10/2026: compressore limitato dalla temperatura esterna
// (SEVERITY_WARNING, nessun allarme che ferma la macchina) -> l'app
// accendeva il rosso per un semplice avviso.
QVariantMap avviso = calmo;
avviso.insert("errors_count", 1);
avviso.insert("alarm_stopping", false);
avviso.insert("error_severity", "WARNING");
check(AiraJson::statusLevel(avviso) == 2,
"livello: avviso (WARNING) = ambra, non rosso");
// Allarme che ferma la macchina: rosso anche con severita' di avviso.
QVariantMap fermo = avviso;
fermo.insert("alarm_stopping", true);
check(AiraJson::statusLevel(fermo) == 3, "livello: allarme che ferma = rosso");
// Severita' alta: rosso anche senza il flag di stop.
QVariantMap critico = avviso;
critico.insert("error_severity", "CRITICAL");
check(AiraJson::statusLevel(critico) == 3, "livello: CRITICAL = rosso");
QVariantMap guasto = avviso;
guasto.insert("error_severity", "ERROR");
check(AiraJson::statusLevel(guasto) == 3, "livello: ERROR = rosso");
// Il rosso vince sul modo attivo.
QVariantMap fermoAssente = assente;
fermoAssente.insert("errors_count", 1);
fermoAssente.insert("alarm_stopping", true);
check(AiraJson::statusLevel(fermoAssente) == 3,
"livello: allarme che ferma prima del modo attivo");
// Senza i campi di gravita' (bridge piu' vecchio) si resta ambra:
// il rosso si accende solo su un guasto che sappiamo essere serio.
check(AiraJson::statusLevel(uno) == 2,
"livello: gravita' ignota = ambra");
check(AiraJson::stateShort(QVariantMap()) == "No data", check(AiraJson::stateShort(QVariantMap()) == "No data",
"stato breve: nessun dato"); "stato breve: nessun dato");
check(AiraJson::stateLong(QVariantMap()).isEmpty(), check(AiraJson::stateLong(QVariantMap()).isEmpty(),