diff --git a/qml/components/NoticeBar.qml b/qml/components/NoticeBar.qml new file mode 100644 index 0000000..73e43f1 --- /dev/null +++ b/qml/components/NoticeBar.qml @@ -0,0 +1,42 @@ +import QtQuick 2.2 +import Sailfish.Silica 1.0 + +// Messaggio dell'ultimo comando, ancorato al bordo inferiore della pagina. +// +// Sta FUORI dal contenuto che scorre: prima il messaggio era la prima riga +// della colonna, quindi premendo un pulsante in fondo alla pagina nasceva +// fuori dallo schermo e non si capiva se il comando fosse partito. +// +// Lo stato sta in ApiClient (api.notice): qui si mostra e basta, cosi' il +// messaggio e' lo stesso su qualsiasi pagina. +Rectangle { + id: root + + anchors { + left: parent.left + right: parent.right + bottom: parent.bottom + } + height: message.height + 2 * Theme.paddingMedium + color: Qt.rgba(0, 0, 0, 0.78) + opacity: api.notice.length > 0 ? 1 : 0 + visible: opacity > 0 + z: 100 + + Behavior on opacity { NumberAnimation { duration: 200 } } + + Label { + id: message + anchors { + left: parent.left + right: parent.right + leftMargin: Theme.horizontalPageMargin + rightMargin: Theme.horizontalPageMargin + verticalCenter: parent.verticalCenter + } + text: api.notice + color: api.noticeIsError ? Theme.errorColor : Theme.highlightColor + font.pixelSize: Theme.fontSizeSmall + wrapMode: Text.Wrap + } +} diff --git a/qml/components/ReadingCell.qml b/qml/components/ReadingCell.qml new file mode 100644 index 0000000..239e4f0 --- /dev/null +++ b/qml/components/ReadingCell.qml @@ -0,0 +1,62 @@ +import QtQuick 2.2 +import Sailfish.Silica 1.0 + +// Riquadro di una lettura grande nella Home: titolo, valore, riga sotto e la +// barra di avanzamento (facoltativa). Toccabile: porta alle regolazioni. +// +// La barra c'e' sempre, anche quando non serve, e resta trasparente: cosi' i +// due riquadri affiancati hanno la stessa altezza senza vincoli circolari. +BackgroundItem { + id: root + + property string title: "" + property string value: "" + property string subtitle: "" + property real progress: 0 + property bool showBar: false + property color valueColor: Theme.primaryColor + + signal tapped() + + height: content.height + 2 * Theme.paddingMedium + + onClicked: root.tapped() + + Column { + id: content + x: Theme.paddingMedium + y: Theme.paddingMedium + width: parent.width - 2 * Theme.paddingMedium + spacing: Theme.paddingSmall + + Label { + width: parent.width + text: root.title + color: Theme.secondaryColor + font.pixelSize: Theme.fontSizeSmall + elide: Text.ElideRight + } + + Label { + width: parent.width + text: root.value + color: root.valueColor + font.pixelSize: Theme.fontSizeHuge + elide: Text.ElideRight + } + + Label { + width: parent.width + text: root.subtitle + color: Theme.secondaryColor + font.pixelSize: Theme.fontSizeExtraSmall + wrapMode: Text.Wrap + } + + ProgressBar { + width: parent.width + value: Math.max(0, Math.min(1, root.progress)) + opacity: root.showBar ? 1 : 0 + } + } +} diff --git a/qml/pages/MainPage.qml b/qml/pages/MainPage.qml index 90c1874..865195b 100644 --- a/qml/pages/MainPage.qml +++ b/qml/pages/MainPage.qml @@ -2,27 +2,65 @@ import QtQuick 2.2 import Sailfish.Silica 1.0 import "../components" -// 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. +// Home: una schermata da GUARDARE, non un modulo da compilare. +// +// Stato in una riga, le due letture che contano (casa e acqua calda), tre +// azioni rapide. Le regolazioni stanno in SetpointsPage, gli altri comandi in +// CommandsPage, i numeri in DataPage, i dettagli in PlantPage: qui solo quello +// che si guarda molte volte al giorno, senza scorrere (docs/UX-STUDIO.md). Page { id: page allowedOrientations: Orientation.All - // obiettivo ACS scelto dall'utente (non sovrascritto dal refresh finche' - // non lo tocca, altrimenti lo slider "salta" a ogni lettura) - property int targetAcs: 55 - property bool targetAcsTouched: false - // obiettivo della casa (zona 1); parte dalla temperatura attuale e non - // viene sovrascritto dal refresh finche' l'utente non lo tocca - property real targetCasa: 20 - property bool targetCasaTouched: false - property string notice: "" - property bool noticeIsError: false + function raw(key) { + return api.summary[key] + } + + // I numeri si formattano nel C++ (ApiClient::formatValue): cosi' il + // separatore decimale segue la lingua dell'app, non il toFixed di JS. + function fmt(value) { return api.formatValue(value) } + + function count(key) { + var v = page.raw(key) + if (v === undefined || v === null) + return "—" + return String(Math.round(Number(v))) + } + + // Colore del pallino di stato. Il livello (0 regolare, 1 modo attivo, + // 2 errore) lo calcola il C++; qui si sceglie il colore, e l'ambra quando + // i numeri sono vecchi: un dato vecchio non e' un dato buono. + function statusColor() { + if (!api.hasData || api.stale) + return "orange" + var livello = api.statusLevel() + if (livello === 2) + return Theme.errorColor + if (livello === 1) + return Theme.highlightColor + return "#4caf50" + } + + // Quanto e' carica l'acqua calda verso l'obiettivo (da 10 °C in su). + function waterProgress() { + var ora = Number(page.raw("dhw_current")) + var obiettivo = Number(page.raw("dhw_target")) + if (!(obiettivo > 10) || isNaN(ora)) + return 0 + return (ora - 10) / (obiettivo - 10) + } + + // Riga sotto il titolo: dice anche quando i numeri sono vecchi. + function updateText() { + if (!api.hasData) + return qsTr("No data yet") + if (api.stale) + return qsTr("Not updated · last %1").arg(api.lastUpdate) + if (api.lastUpdate.length === 0) + return "" + return qsTr("Updated at %1").arg(api.lastUpdate) + } function openSettings() { pageStack.push(Qt.resolvedUrl("SettingsPage.qml")) @@ -40,100 +78,14 @@ Page { pageStack.push(Qt.resolvedUrl("DataPage.qml")) } - function showNotice(msg, isError) { - page.notice = msg - page.noticeIsError = isError === true - noticeTimer.restart() - } - - function raw(key) { - return api.summary[key] - } - - // I numeri si formattano nel C++ (ApiClient::formatValue): cosi' il - // separatore decimale segue la lingua dell'app, non il toFixed di JS. - function fmt(value) { return api.formatValue(value) } - - function temp(key) { - return api.formatValue(page.raw(key)) - } - - function count(key) { - var v = page.raw(key) - if (v === undefined || v === null) - return "—" - return String(Math.round(Number(v))) - } - - // "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) - } - - // 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 - onTriggered: page.notice = "" + Component.onCompleted: { + if (api.configured) + api.refresh() } + // Fuori dal pulsante: dentro il pulsante i figli finiscono nel contentItem. RemorseItem { id: remorse } - Connections { - target: api - onErrorOccurred: page.showNotice(message, true) - onCommandFinished: page.showNotice(message, !ok) - onSummaryChanged: { - if (!page.targetAcsTouched) { - var v = page.raw("dhw_target") - if (v !== undefined && v !== null) - page.targetAcs = Math.round(Number(v)) - } - if (!page.targetCasaTouched) { - var c = page.raw("zone1_setpoint") - if (c !== undefined && c !== null && Number(c) > 0) - page.targetCasa = Math.round(Number(c) * 2) / 2 - } - } - } - SilicaFlickable { id: flick anchors.fill: parent @@ -154,10 +106,6 @@ Page { text: qsTr("Plant") onClicked: page.openPlant() } - MenuItem { - text: qsTr("Other commands") - onClicked: page.openCommands() - } MenuItem { text: qsTr("Settings") onClicked: page.openSettings() @@ -169,17 +117,9 @@ Page { width: parent.width spacing: Theme.paddingSmall - PageHeader { title: "Aira" } - - // ---- avvisi ---------------------------------------------------- // - Label { - visible: page.notice.length > 0 - x: Theme.horizontalPageMargin - width: parent.width - 2 * Theme.horizontalPageMargin - text: page.notice - color: page.noticeIsError ? Theme.errorColor : Theme.highlightColor - font.pixelSize: Theme.fontSizeSmall - wrapMode: Text.Wrap + PageHeader { + title: "Aira" + description: page.updateText() } // ---- bridge non configurato ------------------------------------ // @@ -200,300 +140,158 @@ Page { onClicked: page.openSettings() } - // ---- barra in alto: fuori casa / a casa ------------------------ // + // ---- stato in una riga ----------------------------------------- // Item { visible: api.configured x: Theme.horizontalPageMargin width: parent.width - 2 * Theme.horizontalPageMargin - height: fuori.height + height: Math.max(stato.height, pallino.height, attesa.height) - Label { - id: fuori - anchors.left: parent.left - text: qsTr("Outside %1 °C").arg(page.temp("outdoor_temperature")) - color: Theme.secondaryColor - font.pixelSize: Theme.fontSizeSmall + Rectangle { + id: pallino + width: Theme.fontSizeSmall * 0.6 + height: width + radius: width / 2 + color: page.statusColor() + anchors.verticalCenter: parent.verticalCenter } Label { + id: stato + anchors { + left: pallino.right + leftMargin: Theme.paddingMedium + right: attesa.left + rightMargin: Theme.paddingMedium + verticalCenter: parent.verticalCenter + } + text: api.stateShort() + color: Theme.primaryColor + font.pixelSize: Theme.fontSizeSmall + elide: Text.ElideRight + } + + BusyIndicator { + id: attesa 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 + size: BusyIndicatorSize.Small + running: api.busy + visible: api.busy } } - // ---- messaggio di stato (il "centro" della schermata ufficiale) - // - Label { + // ---- le due letture che contano -------------------------------- // + Row { + id: letture 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 + spacing: Theme.paddingSmall + + ReadingCell { + width: (letture.width - letture.spacing) / 2 + title: qsTr("House") + value: page.fmt(page.raw("zone1_temperature")) + " °C" + subtitle: qsTr("target %1 °C").arg(page.fmt(page.raw("zone1_setpoint"))) + valueColor: api.stale ? Theme.secondaryColor : Theme.primaryColor + onTapped: pageStack.push(Qt.resolvedUrl("SetpointsPage.qml")) + } + + ReadingCell { + width: (letture.width - letture.spacing) / 2 + title: qsTr("Hot water") + value: page.fmt(page.raw("dhw_current")) + " °C" + subtitle: qsTr("target %1 °C").arg(page.fmt(page.raw("dhw_target"))) + progress: page.waterProgress() + showBar: true + valueColor: api.stale ? Theme.secondaryColor : Theme.primaryColor + onTapped: pageStack.push(Qt.resolvedUrl("SetpointsPage.qml")) + } } Label { visible: api.configured x: Theme.horizontalPageMargin width: parent.width - 2 * Theme.horizontalPageMargin - text: page.statusDetail() + text: qsTr("Tap a value to adjust it") 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). + // ---- azioni rapide --------------------------------------------- // + SectionHeader { + visible: api.configured + text: qsTr("Quick actions") + } + + Row { + visible: api.configured + x: Theme.horizontalPageMargin + width: parent.width - 2 * Theme.horizontalPageMargin + spacing: Theme.paddingSmall + + Button { + id: boostBtn + width: (parent.width - 2 * parent.spacing) / 3 + text: qsTr("Boost") + onClicked: remorse.execute(boostBtn, qsTr("Starting DHW boost"), + function() { api.sendCommand("hot_water_boost_on") }) + } + + Button { + id: assenzaBtn + width: (parent.width - 2 * parent.spacing) / 3 + // Il pulsante dice cosa succede premendolo, non lo stato. + text: page.raw("away_mode") ? qsTr("Back home") : qsTr("Away mode") + onClicked: { + if (page.raw("away_mode")) { + remorse.execute(assenzaBtn, qsTr("Away mode off"), + function() { api.sendCommand("away_mode_off") }) + } else { + remorse.execute(assenzaBtn, qsTr("Away mode on"), + function() { api.sendCommand("away_mode_on") }) + } + } + } + + Button { + width: (parent.width - 2 * parent.spacing) / 3 + text: qsTr("Commands") + onClicked: page.openCommands() + } + } + + // ---- letture di contorno (si toccano: portano a Impianto) ------ // BackgroundItem { - id: scheda visible: api.configured x: Theme.horizontalPageMargin width: parent.width - 2 * Theme.horizontalPageMargin - height: schedaCol.height + 3 * Theme.paddingMedium + height: contornoTxt.height + 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" + Label { + id: contornoTxt + anchors { + left: parent.left + right: parent.right + verticalCenter: parent.verticalCenter } + text: qsTr("Outside %1 °C · Thermostat %2 °C · Zones %3") + .arg(page.fmt(page.raw("outdoor_temperature"))) + .arg(page.fmt(page.raw("thermostat_temperature"))) + .arg(page.count("zones")) + color: Theme.secondaryColor + font.pixelSize: Theme.fontSizeExtraSmall + wrapMode: Text.Wrap } } - // ---- riscaldamento della casa ---------------------------------- // - SectionHeader { visible: api.configured; text: qsTr("Heating the house") } - - Label { - visible: api.configured - anchors.horizontalCenter: parent.horizontalCenter - 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 - } - - Slider { - id: casaSlider - visible: api.configured - width: parent.width - label: qsTr("New house target") - minimumValue: 5 - maximumValue: 30 - stepSize: 0.5 - value: page.targetCasa - valueText: page.fmt(value) + " °C" - onValueChanged: { - page.targetCasaTouched = true - page.targetCasa = Math.round(value * 2) / 2 - } - } - - Button { - id: setCasaButton - visible: api.configured - anchors { - left: parent.left - right: parent.right - leftMargin: Theme.horizontalPageMargin - rightMargin: Theme.horizontalPageMargin - } - text: qsTr("Set house to %1 °C").arg(page.fmt(page.targetCasa)) - onClicked: remorse.execute(setCasaButton, - qsTr("House temperature to %1 °C").arg(page.fmt(page.targetCasa)), - function() { api.sendCommand("set_heating_setpoint", page.targetCasa) }) - } - - // ---- acqua calda sanitaria ------------------------------------- // - SectionHeader { visible: api.configured; text: qsTr("Domestic hot water") } - - ProgressBar { - visible: api.configured - anchors.horizontalCenter: parent.horizontalCenter - width: parent.width - 4 * Theme.horizontalPageMargin - minimumValue: 20 - maximumValue: 65 - value: { - var v = page.raw("dhw_current") - return (v === undefined || v === null) ? 0 : Number(v) - } - } - - Slider { - visible: api.configured - width: parent.width - label: qsTr("New target") - minimumValue: 45 - maximumValue: 60 - stepSize: 1 - value: page.targetAcs - valueText: page.fmt(value) + " °C" - onValueChanged: { - page.targetAcsTouched = true - page.targetAcs = Math.round(value) - } - } - - Button { - id: setAcsButton - visible: api.configured - anchors { - left: parent.left - right: parent.right - leftMargin: Theme.horizontalPageMargin - rightMargin: Theme.horizontalPageMargin - } - text: qsTr("Set target %1 °C").arg(page.targetAcs) - onClicked: remorse.execute(setAcsButton, - qsTr("DHW target to %1 °C").arg(page.targetAcs), - function() { api.sendCommand("set_hot_water_target", page.targetAcs) }) - } - - Button { - id: boostOnButton - visible: api.configured - anchors { - left: parent.left - right: parent.right - leftMargin: Theme.horizontalPageMargin - rightMargin: Theme.horizontalPageMargin - } - text: qsTr("Start DHW boost") - onClicked: remorse.execute(boostOnButton, qsTr("Starting DHW boost"), - function() { api.sendCommand("hot_water_boost_on") }) - } - - Button { - id: boostOffButton - visible: api.configured - anchors { - left: parent.left - right: parent.right - leftMargin: Theme.horizontalPageMargin - rightMargin: Theme.horizontalPageMargin - } - text: qsTr("Stop DHW boost") - onClicked: remorse.execute(boostOffButton, qsTr("Stopping DHW boost"), - function() { api.sendCommand("hot_water_boost_off") }) - } - - // ---- altre pagine ---------------------------------------------- // - SectionHeader { visible: api.configured; text: qsTr("Details") } - - 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 - anchors { - left: parent.left - right: parent.right - leftMargin: Theme.horizontalPageMargin - rightMargin: Theme.horizontalPageMargin - } - text: qsTr("Other commands") - onClicked: page.openCommands() - } - - BusyIndicator { - visible: api.busy - anchors.horizontalCenter: parent.horizontalCenter - size: BusyIndicatorSize.Small - running: true - } - - Label { - visible: api.configured - x: Theme.horizontalPageMargin - width: parent.width - 2 * Theme.horizontalPageMargin - text: qsTr("Commands are sent to the bridge and from there to the plant: they may take a few seconds.") - color: Theme.secondaryColor - font.pixelSize: Theme.fontSizeExtraSmall - wrapMode: Text.Wrap - } + Item { width: 1; height: Theme.paddingMedium } } } - Component.onCompleted: { - if (api.configured) - api.refresh() - } + NoticeBar { } } diff --git a/qml/pages/SetpointsPage.qml b/qml/pages/SetpointsPage.qml new file mode 100644 index 0000000..975990c --- /dev/null +++ b/qml/pages/SetpointsPage.qml @@ -0,0 +1,234 @@ +import QtQuick 2.2 +import Sailfish.Silica 1.0 +import "../components" + +// Regolazioni: le grandezze che si cambiano (obiettivo casa, obiettivo acqua +// calda) e il boost, ognuna con il valore attuale sempre visibile, le +// preimpostazioni per le dita fredde e la conferma esplicita. +// +// Pagina a parte perche' la Home deve restare una schermata da guardare: due +// slider dentro la pagina che scorre si contendevano anche il trascinamento. +// Qui lo slider e' la PROPOSTA, il numero grande e' quello vero dell'impianto: +// i due coincidono solo dopo "Imposta". +Page { + id: page + + allowedOrientations: Orientation.All + + property real pendingHouse: 0 + property bool houseTouched: false + property real pendingWater: 0 + property bool waterTouched: false + + function raw(key) { + return api.summary[key] + } + + function fmt(value) { return api.formatValue(value) } + + // Obiettivo vero dell'impianto (zona 1), a passi di mezzo grado. + function houseTarget() { + var v = page.raw("zone1_setpoint") + if (v === undefined || v === null || Number(v) <= 0) + return 0 + return Math.round(Number(v) * 2) / 2 + } + + function waterTarget() { + var v = page.raw("dhw_target") + if (v === undefined || v === null) + return 0 + return Math.round(Number(v)) + } + + // Allinea le proposte ai valori dell'impianto, ma non sovrascrive quello + // che l'utente sta scegliendo. + function syncFromPlant() { + if (!page.houseTouched) + page.pendingHouse = page.houseTarget() + if (!page.waterTouched) + page.pendingWater = page.waterTarget() + } + + function houseChanged() { return Math.abs(page.pendingHouse - page.houseTarget()) > 0.001 } + function waterChanged() { return Math.abs(page.pendingWater - page.waterTarget()) > 0.001 } + + Component.onCompleted: { + page.syncFromPlant() + if (api.configured) + api.refresh() + } + + Connections { + target: api + onSummaryChanged: page.syncFromPlant() + } + + 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("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("Setpoints") } + + // ---- riscaldamento casa ---------------------------------------- // + SectionHeader { text: qsTr("Heating the house") } + + ValueRow { + title: qsTr("Current target") + subtitle: qsTr("What the plant is keeping now") + value: page.fmt(page.houseTarget()) + " °C" + } + + Slider { + id: casaSlider + width: parent.width + label: qsTr("New house target") + minimumValue: 5 + maximumValue: 30 + stepSize: 0.5 + value: page.pendingHouse + valueText: page.fmt(value) + " °C" + onValueChanged: { + // Solo un movimento vero dell'utente segna "toccato": il + // valore che arriva dall'impianto non deve bloccarlo. + if (Math.abs(page.pendingHouse - value) > 0.001) { + page.houseTouched = true + page.pendingHouse = Math.round(value * 2) / 2 + } + } + } + + Row { + x: Theme.horizontalPageMargin + width: col.width - 2 * Theme.horizontalPageMargin + spacing: Theme.paddingSmall + + Repeater { + model: [16, 17, 18, 19, 20, 21] + + Button { + width: (col.width - 2 * Theme.horizontalPageMargin + - 5 * Theme.paddingSmall) / 6 + text: modelData + "°" + onClicked: { + page.houseTouched = true + page.pendingHouse = modelData + } + } + } + } + + ActionButton { + text: qsTr("Set house to %1 °C").arg(page.fmt(page.pendingHouse)) + confirmText: qsTr("House temperature to %1 °C").arg(page.fmt(page.pendingHouse)) + enabled: page.houseChanged() + onTriggered: api.sendCommand("set_heating_setpoint", page.pendingHouse) + } + + // ---- acqua calda sanitaria ------------------------------------- // + SectionHeader { text: qsTr("Domestic hot water") } + + ValueRow { + title: qsTr("DHW target") + subtitle: qsTr("Water now %1 °C").arg(page.fmt(page.raw("dhw_current"))) + value: page.fmt(page.waterTarget()) + " °C" + } + + Slider { + id: acquaSlider + width: parent.width + label: qsTr("New target") + minimumValue: 45 + maximumValue: 60 + stepSize: 1 + value: page.pendingWater + valueText: page.fmt(value) + " °C" + onValueChanged: { + if (Math.abs(page.pendingWater - value) > 0.001) { + page.waterTouched = true + page.pendingWater = Math.round(value) + } + } + } + + Row { + x: Theme.horizontalPageMargin + width: col.width - 2 * Theme.horizontalPageMargin + spacing: Theme.paddingSmall + + Repeater { + model: [45, 47, 50, 55, 60] + + Button { + width: (col.width - 2 * Theme.horizontalPageMargin + - 4 * Theme.paddingSmall) / 5 + text: modelData + "°" + onClicked: { + page.waterTouched = true + page.pendingWater = modelData + } + } + } + } + + ActionButton { + text: qsTr("Set target %1 °C").arg(page.fmt(page.pendingWater)) + confirmText: qsTr("DHW target to %1 °C").arg(page.fmt(page.pendingWater)) + enabled: page.waterChanged() + onTriggered: api.sendCommand("set_hot_water_target", page.pendingWater) + } + + // ---- boost ------------------------------------------------------ // + SectionHeader { text: qsTr("Hot water boost") } + + ActionButton { + text: qsTr("Start DHW boost") + confirmText: qsTr("Starting DHW boost") + onTriggered: api.sendCommand("hot_water_boost_on") + } + + ActionButton { + text: qsTr("Stop DHW boost") + confirmText: qsTr("Stopping DHW boost") + onTriggered: api.sendCommand("hot_water_boost_off") + } + + Label { + x: Theme.horizontalPageMargin + width: parent.width - 2 * Theme.horizontalPageMargin + text: qsTr("Commands go through the bridge and may take a few seconds. The status refreshes by itself afterwards.") + color: Theme.secondaryColor + font.pixelSize: Theme.fontSizeExtraSmall + wrapMode: Text.Wrap + } + + Item { width: 1; height: Theme.paddingMedium } + } + } + + NoticeBar { } +} diff --git a/src/airajson.cpp b/src/airajson.cpp index 4d687f7..7ec6cbb 100644 --- a/src/airajson.cpp +++ b/src/airajson.cpp @@ -198,4 +198,82 @@ QString errorMessage(const QByteArray &body, int httpStatus) return QCoreApplication::translate("AiraJson", "HTTP error %1").arg(httpStatus); } +namespace { + +// Flag booleano del riassunto, con falso come ripiego. +bool flagValue(const QVariantMap &summary, const QString &key) +{ + return summary.value(key).toBool(); +} + +int errorCount(const QVariantMap &summary) +{ + return summary.value(QStringLiteral("errors_count")).toInt(); +} + +bool pumpRunning(const QVariantMap &summary) +{ + return summary.value(QStringLiteral("pump_active_state")).toString() + .contains(QStringLiteral("RUNNING")); +} + +} // namespace + +QString stateShort(const QVariantMap &summary) +{ + if (summary.isEmpty()) + return QCoreApplication::translate("AiraJson", "No data"); + const int errs = errorCount(summary); + if (errs > 0) + return (errs == 1) + ? QCoreApplication::translate("AiraJson", "Attention: one error") + : QCoreApplication::translate("AiraJson", "Attention: %1 errors").arg(errs); + if (flagValue(summary, QStringLiteral("away_mode"))) + return QCoreApplication::translate("AiraJson", "Away mode"); + if (flagValue(summary, QStringLiteral("night_mode"))) + return QCoreApplication::translate("AiraJson", "Night mode"); + if (flagValue(summary, QStringLiteral("force_heating"))) + return QCoreApplication::translate("AiraJson", "Heating at full power"); + if (flagValue(summary, QStringLiteral("hot_water_heating"))) + return QCoreApplication::translate("AiraJson", "Heating the hot water"); + if (pumpRunning(summary)) + return QCoreApplication::translate("AiraJson", "Working"); + return QCoreApplication::translate("AiraJson", "Everything is fine"); +} + +QString stateLong(const QVariantMap &summary) +{ + if (summary.isEmpty()) + return QString(); + const int errs = errorCount(summary); + if (errs > 0) + return (errs == 1) + ? QCoreApplication::translate("AiraJson", "Attention: one error on the plant") + : QCoreApplication::translate("AiraJson", "Attention: %1 errors on the plant").arg(errs); + if (flagValue(summary, QStringLiteral("away_mode"))) + return QCoreApplication::translate("AiraJson", "Away mode: the house is saving energy."); + if (flagValue(summary, QStringLiteral("night_mode"))) + return QCoreApplication::translate("AiraJson", "Night mode is on."); + if (flagValue(summary, QStringLiteral("force_heating"))) + return QCoreApplication::translate("AiraJson", "Boost: the house is heating at full power."); + if (flagValue(summary, QStringLiteral("hot_water_heating"))) + return QCoreApplication::translate("AiraJson", "The heat pump is heating the hot water."); + if (pumpRunning(summary)) + return QCoreApplication::translate("AiraJson", "The heat pump is working."); + return QCoreApplication::translate("AiraJson", "Relax: the heat pump is running smoothly."); +} + +int statusLevel(const QVariantMap &summary) +{ + if (summary.isEmpty()) + return 1; + if (errorCount(summary) > 0) + return 2; + if (flagValue(summary, QStringLiteral("away_mode")) + || flagValue(summary, QStringLiteral("night_mode")) + || flagValue(summary, QStringLiteral("force_heating"))) + return 1; + return 0; +} + } // namespace AiraJson diff --git a/src/airajson.h b/src/airajson.h index 886d2b3..5bf4759 100644 --- a/src/airajson.h +++ b/src/airajson.h @@ -30,6 +30,18 @@ QVariantMap parseStats(const QByteArray &body, bool *ok); // Codice di stato Aira ("OPERATING_STATUS_AUTO") in etichetta italiana. QString modeLabel(const QString &operatingStatus); +// Riga di stato per la Home: breve, per stare in una riga sola +// ("Tutto regolare", "In assenza", "Attenzione: un errore"). +QString stateShort(const QVariantMap &summary); + +// Frase estesa per la pagina Impianto (quella dell'app ufficiale: +// "Relax: la pompa di calore funziona regolarmente"). +QString stateLong(const QVariantMap &summary); + +// Livello dello stato, per il colore della riga: 0 regolare, 1 modo attivo +// (assenza, notte, riscaldamento forzato), 2 errore sull'impianto. +int statusLevel(const QVariantMap &summary); + // Temperatura leggibile: "51,0" oppure "—" se il valore manca. QString formatValue(const QVariant &value); diff --git a/src/apiclient.cpp b/src/apiclient.cpp index 78ffc34..4eeeb21 100644 --- a/src/apiclient.cpp +++ b/src/apiclient.cpp @@ -3,8 +3,10 @@ #include "settings.h" #include +#include #include #include +#include #include #include @@ -12,7 +14,32 @@ ApiClient::ApiClient(Settings *settings, QObject *parent) : QObject(parent) , m_settings(settings) , m_busy(false) + , m_stale(false) + , m_noticeIsError(false) + , m_noticeTimer(0) { + // Il messaggio dell'ultimo comando (o dell'ultimo errore) vale per ogni + // pagina: lo stato sta qui, si spegne da solo e la UI lo mostra ancorato + // in fondo alla schermata (NoticeBar), fuori dal contenuto che scorre. + m_noticeTimer = new QTimer(this); + m_noticeTimer->setSingleShot(true); + m_noticeTimer->setInterval(7000); + connect(m_noticeTimer, &QTimer::timeout, this, [this]() { + if (m_notice.isEmpty()) + return; + m_notice.clear(); + emit noticeChanged(); + }); + connect(this, &ApiClient::errorOccurred, this, [this](const QString &message) { + setNotice(message, true); + }); + connect(this, &ApiClient::commandFinished, this, [this](bool ok, const QString &message) { + setNotice(message, !ok); + }); + connect(this, &ApiClient::statsRefreshed, this, [this](bool ok, const QString &message) { + setNotice(message, !ok); + }); + if (m_settings) { // La UI deve riflettere subito il passaggio da "non configurato" a // "configurato" quando si salvano indirizzo o token. @@ -29,6 +56,30 @@ bool ApiClient::configured() const && !m_settings->token().trimmed().isEmpty(); } +QString ApiClient::stateShort() const +{ + return AiraJson::stateShort(m_summary); +} + +QString ApiClient::stateLong() const +{ + return AiraJson::stateLong(m_summary); +} + +int ApiClient::statusLevel() const +{ + return AiraJson::statusLevel(m_summary); +} + +void ApiClient::setNotice(const QString &message, bool isError) +{ + m_notice = message; + m_noticeIsError = isError; + emit noticeChanged(); + if (!message.isEmpty()) + m_noticeTimer->start(); +} + void ApiClient::setBusy(bool value) { if (value == m_busy) @@ -102,6 +153,10 @@ void ApiClient::refresh() ? tr("Server unreachable (timeout)") : AiraJson::errorMessage(body, status); qWarning() << "[api] summary non riuscito:" << status << msg; + if (!m_stale) { + m_stale = true; + emit staleChanged(); + } emit errorOccurred(msg); return; } @@ -110,10 +165,22 @@ void ApiClient::refresh() const QVariantMap summary = AiraJson::parseSummary(body, &ok); if (!ok) { qWarning() << "[api] riassunto non interpretabile"; + if (!m_stale) { + m_stale = true; + emit staleChanged(); + } emit errorOccurred(tr("Unreadable response from the bridge")); return; } m_summary = summary; + if (m_stale) { + m_stale = false; + emit staleChanged(); + } + // Ora dell'ultimo aggiornamento riuscito: la Home la mostra, cosi' un + // numero vecchio non passa per un numero fresco. + m_lastUpdate = QLocale::system().toString(QTime::currentTime(), QLocale::ShortFormat); + emit lastUpdateChanged(); emit summaryChanged(summary); }); } diff --git a/src/apiclient.h b/src/apiclient.h index 04d25c7..6468d14 100644 --- a/src/apiclient.h +++ b/src/apiclient.h @@ -7,6 +7,7 @@ #include class QNetworkReply; +class QTimer; class Settings; // Client HTTP verso il bridge sul server (~/workspace/Carlo/Aira/aira_bridge.py). @@ -24,6 +25,16 @@ class ApiClient : public QObject // 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) + // Salute dell'aggiornamento: "stale" = l'ultimo tentativo non e' riuscito, + // quindi i numeri a schermo sono vecchi e vanno mostrati spenti. + Q_PROPERTY(bool stale READ stale NOTIFY staleChanged) + Q_PROPERTY(QString lastUpdate READ lastUpdate NOTIFY lastUpdateChanged) + Q_PROPERTY(bool hasData READ hasData NOTIFY summaryChanged) + // Messaggio dell'ultimo comando (o errore), mostrato in fondo alla pagina: + // sta qui e non nel QML perche' vale per OGNI pagina, e perche' cosi' il + // messaggio e' lo stesso ovunque si stia. + Q_PROPERTY(QString notice READ notice NOTIFY noticeChanged) + Q_PROPERTY(bool noticeIsError READ noticeIsError NOTIFY noticeChanged) public: explicit ApiClient(Settings *settings, QObject *parent = 0); @@ -32,6 +43,18 @@ public: bool configured() const; QVariantMap summary() const { return m_summary; } QVariantMap stats() const { return m_stats; } + bool stale() const { return m_stale; } + QString lastUpdate() const { return m_lastUpdate; } + bool hasData() const { return !m_summary.isEmpty(); } + QString notice() const { return m_notice; } + bool noticeIsError() const { return m_noticeIsError; } + + // Stato leggibile in una riga (stateShort) e per esteso (stateLong), piu' + // il livello da cui la UI prende il colore. Stanno in C++ perche' sono + // logica pura: verificate in tests/core_test.cpp e non duplicate fra pagine. + Q_INVOKABLE QString stateShort() const; + Q_INVOKABLE QString stateLong() const; + Q_INVOKABLE int statusLevel() const; // Scarica il riassunto e lo emette con summaryChanged(). Q_INVOKABLE void refresh(); @@ -56,6 +79,9 @@ public: signals: void busyChanged(); void configuredChanged(); + void staleChanged(); + void lastUpdateChanged(); + void noticeChanged(); void summaryChanged(const QVariantMap &summary); void statsChanged(const QVariantMap &stats); void statsRefreshed(bool ok, const QString &message); @@ -68,10 +94,17 @@ private: const QByteArray &body, int timeoutSec); void armTimeout(QNetworkReply *reply, int seconds); void setBusy(bool value); + // Mostra un messaggio in fondo alla pagina per qualche secondo. + void setNotice(const QString &message, bool isError); Settings *m_settings; QNetworkAccessManager m_nam; bool m_busy; + bool m_stale; + QString m_lastUpdate; + QString m_notice; + bool m_noticeIsError; + QTimer *m_noticeTimer; QVariantMap m_summary; QVariantMap m_stats; }; diff --git a/tests/core_test.cpp b/tests/core_test.cpp index 9927f5a..dbb7ecf 100644 --- a/tests/core_test.cpp +++ b/tests/core_test.cpp @@ -190,6 +190,67 @@ int main(int argc, char *argv[]) } } + // --- stato in una riga e per esteso (Home e pagina Impianto) ----------- // + { + QVariantMap calmo; + calmo.insert("errors_count", 0); + calmo.insert("away_mode", false); + calmo.insert("night_mode", false); + calmo.insert("force_heating", false); + calmo.insert("hot_water_heating", false); + calmo.insert("pump_active_state", "PUMP_ACTIVE_STATE_IDLE"); + check(AiraJson::stateShort(calmo) == "Everything is fine", + "stato breve: nessuna attivita'"); + check(AiraJson::stateLong(calmo) == "Relax: the heat pump is running smoothly.", + "stato esteso: frase dell'app ufficiale"); + check(AiraJson::statusLevel(calmo) == 0, "livello: regolare"); + + QVariantMap lavora = calmo; + lavora.insert("pump_active_state", "PUMP_ACTIVE_STATE_RUNNING"); + check(AiraJson::stateShort(lavora) == "Working", "stato breve: pompa in funzione"); + check(AiraJson::statusLevel(lavora) == 0, + "livello: pompa in funzione e' comunque regolare"); + + QVariantMap acqua = calmo; + acqua.insert("hot_water_heating", true); + check(AiraJson::stateShort(acqua) == "Heating the hot water", + "stato breve: sta scaldando l'acqua"); + check(AiraJson::stateLong(acqua) == "The heat pump is heating the hot water.", + "stato esteso: acqua calda in carica"); + + QVariantMap assente = calmo; + assente.insert("away_mode", true); + check(AiraJson::stateShort(assente) == "Away mode", "stato breve: assenza"); + check(AiraJson::statusLevel(assente) == 1, "livello: modo attivo, non regolare"); + + QVariantMap uno = calmo; + uno.insert("errors_count", 1); + check(AiraJson::stateShort(uno) == "Attention: one error", + "stato breve: un errore (singolare)"); + check(AiraJson::stateLong(uno) == "Attention: one error on the plant", + "stato esteso: un errore"); + check(AiraJson::statusLevel(uno) == 2, "livello: errore"); + + QVariantMap due = calmo; + due.insert("errors_count", 2); + check(AiraJson::stateShort(due) == "Attention: 2 errors", + "stato breve: due errori (plurale)"); + check(AiraJson::stateLong(due) == "Attention: 2 errors on the plant", + "stato esteso: due errori"); + + // L'errore ha la precedenza su tutto: e' quello che va visto per primo. + QVariantMap misto = assente; + misto.insert("errors_count", 3); + check(AiraJson::stateShort(misto) == "Attention: 3 errors", + "stato breve: l'errore vince sul modo attivo"); + check(AiraJson::statusLevel(misto) == 2, "livello: errore prima di tutto"); + + check(AiraJson::stateShort(QVariantMap()) == "No data", + "stato breve: nessun dato"); + check(AiraJson::stateLong(QVariantMap()).isEmpty(), + "stato esteso: nessun dato = nessuna frase"); + } + qInfo() << "----"; qInfo() << checks - failures << "/" << checks << "verifiche superate"; return failures == 0 ? 0 : 1; diff --git a/translations/harbour-aira-it.ts b/translations/harbour-aira-it.ts index d8df8a6..0a59ade 100644 --- a/translations/harbour-aira-it.ts +++ b/translations/harbour-aira-it.ts @@ -63,70 +63,155 @@ HTTP error %1 errore HTTP %1 + + + No data + Nessun dato + + + + Attention: one error + Attenzione: un errore + + + + Attention: %1 errors + Attenzione: %1 errori + + + + Away mode + Assenza + + + + Night mode + Modalità notte + + + + Heating at full power + Riscaldamento al massimo + + + + Heating the hot water + Sta scaldando l'acqua + + + + Working + In funzione + + + + Everything is fine + Tutto regolare + + + + 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. + 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 - + 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 @@ -434,263 +519,111 @@ MainPage - yes - sì + + No data yet + Nessun dato - no - no + + Not updated · last %1 + Non aggiornato · ultimo %1 - + + Updated at %1 + Aggiornato alle %1 + + + Refresh Aggiorna - - + + Settings Impostazioni - - - Other commands - Altri comandi + + House + Casa - - Attention: one error on the plant - Attenzione: un errore sull'impianto + + + target %1 °C + obiettivo %1 °C - - Attention: %1 errors on the plant - Attenzione: %1 errori sull'impianto + + Hot water + Acqua calda - - Away mode: the house is saving energy. - Modalità assenza: la casa sta risparmiando. + + Tap a value to adjust it + Tocca un valore per regolarlo - - Night mode is on. - Modalità notte attiva. + + Quick actions + Azioni rapide - - Boost: the house is heating at full power. - Boost: la casa sta scaldando a pieno regime. + + Boost + Boost - - The heat pump is heating the hot water. - La pompa di calore sta scaldando l'acqua calda. + + Back home + Torna a casa - - The heat pump is working. - La pompa di calore sta lavorando. + + Away mode off + Assenza disattivata - - Relax: the heat pump is running smoothly. - Relax: la pompa di calore funziona regolarmente. + + Away mode on + Assenza attivata - - %1 · last reading %2 - %1 · ultima lettura %2 + + Commands + Comandi - + + Outside %1 °C · Thermostat %2 °C · Zones %3 + Esterno %1 °C · Termostato %2 °C · Zone %3 + + + 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 - - - Indoor temperature (pump) - 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. + Assenza @@ -886,6 +819,124 @@ Errori segnalati + + SetpointsPage + + + Refresh + Aggiorna + + + + Data + Dati + + + + Plant + Impianto + + + + Settings + Impostazioni + + + + Setpoints + Regolazioni + + + + Heating the house + Riscaldamento della casa + + + + Current target + Obiettivo attuale + + + + What the plant is keeping now + Quello che l'impianto mantiene adesso + + + + 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 + + + + Water now %1 °C + Acqua ora %1 °C + + + + New target + Nuovo obiettivo + + + + Set target %1 °C + Imposta obiettivo %1 °C + + + + DHW target to %1 °C + Obiettivo ACS a %1 °C + + + + Hot water boost + Boost acqua calda + + + + 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 + + + + 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. + + SettingsPage