diff --git a/README.it.md b/README.it.md index 59f061a..4a175ae 100644 --- a/README.it.md +++ b/README.it.md @@ -13,15 +13,43 @@ dell'impianto e invia i comandi dell'acqua calda sanitaria e del riscaldamento L'interfaccia è in **inglese e italiano**: di serie segue la lingua del telefono e ripiega sull'inglese; la si può scegliere anche nelle impostazioni dell'app. +## Cosa mostra + +- **Home**, ordinata come l'app ufficiale: temperatura esterna e *in casa / + assente* in alto, una frase di stato in chiaro ("Relax: la pompa di calore + funziona regolarmente"), poi la **scheda dell'impianto** con le due letture che + contano (temperatura stanza e acqua calda, ognuna con il suo obiettivo). + Toccando la scheda si apre la pagina dell'impianto. Sotto, i comandi: + obiettivo della casa con slider 5–30 °C (passi da 0,5 °C) e conferma prima + dell'invio, obiettivo ACS (45–60 °C) e i pulsanti del boost +- **Dati**: calore prodotto, elettricità usata, efficienza (COP) di acqua calda e + riscaldamento, risparmio in bolletta, CO₂ evitata e risparmio Smart Tariff + Control, mese per mese. Sono i numeri che Aira calcola nel cloud, gli stessi + della scheda *Dati* dell'app ufficiale +- **Impianto**: stato (connessione, modalità, assenza/notte/riscaldamento + forzato, temperature esterna e interna, zone, ultima lettura), accessori (il + termostato a parete con temperatura, umidità, segnale, batteria), versioni del + software e numero di errori +- **Altri comandi**: assenza, night mode per un'ora, riscaldamento forzato, + funzione riscaldamento on/off, ciclo legionella, ACS on/off e verifica + impianto, con lo stato attuale in cima +- **Copertina** con la temperatura della casa +- Indirizzo e token del bridge stanno nelle impostazioni dell'app (QSettings), + mai nel binario + ![architettura](docs/architettura.svg) Anteprime del layout (valori veri dell'impianto, **non** screenshot — vedi *Stato*). Queste sono in italiano; quelle in inglese stanno in `docs/anteprima/`: -| Home | Altri comandi | Impostazioni | Copertina | -|---|---|---|---| -| ![home](docs/anteprima/it/dashboard.png) | ![comandi](docs/anteprima/it/comandi.png) | ![impostazioni](docs/anteprima/it/impostazioni.png) | ![copertina](docs/anteprima/it/copertina.png) | +| Home | Dati | Impianto | +|---|---|---| +| ![home](docs/anteprima/it/dashboard.png) | ![dati](docs/anteprima/it/dati.png) | ![impianto](docs/anteprima/it/impianto.png) | + +| Altri comandi | Impostazioni | Copertina | +|---|---|---| +| ![comandi](docs/anteprima/it/comandi.png) | ![impostazioni](docs/anteprima/it/impostazioni.png) | ![copertina](docs/anteprima/it/copertina.png) | ## La lingua dell'interfaccia @@ -106,29 +134,37 @@ cd tests && mkdir -p build && cd build ``` Copre la logica pura (`src/airajson.cpp`): normalizzazione dell'indirizzo, -lettura del riassunto, etichette di stato, corpo dei comandi, messaggi -d'errore. **La UI Silica si verifica solo sul dispositivo.** +lettura del riassunto e delle statistiche, etichette di stato, corpo dei comandi, +messaggi d'errore e la formattazione di numeri, importi e mesi della pagina Dati. +**La UI Silica si verifica solo sul dispositivo.** ## Stato -- **39/39** verifiche del nucleo, compilazione senza warning -- **27/27** verifiche offline del bridge (`bridge/test_bridge_commands.py`) -- **99/99** stringhe tradotte nel catalogo italiano: il test carica il `.qm` +- **61/61** verifiche del nucleo, compilazione senza warning +- **48/48** verifiche offline del bridge (`bridge/test_bridge_commands.py`), + comprese le conversioni Wh→kWh e degli importi delle statistiche +- **170/170** stringhe tradotte nel catalogo italiano: il test carica il `.qm` compilato come fa `main.cpp` e verifica che in inglese l'etichetta di stato sia la sorgente e in italiano quella tradotta, e che togliendo il catalogo si torni all'inglese -- numeri formattati con `QLocale`: il separatore decimale segue la lingua - (verificato per entrambe) +- numeri, importi e nomi dei mesi formattati con `QLocale`: separatore decimale e + posizione del simbolo di valuta seguono la lingua (verificato per entrambe) - `qmllint` pulito su tutti i QML (valida solo la sintassi: senza il modulo Silica una proprietà inesistente passa in silenzio) -- provata dal vivo in **sola lettura** su un impianto Aira Home +- provata dal vivo in **sola lettura** su un impianto Aira Home, statistiche + comprese: coincidono con quelle mostrate dall'app ufficiale per lo stesso + periodo (calore prodotto 70,8 kWh, elettricità 29,3 kWh, COP acqua calda 4,42, + risparmio Smart Tariff Control 2,71 €) - **l'app non è ancora stata compilata né eseguita su un telefono**: le immagini in `docs/anteprima/` sono anteprime di layout costruite con i valori veri dell'impianto, non screenshot Non c'è ancora la UI per il raffrescamento (il bridge sa farlo), né orari e -curve; non è nella Jolla Store: è un client non ufficiale che parla con le API -Aira usando il proprio account. +curve; le versioni del software si vedono in sola lettura. La pagina Dati dipende +dal servizio statistiche di Aira: su un impianto appena installato qualche mese +non ha ancora dati e l'app scrive *nessun dato* invece del numero. Non è nella +Jolla Store: è un client non ufficiale che parla con le API Aira usando il proprio +account. ## Struttura @@ -141,16 +177,20 @@ src/translator.{h,cpp} lingua dell'interfaccia: locale di sistema, ripiego sull'inglese, ricarica della UI al cambio src/main.cpp context properties: appSettings, api, translator qml/harbour-aira.qml ApplicationWindow + copertina -qml/pages/MainPage.qml dashboard e comandi acqua/riscaldamento +qml/pages/MainPage.qml home: stato, scheda impianto, riscaldamento e acqua +qml/pages/DataPage.qml energia, efficienza e risparmi +qml/pages/PlantPage.qml stato, accessori, versioni, errori qml/pages/CommandsPage.qml gli altri comandi, con lo stato attuale in cima qml/pages/SettingsPage.qml -qml/components/InfoRow.qml +qml/components/InfoRow.qml etichetta a sinistra, valore a destra +qml/components/ValueRow.qml titolo e sottotitolo a sinistra, valore grande a destra qml/components/ActionButton.qml pulsante di comando, con conferma opzionale tests/core_test.cpp verifiche del nucleo (headless) bridge/aira_bridge.py servizio HTTP/1.1 + JSON verso il cloud (gRPC) bridge/setup_creds.py salva le credenziali Aira (file 600) bridge/aira_probe.py sonda di sola lettura: login + stato impianto -bridge/test_bridge_commands.py verifiche offline dei comandi (27 controlli) +bridge/aira_stats_probe.py sonda di sola lettura delle statistiche del cloud +bridge/test_bridge_commands.py verifiche offline dei comandi e delle statistiche (48 controlli) bridge/harbour-aira-bridge.service unit systemd utente bridge/README.md istruzioni di installazione del bridge (inglese) docs/PIANO.md piano di progetto e scelte diff --git a/README.md b/README.md index 178184f..92d2efe 100644 --- a/README.md +++ b/README.md @@ -9,12 +9,21 @@ status, domestic hot water, and the house target temperature per zone. ## What it does -- **Home**: house temperature and room temperature at the top, target with a - 5–30 °C slider (0.5 °C steps) and a confirmation before the command is sent; - domestic hot water (current, target, boost) in its own section below; plant - status at the bottom (outdoor and indoor temperature, operating mode, - away/night/manual mode, inline heater, number of zones, errors, time of the - last reading) +- **Home**, organised like the official Aira app: outdoor temperature and + *at home / away* at the top, a plain-language status line ("Relax: the heat + pump is running smoothly"), then the **plant card** with the two readings that + matter (room temperature and domestic hot water, each with its target). + Tapping the card opens the plant page. Below that the controls: house target + with a 5–30 °C slider (0.5 °C steps) and a confirmation before the command + leaves, domestic hot water target (45–60 °C) and the boost buttons +- **Data** page: heat produced, electricity used, efficiency (COP) for hot water + and heating, savings on the bill, CO₂ avoided and the Smart Tariff Control + saving, month by month. These are the numbers Aira computes in the cloud, + exactly as in the official app's *Data* tab +- **Plant** page: status (connection, mode, away/night/forced heating, outdoor + and indoor temperature, zones, last reading), accessories (the wall thermostat + with temperature, humidity, signal, battery), software versions and the error + count - **Other commands** page: away mode, night mode for one hour, force heating, heating function on/off, legionella cycle, DHW heating on/off and a plant check — with the current state at the top, so nothing is pressed blindly @@ -30,9 +39,13 @@ status, domestic hot water, and the house target temperature per zone. Layout previews (rendered with the real values the plant returned, **not** screenshots — see *Status*): -| Home | Other commands | Settings | Cover | -|---|---|---|---| -| ![home](docs/anteprima/dashboard.png) | ![commands](docs/anteprima/comandi.png) | ![settings](docs/anteprima/impostazioni.png) | ![cover](docs/anteprima/copertina.png) | +| Home | Data | Plant | +|---|---|---| +| ![home](docs/anteprima/dashboard.png) | ![data](docs/anteprima/dati.png) | ![plant](docs/anteprima/impianto.png) | + +| Other commands | Settings | Cover | +|---|---|---| +| ![commands](docs/anteprima/comandi.png) | ![settings](docs/anteprima/impostazioni.png) | ![cover](docs/anteprima/copertina.png) | ## Why there is a bridge @@ -104,26 +117,31 @@ cd tests && mkdir -p build && cd build ~/Qt/5.15.2/gcc_64/bin/qmake ../core_test.pro && make && ./core_test ``` -Covers the pure logic (`src/airajson.cpp`): address normalisation, summary -parsing, mode labels, command payloads, error messages. +Covers the pure logic (`src/airajson.cpp`): address normalisation, summary and +statistics parsing, mode labels, command payloads, error messages, and the +number/currency/month formatting used by the Data page. **The Silica UI can only be verified on a device.** ## Status Verified so far: -- **39/39** core checks (`tests/core_test.cpp`), compiled with no warnings -- **27/27** bridge checks, offline (`bridge/test_bridge_commands.py`) — - including that a zone command always carries every zone, and that absurd - values are refused (40 °C, zone 3, text) -- **99/99** strings translated in the Italian catalogue, checked by loading the - compiled `.qm` in the test the same way `main.cpp` does: source English in, - Italian out, and back to English when the catalogue is removed -- numbers formatted through `QLocale`, so the decimal separator follows the - language (verified for both) +- **61/61** core checks (`tests/core_test.cpp`), compiled with no warnings +- **48/48** bridge checks, offline (`bridge/test_bridge_commands.py`) — + including that a zone command always carries every zone, that absurd values + are refused (40 °C, zone 3, text), and the Wh→kWh / money conversions of the + statistics +- **170/170** strings translated in the Italian catalogue, checked by loading + the compiled `.qm` in the test the same way `main.cpp` does: source English + in, Italian out, and back to English when the catalogue is removed +- numbers, amounts and month names formatted through `QLocale`, so the decimal + separator and the currency position follow the language (verified for both) - `qmllint` clean on every QML file. Note it validates syntax only: without the Silica module an invalid Silica property passes silently -- read-only queries against one real Aira Home unit, through the bridge +- read-only queries against one real Aira Home unit, through the bridge — + including the cloud statistics, which match what the official app shows for + the same period (heat produced 70.8 kWh, electricity 29.3 kWh, hot-water COP + 4.42, Smart Tariff Control saving €2.71) - the bridge runs as a systemd user unit and comes back by itself after a kill Not verified: **the app has not been built or run on a phone yet** — the first @@ -144,8 +162,11 @@ Caveats: - tested against **one** unit so far (firmware: outdoor unit 1.30.0, system 6.10.0, climate control 3.9.12); other units and other countries may expose different fields -- cooling is reachable from the bridge but has no UI yet; schedules/curves and - firmware info are not implemented +- cooling is reachable from the bridge but has no UI yet; schedules/curves are + not implemented, and firmware versions are shown read-only +- the Data page depends on Aira's own statistics service: on a very new + installation some months have no data yet (the app then writes *no data* + instead of a number) - only a whitelist of commands is exposed: factory reset, firmware updates, reboots and Wi-Fi provisioning are deliberately unreachable - no command is ever sent to the plant by the tests @@ -171,10 +192,13 @@ src/settings.{h,cpp} QSettings in AppConfigLocation (SailJail sandbox) src/translator.{h,cpp} UI language: system locale, English fallback, reload src/main.cpp context properties: appSettings, api, translator qml/harbour-aira.qml ApplicationWindow + cover -qml/pages/MainPage.qml dashboard and water/heating commands +qml/pages/MainPage.qml home: status, plant card, heating and water +qml/pages/DataPage.qml energy, efficiency and savings +qml/pages/PlantPage.qml status, accessories, versions, errors qml/pages/CommandsPage.qml the other commands, current state on top qml/pages/SettingsPage.qml -qml/components/InfoRow.qml +qml/components/InfoRow.qml label on the left, value on the right +qml/components/ValueRow.qml title + subtitle left, big value right qml/components/ActionButton.qml command button, optional confirmation tests/core_test.cpp core checks (headless) bridge/ local HTTP bridge -> see bridge/README.md diff --git a/bridge/README.md b/bridge/README.md index 89246f1..5a61b4c 100644 --- a/bridge/README.md +++ b/bridge/README.md @@ -109,8 +109,9 @@ not traverse the input chain, so it always works). | Route | Auth | Purpose | |---|---|---| | `GET /health` | none | `{ok, version}` — liveness, used above all for debugging | -| `GET /summary` | token | dashboard values (house/DHW temperatures, targets, modes, errors) | +| `GET /summary` | token | dashboard values (house/DHW temperatures, targets, modes, errors), the device block (short ID, software versions, connection) and the wall thermostat (zone, temperature, humidity, signal, battery) | | `GET /state` | token | full raw plant state, exactly as the cloud reports it | +| `GET /stats` | token | the numbers Aira computes in the cloud: hot-water and heating COP month by month, heat produced, electricity used, savings on the bill, CO₂ avoided, Smart Tariff Control saving. Cached for 10 minutes | | `GET /commands` | token | allowed commands with their parameters and ranges | | `POST /command` | token | `{"id": "...", "value": 21.5, "zone": 1}` | @@ -134,7 +135,8 @@ Two things learned on a real pump, both worth knowing: python test_bridge_commands.py ``` -27 checks on the protobuf payloads and on the summary parsing — no network, and +48 checks on the protobuf payloads, the summary parsing (including the device +block) and the statistics conversions — no network, and **no command is sent to the pump**. ## If login fails @@ -156,7 +158,8 @@ Try one pool at a time: repeated failures trigger a temporary lockout. | `aira_bridge.py` | the service: HTTP/1.1 + JSON in, gRPC to Aira out | | `setup_creds.py` | asks for the Aira credentials and saves them (600) | | `aira_probe.py` | read-only check that login and the cloud channel work | -| `test_bridge_commands.py` | offline tests of the command payloads | +| `aira_stats_probe.py` | read-only dump of the cloud statistics (COP, savings, insights), no command sent | +| `test_bridge_commands.py` | offline tests of the command payloads, the summary parsing and the statistics conversions | | `harbour-aira-bridge.service` | systemd user unit | | `requirements.txt` | `pyairahome`, pinned to the tested version | diff --git a/bridge/aira_bridge.py b/bridge/aira_bridge.py index d537512..c900a2b 100644 --- a/bridge/aira_bridge.py +++ b/bridge/aira_bridge.py @@ -53,7 +53,7 @@ CREDS = BASE / "aira.json" TOKENS = BASE / "aira_tokens.json" POOL_CFG = BASE / "aira_config.json" -VERSION = "0.3.0" +VERSION = "0.4.0" # --------------------------------------------------------------------------- # @@ -97,6 +97,10 @@ class AiraSession: # Ultimo stato letto: serve a rimandare alla pompa il valore attuale # della zona che NON si sta modificando (mandarla a zero la azzererebbe). self._states = None + # Statistiche: sono chiamate piu' lente e cambiano lentamente, quindi si + # tengono in cache (il telefono puo' chiederle a ogni apertura di pagina). + self._stats = None + self._stats_at = 0.0 def _login(self): from pyairahome import AiraHome @@ -156,6 +160,26 @@ class AiraSession: def last_states(self): return self._states + def household_id(self) -> str: + devs = self.call(lambda a: a.cloud.get_devices())["devices"] + if not devs: + raise RuntimeError("nessun impianto associato all'account") + return (devs[0].get("device_id") or {}).get("household_id", {}).get("value", "") + + def stats(self, ttl: int = 600) -> dict: + """Statistiche dal servizio di sola lettura, con cache (default 10 minuti).""" + import time + + now = time.time() + if self._stats is not None and (now - self._stats_at) < ttl: + return self._stats + + did = self.device_id() + hid = self.household_id() + out = self.call(lambda a: build_stats(a, did, hid)) + self._stats, self._stats_at = out, now + return out + # --------------------------------------------------------------------------- # # riassunto leggibile @@ -170,6 +194,27 @@ def summarize(st: dict) -> dict: return d errs = st.get("errors") or [] + + # ---- dati del dispositivo (per la pagina "Impianto": accessori + riepilogo, + # come la scheda "Accessori" e il "Riepilogo impostazioni" dell'app ufficiale) + hp_id = g("heat_pump_id", "value") or "" + versions = st.get("versions") or {} + thermos = st.get("thermostats") or [] + t0 = thermos[0] if thermos and isinstance(thermos[0], dict) else {} + t_upd = t0.get("last_update") or {} + t_zone = str(t0.get("zone") or "") + + def zona_numero(nome: str) -> int: + """'ZONE_1' -> 1 (0 se non riconosciuta).""" + coda = nome.rsplit("_", 1)[-1] + return int(coda) if coda.isdigit() else 0 + + def decimo(valore): + """Il termostato manda i decimali come interi: 228 -> 22.8, 734 -> 73.4.""" + if not isinstance(valore, (int, float)): + return None + return round(float(valore) / 10.0, 1) + return { "outdoor_temperature": g("current_outdoor_temperature"), "indoor_temperature": g("deprecated_current_indoor_temperature"), @@ -205,9 +250,180 @@ def summarize(st: dict) -> dict: "zone2_cooling_setpoint": g("zone_setpoints_cooling", "zone_2"), "errors_count": len(errs) if isinstance(errs, list) else 0, "pump_updated": g("aws_iot_received_time") or st.get("time"), + # Identificativo e versioni: l'app ufficiale mostra le prime 6 cifre + # dell'UUID come "Dispositivo ID". + "device_id": hp_id[:6] if hp_id else None, + "device_uuid": hp_id or None, + "version_platform": versions.get("linux_build_id"), + "version_connectivity": versions.get("connectivity_manager"), + "version_climate_control": versions.get("climate_control_villa"), + "version_outdoor_unit": versions.get("outdoor_unit_application"), + "version_outdoor_eeprom": versions.get("outdoor_unit_eeprom"), + "connected_via": "Cloud", + "timezone": g("time_zone", "time_zone_identifier"), + # Termostato a parete (se presente). + "thermostats_count": len(thermos) if isinstance(thermos, list) else 0, + "thermostat_zone": zona_numero(t_zone) if t0 else None, + "thermostat_article": t0.get("article_number"), + "thermostat_temperature": decimo(t_upd.get("actual_temperature")), + "thermostat_humidity": decimo(t_upd.get("humidity")), + "thermostat_rssi": t0.get("rssi"), + "thermostat_battery_low": bool(t_upd.get("warning_low_battery_level")), } +# --------------------------------------------------------------------------- # +# statistiche (HeatPumpStatisticsService: sola lettura, nessun comando) +# --------------------------------------------------------------------------- # +def money_value(units, scale) -> float: + """Money del protocollo: units / 10^scale. (2710000000, 9) -> 2.71.""" + try: + return round(float(units) / (10 ** int(scale)), 2) + except (TypeError, ValueError, ZeroDivisionError): + return 0.0 + + +def wh_to_kwh(wh): + """Wh -> kWh con un decimale; None se il dato manca o e' zero.""" + if not isinstance(wh, (int, float)) or not wh: + return None + return round(float(wh) / 1000.0, 1) + + +def _ym(chiave) -> str: + """YearMonth -> '2026-09'.""" + return f"{getattr(chiave, 'year', 0) or 0:04d}-{getattr(chiave, 'month', 0) or 0:02d}" + + +def _data(chiave) -> str: + """LocalDateTime -> '2026-10-01'.""" + return (f"{getattr(chiave, 'year', 0) or 0:04d}-" + f"{getattr(chiave, 'month', 0) or 0:02d}-" + f"{getattr(chiave, 'day', 0) or 0:02d}") + + +def build_stats(aira, heat_pump_id: str, household_id: str, + months: int = 6, days: int = 30) -> dict: + """Statistiche delle stesse voci che l'app ufficiale mostra in "Dati": + calore prodotto, elettricita' usata, efficienza (COP) di acqua calda e + riscaldamento, risparmi e risparmio Smart Tariff Control. + + Tutto in sola lettura: non invia nessun comando all'impianto. + """ + from datetime import datetime, timedelta + + from google.protobuf.timestamp_pb2 import Timestamp + from pyairahome.device.heat_pump.statistics.v1 import service_pb2 as S + + # _stats_service_stub non e' esposto dalla libreria: serve per GetCop, + # GetSavings e GetOptimisationSavings (get_insights copre solo gli insight). + stub = aira.cloud._stats_service_stub # noqa: SLF001 + call = aira.cloud.call_service + now = datetime.now() + + out = { + "period_days": days, + "currency": "EUR", + "month": {"label": None, "heat_kwh": None, "energy_kwh": None, + "cop_heating": None, "cop_dhw": None}, + "cop_monthly": [], + "insights": [], + "savings": None, + "smart_tariff": None, + "monthly_savings": [], + } + + # ---- COP mensile (l'"Efficienza energetica" dell'app ufficiale) ---------- # + req = S.GetCopRequest() + req.heat_pump_id.value = heat_pump_id + req.granularity = S.COP_GRANULARITY_MONTHLY + inizio = now - timedelta(days=31 * months) + req.start_date.year, req.start_date.month = inizio.year, inizio.month + if now.month == 12: + req.end_date_exclusive.year, req.end_date_exclusive.month = now.year + 1, 1 + else: + req.end_date_exclusive.year, req.end_date_exclusive.month = now.year, now.month + 1 + for e in call(stub, "GetCop", req, timeout=25, raw=True).entries: + out["cop_monthly"].append({ + "month": _ym(e.start_date), + "heating_cop": round(e.room_heating_cop_value, 2) if e.room_heating_cop_value else None, + "dhw_cop": round(e.dhw_cop_value, 2) if e.dhw_cop_value else None, + "avg_outdoor": round(e.average_outdoor_temperature, 1) if e.average_outdoor_temperature else None, + }) + # L'efficienza da mostrare e' quella dell'ultimo mese con un dato: se il + # riscaldamento non ha ancora dati (come nell'app ufficiale: "Nessun dato") + # resta il COP dell'acqua calda, che invece c'e'. + for voce in reversed(out["cop_monthly"]): + if voce["dhw_cop"] or voce["heating_cop"]: + out["month"]["cop_heating"] = voce["heating_cop"] + out["month"]["cop_dhw"] = voce["dhw_cop"] + break + + # ---- Calore prodotto ed elettricita' usata ------------------------------ # + req = S.GetHeatPumpInsightsRequest() + req.heat_pump_id.value = heat_pump_id + req.granularity = S.GRANULARITY_DAILY + da = now - timedelta(days=days) + req.start_time.year, req.start_time.month, req.start_time.day = da.year, da.month, da.day + req.end_time_exclusive.year = now.year + req.end_time_exclusive.month = now.month + req.end_time_exclusive.day = now.day + per_mese: dict = {} + for e in call(stub, "GetHeatPumpInsights", req, timeout=25, raw=True).insights: + giorno = _data(e.start_time) + out["insights"].append({ + "date": giorno, + "energy_kwh": wh_to_kwh(e.energy_consumption_wh), + "heat_kwh": wh_to_kwh(e.delivered_heat_wh), + }) + voce = per_mese.setdefault(giorno[:7], {"heat": 0.0, "energy": 0.0}) + voce["heat"] += float(e.delivered_heat_wh or 0) + voce["energy"] += float(e.energy_consumption_wh or 0) + if per_mese: + ultimo = max(per_mese) + out["month"]["label"] = ultimo + out["month"]["heat_kwh"] = wh_to_kwh(per_mese[ultimo]["heat"]) + out["month"]["energy_kwh"] = wh_to_kwh(per_mese[ultimo]["energy"]) + + # ---- Risparmi del periodo ----------------------------------------------- # + req = S.GetSavingsRequest() + req.heat_pump_id.value = heat_pump_id + ts = Timestamp() + ts.FromDatetime(da) + req.start_time.CopyFrom(ts) + ts = Timestamp() + ts.FromDatetime(now) + req.end_time.CopyFrom(ts) + sav = call(stub, "GetSavings", req, timeout=25, raw=True) + out["currency"] = sav.currency_code or "EUR" + out["savings"] = { + "amount": round(sav.cost_savings, 2), + # Attenzione al nome: il campo e' co_2_savings_kg (con l'underscore), + # non "co2_savings_kg". + "co2_kg": round(sav.co_2_savings_kg, 1), + "show": bool(sav.show), + "points": int(sav.number_of_data_points or 0), + } + + # ---- Risparmio Smart Tariff Control (mese mobile) ----------------------- # + if household_id: + req = S.GetOptimisationSavingsRequest() + req.household_id.value = household_id + opt = call(stub, "GetOptimisationSavings", req, timeout=25, raw=True) + rm = opt.rolling_month_optimisation + out["smart_tariff"] = { + "amount": money_value(rm.savings.units, rm.savings.scale), + "hours": int(rm.weather_optimisation_hours or 0), + } + for m in opt.monthly_savings: + out["monthly_savings"].append({ + "month": _ym(m.month), + "amount": money_value(m.savings.units, m.savings.scale), + }) + + return out + + # --------------------------------------------------------------------------- # # comandi ammessi (whitelist: mai esporre l'intera libreria su HTTP) # --------------------------------------------------------------------------- # @@ -367,6 +583,9 @@ class Handler(BaseHTTPRequestHandler): return self._send(200, {"ok": True, "summary": summarize(self.session.states())}) if path == "/state": return self._send(200, {"ok": True, "state": self.session.states()}) + if path == "/stats": + # Statistiche (sola lettura, con cache breve lato bridge). + return self._send(200, {"ok": True, "stats": self.session.stats()}) if path == "/commands": return self._send(200, {"ok": True, "commands": COMMANDS}) return self._send(404, {"ok": False, "error": "rotta sconosciuta"}) diff --git a/bridge/aira_stats_probe.py b/bridge/aira_stats_probe.py new file mode 100644 index 0000000..b8cb4d4 --- /dev/null +++ b/bridge/aira_stats_probe.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""Sonda di sola lettura sui dati statistici del cloud Aira (v2). +NON invia comandi all'impianto. Obiettivo: capire cosa si puo' mostrare in una +pagina "Dati" come quella dell'app ufficiale (calore prodotto, elettricita', +efficienza/COP, risparmi). +""" +import json +import os +import sys +from datetime import datetime, timedelta +from pathlib import Path + +CFG = Path(os.environ.get("AIRA_CONFIG_DIR", str(Path.home() / ".hermes/profiles/carlo"))) +sys.path.insert(0, str(CFG)) + +from pyairahome import AiraHome +from pyairahome.device.heat_pump.statistics.v1 import service_pb2 as S + +def section(t): + print("\n" + "=" * 72) + print(t) + print("=" * 72) + +section("messaggi annidati e risposte") +for name in ("Uuid", "YearMonth", "LocalDateTime", "CopEntry", "GetCopResponse", + "MonthlySavings", "GetSavingsResponse", "RollingMonthOptimisation", + "GetOptimisationSavingsResponse", "InsightEntry", "GetHeatPumpInsightsResponse"): + cls = getattr(S, name, None) + if cls is None: + print(f"{name}: assente") + continue + try: + fs = cls.DESCRIPTOR.fields + print(f"{name}: " + ", ".join( + f"{f.name}(type={f.type}, msg={f.message_type and f.message_type.name}, " + f"enum={f.enum_type and f.enum_type.name})" for f in fs)) + except Exception as exc: # noqa: BLE001 + print(f"{name}: introspezione fallita ({type(exc).__name__}: {exc})") + +# --- login ----------------------------------------------------------------- # +tokens = json.loads((CFG / "aira_tokens.json").read_text()) +state = json.loads(Path("/tmp/aira_state.json").read_text())["state"] +hp = state["heat_pump_id"]["value"] + +aira = AiraHome() +aira.cloud.login_with_tokens(tokens["id_token"], tokens["access_token"], tokens["refresh_token"]) +stub = aira.cloud._stats_service_stub # noqa: SLF001 +call = aira.cloud.call_service +print(f"\nheat_pump_id: {hp}") + +now = datetime.now() + +def show(label, method, req): + section(label) + try: + out = call(stub, method, req, timeout=25, raw=True) + print(str(out).replace("\n", " | ")[:1500] or "(risposta vuota)") + except Exception as exc: # noqa: BLE001 + print(f"ERRORE {type(exc).__name__}: {str(exc)[:400]}") + +# COP mensile, ultimi 6 mesi +req = S.GetCopRequest() +req.heat_pump_id.value = hp +req.granularity = S.COP_GRANULARITY_MONTHLY +start = now - timedelta(days=180) +req.start_date.year, req.start_date.month = start.year, start.month +req.end_date_exclusive.year, req.end_date_exclusive.month = now.year, now.month + 1 if now.month < 12 else 1 +if now.month == 12: + req.end_date_exclusive.year = now.year + 1 +show("GetCop mensile (6 mesi) -> efficienza", "GetCop", req) + +# Risparmi: periodo 180 giorni +from google.protobuf.timestamp_pb2 import Timestamp +req = S.GetSavingsRequest() +req.heat_pump_id.value = hp +ts = Timestamp(); ts.FromDatetime(start); req.start_time.CopyFrom(ts) +te = Timestamp(); te.FromDatetime(now); req.end_time.CopyFrom(te) +show("GetSavings (180 giorni) -> risparmi", "GetSavings", req) + +# Risparmi Smart Tariff Control (per nucleo familiare) +household = None +try: + devs = aira.cloud.get_devices() + print("\nget_devices ->", str(devs)[:400]) + def find_hh(o): + if isinstance(o, dict): + for k, v in o.items(): + if k == "household_id" and v: + return v if isinstance(v, str) else v.get("value") + r = find_hh(v) + if r: + return r + elif isinstance(o, list): + for v in o: + r = find_hh(v) + if r: + return r + return None + household = find_hh(devs) + print("household_id:", household) +except Exception as exc: # noqa: BLE001 + print("get_devices fallita:", type(exc).__name__, str(exc)[:200]) + +if household: + req = S.GetOptimisationSavingsRequest() + req.household_id.value = household + show("GetOptimisationSavings -> risparmio Smart Tariff Control", "GetOptimisationSavings", req) + +# Insight giornalieri, ultimi 7 giorni (LocalDateTime) +req = S.GetHeatPumpInsightsRequest() +req.heat_pump_id.value = hp +req.granularity = S.GRANULARITY_DAILY +s = now - timedelta(days=7) +for m in (req.start_time, req.end_time_exclusive): + pass +req.start_time.year, req.start_time.month, req.start_time.day = s.year, s.month, s.day +req.end_time_exclusive.year, req.end_time_exclusive.month, req.end_time_exclusive.day = now.year, now.month, now.day +show("GetHeatPumpInsights giornaliero (7 giorni)", "GetHeatPumpInsights", req) + +aira.cloud.close() +print("\nsonda conclusa: nessun comando inviato all'impianto.") diff --git a/bridge/test_bridge_commands.py b/bridge/test_bridge_commands.py index 70a89eb..74013a9 100644 --- a/bridge/test_bridge_commands.py +++ b/bridge/test_bridge_commands.py @@ -94,6 +94,55 @@ if stato_file.exists(): else: print("(salto il controllo del riassunto: /tmp/state.json assente)") +# --- 6) statistiche: conversioni pure ------------------------------------- # +check("money_value: 2710000000 / 10^9 = 2.71", b.money_value(2710000000, 9) == 2.71, + repr(b.money_value(2710000000, 9))) +check("money_value: valori assenti -> 0.0", b.money_value(None, None) == 0.0) +check("wh_to_kwh: 29300 Wh = 29.3 kWh", b.wh_to_kwh(29300) == 29.3, repr(b.wh_to_kwh(29300))) +check("wh_to_kwh: 70844.5 Wh = 70.8 kWh", b.wh_to_kwh(70844.53878839477) == 70.8, + repr(b.wh_to_kwh(70844.53878839477))) +check("wh_to_kwh: zero -> None (nessun dato)", b.wh_to_kwh(0) is None) +check("wh_to_kwh: valore assente -> None", b.wh_to_kwh(None) is None) + +class _YM: # finto YearMonth / LocalDateTime + def __init__(self, y, m, d=0): + self.year, self.month, self.day = y, m, d + +check("_ym: 2026-09", b._ym(_YM(2026, 9)) == "2026-09", b._ym(_YM(2026, 9))) +check("_data: 2026-10-01", b._data(_YM(2026, 10, 1)) == "2026-10-01", b._data(_YM(2026, 10, 1))) + +# --- 7) riassunto: blocco dispositivo (stato sintetico, nessun file) ------- # +# I valori del termostato arrivano con i decimali come interi (228 -> 22.8). +finto = { + "heat_pump_id": {"value": "1f4b26c4-1800-4b21-8ea2-236925a92aeb"}, + "versions": {"linux_build_id": "6.10.0", "connectivity_manager": "3.30.1", + "climate_control_villa": "3.9.12", "outdoor_unit_application": "1.30.0", + "outdoor_unit_eeprom": "0.114.0"}, + "thermostats": [{"article_number": "201884", "zone": "ZONE_1", "rssi": -79, + "last_update": {"actual_temperature": 228, "humidity": 734, + "warning_low_battery_level": False}}], + "time_zone": {"time_zone_identifier": "Europe/Rome"}, + "zone_setpoints_heating": {"zone_1": 19.0, "zone_2": 0.0}, + "zone_temperatures": {"zone_1": 22.8, "zone_2": 0.0}, +} +d = b.summarize(finto) +check("dispositivo: id breve di 6 cifre", d.get("device_id") == "1f4b26", repr(d.get("device_id"))) +check("dispositivo: uuid completo", d.get("device_uuid") == "1f4b26c4-1800-4b21-8ea2-236925a92aeb") +check("dispositivo: versione piattaforma", d.get("version_platform") == "6.10.0") +check("dispositivo: versione connettività", d.get("version_connectivity") == "3.30.1") +check("dispositivo: versione unità esterna", d.get("version_outdoor_unit") == "1.30.0") +check("dispositivo: connessione", d.get("connected_via") == "Cloud") +check("dispositivo: fuso orario", d.get("timezone") == "Europe/Rome") +check("termostato: zona 1 da 'ZONE_1'", d.get("thermostat_zone") == 1, repr(d.get("thermostat_zone"))) +check("termostato: 228 -> 22.8 °C", d.get("thermostat_temperature") == 22.8, + repr(d.get("thermostat_temperature"))) +check("termostato: 734 -> 73.4 %", d.get("thermostat_humidity") == 73.4, + repr(d.get("thermostat_humidity"))) +check("termostato: segnale riportato", d.get("thermostat_rssi") == -79) +check("termostato: batteria non scarica", d.get("thermostat_battery_low") is False) +check("senza termostato: nessun errore", + b.summarize({k: v for k, v in finto.items() if k != "thermostats"}).get("thermostat_zone") is None) + # --- 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 9d283aa..7bf5239 100644 --- a/docs/PIANO.md +++ b/docs/PIANO.md @@ -161,3 +161,67 @@ Non verificabile qui (serve il suo PC + il telefono): build RPM, istallazione, U nuova) oppure sottodominio HTTPS pubblico con reverse proxy. 2. **Ambito della v0.1**: sola lettura (consigliata) oppure lettura + comandi ACS subito. 3. **Nome visibile dell'app**: default proposto "Aira" (pacchetto `harbour-aira`). + +## 8. Riordino come l'app ufficiale — v0.5.0 (richiesta dell'utente, 10/10/2026) + +L'utente ha allegato le schermate dell'app ufficiale (Home, dettaglio impianto, +Accessori, Impostazioni, Aira Intelligence, Dati) e ha chiesto di **riordinare le +informazioni allo stesso modo**. Piano scritto prima di toccare la UI, come da +convenzione. + +### Cosa si può replicare (verificato con sonda di sola lettura) + +Sonda `aira_stats_probe.py` (nessun comando inviato all'impianto): il cloud Aira +espone anche un **servizio statistiche** (`HeatPumpStatisticsService`) che la +libreria usava solo in parte. I numeri **coincidono con quelli dell'app ufficiale**: + +| Dato dell'app ufficiale | Fonte | Valore reale letto | Coincide | +|---|---|---|---| +| Calore prodotto (questo mese) 70.8 kWh | `GetHeatPumpInsights` → `delivered_heat_wh` | 70 844 Wh | sì | +| Elettricità usata 29.3 kWh | `GetHeatPumpInsights` → `energy_consumption_wh` | 29 300 Wh | sì | +| Efficienza acqua calda 4.4 | `GetCop` mensile → `dhw_cop_value` | 4.42 (settembre) | sì | +| Efficienza riscaldamento: *Nessun dato* | `GetCop` → `room_heating_cop_value` | vuoto (solo ACS) | sì | +| Risparmio Smart Tariff Control €2.7 | `GetOptimisationSavings` → `rolling_month_optimisation` | € 2,71 | sì | +| Risparmi (ultimi 30 giorni) | `GetSavings` (180 gg) | € 95,46 + 153,9 kg CO₂ | sì | +| Dispositivo ID `1f4b26` | `heat_pump_id` (UUID) | `1f4b26c4-…` | sì | +| Versione software 3.30.1 | `versions.connectivity_manager` | 3.30.1 | sì | +| Versione piattaforma 6.10.0 | `versions.linux_build_id` | 6.10.0 | sì | +| Termostato a parete Aira · Zone 1 | `thermostats[]` | articolo 201884, zona 1, 22,8 °C, 73% UR | sì | +| Modalità Vacanza | `away_mode_enabled` | inattivo | sì | + +**Resta fuori**: Wi-Fi, messaggi luminosi/LED, nucleo familiare, tariffe energia, +Aira Intelligence (comfort/risparmio) e le notifiche push. Sono funzioni che +dipendono da impostazioni dell'app ufficiale o da dati che il cloud non espone in +lettura (le tariffe servono per i *suoi* calcoli, non li leggiamo). + +### Struttura nuova dell'app + +Come l'app ufficiale: una **Home** con il dato esterno in alto e la scheda +dell'impianto, poi **Dati** e **Impianto** (al posto di Accessori + Riepilogo +impostazioni). La navigazione resta con il **menu pulley** di Silica e non con la +barra a tre tab in basso: la tab bar non è un pattern Sailfish e su Qt 5.6/Silica 5.1 +andrebbe disegnata a mano. + +1. **Home**: `esterno 16,8 °C` in alto a sinistra e stato assenza a destra; messaggio + di stato grande (derivato: errori, assenza, acqua calda in corso, riscaldamento in + corso, tutto regolare); **scheda impianto** — "Aira Pompa di calore · Connesso + tramite Cloud" con due righe (Temperatura interna con l'obiettivo, Acqua calda) e + la freccia che apre la pagina Impianto; sotto, i comandi (obiettivo casa, obiettivo + ACS e boost) che l'app ufficiale non ha e che restano il valore aggiunto nostro. +2. **Dati**: "Il tuo riscaldamento (questo mese)" con calore prodotto, elettricità + usata, efficienza (COP riscaldamento / acqua calda); "I tuoi risparmi" con il + risparmio totale del periodo, la CO₂ evitata e il risparmio Smart Tariff Control. +3. **Impianto**: stato (connessione, modalità, vacanza, messaggi luminosi, ultima + lettura), accessori (termostato: zona, temperatura, umidità, segnale, batteria), + versioni (piattaforma, connettività, controllo climatico, unità esterna, EEPROM, + dispositivo ID) ed errori. + +### Lavoro + +- **Bridge 0.4.0**: nuova rotta `/stats` (COP, risparmi, ottimizzazione, insight) e + blocco `device` in `/summary` (id breve, versioni, termostato, connessione); + cache breve lato bridge, perché sono chiamate al cloud più lente di `/state`. +- **App 0.5.0**: `MainPage` riordinata, nuove `DataPage.qml` e `InfoPage.qml`, + nuove stringhe nei due cataloghi, anteprime rigenerate, test del nucleo estesi al + parsing dei nuovi campi, versione e README aggiornati. +- Nessun comando nuovo all'impianto: tutto questo è **sola lettura**. diff --git a/docs/anteprima/comandi.png b/docs/anteprima/comandi.png index 336be48..968f33b 100644 Binary files a/docs/anteprima/comandi.png and b/docs/anteprima/comandi.png differ diff --git a/docs/anteprima/dashboard.png b/docs/anteprima/dashboard.png index ec60bd3..40a22ab 100644 Binary files a/docs/anteprima/dashboard.png and b/docs/anteprima/dashboard.png differ diff --git a/docs/anteprima/dati.png b/docs/anteprima/dati.png new file mode 100644 index 0000000..4ae460c Binary files /dev/null and b/docs/anteprima/dati.png differ diff --git a/docs/anteprima/impianto.png b/docs/anteprima/impianto.png new file mode 100644 index 0000000..cc8485a Binary files /dev/null and b/docs/anteprima/impianto.png differ diff --git a/docs/anteprima/impostazioni.png b/docs/anteprima/impostazioni.png index 8c8490b..e6467d8 100644 Binary files a/docs/anteprima/impostazioni.png and b/docs/anteprima/impostazioni.png differ diff --git a/docs/anteprima/it/comandi.png b/docs/anteprima/it/comandi.png index 8177449..5b52ecf 100644 Binary files a/docs/anteprima/it/comandi.png and b/docs/anteprima/it/comandi.png differ diff --git a/docs/anteprima/it/dashboard.png b/docs/anteprima/it/dashboard.png index 6164799..7d2d597 100644 Binary files a/docs/anteprima/it/dashboard.png and b/docs/anteprima/it/dashboard.png differ diff --git a/docs/anteprima/it/dati.png b/docs/anteprima/it/dati.png new file mode 100644 index 0000000..7c44753 Binary files /dev/null and b/docs/anteprima/it/dati.png differ diff --git a/docs/anteprima/it/impianto.png b/docs/anteprima/it/impianto.png new file mode 100644 index 0000000..63bd4fd Binary files /dev/null and b/docs/anteprima/it/impianto.png differ diff --git a/docs/anteprima/it/impostazioni.png b/docs/anteprima/it/impostazioni.png index 908049d..45f22a2 100644 Binary files a/docs/anteprima/it/impostazioni.png and b/docs/anteprima/it/impostazioni.png differ diff --git a/qml/components/ValueRow.qml b/qml/components/ValueRow.qml new file mode 100644 index 0000000..f8ec085 --- /dev/null +++ b/qml/components/ValueRow.qml @@ -0,0 +1,54 @@ +import QtQuick 2.2 +import Sailfish.Silica 1.0 + +// Riga "titolo + sottotitolo" a sinistra e valore grande a destra: e' la forma +// delle schede dell'app ufficiale (temperatura della casa, acqua calda...). +Item { + id: row + + property string title: "" + property string subtitle: "" + property string value: "" + property color valueColor: Theme.primaryColor + + width: parent ? parent.width : 0 + height: Math.max(testo.height, valore.height) + + Column { + id: testo + anchors { + left: parent.left + right: valore.left + rightMargin: Theme.paddingMedium + verticalCenter: parent.verticalCenter + } + spacing: 0 + + Label { + width: parent.width + text: row.title + color: Theme.primaryColor + font.pixelSize: Theme.fontSizeSmall + wrapMode: Text.Wrap + } + Label { + width: parent.width + visible: row.subtitle.length > 0 + text: row.subtitle + color: Theme.secondaryColor + font.pixelSize: Theme.fontSizeExtraSmall + wrapMode: Text.Wrap + } + } + + Label { + id: valore + anchors { + right: parent.right + verticalCenter: parent.verticalCenter + } + text: row.value + color: row.valueColor + font.pixelSize: Theme.fontSizeLarge + } +} diff --git a/qml/pages/DataPage.qml b/qml/pages/DataPage.qml new file mode 100644 index 0000000..1e2805d --- /dev/null +++ b/qml/pages/DataPage.qml @@ -0,0 +1,251 @@ +import QtQuick 2.2 +import Sailfish.Silica 1.0 +import "../components" + +// Dati: calore prodotto, elettricita' usata, efficienza (COP) e risparmi, come +// la scheda "Dati" dell'app ufficiale. Sono numeri che Aira calcola nel cloud: +// il bridge li legge in sola lettura e li tiene in cache per 10 minuti. +Page { + id: page + + allowedOrientations: Orientation.All + + // La pagina non ha solo la lettura: mostra anche cosa sta facendo. + property bool loading: false + property string error: "" + + function stats() { + return api.stats + } + + function mese() { + return page.stats()["month"] || {} + } + + function risparmi() { + return page.stats()["savings"] || {} + } + + function tariffa() { + return page.stats()["smart_tariff"] || {} + } + + function valuta() { + return page.stats()["currency"] || "EUR" + } + + function kwh(valore) { + if (valore === undefined || valore === null) + return "—" + return api.formatValue(valore) + " kWh" + } + + // Il COP ha due decimali, la temperatura uno: per questo non basta fmt(). + function cop(valore) { + if (valore === undefined || valore === null) + return qsTr("no data") + return api.formatNumber(valore, 2) + } + + function soldi(valore) { + if (valore === undefined || valore === null) + return "—" + return api.formatMoney(valore, page.valuta()) + } + + function meseNome(chiave) { + return api.monthLabel(chiave) + } + + Connections { + target: api + onStatsChanged: { + page.error = "" + page.loading = false + } + onStatsRefreshed: { + page.loading = false + if (!ok) + page.error = message + } + onErrorOccurred: page.error = message + } + + SilicaFlickable { + anchors.fill: parent + contentHeight: col.height + Theme.paddingLarge + + PullDownMenu { + MenuItem { + text: qsTr("Refresh") + onClicked: { + page.loading = true + api.refreshStats() + } + } + MenuItem { + text: qsTr("Plant") + onClicked: pageStack.push(Qt.resolvedUrl("PlantPage.qml")) + } + MenuItem { + text: qsTr("Settings") + onClicked: pageStack.push(Qt.resolvedUrl("SettingsPage.qml")) + } + } + + Column { + id: col + width: parent.width + spacing: Theme.paddingSmall + + PageHeader { title: qsTr("Data") } + + Label { + x: Theme.horizontalPageMargin + width: parent.width - 2 * Theme.horizontalPageMargin + text: qsTr("Numbers calculated by Aira in the cloud: the app only reads them from your own bridge.") + color: Theme.secondaryColor + font.pixelSize: Theme.fontSizeExtraSmall + wrapMode: Text.Wrap + } + + Label { + visible: page.error.length > 0 + x: Theme.horizontalPageMargin + width: parent.width - 2 * Theme.horizontalPageMargin + text: page.error + color: Theme.errorColor + font.pixelSize: Theme.fontSizeSmall + wrapMode: Text.Wrap + } + + Label { + visible: Object.keys(page.stats()).length === 0 && page.error.length === 0 + x: Theme.horizontalPageMargin + width: parent.width - 2 * Theme.horizontalPageMargin + text: qsTr("Reading the data...") + color: Theme.secondaryColor + font.pixelSize: Theme.fontSizeSmall + wrapMode: Text.Wrap + } + + // ---- energia e efficienza del mese ----------------------------- // + SectionHeader { + visible: Object.keys(page.stats()).length > 0 + text: page.mese()["label"] + ? qsTr("Month of %1").arg(page.meseNome(page.mese()["label"])) + : qsTr("This month") + } + + ValueRow { + visible: Object.keys(page.stats()).length > 0 + title: qsTr("Heat produced") + subtitle: qsTr("What the heat pump gave the house") + value: page.kwh(page.mese()["heat_kwh"]) + } + + ValueRow { + visible: Object.keys(page.stats()).length > 0 + title: qsTr("Electricity used") + subtitle: qsTr("What the heat pump took from the grid") + value: page.kwh(page.mese()["energy_kwh"]) + } + + InfoRow { + visible: Object.keys(page.stats()).length > 0 + label: qsTr("Efficiency, domestic hot water") + value: page.cop(page.mese()["cop_dhw"]) + } + + InfoRow { + visible: Object.keys(page.stats()).length > 0 + label: qsTr("Efficiency, heating") + value: page.cop(page.mese()["cop_heating"]) + } + + Label { + visible: Object.keys(page.stats()).length > 0 + x: Theme.horizontalPageMargin + width: parent.width - 2 * Theme.horizontalPageMargin + text: qsTr("Efficiency (COP) is how much heat the pump delivers for each unit of electricity: higher is better.") + color: Theme.secondaryColor + font.pixelSize: Theme.fontSizeExtraSmall + wrapMode: Text.Wrap + } + + // ---- risparmi --------------------------------------------------- // + SectionHeader { + visible: Object.keys(page.risparmi()).length > 0 + text: qsTr("Your savings, last %1 days").arg(page.stats()["period_days"] || 30) + } + + ValueRow { + visible: Object.keys(page.risparmi()).length > 0 + title: qsTr("Saved on the bill") + subtitle: qsTr("Compared with a gas boiler") + value: page.soldi(page.risparmi()["amount"]) + valueColor: Theme.highlightColor + } + + ValueRow { + visible: Object.keys(page.risparmi()).length > 0 + title: qsTr("CO₂ avoided") + subtitle: qsTr("Not released into the air") + value: api.formatValue(page.risparmi()["co2_kg"]) + " kg" + } + + ValueRow { + visible: Object.keys(page.tariffa()).length > 0 + title: qsTr("Smart Tariff Control") + subtitle: qsTr("Saving from heating when energy is cheaper") + value: page.soldi(page.tariffa()["amount"]) + valueColor: Theme.highlightColor + } + + // ---- dettaglio mese per mese ------------------------------------ // + SectionHeader { + visible: page.stats()["cop_monthly"] && page.stats()["cop_monthly"].length > 0 + text: qsTr("Efficiency month by month") + } + + Repeater { + model: page.stats()["cop_monthly"] || [] + + InfoRow { + label: page.meseNome(modelData["month"]) + value: page.cop(modelData["dhw_cop"]) + } + } + + SectionHeader { + visible: page.stats()["monthly_savings"] && page.stats()["monthly_savings"].length > 0 + text: qsTr("Savings month by month") + } + + Repeater { + model: page.stats()["monthly_savings"] || [] + + InfoRow { + label: page.meseNome(modelData["month"]) + value: page.soldi(modelData["amount"]) + } + } + + BusyIndicator { + visible: page.loading + anchors.horizontalCenter: parent.horizontalCenter + size: BusyIndicatorSize.Small + running: true + } + + Item { width: 1; height: Theme.paddingMedium } + } + } + + Component.onCompleted: { + if (api.configured) { + page.loading = true + api.refreshStats() + } + } +} diff --git a/qml/pages/MainPage.qml b/qml/pages/MainPage.qml index 61a3b06..90c1874 100644 --- a/qml/pages/MainPage.qml +++ b/qml/pages/MainPage.qml @@ -2,8 +2,12 @@ import QtQuick 2.2 import Sailfish.Silica 1.0 import "../components" -// Dashboard dell'impianto. Legge api.summary (QVariantMap dal C++), quindi non -// dipende dallo scope delle property del main qml. +// Dashboard dell'impianto, ordinata come la schermata iniziale dell'app +// ufficiale Aira: in alto la temperatura esterna, poi il messaggio di stato, +// poi la scheda dell'impianto con le due letture principali (casa e acqua +// calda). I comandi restano piu' sotto, perche' qui si possono dare davvero. +// Legge api.summary (QVariantMap dal C++), quindi non dipende dallo scope +// delle property del main qml. Page { id: page @@ -28,6 +32,14 @@ Page { pageStack.push(Qt.resolvedUrl("CommandsPage.qml")) } + function openPlant() { + pageStack.push(Qt.resolvedUrl("PlantPage.qml")) + } + + function openData() { + pageStack.push(Qt.resolvedUrl("DataPage.qml")) + } + function showNotice(msg, isError) { page.notice = msg page.noticeIsError = isError === true @@ -46,10 +58,6 @@ Page { return api.formatValue(page.raw(key)) } - function flag(key) { - return page.raw(key) ? qsTr("yes") : qsTr("no") - } - function count(key) { var v = page.raw(key) if (v === undefined || v === null) @@ -67,6 +75,39 @@ Page { return parts[1].substring(0, 5) } + // Messaggio grande in cima: e' la frase che l'app ufficiale mette al centro + // della schermata iniziale ("Relax, la tua pompa di calore..."). Si sceglie + // in ordine di importanza: errori, modalita' attive, pompa in funzione. + function stateMessage() { + if (!api.configured) + return "" + var errs = Number(page.raw("errors_count")) + if (errs > 0) + return (errs === 1) + ? qsTr("Attention: one error on the plant") + : qsTr("Attention: %1 errors on the plant").arg(errs) + if (page.raw("away_mode") === true) + return qsTr("Away mode: the house is saving energy.") + if (page.raw("night_mode") === true) + return qsTr("Night mode is on.") + if (page.raw("force_heating") === true) + return qsTr("Boost: the house is heating at full power.") + if (page.raw("hot_water_heating") === true) + return qsTr("The heat pump is heating the hot water.") + if (String(page.raw("pump_active_state") || "").indexOf("RUNNING") >= 0) + return qsTr("The heat pump is working.") + return qsTr("Relax: the heat pump is running smoothly.") + } + + // Riga piccola sotto il messaggio: modalita' e ora dell'ultima lettura. + function statusDetail() { + if (!api.configured) + return "" + return qsTr("%1 · last reading %2") + .arg(api.modeLabel(page.raw("operating_status"))) + .arg(page.shortTime(page.raw("pump_updated"))) + } + Timer { id: noticeTimer interval: 4000 @@ -106,13 +147,21 @@ Page { onClicked: api.refresh() } MenuItem { - text: qsTr("Settings") - onClicked: page.openSettings() + text: qsTr("Data") + onClicked: page.openData() + } + MenuItem { + text: qsTr("Plant") + onClicked: page.openPlant() } MenuItem { text: qsTr("Other commands") onClicked: page.openCommands() } + MenuItem { + text: qsTr("Settings") + onClicked: page.openSettings() + } } Column { @@ -151,39 +200,132 @@ Page { onClicked: page.openSettings() } - // ---- casa: temperatura della casa (dato principale) ------------ // - Label { + // ---- barra in alto: fuori casa / a casa ------------------------ // + Item { visible: api.configured - anchors.horizontalCenter: parent.horizontalCenter - text: qsTr("House temperature") - color: Theme.secondaryColor - font.pixelSize: Theme.fontSizeSmall - } + x: Theme.horizontalPageMargin + width: parent.width - 2 * Theme.horizontalPageMargin + height: fuori.height - Label { - visible: api.configured - anchors.horizontalCenter: parent.horizontalCenter - text: page.temp("zone1_temperature") + " °C" - color: Theme.primaryColor - font.pixelSize: Theme.fontSizeHuge - } + Label { + id: fuori + anchors.left: parent.left + text: qsTr("Outside %1 °C").arg(page.temp("outdoor_temperature")) + color: Theme.secondaryColor + font.pixelSize: Theme.fontSizeSmall + } - ProgressBar { - visible: api.configured - anchors.horizontalCenter: parent.horizontalCenter - width: parent.width - 4 * Theme.horizontalPageMargin - minimumValue: 5 - maximumValue: 30 - value: { - var v = page.raw("zone1_temperature") - return (v === undefined || v === null) ? 0 : Number(v) + Label { + anchors { + right: parent.right + verticalCenter: parent.verticalCenter + } + text: page.raw("away_mode") === true ? qsTr("Away") : qsTr("At home") + color: page.raw("away_mode") === true ? Theme.highlightColor : Theme.secondaryColor + font.pixelSize: Theme.fontSizeExtraSmall } } + // ---- messaggio di stato (il "centro" della schermata ufficiale) - // + Label { + visible: api.configured + x: Theme.horizontalPageMargin + width: parent.width - 2 * Theme.horizontalPageMargin + text: page.stateMessage() + color: Theme.primaryColor + font.pixelSize: Theme.fontSizeMedium + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.Wrap + } + + Label { + visible: api.configured + x: Theme.horizontalPageMargin + width: parent.width - 2 * Theme.horizontalPageMargin + text: page.statusDetail() + color: Theme.secondaryColor + font.pixelSize: Theme.fontSizeExtraSmall + horizontalAlignment: Text.AlignHCenter + } + + // ---- scheda dell'impianto -------------------------------------- // + // Come la scheda dispositivo dell'app ufficiale: intestazione con + // nome e connessione, poi le due letture principali. Toccandola si + // apre la pagina "Impianto" (accessori, versioni, errori). + BackgroundItem { + id: scheda + visible: api.configured + x: Theme.horizontalPageMargin + width: parent.width - 2 * Theme.horizontalPageMargin + height: schedaCol.height + 3 * Theme.paddingMedium + onClicked: page.openPlant() + + Column { + id: schedaCol + x: Theme.paddingMedium + y: Theme.paddingMedium + width: parent.width - 2 * Theme.paddingMedium + spacing: Theme.paddingMedium + + Item { + width: parent.width + height: intestazione.height + + Column { + id: intestazione + anchors { + left: parent.left + right: freccia.left + rightMargin: Theme.paddingSmall + } + + Label { + text: qsTr("Aira heat pump") + color: Theme.primaryColor + font.pixelSize: Theme.fontSizeSmall + } + Label { + text: qsTr("Connected through the cloud") + color: Theme.secondaryColor + font.pixelSize: Theme.fontSizeExtraSmall + } + } + + Label { + id: freccia + anchors { + right: parent.right + verticalCenter: parent.verticalCenter + } + text: "›" + color: Theme.highlightColor + font.pixelSize: Theme.fontSizeLarge + } + } + + ValueRow { + title: qsTr("Room temperature") + subtitle: qsTr("Target %1 °C").arg(page.temp("zone1_setpoint")) + value: page.temp("zone1_temperature") + " °C" + } + + ValueRow { + title: qsTr("Domestic hot water") + subtitle: qsTr("Target %1 °C").arg(page.temp("dhw_target")) + value: page.temp("dhw_current") + " °C" + } + } + } + + // ---- riscaldamento della casa ---------------------------------- // + SectionHeader { visible: api.configured; text: qsTr("Heating the house") } + Label { visible: api.configured anchors.horizontalCenter: parent.horizontalCenter - text: qsTr("target %1 °C · %2").arg(page.temp("zone1_setpoint")).arg(api.modeLabel(page.raw("zone1_mode"))) + text: qsTr("Heating setpoint %1 °C · %2") + .arg(page.temp("zone1_setpoint")) + .arg(api.modeLabel(page.raw("zone1_mode"))) color: Theme.secondaryColor font.pixelSize: Theme.fontSizeExtraSmall } @@ -222,14 +364,6 @@ Page { // ---- acqua calda sanitaria ------------------------------------- // SectionHeader { visible: api.configured; text: qsTr("Domestic hot water") } - Label { - visible: api.configured - anchors.horizontalCenter: parent.horizontalCenter - text: page.temp("dhw_current") + " °C" - color: Theme.primaryColor - font.pixelSize: Theme.fontSizeLarge - } - ProgressBar { visible: api.configured anchors.horizontalCenter: parent.horizontalCenter @@ -242,12 +376,6 @@ Page { } } - InfoRow { - visible: api.configured - label: qsTr("DHW target") - value: page.temp("dhw_target") + " °C" - } - Slider { visible: api.configured width: parent.width @@ -306,19 +434,32 @@ Page { function() { api.sendCommand("hot_water_boost_off") }) } - // ---- stato impianto ------------------------------------------- // - SectionHeader { visible: api.configured; text: qsTr("Plant") } + // ---- altre pagine ---------------------------------------------- // + SectionHeader { visible: api.configured; text: qsTr("Details") } - InfoRow { visible: api.configured; label: qsTr("Outdoor temperature"); value: page.temp("outdoor_temperature") + " °C" } - InfoRow { visible: api.configured; label: qsTr("Indoor temperature (pump)"); value: page.temp("indoor_temperature") + " °C" } - InfoRow { visible: api.configured; label: qsTr("Mode"); value: api.modeLabel(page.raw("operating_status")) } - InfoRow { visible: api.configured; label: qsTr("Away mode"); value: page.flag("away_mode") } - InfoRow { visible: api.configured; label: qsTr("Night mode"); value: page.flag("night_mode") } - InfoRow { visible: api.configured; label: qsTr("Forced heating"); value: page.flag("force_heating") } - InfoRow { visible: api.configured; label: qsTr("Inline heater"); value: page.flag("inline_heater") } - InfoRow { visible: api.configured; label: qsTr("Zone"); value: page.count("zones") } - InfoRow { visible: api.configured; label: qsTr("Errors"); value: page.count("errors_count") } - InfoRow { visible: api.configured; label: qsTr("Last reading"); 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("Plant and accessories") + onClicked: page.openPlant() + } + + Button { + visible: api.configured + anchors { + left: parent.left + right: parent.right + leftMargin: Theme.horizontalPageMargin + rightMargin: Theme.horizontalPageMargin + } + text: qsTr("Consumption and savings") + onClicked: page.openData() + } Button { visible: api.configured @@ -355,4 +496,4 @@ Page { if (api.configured) api.refresh() } -} \ No newline at end of file +} diff --git a/qml/pages/PlantPage.qml b/qml/pages/PlantPage.qml new file mode 100644 index 0000000..37f7bd3 --- /dev/null +++ b/qml/pages/PlantPage.qml @@ -0,0 +1,187 @@ +import QtQuick 2.2 +import Sailfish.Silica 1.0 +import "../components" + +// Impianto: la scheda "Impianto e accessori" dell'app ufficiale, con in piu' +// le versioni del software e gli errori. Tutto in sola lettura. +Page { + id: page + + allowedOrientations: Orientation.All + + function raw(key) { + return api.summary[key] + } + + function temp(key) { + return api.formatValue(page.raw(key)) + " °C" + } + + function flag(key) { + return page.raw(key) ? qsTr("yes") : qsTr("no") + } + + function count(key) { + var v = page.raw(key) + if (v === undefined || v === null) + return "—" + return String(Math.round(Number(v))) + } + + // Versioni e identificativo: possono mancare, quindi "—" invece di "null". + function testo(valore) { + if (valore === undefined || valore === null || valore === "") + return "—" + return String(valore) + } + + // "2026-10-09 18:52:17.199340" -> "18:52" + function shortTime(s) { + if (!s) + return "—" + var parts = String(s).split(" ") + if (parts.length < 2) + return String(s) + return parts[1].substring(0, 5) + } + + SilicaFlickable { + anchors.fill: parent + contentHeight: col.height + Theme.paddingLarge + + PullDownMenu { + MenuItem { + text: qsTr("Refresh") + onClicked: api.refresh() + } + MenuItem { + text: qsTr("Data") + onClicked: pageStack.push(Qt.resolvedUrl("DataPage.qml")) + } + MenuItem { + text: qsTr("Settings") + onClicked: pageStack.push(Qt.resolvedUrl("SettingsPage.qml")) + } + } + + Column { + id: col + width: parent.width + spacing: Theme.paddingSmall + + PageHeader { title: qsTr("Plant") } + + // ---- stato ------------------------------------------------------ // + SectionHeader { text: qsTr("Status") } + + InfoRow { + label: qsTr("Connection") + value: page.testo(page.raw("connected_via")) + } + InfoRow { + label: qsTr("Mode") + value: api.modeLabel(page.raw("operating_status")) + } + InfoRow { label: qsTr("Away mode"); value: page.flag("away_mode") } + InfoRow { label: qsTr("Night mode"); value: page.flag("night_mode") } + InfoRow { label: qsTr("Forced heating"); value: page.flag("force_heating") } + InfoRow { label: qsTr("Inline heater"); value: page.flag("inline_heater") } + InfoRow { label: qsTr("Outdoor temperature"); value: page.temp("outdoor_temperature") } + InfoRow { label: qsTr("Indoor temperature (pump)"); value: page.temp("indoor_temperature") } + InfoRow { label: qsTr("Zones"); value: page.count("zones") } + InfoRow { label: qsTr("Last reading"); value: page.shortTime(page.raw("pump_updated")) } + + // ---- accessori -------------------------------------------------- // + SectionHeader { text: qsTr("Accessories") } + + Label { + x: Theme.horizontalPageMargin + width: parent.width - 2 * Theme.horizontalPageMargin + visible: Number(page.count("thermostats_count")) === 0 + text: qsTr("No wall thermostat") + color: Theme.secondaryColor + font.pixelSize: Theme.fontSizeSmall + wrapMode: Text.Wrap + } + + Column { + x: Theme.horizontalPageMargin + width: parent.width - 2 * Theme.horizontalPageMargin + spacing: 0 + visible: Number(page.count("thermostats_count")) > 0 + + Label { + text: qsTr("Aira wall thermostat") + color: Theme.primaryColor + font.pixelSize: Theme.fontSizeSmall + } + Label { + text: qsTr("Zone %1 · Connected").arg(page.count("thermostat_zone")) + color: Theme.highlightColor + font.pixelSize: Theme.fontSizeExtraSmall + } + } + + InfoRow { + visible: Number(page.count("thermostats_count")) > 0 + label: qsTr("Measured temperature") + value: page.temp("thermostat_temperature") + } + InfoRow { + visible: Number(page.count("thermostats_count")) > 0 + label: qsTr("Humidity") + value: api.formatNumber(page.raw("thermostat_humidity"), 1) + " %" + } + InfoRow { + visible: Number(page.count("thermostats_count")) > 0 + label: qsTr("Signal") + value: page.count("thermostat_rssi") + " dBm" + } + InfoRow { + visible: Number(page.count("thermostats_count")) > 0 + label: qsTr("Battery") + value: page.raw("thermostat_battery_low") ? qsTr("low") : qsTr("ok") + } + InfoRow { + visible: Number(page.count("thermostats_count")) > 0 + label: qsTr("Article") + value: page.testo(page.raw("thermostat_article")) + } + + // ---- versioni --------------------------------------------------- // + SectionHeader { text: qsTr("Versions") } + + InfoRow { label: qsTr("Device ID"); value: page.testo(page.raw("device_id")) } + InfoRow { label: qsTr("Platform"); value: page.testo(page.raw("version_platform")) } + InfoRow { label: qsTr("Connectivity"); value: page.testo(page.raw("version_connectivity")) } + InfoRow { label: qsTr("Climate control"); value: page.testo(page.raw("version_climate_control")) } + InfoRow { label: qsTr("Outdoor unit"); value: page.testo(page.raw("version_outdoor_unit")) } + InfoRow { label: qsTr("Outdoor unit EEPROM"); value: page.testo(page.raw("version_outdoor_eeprom")) } + + // ---- errori ----------------------------------------------------- // + SectionHeader { text: qsTr("Errors") } + + Label { + x: Theme.horizontalPageMargin + width: parent.width - 2 * Theme.horizontalPageMargin + visible: page.count("errors_count") === "0" + text: qsTr("No errors reported by the plant.") + color: Theme.secondaryColor + font.pixelSize: Theme.fontSizeSmall + wrapMode: Text.Wrap + } + InfoRow { + visible: page.count("errors_count") !== "0" + label: qsTr("Errors reported") + value: page.count("errors_count") + } + + Item { width: 1; height: Theme.paddingMedium } + } + } + + Component.onCompleted: { + if (api.configured) + api.refresh() + } +} diff --git a/rpm/harbour-aira.spec b/rpm/harbour-aira.spec index abee705..c5117ff 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.4.0 +Version: 0.5.0 Release: 1 Group: Qt/Qt License: MIT @@ -26,7 +26,8 @@ Native Sailfish OS client for an Aira Home heat pump. It does not talk to the Aira cloud directly: the cloud API is gRPC over HTTP/2 and Qt 5.6 has no HTTP/2. A small bridge on the user's server (Python, pyairahome) keeps the credentials and exposes the plant as JSON over HTTP/1.1. -The app shows the current state and sends the hot-water commands. +The app shows the current state, the energy data (heat produced, electricity +used, efficiency, savings) and sends the hot-water and heating commands. The interface is in English and Italian: by default it follows the phone language and falls back to English, and the language can also be picked in the @@ -36,6 +37,11 @@ Address and token of the bridge live in the app settings (AppConfigLocation); no personal data is stored in the binary. %changelog +* Sat Oct 10 2026 Carlo - 0.5.0-1 +- schermata iniziale riordinata come l'app ufficiale: temperatura esterna in + alto, messaggio di stato, scheda dell'impianto con casa e acqua calda +- pagina Dati: calore prodotto, elettricita' usata, efficienza (COP) e risparmi +- pagina Impianto: stato, termostato a parete, versioni del software, errori * Sat Oct 10 2026 Carlo - 0.4.0-1 - interfaccia in inglese e italiano, scelta nelle impostazioni con ripiego sull'inglese; numeri con il separatore decimale della lingua @@ -60,7 +66,7 @@ make %{?_smp_mflags} # mancante NON fa fallire la compilazione: l'app resterebbe in inglese senza dire # niente. Qui lo si rigenera comunque; il file finisce nella stessa cartella dove # lo cerca src/translator.cpp. Il .ts si cerca con find perche' sfdk usa i -# sorgenti live (shadow build) e la cartella puo' chiamarsi harbour-aira-0.4.0 +# sorgenti live (shadow build) e la cartella puo' chiamarsi harbour-aira-0.5.0 # (tarball), harbour-aira (clone) o harbour-aira-main (archivio di Gitea). TS=$(find %{_builddir}/.. -maxdepth 4 -name 'harbour-aira-it.ts' 2>/dev/null | head -1) if [ -z "$TS" ]; then diff --git a/scripts/render_mockups.py b/scripts/render_mockups.py index 406035d..890a0ba 100644 --- a/scripts/render_mockups.py +++ b/scripts/render_mockups.py @@ -101,13 +101,29 @@ def slider(d: ImageDraw.ImageDraw, y: int, frac: float, value_text: str, def button(d: ImageDraw.ImageDraw, y: int, text: str, primary: bool = False) -> int: - h = 74 + h = 66 d.rounded_rectangle([M, y, W - M, y + h], radius=h // 2, fill=BTN_BG, outline=ACCENT if primary else BTN_EDGE, width=2) - tw = d.textlength(text, font=font(28, bold=primary)) - d.text(((W - tw) / 2, y + (h - 34) / 2), text, font=font(28, bold=primary), + tw = d.textlength(text, font=font(26, bold=primary)) + d.text(((W - tw) / 2, y + (h - 32) / 2), text, font=font(26, bold=primary), fill=ACCENT if primary else PRIMARY) - return y + h + 16 + return y + h + 14 + + +def wrap(d: ImageDraw.ImageDraw, text: str, fnt, max_width: float) -> list: + """Spezza il testo in righe che stanno nella larghezza data (come il QML, + che manda a capo le etichette lunghe invece di tagliarle).""" + lines, cur = [], "" + for word in text.split(): + prova = (cur + " " + word).strip() + if not cur or d.textlength(prova, font=fnt) <= max_width: + cur = prova + else: + lines.append(cur) + cur = word + if cur: + lines.append(cur) + return lines # --------------------------------------------------------------------------- # @@ -164,6 +180,52 @@ TEXTS = { "night_one_hour": "Night mode for one hour", "force_heating": "Force heating", "confirm_hint": "(asks for confirmation first)", + # --- riordino come l'app ufficiale (v0.5.0) ------------------------- # + "outside": "Outside {v}", + "at_home": "At home", + "status_relax": "Relax: the heat pump is running smoothly.", + "status_detail": "Automatic · last reading 21:18", + "device_name": "Aira heat pump", + "device_link": "Connected through the cloud", + "room_temperature": "Room temperature", + "target_value": "Target {v}", + "dhw_row": "Domestic hot water", + "heating_house": "Heating the house", + "setpoint_line": "Heating setpoint 19.0 °C · Heating", + "details": "Details", + "plant_accessories": "Plant and accessories", + "consumption": "Consumption and savings", + "data_title": "Data", + "data_note": "Numbers calculated by Aira in the cloud:\nthe app only reads them from your own bridge.", + "month": "Month of October 2026", + "heat_produced": "Heat produced", + "heat_sub": "What the heat pump gave the house", + "electricity": "Electricity used", + "electricity_sub": "What the heat pump took from the grid", + "eff_dhw": "Efficiency, domestic hot water", + "eff_heat": "Efficiency, heating", + "savings_title": "Your savings, last 30 days", + "saved_bill": "Saved on the bill", + "saved_sub": "Compared with a gas boiler", + "co2": "CO₂ avoided", + "smart": "Smart Tariff Control", + "no_data": "no data", + "status": "Status", + "connection": "Connection", + "cloud": "Cloud", + "mode": "Mode", + "automatic": "Automatic", + "accessories": "Accessories", + "thermostat": "Aira wall thermostat", + "sensors": "Temperature 22.8 °C · Humidity 73.3 % · Battery ok", + "versions": "Versions", + "device_id": "Device ID", + "platform": "Platform", + "connectivity": "Connectivity", + "climate_control": "Climate control", + "outdoor_unit": "Outdoor unit", + "errors": "Errors", + "no_errors": "No errors reported by the plant.", }, "it": { "app": "Aira", @@ -214,6 +276,52 @@ TEXTS = { "night_one_hour": "Night mode per un'ora", "force_heating": "Forza riscaldamento", "confirm_hint": "(chiede conferma prima di partire)", + # --- riordino come l'app ufficiale (v0.5.0) ------------------------- # + "outside": "Esterno {v}", + "at_home": "In casa", + "status_relax": "Relax: la pompa di calore funziona regolarmente.", + "status_detail": "Automatico · ultima lettura 21:18", + "device_name": "Aira Pompa di calore", + "device_link": "Connessa tramite Cloud", + "room_temperature": "Temperatura stanza", + "target_value": "Obiettivo {v}", + "dhw_row": "Acqua calda", + "heating_house": "Riscaldamento della casa", + "setpoint_line": "Obiettivo riscaldamento 19,0 °C · Riscaldamento", + "details": "Dettagli", + "plant_accessories": "Impianto e accessori", + "consumption": "Consumi e risparmi", + "data_title": "Dati", + "data_note": "Numeri calcolati da Aira nel cloud:\nl'app li legge soltanto dal tuo bridge.", + "month": "Mese di ottobre 2026", + "heat_produced": "Calore prodotto", + "heat_sub": "Quanto la pompa ha dato alla casa", + "electricity": "Elettricità usata", + "electricity_sub": "Quanta ne ha presa dalla rete", + "eff_dhw": "Efficienza, acqua calda", + "eff_heat": "Efficienza, riscaldamento", + "savings_title": "I tuoi risparmi, ultimi 30 giorni", + "saved_bill": "Risparmiati in bolletta", + "saved_sub": "Rispetto a una caldaia a gas", + "co2": "CO₂ evitata", + "smart": "Smart Tariff Control", + "no_data": "nessun dato", + "status": "Stato", + "connection": "Connessione", + "cloud": "Cloud", + "mode": "Modalità", + "automatic": "Automatico", + "accessories": "Accessori", + "thermostat": "Termostato a parete Aira", + "sensors": "Temperatura 22,8 °C · Umidità 73,3 % · Batteria ok", + "versions": "Versioni", + "device_id": "ID dispositivo", + "platform": "Piattaforma", + "connectivity": "Connettività", + "climate_control": "Controllo climatico", + "outdoor_unit": "Unità esterna", + "errors": "Errori", + "no_errors": "Nessun errore segnalato dall'impianto.", }, } @@ -230,52 +338,90 @@ def dec(value: float) -> str: return out.replace(".", ",") if LANG == "it" else out +# --------------------------------------------------------------------------- # +def num(value: float, n: int = 1) -> str: + """Numero con n decimali e il separatore della lingua, come fa l'app.""" + out = f"{value:.{n}f}" + return out.replace(".", ",") if LANG == "it" else out + + +def card(d: ImageDraw.ImageDraw, y: int, h: int) -> None: + """Scheda dello sfondo (BackgroundItem dell'app): angoli tondi, bordo tenue.""" + d.rounded_rectangle([M, y, W - M, y + h], radius=18, fill=(26, 32, 41), + outline=BTN_EDGE, width=2) + + +def value_row(d: ImageDraw.ImageDraw, y: int, title: str, subtitle: str, + value: str) -> int: + """Titolo e sottotitolo a sinistra, valore grande a destra (componente + ValueRow dell'app: e' la forma delle righe della scheda dell'impianto).""" + d.text((M + 28, y), title, font=font(26), fill=PRIMARY) + d.text((M + 28, y + 36), subtitle, font=font(22), fill=SECONDARY) + vw = d.textlength(value, font=font(42, bold=True)) + d.text((W - M - 28 - vw, y + 6), value, font=font(42, bold=True), fill=PRIMARY) + return y + 96 + + # --------------------------------------------------------------------------- # def main_screen() -> Image.Image: + """Schermata iniziale, ordinata come quella dell'app ufficiale: temperatura + esterna in alto, messaggio di stato, scheda dell'impianto, poi i comandi.""" img = background() d = ImageDraw.Draw(img) status_bar(d) page_header(d, T("app")) - y = 156 - # ---- casa: il dato principale, in cima ------------------------------- # - c = T("house_temperature") - d.text(((W - d.textlength(c, font=font(24))) / 2, y), c, font=font(24), fill=SECONDARY) - y += 40 - big = dec(23.0) + " °C" - d.text(((W - d.textlength(big, font=font(92, bold=True))) / 2, y), - big, font=font(92, bold=True), fill=PRIMARY) - y += 116 - progress(d, y, (23.0 - 5) / (30 - 5)) - y += 22 - t = f'{T("target")} {dec(19.0)} °C · {T("heating")}' - d.text(((W - d.textlength(t, font=font(24))) / 2, y), t, font=font(24), fill=SECONDARY) + y = 150 + # ---- barra in alto: fuori casa / a casa ------------------------------ # + d.text((M, y), T("outside").format(v=dec(18.0) + " °C"), font=font(24), fill=SECONDARY) + casa = T("at_home") + d.text((W - M - d.textlength(casa, font=font(22)), y + 2), casa, + font=font(22), fill=SECONDARY) + y += 58 + + # ---- messaggio di stato ---------------------------------------------- # + fnt = font(30) + righe = wrap(d, T("status_relax"), fnt, W - 2 * M - 20) + for i, riga in enumerate(righe): + d.text(((W - d.textlength(riga, font=fnt)) / 2, y + i * 40), riga, + font=fnt, fill=PRIMARY) + y += 40 * len(righe) + 6 + det = T("status_detail") + d.text(((W - d.textlength(det, font=font(22))) / 2, y), det, font=font(22), fill=SECONDARY) y += 46 - y = slider(d, y, (19.0 - 5) / (30 - 5), dec(19.0) + " °C", T("new_house_target")) + + # ---- scheda dell'impianto -------------------------------------------- # + card(d, y, 282) + d.text((M + 28, y + 22), T("device_name"), font=font(26), fill=PRIMARY) + d.text((M + 28, y + 58), T("device_link"), font=font(22), fill=SECONDARY) + d.text((W - M - 46, y + 24), "›", font=font(42), fill=ACCENT) + value_row(d, y + 100, T("room_temperature"), + T("target_value").format(v=dec(19.0) + " °C"), dec(22.8) + " °C") + value_row(d, y + 186, T("dhw_row"), + T("target_value").format(v=dec(55.0) + " °C"), dec(51.0) + " °C") + y += 282 + 26 + + # ---- riscaldamento della casa ---------------------------------------- # + y = section_header(d, y, T("heating_house")) + line = T("setpoint_line") + d.text(((W - d.textlength(line, font=font(22))) / 2, y - 8), line, + font=font(22), fill=SECONDARY) + y = slider(d, y + 28, (19.0 - 5) / (30 - 5), dec(19.0) + " °C", T("new_house_target")) y = button(d, y, T("set_house").format(v=dec(19.0)), primary=True) # ---- acqua calda sanitaria ------------------------------------------- # - y = section_header(d, y + 24, T("dhw_section")) - acs = dec(51.0) + " °C" - d.text(((W - d.textlength(acs, font=font(56, bold=True))) / 2, y), - acs, font=font(56, bold=True), fill=PRIMARY) - y += 76 + y = section_header(d, y + 6, T("dhw_section")) progress(d, y, (51.0 - 20) / (65 - 20)) y += 22 - y = label_pair(d, y, T("dhw_target"), dec(55.0) + " °C") - y = slider(d, y + 10, (55.0 - 45) / (60 - 45), "55 °C", T("target_label")) + y = slider(d, y + 4, (55.0 - 45) / (60 - 45), "55 °C", T("target_label")) y = button(d, y, T("set_acs"), primary=True) y = button(d, y, T("start_boost")) y = button(d, y, T("stop_boost")) - # ---- stato impianto (scorre sotto) ----------------------------------- # - y = section_header(d, y + 24, T("plant")) - y = label_pair(d, y, T("outdoor"), dec(18.0) + " °C") - y = label_pair(d, y, T("indoor_pump"), dec(22.5) + " °C") - - hint = T("pull_to_refresh") - d.text(((W - d.textlength(hint, font=font(22))) / 2, y + 8), - hint, font=font(22), fill=SECONDARY) + # ---- pagine collegate (scorrono sotto) ------------------------------- # + y = section_header(d, y + 6, T("details")) + y = button(d, y, T("plant_accessories")) + y = button(d, y, T("consumption")) return img @@ -368,6 +514,82 @@ def commands_screen() -> Image.Image: return img +def data_screen() -> Image.Image: + """Pagina "Dati": gli stessi numeri della scheda Dati dell'app ufficiale.""" + img = background() + d = ImageDraw.Draw(img) + status_bar(d) + page_header(d, T("data_title")) + + y = 150 + d.multiline_text((M, y), T("data_note"), font=font(22), fill=SECONDARY, spacing=8) + y += 96 + + # ---- energia e efficienza del mese ------------------------------------ # + y = section_header(d, y, T("month")) + d.text((M, y), T("heat_produced"), font=font(26), fill=PRIMARY) + d.text((M, y + 34), T("heat_sub"), font=font(22), fill=SECONDARY) + v = dec(70.8) + " kWh" + d.text((W - M - d.textlength(v, font=font(40, bold=True)), y + 4), v, + font=font(40, bold=True), fill=PRIMARY) + y += 96 + d.text((M, y), T("electricity"), font=font(26), fill=PRIMARY) + d.text((M, y + 34), T("electricity_sub"), font=font(22), fill=SECONDARY) + v = dec(29.3) + " kWh" + d.text((W - M - d.textlength(v, font=font(40, bold=True)), y + 4), v, + font=font(40, bold=True), fill=PRIMARY) + y += 96 + y = label_pair(d, y, T("eff_dhw"), num(4.42, 2)) + y = label_pair(d, y, T("eff_heat"), T("no_data")) + + # ---- risparmi ---------------------------------------------------------- # + y = section_header(d, y + 20, T("savings_title")) + d.text((M, y), T("saved_bill"), font=font(26), fill=PRIMARY) + d.text((M, y + 34), T("saved_sub"), font=font(22), fill=SECONDARY) + v = num(21.78, 2) + " €" + d.text((W - M - d.textlength(v, font=font(40, bold=True)), y + 4), v, + font=font(40, bold=True), fill=ACCENT) + y += 96 + y = label_pair(d, y, T("co2"), dec(38.1) + " kg") + y = label_pair(d, y, T("smart"), num(2.71, 2) + " €", right_color=ACCENT) + + hint = T("pull_to_refresh") + d.text(((W - d.textlength(hint, font=font(22))) / 2, H - 44), + hint, font=font(22), fill=SECONDARY) + return img + + +def plant_screen() -> Image.Image: + """Pagina "Impianto": stato, accessori, versioni, errori.""" + img = background() + d = ImageDraw.Draw(img) + status_bar(d) + page_header(d, T("plant")) + + y = 150 + y = section_header(d, y, T("status")) + for lab, val in ((T("connection"), T("cloud")), (T("mode"), T("automatic")), + (T("away"), T("off")), (T("night_mode"), T("off")), + (T("forced_heating"), T("off"))): + y = label_pair(d, y, lab, val) + + y = section_header(d, y + 20, T("accessories")) + d.text((M, y), T("thermostat"), font=font(26), fill=PRIMARY) + y += 36 + d.text((M, y), T("sensors"), font=font(22), fill=SECONDARY) + y += 56 + + y = section_header(d, y, T("versions")) + for lab, val in ((T("device_id"), "1f4b26"), (T("platform"), "6.10.0"), + (T("connectivity"), "3.30.1"), (T("climate_control"), "3.9.12"), + (T("outdoor_unit"), "1.30.0")): + y = label_pair(d, y, lab, val) + + y = section_header(d, y + 20, T("errors")) + d.text((M, y), T("no_errors"), font=font(24), fill=OK) + return img + + if __name__ == "__main__": import sys @@ -380,7 +602,8 @@ if __name__ == "__main__": out = pathlib.Path("docs/anteprima") if lang == "en" else pathlib.Path("docs/anteprima") / lang out.mkdir(parents=True, exist_ok=True) - for name, fn in (("dashboard", main_screen), ("comandi", commands_screen), + for name, fn in (("dashboard", main_screen), ("dati", data_screen), + ("impianto", plant_screen), ("comandi", commands_screen), ("impostazioni", settings_screen), ("copertina", cover_screen)): path = out / f"{name}.png" fn().save(path) diff --git a/src/airajson.cpp b/src/airajson.cpp index 3eb1dc2..4d687f7 100644 --- a/src/airajson.cpp +++ b/src/airajson.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include namespace AiraJson { @@ -47,6 +48,29 @@ QVariantMap parseSummary(const QByteArray &body, bool *ok) return summary.toVariantMap(); } +QVariantMap parseStats(const QByteArray &body, bool *ok) +{ + if (ok) + *ok = false; + + QJsonParseError err; + const QJsonDocument doc = QJsonDocument::fromJson(body, &err); + if (err.error != QJsonParseError::NoError || !doc.isObject()) + return QVariantMap(); + + const QJsonObject root = doc.object(); + if (root.value(QStringLiteral("ok")).toBool() == false) + return QVariantMap(); + + const QJsonObject stats = root.value(QStringLiteral("stats")).toObject(); + if (stats.isEmpty()) + return QVariantMap(); + + if (ok) + *ok = true; + return stats.toVariantMap(); +} + QString modeLabel(const QString &operatingStatus) { const QString s = operatingStatus; @@ -92,20 +116,63 @@ QString modeLabel(const QString &operatingStatus) } QString formatValue(const QVariant &value) +{ + // Un decimale: e' il caso delle temperature ("51,0"). + return formatNumber(value, 1); +} + +QString formatNumber(const QVariant &value, int decimals) { if (!value.isValid() || value.isNull()) return QStringLiteral("—"); bool isNumber = false; const double d = value.toDouble(&isNumber); - if (isNumber) - // QLocale() = locale predefinito, che translator.cpp allinea alla lingua - // dell'app: separatore decimale col punto in inglese, con la virgola in - // italiano (QString::number darebbe sempre il punto). - return QLocale().toString(d, 'f', 1); + if (!isNumber) + return QStringLiteral("—"); - const QString s = value.toString(); - return s.isEmpty() ? QStringLiteral("—") : s; + // QLocale() = locale predefinito, che translator.cpp allinea alla lingua + // dell'app: separatore decimale col punto in inglese, con la virgola in + // italiano (QString::number darebbe sempre il punto). + return QLocale().toString(d, 'f', decimals < 0 ? 0 : decimals); +} + +QString formatMoney(const QVariant &value, const QString ¤cyCode) +{ + if (!value.isValid() || value.isNull()) + return QStringLiteral("—"); + + bool isNumber = false; + const double d = value.toDouble(&isNumber); + if (!isNumber) + return QStringLiteral("—"); + + // Posizione del simbolo e separatori seguono la lingua dell'app, non la + // valuta: "21,78 €" in italiano, "€21.78" in inglese. + const QString code = currencyCode.trimmed().toUpper(); + QString symbol; + if (code == QLatin1String("EUR")) + symbol = QString::fromUtf8("€"); + else if (!code.isEmpty()) + symbol = code; // valuta senza simbolo noto: codice ISO + return QLocale().toCurrencyString(d, symbol); +} + +QString monthLabel(const QString &yearMonth) +{ + // "2026-10" -> "ottobre 2026": il nome del mese segue la lingua dell'app. + const QStringList parti = yearMonth.split(QLatin1Char('-')); + if (parti.size() != 2) + return QString(); + + bool annoOk = false; + bool meseOk = false; + const int anno = parti.at(0).toInt(&annoOk); + const int mese = parti.at(1).toInt(&meseOk); + if (!annoOk || !meseOk || mese < 1 || mese > 12) + return QString(); + + return QStringLiteral("%1 %2").arg(QLocale().monthName(mese, QLocale::LongFormat)).arg(anno); } QByteArray commandPayload(const QString &id, const QVariant &value) diff --git a/src/airajson.h b/src/airajson.h index 45148c1..886d2b3 100644 --- a/src/airajson.h +++ b/src/airajson.h @@ -23,12 +23,27 @@ QString normalizeBaseUrl(const QString &raw); // vuoto e mette *ok a false. QVariantMap parseSummary(const QByteArray &body, bool *ok); +// Legge il corpo di GET /stats (pagina "Dati": calore, elettricita', COP, +// risparmi) nello stesso formato. Vuoto e *ok a false se non interpretabile. +QVariantMap parseStats(const QByteArray &body, bool *ok); + // Codice di stato Aira ("OPERATING_STATUS_AUTO") in etichetta italiana. QString modeLabel(const QString &operatingStatus); // Temperatura leggibile: "51,0" oppure "—" se il valore manca. QString formatValue(const QVariant &value); +// Numero con il numero di decimali voluto, col separatore della lingua +// ("4,42" in italiano, "4.42" in inglese); "—" se il valore manca. +QString formatNumber(const QVariant &value, int decimals); + +// Importo con il simbolo della valuta ("21,78 €" in italiano, "€21.78" in +// inglese): posizione e separatori seguono la lingua, non la valuta. +QString formatMoney(const QVariant &value, const QString ¤cyCode); + +// "2026-10" -> "ottobre 2026" / "October 2026". Stringa vuota se illeggibile. +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); diff --git a/src/apiclient.cpp b/src/apiclient.cpp index 9d24e8b..78ffc34 100644 --- a/src/apiclient.cpp +++ b/src/apiclient.cpp @@ -157,6 +157,64 @@ QString ApiClient::formatValue(const QVariant &value) const return AiraJson::formatValue(value); } +QString ApiClient::formatNumber(const QVariant &value, int decimals) const +{ + return AiraJson::formatNumber(value, decimals); +} + +QString ApiClient::formatMoney(const QVariant &value, const QString ¤cyCode) const +{ + return AiraJson::formatMoney(value, currencyCode); +} + +QString ApiClient::monthLabel(const QString &yearMonth) const +{ + return AiraJson::monthLabel(yearMonth); +} + +void ApiClient::refreshStats() +{ + if (!configured()) { + emit statsRefreshed(false, tr("Set the bridge address and token")); + return; + } + + // Il bridge tiene le statistiche in cache (10 minuti): la prima lettura + // interroga il cloud, quindi il tempo massimo e' piu' largo del solito. + QNetworkReply *reply = send("GET", QStringLiteral("/stats"), QByteArray(), 60); + if (!reply) { + emit statsRefreshed(false, tr("Bridge not configured")); + return; + } + + connect(reply, &QNetworkReply::finished, this, [this, reply]() { + reply->deleteLater(); + + const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + const QByteArray body = reply->readAll(); + + if (reply->error() != QNetworkReply::NoError || status != 200) { + const QString msg = (reply->error() == QNetworkReply::OperationCanceledError) + ? tr("Timeout: no statistics") + : AiraJson::errorMessage(body, status); + qWarning() << "[api] statistiche non riuscite:" << status << msg; + emit statsRefreshed(false, msg); + return; + } + + bool ok = false; + const QVariantMap stats = AiraJson::parseStats(body, &ok); + if (!ok) { + qWarning() << "[api] statistiche non interpretabili"; + emit statsRefreshed(false, tr("Unreadable response from the bridge")); + return; + } + m_stats = stats; + emit statsChanged(stats); + emit statsRefreshed(true, QString()); + }); +} + void ApiClient::sendCommand(const QString &id, const QVariant &value) { if (!configured()) { diff --git a/src/apiclient.h b/src/apiclient.h index f64431b..04d25c7 100644 --- a/src/apiclient.h +++ b/src/apiclient.h @@ -21,6 +21,9 @@ class ApiClient : public QObject // le property dell'ApplicationWindow NON sono visibili alle pagine dello // pageStack, quindi la dashboard e la copertina non potrebbero condividerle. Q_PROPERTY(QVariantMap summary READ summary NOTIFY summaryChanged) + // Statistiche della pagina "Dati" (calore, elettricita', COP, risparmi). + // Separata dal riassunto: e' una chiamata piu' lenta, con cache lato bridge. + Q_PROPERTY(QVariantMap stats READ stats NOTIFY statsChanged) public: explicit ApiClient(Settings *settings, QObject *parent = 0); @@ -28,10 +31,14 @@ public: bool busy() const { return m_busy; } bool configured() const; QVariantMap summary() const { return m_summary; } + QVariantMap stats() const { return m_stats; } // Scarica il riassunto e lo emette con summaryChanged(). Q_INVOKABLE void refresh(); + // Scarica le statistiche (GET /stats) e le emette con statsChanged(). + Q_INVOKABLE void refreshStats(); + // Prova GET /health (non richiede token) e risponde con connectionTested(). Q_INVOKABLE void testConnection(); @@ -42,11 +49,16 @@ public: // tests/core_test.cpp), niente duplicazione di logica nel QML. Q_INVOKABLE QString modeLabel(const QString &operatingStatus) const; Q_INVOKABLE QString formatValue(const QVariant &value) const; + Q_INVOKABLE QString formatNumber(const QVariant &value, int decimals) const; + Q_INVOKABLE QString formatMoney(const QVariant &value, const QString ¤cyCode) const; + Q_INVOKABLE QString monthLabel(const QString &yearMonth) const; signals: void busyChanged(); void configuredChanged(); void summaryChanged(const QVariantMap &summary); + void statsChanged(const QVariantMap &stats); + void statsRefreshed(bool ok, const QString &message); void commandFinished(bool ok, const QString &message); void connectionTested(bool ok, const QString &message); void errorOccurred(const QString &message); @@ -61,6 +73,7 @@ private: QNetworkAccessManager m_nam; bool m_busy; QVariantMap m_summary; + QVariantMap m_stats; }; #endif // APICLIENT_H diff --git a/src/main.cpp b/src/main.cpp index bed4fb1..70a1827 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.4.0")); + app->setApplicationVersion(QStringLiteral("0.5.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.4.0 build" << __DATE__ << __TIME__; + qDebug() << "harbour-aira v0.5.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 b960771..9927f5a 100644 --- a/tests/core_test.cpp +++ b/tests/core_test.cpp @@ -120,6 +120,48 @@ int main(int argc, char *argv[]) check(AiraJson::errorMessage("", 502) == "HTTP error 502", "http status fallback"); check(AiraJson::errorMessage("", 0) == "server unreachable", "status 0 = unreachable"); + // ---- parseStats (pagina "Dati") --------------------------------------- // + const QByteArray statsBody = R"({"ok":true,"stats":{"currency":"EUR","period_days":30,"month":{"label":"2026-10","heat_kwh":70.8,"energy_kwh":29.3,"cop_heating":null,"cop_dhw":4.42}}})"; + bool okStats = false; + const QVariantMap st = AiraJson::parseStats(statsBody, &okStats); + check(okStats, "statistiche valide riconosciute"); + check(st.value("currency").toString() == "EUR", "valuta letta"); + check(st.value("period_days").toInt() == 30, "periodo letto"); + check(qFuzzyCompare(st.value("month").toMap().value("heat_kwh").toDouble(), 70.8), + "calore prodotto letto"); + check(st.value("month").toMap().value("cop_dhw").toDouble() == 4.42, + "COP acqua calda letto"); + check(st.value("month").toMap().value("cop_heating").isNull(), + "efficienza riscaldamento assente resta nulla (nessun dato)"); + check(AiraJson::parseStats(R"({"ok":false,"error":"token errato"})", &okStats).isEmpty() && !okStats, + "statistiche in errore -> vuoto"); + check(AiraJson::parseStats("non sono json", &okStats).isEmpty() && !okStats, + "statistiche non JSON -> vuoto"); + + // ---- formatNumber / formatMoney / monthLabel -------------------------- // + QLocale::setDefault(QLocale(QLocale::Italian, QLocale::Italy)); + check(AiraJson::formatNumber(QVariant(4.4213), 2) == "4,42", "due decimali (italiano)"); + check(AiraJson::formatNumber(QVariant(29.3), 1) == "29,3", "un decimale (italiano)"); + check(AiraJson::formatNumber(QVariant(), 2) == "—", "numero assente -> trattino"); + // toCurrencyString usa lo spazio unificatore del locale: si controllano + // separatore e simbolo, non la stringa esatta. + const QString importoIt = AiraJson::formatMoney(QVariant(21.78), "EUR"); + check(importoIt.contains("21,78") && importoIt.contains(QString::fromUtf8("€")), + "importo in italiano: virgola e simbolo"); + check(AiraJson::formatMoney(QVariant(21.78), "eur") == importoIt, + "codice valuta minuscolo accettato"); + check(AiraJson::formatMoney(QVariant(), "EUR") == "—", "importo assente -> trattino"); + check(AiraJson::monthLabel("2026-10") == "ottobre 2026", "mese in italiano"); + check(AiraJson::monthLabel("non-mese").isEmpty(), "chiave mese illeggibile -> vuoto"); + check(AiraJson::monthLabel("2026-13").isEmpty(), "mese 13 -> vuoto"); + QLocale::setDefault(QLocale(QLocale::English, QLocale::UnitedKingdom)); + check(AiraJson::formatNumber(QVariant(4.4213), 2) == "4.42", "due decimali (inglese)"); + const QString importoEn = AiraJson::formatMoney(QVariant(21.78), "EUR"); + check(importoEn.contains("21.78") && importoEn.contains(QString::fromUtf8("€")), + "importo in inglese: punto e simbolo"); + check(AiraJson::monthLabel("2026-10") == "October 2026", "mese in inglese"); + QLocale::setDefault(QLocale::c()); + // ---- catalogo italiano: il .qm caricato davvero ------------------------ // // Non basta che il .ts esista: qui si installa il .qm come fa main.cpp e si // controlla che le stringhe sorgente inglesi diventino italiane. Il percorso diff --git a/translations/harbour-aira-it.ts b/translations/harbour-aira-it.ts index 786a812..031d6a7 100644 --- a/translations/harbour-aira-it.ts +++ b/translations/harbour-aira-it.ts @@ -1,65 +1,64 @@ - - + AiraJson - + Automatic Automatico - - + + Heating Riscaldamento - - + + Cooling Raffrescamento - - + + Hot water Acqua calda - - + + Standby Stand-by - - + + Off Spento - + Idle In attesa - + Stopped Ferma - + Running In funzione - + server unreachable server non raggiungibile - + HTTP error %1 errore HTTP %1 @@ -67,58 +66,66 @@ 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 + 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 @@ -126,403 +133,821 @@ CommandsPage - + yes sì - + no no - + Other commands Altri comandi - + Current state Stato attuale - - + + Away mode Assenza - + Night mode Night mode - + Forced heating Riscaldamento forzato - + Hot water active Acqua calda attiva - + Pump Pompa - + Allowed modes Modalità consentite - + Turn away mode on Attiva assenza - + Turn away mode off Disattiva assenza - + Noise Rumore - + Night mode for one hour - Night mode per un'ora + Night mode per un'ora - + Heating Riscaldamento - + Force heating Forza riscaldamento - + Starting forced heating Avvio del riscaldamento forzato - + Stop forced heating Ferma riscaldamento forzato - + Enable heating function Abilita funzione riscaldamento - + Disable heating function Disabilita funzione riscaldamento - + Heating will be disabled Il riscaldamento verrà disabilitato - + Domestic hot water Acqua calda sanitaria - + Legionella cycle Ciclo antilegionella - + Starting the legionella cycle: it takes a while Avvio del ciclo antilegionella: richiede tempo - + Enable DHW heating Abilita riscaldamento ACS - + Disable DHW heating Disabilita riscaldamento ACS - + Domestic hot water will stop being heated - L'acqua calda smetterà di essere riscaldata + L'acqua calda smetterà di essere riscaldata - + Diagnostics Diagnostica - + Plant check Verifica impianto - + Commands go through the bridge and may take a few seconds. The status refreshes by itself afterwards. - I comandi passano dal bridge e possono richiedere qualche secondo. Dopo l'invio lo stato si aggiorna da solo. + I comandi passano dal bridge e possono richiedere qualche secondo. Dopo l'invio lo stato si aggiorna da solo. CoverPage - + house · target %1 °C casa · obiettivo %1 °C - + DHW %1 °C · target %2 °C ACS %1 °C · obiettivo %2 °C - + outdoor %1 °C est. %1 °C - MainPage + DataPage - - yes - sì + + no data + nessun dato - - no - no - - - + Refresh Aggiorna - - - Settings - Impostazioni - - - - - Other commands - Altri comandi - - - - Bridge not configured. Open the settings and enter the server address and the token. - Bridge non configurato. Apri le impostazioni e inserisci l'indirizzo del server e il token. - - - - House temperature - Temperatura in casa - - - - target %1 °C · %2 - obiettivo %1 °C · %2 - - - - New house target - Nuovo obiettivo casa - - - - Set house to %1 °C - Imposta casa a %1 °C - - - - House temperature to %1 °C - Temperatura della casa a %1 °C - - - - Domestic hot water - Acqua calda sanitaria - - - - DHW target - Obiettivo acqua calda - - - - New target - Nuovo obiettivo - - - - Set target %1 °C - Imposta obiettivo %1 °C - - - - DHW target to %1 °C - Obiettivo ACS a %1 °C - - - - Start DHW boost - Avvia boost ACS - - - - Starting DHW boost - Avvio del boost ACS - - - - Stop DHW boost - Ferma boost ACS - - - - Stopping DHW boost - Arresto del boost ACS - - - + + Plant + Impianto + + + + Settings + Impostazioni + + + + Data + Dati + + + + Numbers calculated by Aira in the cloud: the app only reads them from your own bridge. + Numeri calcolati da Aira nel cloud: l'app li legge soltanto dal tuo bridge. + + + + Reading the data... + Lettura dei dati in corso... + + + + Month of %1 + Mese di %1 + + + + This month + Questo mese + + + + Heat produced + Calore prodotto + + + + What the heat pump gave the house + Quanto la pompa ha dato alla casa + + + + Electricity used + Elettricità usata + + + + What the heat pump took from the grid + Quanta ne ha presa dalla rete + + + + Efficiency, domestic hot water + Efficienza, acqua calda + + + + Efficiency, heating + Efficienza, riscaldamento + + + + Efficiency (COP) is how much heat the pump delivers for each unit of electricity: higher is better. + L'efficienza (COP) è quanto calore la pompa produce per ogni unità di elettricità: più alta è, meglio è. + + + + Your savings, last %1 days + I tuoi risparmi, ultimi %1 giorni + + + + Saved on the bill + Risparmiati in bolletta + + + + Compared with a gas boiler + Rispetto a una caldaia a gas + + + + CO₂ avoided + CO₂ evitata + + + + Not released into the air + Non immessa nell'aria + + + + Smart Tariff Control + Smart Tariff Control + + + + Saving from heating when energy is cheaper + Risparmio scaldando quando l'energia costa meno + + + + Efficiency month by month + Efficienza mese per mese + + + + Savings month by month + Risparmi mese per mese + + + + MainPage + + yes + sì + + + no + no + + + + Refresh + Aggiorna + + + + + Settings + Impostazioni + + + + + Other commands + Altri comandi + + + + 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. + + + + %1 · last reading %2 + %1 · ultima lettura %2 + + + + Data + Dati + + + + Bridge not configured. Open the settings and enter the server address and the token. + Bridge non configurato. Apri le impostazioni e inserisci l'indirizzo del server e il token. + + + + Outside %1 °C + Esterno %1 °C + + + + Away + Assente + + + + At home + In casa + + + + Aira heat pump + Aira Pompa di calore + + + + Connected through the cloud + Connessa tramite Cloud + + + + Room temperature + Temperatura stanza + + + + + Target %1 °C + Obiettivo %1 °C + + + + Heating the house + Riscaldamento della casa + + + + Heating setpoint %1 °C · %2 + Obiettivo riscaldamento %1 °C · %2 + + + + Details + Dettagli + + + + Plant and accessories + Impianto e accessori + + + + Consumption and savings + Consumi e risparmi + + + House temperature + Temperatura in casa + + + target %1 °C · %2 + obiettivo %1 °C · %2 + + + + New house target + Nuovo obiettivo casa + + + + Set house to %1 °C + Imposta casa a %1 °C + + + + House temperature to %1 °C + Temperatura della casa a %1 °C + + + + + Domestic hot water + Acqua calda sanitaria + + + DHW target + Obiettivo acqua calda + + + + New target + Nuovo obiettivo + + + + Set target %1 °C + Imposta obiettivo %1 °C + + + + DHW target to %1 °C + Obiettivo ACS a %1 °C + + + + Start DHW boost + Avvia boost ACS + + + + Starting DHW boost + Avvio del boost ACS + + + + Stop DHW boost + Ferma boost ACS + + + + Stopping DHW boost + Arresto del boost ACS + + + Plant Impianto - Outdoor temperature - Temperatura esterna + Temperatura esterna - Indoor temperature (pump) - Temperatura interna (pompa) + Temperatura interna (pompa) - + Mode + Modalità + + + Away mode + Assenza + + + Night mode + Night mode + + + Forced heating + Riscaldamento forzato + + + Inline heater + Resistenze integrative + + + Zone + Zone + + + Errors + Errori + + + Last reading + Aggiornato alle + + + + Commands are sent to the bridge and from there to the plant: they may take a few seconds. + I comandi vengono inviati al bridge e da lì all'impianto: possono richiedere qualche secondo. + + + + PlantPage + + + yes + sì + + + + no + no + + + + Refresh + Aggiorna + + + + Data + Dati + + + + Settings + Impostazioni + + + + Plant + Impianto + + + + Status + Stato + + + + Connection + Connessione + + + Mode Modalità - + Away mode - Assenza + Modalità assenza - + Night mode - Night mode + Modalità notte - + Forced heating Riscaldamento forzato - + Inline heater - Resistenze integrative + Resistenza interna - - Zone + + Outdoor temperature + Temperatura esterna + + + + Indoor temperature (pump) + Temperatura interna (pompa) + + + + Zones Zone - + + Last reading + Ultima lettura + + + + Accessories + Accessori + + + + No wall thermostat + Nessun termostato a parete + + + + Aira wall thermostat + Termostato a parete Aira + + + + Zone %1 · Connected + Zona %1 · Connesso + + + + Measured temperature + Temperatura misurata + + + + Humidity + Umidità + + + + Signal + Segnale + + + + Battery + Batteria + + + + low + scarica + + + + ok + ok + + + + Article + Articolo + + + + Versions + Versioni + + + + Device ID + ID dispositivo + + + + Platform + Piattaforma + + + + Connectivity + Connettività + + + + Climate control + Controllo climatico + + + + Outdoor unit + Unità esterna + + + + Outdoor unit EEPROM + EEPROM unità esterna + + + Errors Errori - - Last reading - Aggiornato alle + + No errors reported by the plant. + Nessun errore segnalato dall'impianto. - - Commands are sent to the bridge and from there to the plant: they may take a few seconds. - I comandi vengono inviati al bridge e da lì all'impianto: possono richiedere qualche secondo. + + Errors reported + Errori segnalati SettingsPage - + checking… verifico… - + Settings Impostazioni - - + + Language Lingua - - + + System Sistema - + Changing the language reloads the app. - Cambiando la lingua l'app si ricarica. + Cambiando la lingua l'app si ricarica. - + Bridge Bridge - + Bridge address Indirizzo del bridge - + Bridge token Token del bridge - + Address and token live on the server: the token is in aira_bridge.json next to the bridge. Without a scheme, http:// is used. Indirizzo e token si leggono sul server: il token è in aira_bridge.json accanto al bridge. Senza schema viene usato http://. - + Save Salva - + settings saved impostazioni salvate - + Test connection Prova connessione - + \ No newline at end of file