0.5.0: riordino come l'app ufficiale (home, Dati, Impianto)

- home: temperatura esterna e in casa/assente in alto, messaggio di stato,
  scheda impianto con temperatura stanza e acqua calda; comandi sotto
- pagina Dati: calore prodotto, elettricita' usata, COP acqua calda e
  riscaldamento, risparmio, CO2 evitata, Smart Tariff Control (mese per mese)
- pagina Impianto: stato, termostato a parete, versioni software, errori
- bridge 0.4.0: rotta /stats (servizio statistiche del cloud) e blocco
  dispositivo in /summary (ID breve, versioni, termostato)
- C++: parseStats, formatNumber, formatMoney, monthLabel (separatore e
  valuta secondo la lingua); 61/61 verifiche del nucleo
- anteprime aggiornate (dati, impianto) in inglese e italiano
This commit is contained in:
2026-10-10 08:59:03 +02:00
parent ec7333a185
commit 4f279a18b0
30 changed files with 2355 additions and 353 deletions
+74 -7
View File
@@ -5,6 +5,7 @@
#include <QJsonObject>
#include <QJsonValue>
#include <QLocale>
#include <QStringList>
#include <QUrl>
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 &currencyCode)
{
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)
+15
View File
@@ -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 &currencyCode);
// "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);
+58
View File
@@ -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 &currencyCode) 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()) {
+13
View File
@@ -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 &currencyCode) 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
+2 -2
View File
@@ -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);