Files
harbour-aira/src/apiclient.cpp
T
Carlo 35297a0fa0 0.7.5: il corpo JSON delle misure (AiraJson::layoutPayload)
send() prende QByteArray, non la mappa: la build l'ha detto subito.
2026-10-11 17:18:40 +02:00

396 lines
13 KiB
C++

#include "apiclient.h"
#include "airajson.h"
#include "settings.h"
#include <QDebug>
#include <QLocale>
#include <QNetworkRequest>
#include <QNetworkReply>
#include <QTime>
#include <QTimer>
#include <QUrl>
ApiClient::ApiClient(Settings *settings, QObject *parent)
: QObject(parent)
, m_settings(settings)
, m_busy(false)
, m_stale(false)
, m_noticeIsError(false)
, m_noticeTimer(0)
, m_refreshTimer(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);
});
// Dopo un comando riuscito lo stato si rilegge da solo: l'impianto applica
// in qualche istante, e senza questa rilettura gli interruttori della
// pagina dei comandi resterebbero indietro rispetto alla pompa.
m_refreshTimer = new QTimer(this);
m_refreshTimer->setSingleShot(true);
m_refreshTimer->setInterval(2000);
connect(m_refreshTimer, &QTimer::timeout, this, &ApiClient::refresh);
connect(this, &ApiClient::commandFinished, this, [this](bool ok, const QString &) {
if (ok)
m_refreshTimer->start();
});
// Un secondo alla volta mentre il boost e' in corso: i secondi veri li da'
// il bridge a ogni rilettura (ogni 30 s), qui si scorre in mezzo.
m_boostActive = false;
m_boostRemaining = 0;
m_boostTimer = new QTimer(this);
m_boostTimer->setInterval(1000);
connect(m_boostTimer, &QTimer::timeout, this, &ApiClient::tickBoost);
if (m_settings) {
// La UI deve riflettere subito il passaggio da "non configurato" a
// "configurato" quando si salvano indirizzo o token.
connect(m_settings, &Settings::baseUrlChanged, this, &ApiClient::configuredChanged);
connect(m_settings, &Settings::tokenChanged, this, &ApiClient::configuredChanged);
}
}
bool ApiClient::configured() const
{
if (!m_settings)
return false;
return !AiraJson::normalizeBaseUrl(m_settings->baseUrl()).isEmpty()
&& !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)
return;
m_busy = value;
emit busyChanged();
}
void ApiClient::armTimeout(QNetworkReply *reply, int seconds)
{
// QNetworkAccessManager non applica NESSUN timeout: senza watchdog una
// richiesta verso un host irraggiungibile resta appesa e la UI sembra morta.
QTimer *t = new QTimer(reply); // figlio della reply: si pulisce da solo
t->setSingleShot(true);
t->setInterval(seconds * 1000);
connect(t, &QTimer::timeout, reply, [reply]() {
if (reply->isRunning())
reply->abort();
});
connect(reply, &QNetworkReply::finished, t, &QTimer::stop);
t->start();
}
QNetworkReply *ApiClient::send(const QByteArray &verb, const QString &path,
const QByteArray &body, int timeoutSec)
{
const QString base = AiraJson::normalizeBaseUrl(m_settings ? m_settings->baseUrl() : QString());
if (base.isEmpty()) {
emit errorOccurred(tr("Bridge address not configured"));
return 0;
}
const QUrl url(base + path);
QNetworkRequest request(url); // variabile intermedia: evita il most-vexing-parse
request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json"));
request.setRawHeader("Authorization", "Bearer " + m_settings->token().toUtf8());
QNetworkReply *reply = 0;
if (verb == "GET")
reply = m_nam.get(request);
else
reply = m_nam.post(request, body);
armTimeout(reply, timeoutSec);
return reply;
}
void ApiClient::refresh()
{
if (!configured()) {
emit errorOccurred(tr("Set the bridge address and token"));
return;
}
setBusy(true);
QNetworkReply *reply = send("GET", QStringLiteral("/summary"), QByteArray(), 15);
if (!reply) {
setBusy(false);
return;
}
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
reply->deleteLater();
setBusy(false);
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("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;
}
bool ok = false;
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;
setBoostFromSummary(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);
});
}
void ApiClient::testConnection()
{
const QString base = AiraJson::normalizeBaseUrl(m_settings ? m_settings->baseUrl() : QString());
if (base.isEmpty()) {
emit connectionTested(false, tr("Enter the bridge address"));
return;
}
// /health non richiede token: cosi' si distingue "server irraggiungibile"
// da "token sbagliato".
const QUrl url(base + QStringLiteral("/health"));
QNetworkRequest request(url); // variabile intermedia (most vexing parse)
QNetworkReply *reply = m_nam.get(request);
armTimeout(reply, 10);
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
reply->deleteLater();
const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
if (reply->error() != QNetworkReply::NoError || status != 200) {
const QString msg = (reply->error() == QNetworkReply::OperationCanceledError)
? tr("Timeout: no response")
: tr("Unreachable (HTTP %1)").arg(status);
emit connectionTested(false, msg);
return;
}
emit connectionTested(true, tr("Bridge reachable"));
});
}
QString ApiClient::modeLabel(const QString &operatingStatus) const
{
return AiraJson::modeLabel(operatingStatus);
}
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());
});
}
// Le misure della Home vanno al bridge, che le scrive nel suo registro: cosi'
// per correggere la disposizione non servono ne' terminale sul telefono ne'
// screenshot da interpretare a occhio.
void ApiClient::reportLayout(const QVariantMap &info)
{
QNetworkReply *reply = send("POST", QStringLiteral("/layout"),
AiraJson::layoutPayload(info), 5);
if (!reply)
return;
connect(reply, &QNetworkReply::finished, reply, &QObject::deleteLater);
}
void ApiClient::sendCommand(const QString &id, const QVariant &value, const QVariantMap &extra)
{
if (!configured()) {
emit commandFinished(false, tr("Set the bridge address and token"));
return;
}
const QByteArray payload = AiraJson::commandPayload(id, value, extra);
qDebug() << "[api] comando:" << id; // mai i valori: qui potrebbe passare altro
QNetworkReply *reply = send("POST", QStringLiteral("/command"), payload, 30);
if (!reply) {
emit commandFinished(false, tr("Bridge not configured"));
return;
}
setBusy(true);
connect(reply, &QNetworkReply::finished, this, [this, reply, id]() {
reply->deleteLater();
setBusy(false);
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: command not sent")
: AiraJson::errorMessage(body, status);
qWarning() << "[api] comando" << id << "fallito:" << status << msg;
emit commandFinished(false, msg);
return;
}
emit commandFinished(true, tr("Command sent"));
refresh(); // i valori letti cambiano: rileggo
});
}
QString ApiClient::boostCountdown() const
{
return AiraJson::countdown(m_boostRemaining);
}
// I secondi veri arrivano dal riassunto: il bridge li ricava dalla FINE del
// boost letta dall'impianto. Il timer li fa solo scorrere fra una lettura e
// l'altra, cosi' il conto non riparte da capo quando si riapre la app (e non
// sbaglia se il boost e' stato avviato da un'altra parte).
void ApiClient::setBoostFromSummary(const QVariantMap &summary)
{
const int restanti = summary.value(QStringLiteral("boost_remaining_seconds")).toInt();
const bool attivo = restanti > 0;
if (!attivo && !m_boostActive && m_boostRemaining == 0) {
m_boostTimer->stop(); // niente da mostrare: non disturbo
return;
}
m_boostRemaining = restanti;
m_boostActive = attivo;
if (attivo)
m_boostTimer->start();
else
m_boostTimer->stop();
emit boostChanged();
}
void ApiClient::tickBoost()
{
if (m_boostRemaining > 0)
--m_boostRemaining;
if (m_boostRemaining <= 0) {
m_boostRemaining = 0;
m_boostActive = false;
m_boostTimer->stop(); // arrivato a 00:00
}
emit boostChanged();
}