Files
shipheart-api/main.cpp
T

1495 lines
73 KiB
C++

#include <QCoreApplication>
#include <QHttpServer>
#include <QHttpHeaders>
#include <QTcpServer>
#include <QSqlDatabase>
#include <QSqlQuery>
#include <QSqlError>
#include <QJsonObject>
#include <QJsonArray>
#include <QJsonDocument>
#include <QUrlQuery>
#include <QDateTime>
#include <QMap>
#include <QVector>
#include <QPair>
#include <QDebug>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QRegularExpression>
#include "_version.h"
// ============================================================================
// shipheart_api - Phase 4 : première tranche verticale, domaine "circuit"
// ----------------------------------------------------------------------------
// Deux endpoints réels, remplaçant le spike (/ping conservé comme sonde de vie
// générique, /test-db retiré car superflu désormais) :
// - GET /elements/familles?type=circuit
// - GET /elements?type=circuit&famille=<id>
// Logique et requêtes SQL reprises à l'identique de home.qml (init_circuits(),
// mise_a_jour_onglets_circuits(), mise_a_jour_circuits() - cf synthèse §20.4)
// et des constantes documentées dans le schéma (TB_Circuits.Etat, cf §20).
// ============================================================================
// Types de familles (cf TB_Familles.Type_source / TB_Events.Type_source) :
static const int TYPE_SOURCE_CIRCUIT = 1;
static const int TYPE_SOURCE_CAPTEUR = 2;
static const int TYPE_SOURCE_RESERVOIR = 3;
static const int TYPE_SOURCE_BATTERIE = 4;
// États circuit (cf commentaire colonne TB_Circuits.Etat dans le schéma) :
static const int ETAT_CIRCUIT_OK = 0;
static const int ETAT_CIRCUIT_LIMITATION = 1;
static const int ETAT_CIRCUIT_PROTECTION = 2;
static const int ETAT_CIRCUIT_DISJONCTE = 3;
static const int ETAT_CIRCUIT_ABSENCE_TERRE = 8;
static const int ETAT_CIRCUIT_ABSENCE_MASSE = 9;
static const int ETAT_COMMANDE_CARTE_OK = 0;
// Types d'entrées capteur (cf commentaire colonne TB_Capteurs.Type_entrees) :
static const int TYPE_ENTREES_AUX = 1;
static const int TYPE_ENTREES_NMEA = 7;
static const int TYPE_ENTREES_CALCULEE_BOOLEEN = 20;
// Types de conversion d'unité (cf commentaire colonne TB_Variables.Conversion_unite,
// confirmé identique à shipheart_type_unite_* dans main.qml) :
static const int TYPE_UNITE_VOLUME = 1;
static const int TYPE_UNITE_TEMPERATURE = 2;
static const int TYPE_UNITE_LATITUDE = 3;
static const int TYPE_UNITE_LONGITUDE = 4;
// Unités cibles (cf main.qml, shipheart_unite_volume_*/shipheart_unite_temperature_*) :
static const int UNITE_VOLUME_LITRES = 0;
static const int UNITE_VOLUME_US_GALLONS = 1;
static const int UNITE_VOLUME_IMP_GALLONS = 2;
static const int UNITE_TEMPERATURE_CELSIUS = 0;
static const int UNITE_TEMPERATURE_FAHRENHEIT = 1;
// ----------------------------------------------------------------------------
// Conversions - traduction fidèle de fonctions.js (conversion_volume(),
// conversion_temperature(), chaine_latitude(), chaine_longitude()) et de
// fonctions_localisation.js (symbole_unite_volume(), symbole_unite_temperature()).
// ----------------------------------------------------------------------------
// Troncature d'une date à un palier propre selon la période d'analyse -
// traduction fidèle de ajustement_periode_analyse() (fonctions_date_heure.js
// natif). Appliquée à la fois à la date de début et à la date de fin :
// tronquer la fin vers le bas exclut naturellement l'intervalle "en cours"
// (pas encore terminé), sans étape séparée.
static QDateTime tronquer_periode(const QDateTime &dt, int periode)
{
QDateTime r = dt;
const QTime t = r.time();
switch (periode) {
case 0: // 1 heure -> palier de 5 minutes
r.setTime(QTime(t.hour(), (t.minute() / 5) * 5, 0));
break;
case 1: // 24 heures -> heure pleine
r.setTime(QTime(t.hour(), 0, 0));
break;
case 2: // 7 jours -> tranche de 6h
r.setTime(QTime((t.hour() / 6) * 6, 0, 0));
break;
default: // 30 jours -> minuit
r.setTime(QTime(0, 0, 0));
break;
}
return r;
}
static double conversion_volume(double val, int unite)
{
switch (unite) {
case UNITE_VOLUME_US_GALLONS: return val / 3.785411784;
case UNITE_VOLUME_IMP_GALLONS: return val / 4.546090;
case UNITE_VOLUME_LITRES:
default: return val;
}
}
static double conversion_temperature(double val, int unite)
{
switch (unite) {
case UNITE_TEMPERATURE_FAHRENHEIT: return (val * 1.8) + 32.0;
case UNITE_TEMPERATURE_CELSIUS:
default: return val;
}
}
static QString symbole_unite_volume(int unite)
{
switch (unite) {
case UNITE_VOLUME_US_GALLONS: return QStringLiteral("US Ga");
case UNITE_VOLUME_IMP_GALLONS: return QStringLiteral("Imp. Ga");
case UNITE_VOLUME_LITRES:
default: return QStringLiteral("lit.");
}
}
static QString symbole_unite_temperature(int unite)
{
return (unite == UNITE_TEMPERATURE_FAHRENHEIT) ? QStringLiteral("°F") : QStringLiteral("°C");
}
static QString chaine_latitude(double lat)
{
const int deg = static_cast<int>(lat);
const double minutes = (lat - deg) * 60.0;
const QString suffixe = (lat >= 0) ? QStringLiteral("N") : QStringLiteral("S");
return QString("%1°%2' %3").arg(deg).arg(minutes, 0, 'f', 3).arg(suffixe);
}
static QString chaine_longitude(double lon)
{
const int deg = static_cast<int>(lon);
const double minutes = (lon - deg) * 60.0;
const QString suffixe = (lon >= 0) ? QStringLiteral("E") : QStringLiteral("W");
return QString("%1°%2' %3").arg(deg).arg(minutes, 0, 'f', 3).arg(suffixe);
}
// ----------------------------------------------------------------------------
// En-têtes communs à toutes les réponses de l'API.
// NB (synthèse §49) : le nom "withCors" est historique. L'en-tête CORS
// "Access-Control-Allow-Origin: *" a été RETIRÉ : le client WASM appelle
// désormais l'API sous /api, sur la MÊME origine que la page (relais nginx du
// CM5, et Traefik avec vérification de la session Live à distance) - il n'y a
// plus de requête entre origines. Le conserver aurait permis à n'importe quel
// site ouvert dans le navigateur d'un utilisateur de lire les réponses de
// l'API. Les appels du VPS (archives) ne passent pas par un navigateur et ne
// sont pas concernés par CORS.
// ----------------------------------------------------------------------------
static QHttpServerResponse withCors(QHttpServerResponse &&resp)
{
QHttpHeaders headers = resp.headers();
// Empêche le navigateur de mettre en cache les réponses - sans ça, des
// requêtes identiques répétées (rafraîchissement périodique) pourraient
// recevoir une réponse mise en cache plutôt que la valeur réellement à
// jour en base.
headers.append(QHttpHeaders::WellKnownHeader::CacheControl, "no-store");
resp.setHeaders(std::move(headers));
return std::move(resp);
}
// ----------------------------------------------------------------------------
// Archives (cf synthèse §32) - répertoire d'upload périodique du bateau vers
// ship2net. Chemin à confirmer/ajuster si différent en pratique sur le CM5.
// ----------------------------------------------------------------------------
static const QString REPERTOIRE_ARCHIVES = "/home/koriolan/shipheart/server/upload";
// Un nom d'archive valide correspond STRICTEMENT au format généré côté CM5 :
// <Adresse_mac (12 hex)>_<Date_fichier (yyMMdd_HHmmss)>_<empreinte (16 hex)>.sql.gz.enc
// Validation impérative avant de construire un chemin sur le disque à partir
// d'un nom reçu en paramètre d'URL - sans elle, un nom arbitraire (par
// exemple "../../etc/passwd") pourrait faire lire ou supprimer n'importe quel
// fichier du système, pas seulement une archive légitime.
static bool nom_archive_est_valide(const QString &nom)
{
static const QRegularExpression motif(
"^[0-9A-Fa-f]{12}_\\d{6}_\\d{6}_[0-9A-Fa-f]{16}\\.sql\\.gz\\.enc$");
return motif.match(nom).hasMatch();
}
// ----------------------------------------------------------------------------
// Vérifie que la connexion MariaDB (ouverte une seule fois au démarrage) est
// toujours active, et la rétablit sinon. isOpen() peut rester vrai même après
// une coupure réseau ou un dépassement de wait_timeout côté serveur - un test
// léger (SELECT 1) confirme que la connexion répond réellement. Sans ce
// contrôle, l'API reste "vivante" indéfiniment mais chaque requête SQL échoue
// silencieusement après une longue période d'inactivité (constaté après
// plusieurs semaines d'exécution continue, cf synthèse).
// À appeler en tout premier dans chaque route qui touche la base.
// ----------------------------------------------------------------------------
static void assurer_connexion_bdd()
{
QSqlDatabase db = QSqlDatabase::database();
if (db.isOpen()) {
QSqlQuery test(db);
if (test.exec("SELECT 1")) return;
}
qWarning() << "Connexion MariaDB perdue, tentative de reconnexion...";
db.close();
if (db.open()) {
qInfo() << "Reconnexion MariaDB réussie.";
} else {
qCritical() << "Reconnexion MariaDB échouée :" << db.lastError().text();
}
}
int main(int argc, char *argv[])
{
QCoreApplication app(argc, argv);
qInfo() << "shipheart_api version" << VERSION;
// --- Connexion MariaDB locale ---
QSqlDatabase db = QSqlDatabase::addDatabase("QMYSQL");
db.setHostName("localhost");
db.setUserName("koriolan");
db.setPassword("Mp7Hgf12bV9zm");
db.setDatabaseName("shipheart");
if (!db.open()) {
qCritical() << "Connexion MariaDB échouée :" << db.lastError().text();
return 1;
}
qInfo() << "Connexion MariaDB réussie.";
QHttpServer server;
// Sonde de vie générique, sans BDD :
server.route("/ping", []() {
QJsonObject obj;
obj["status"] = "ok";
return withCors(QHttpServerResponse(QJsonDocument(obj).toJson()));
});
// ------------------------------------------------------------------
// GET /elements/familles?type=circuit
// Liste des familles avec état d'alarme agrégé (fusionne les deux
// requêtes onglets de mise_a_jour_onglets_circuits()).
// ------------------------------------------------------------------
server.route("/elements/familles", [](const QHttpServerRequest &request) {
assurer_connexion_bdd();
QUrlQuery query(request.query());
const QString type = query.queryItemValue("type");
int typeSource;
if (type == "circuits") typeSource = TYPE_SOURCE_CIRCUIT;
else if (type == "batteries") typeSource = TYPE_SOURCE_BATTERIE;
else if (type == "capteurs") typeSource = TYPE_SOURCE_CAPTEUR;
else if (type == "reservoirs") typeSource = TYPE_SOURCE_RESERVOIR;
else {
QJsonObject err;
err["status"] = "erreur";
err["message"] = "type non supporté (circuit, batterie, capteur, reservoir)";
return withCors(QHttpServerResponse(QJsonDocument(err).toJson(), QHttpServerResponder::StatusCode::NotImplemented));
}
QJsonArray familles;
QSqlQuery q1(QString("SELECT Cle_primaire, Nom FROM TB_Familles WHERE Type_source=%1 ORDER BY Priorite_affichage").arg(typeSource));
while (q1.next()) {
const int idFamille = q1.value(0).toInt();
const QString nom = q1.value(1).toString();
int etat = 0;
if (type == "circuits") {
// Alarmes (Events) liées à cette famille :
QSqlQuery q2;
q2.prepare("SELECT COUNT(*) FROM TB_Events WHERE Type_source=:type AND Validite=1 AND Alarme_source>0 "
"AND Etat_test=1 AND ID_Source IN (SELECT Cle_primaire FROM TB_Circuits WHERE ID_Famille=:famille)");
q2.bindValue(":type", typeSource);
q2.bindValue(":famille", idFamille);
q2.exec();
if (q2.next() && q2.value(0).toInt() > 0) etat = 1;
// État agrégé des circuits de la famille :
QSqlQuery q3;
q3.prepare("SELECT Etat, Etat_commande_carte, Etat_systeme FROM TB_Circuits "
"WHERE ID_Famille=:famille AND Priorite_affichage_accueil>0");
q3.bindValue(":famille", idFamille);
q3.exec();
while (q3.next()) {
const int etatCircuit = q3.value(0).toInt();
const int etatCommandeCarte = q3.value(1).toInt();
const int etatSysteme = q3.value(2).toInt();
if (etatSysteme != 0
|| etatCircuit == ETAT_CIRCUIT_LIMITATION
|| etatCircuit == ETAT_CIRCUIT_PROTECTION
|| etatCircuit == ETAT_CIRCUIT_DISJONCTE
|| etatCircuit == ETAT_CIRCUIT_ABSENCE_TERRE
|| etatCircuit == ETAT_CIRCUIT_ABSENCE_MASSE) {
etat = 2;
} else if (etat < 2 && (etatCommandeCarte != ETAT_COMMANDE_CARTE_OK || etatCircuit != ETAT_CIRCUIT_OK)) {
etat = 1;
}
}
} else if (type == "batteries") {
// Règle plus simple que les circuits (cf §20.4/§21.8 synthèse) :
// rouge si Etat_systeme en défaut sur une batterie de la famille,
// sinon orange si au moins une alarme (Events), sinon vert.
// Alarmes (Events) liées à cette famille :
QSqlQuery q2;
q2.prepare("SELECT COUNT(*) FROM TB_Events WHERE Type_source=:type AND Validite=1 AND Alarme_source>0 "
"AND Etat_test=1 AND ID_Source IN (SELECT Cle_primaire FROM TB_Batteries WHERE ID_Famille=:famille)");
q2.bindValue(":type", typeSource);
q2.bindValue(":famille", idFamille);
q2.exec();
if (q2.next() && q2.value(0).toInt() > 0) etat = 1;
// Etat_systeme des batteries de la famille :
QSqlQuery q3;
q3.prepare("SELECT Etat_systeme FROM TB_Batteries WHERE ID_Famille=:famille AND Priorite_affichage_accueil>0");
q3.bindValue(":famille", idFamille);
q3.exec();
while (q3.next()) {
if (q3.value(0).toInt() != 0) etat = 2;
}
} else if (type == "capteurs") {
// Période d'obsolescence NMEA (TB_Configuration) :
int periodeObsolescenceNmea = 0;
{
QSqlQuery qc("SELECT Valeur FROM TB_Configuration WHERE Nom='Periode_obsolescence_capteur_nmea'");
if (qc.next()) periodeObsolescenceNmea = qc.value(0).toInt();
}
// Alarmes (Events) liées à cette famille :
QSqlQuery q2;
q2.prepare("SELECT COUNT(*) FROM TB_Events WHERE Type_source=:type AND Validite=1 AND Alarme_source>0 "
"AND Etat_test=1 AND ID_Source IN (SELECT Cle_primaire FROM TB_Capteurs WHERE ID_Famille=:famille)");
q2.bindValue(":type", typeSource);
q2.bindValue(":famille", idFamille);
q2.exec();
if (q2.next() && q2.value(0).toInt() > 0) etat = 1;
QSqlQuery q3;
q3.prepare(
"SELECT Etat, Etat_systeme, "
"IF(Type_entrees=:typeNmea, (SELECT (UNIX_TIMESTAMP() - UNIX_TIMESTAMP(Date_maj)) FROM TB_Nmea2000 "
"WHERE Pgn=SUBSTRING_INDEX(Entrees, ':', 1) AND Field=SUBSTRING_INDEX(Entrees, ':', -1)), 0) AS Age "
"FROM TB_Capteurs WHERE ID_Famille=:famille AND Priorite_affichage_accueil>0"
);
q3.bindValue(":typeNmea", TYPE_ENTREES_NMEA);
q3.bindValue(":famille", idFamille);
q3.exec();
while (q3.next()) {
const int etatCapteur = q3.value(0).toInt();
const int etatSysteme = q3.value(1).toInt();
const int age = q3.value(2).toInt();
if (age > periodeObsolescenceNmea && etat < 1) etat = 1;
if ((etatCapteur > 0 || etatSysteme != 0) && etat < 2) etat = 2;
}
} else if (type == "reservoirs") {
// Alarmes (Events) liées à cette famille :
QSqlQuery q2;
q2.prepare("SELECT COUNT(*) FROM TB_Events WHERE Type_source=:type AND Validite=1 AND Alarme_source>0 "
"AND Etat_test=1 AND ID_Source IN (SELECT Cle_primaire FROM TB_Reservoirs WHERE ID_Famille=:famille)");
q2.bindValue(":type", typeSource);
q2.bindValue(":famille", idFamille);
q2.exec();
if (q2.next() && q2.value(0).toInt() > 0) etat = 1;
// Défaut ordre des sondes AUX (Etat_entrees) ou Etat_systeme en défaut :
QSqlQuery q3;
q3.prepare("SELECT Etat_entrees, Etat_systeme FROM TB_Reservoirs "
"WHERE ID_Famille=:famille AND Priorite_affichage_accueil>0");
q3.bindValue(":famille", idFamille);
q3.exec();
while (q3.next()) {
const int etatEntrees = q3.value(0).toInt();
const int etatSysteme = q3.value(1).toInt();
if ((etatEntrees > 0 || etatSysteme != 0) && etat < 2) etat = 2;
}
}
QJsonObject obj;
obj["id"] = idFamille;
obj["nom"] = nom;
obj["etat"] = etat;
familles.append(obj);
}
QJsonObject reponse;
reponse["status"] = "ok";
reponse["familles"] = familles;
return withCors(QHttpServerResponse(QJsonDocument(reponse).toJson()));
});
// ------------------------------------------------------------------
// GET /elements?type=circuit&famille=<id>
// Liste des circuits de la famille - sert chargement initial ET
// rafraîchissement périodique (même requête que init_circuits() et
// mise_a_jour_circuits(), cf §20.4 : ce n'est pas deux besoins
// différents, mais un seul, appelé deux fois).
// ------------------------------------------------------------------
server.route("/elements", [](const QHttpServerRequest &request) {
assurer_connexion_bdd();
QUrlQuery query(request.query());
const QString type = query.queryItemValue("type");
const QString familleStr = query.queryItemValue("famille");
if (type != "circuits" && type != "batteries" && type != "capteurs" && type != "reservoirs") {
QJsonObject err;
err["status"] = "erreur";
err["message"] = "type non supporté (circuit, batterie, capteur, reservoir)";
return withCors(QHttpServerResponse(QJsonDocument(err).toJson(), QHttpServerResponder::StatusCode::NotImplemented));
}
bool ok = false;
const int idFamille = familleStr.toInt(&ok);
if (!ok) {
QJsonObject err;
err["status"] = "erreur";
err["message"] = "paramètre 'famille' manquant ou invalide";
return withCors(QHttpServerResponse(QJsonDocument(err).toJson(), QHttpServerResponder::StatusCode::BadRequest));
}
QJsonArray elements;
if (type == "circuits") {
QSqlQuery q;
q.prepare(
"SELECT Cle_primaire, Etat_systeme, Nom, Etat_commande_carte, Mode_commande, Etat, Consigne, Old_consigne, "
"Mode_test, Tension, Intensite, Frequence, "
"(SELECT Type FROM TB_Modules_config WHERE Adresse_k_bus=TB_Circuits.Adresse_module) AS type_circuit, "
"No_circuit, "
"(SELECT COUNT(*) FROM TB_Events WHERE Validite=1 AND Visibilite=1 AND "
"(Action_circuits LIKE CONCAT(TB_Circuits.Cle_primaire,'/%') OR Action_circuits LIKE CONCAT('%;', TB_Circuits.Cle_primaire,'/%'))) AS nb_events, "
"Validation_cdes_events, "
"(SELECT COUNT(*) FROM TB_Events WHERE Validite=1 AND Type_source=:type AND Alarme_source>0 AND Etat_test=1 "
"AND ID_Source=TB_Circuits.Cle_primaire) AS Nb_alarmes "
"FROM TB_Circuits WHERE Priorite_affichage_accueil>0 AND ID_Famille=:famille "
"ORDER BY Priorite_affichage_accueil, Cle_primaire"
);
q.bindValue(":type", TYPE_SOURCE_CIRCUIT);
q.bindValue(":famille", idFamille);
q.exec();
while (q.next()) {
QJsonObject obj;
obj["id"] = q.value(0).toInt();
obj["etat_systeme"] = q.value(1).toInt();
obj["nom"] = q.value(2).toString();
obj["etat_commande_carte"] = q.value(3).toInt();
obj["mode_commande"] = q.value(4).toInt();
obj["etat"] = q.value(5).toInt();
obj["consigne"] = q.value(6).toInt();
obj["old_consigne"] = q.value(7).toInt();
obj["mode_test"] = q.value(8).toInt();
obj["tension"] = q.value(9).toDouble();
obj["intensite"] = q.value(10).toDouble();
obj["frequence"] = q.value(11).toDouble();
obj["type_circuit"] = q.value(12).toInt();
obj["no_circuit"] = q.value(13).toInt();
obj["validation_cdes_events"] = (q.value(14).toInt() == 0) ? -1 : q.value(15).toInt();
obj["etat_events"] = q.value(16).toInt();
elements.append(obj);
}
} else if (type == "batteries") {
// Rappel : le champ "etat" retourné ici est le NOMBRE D'ALARMES,
// pas un code d'état comme pour les circuits - fidèle au modèle
// natif existant (modele_batteries), pas une incohérence introduite ici.
QSqlQuery q;
q.prepare(
"SELECT Cle_primaire, Etat_systeme, Nom, Type, Soc, Tension, Tension_nominale, "
"(SELECT COUNT(*) FROM TB_Events WHERE Validite=1 AND Type_source=:type AND Alarme_source>0 AND Etat_test=1 "
"AND ID_Source=TB_Batteries.Cle_primaire) AS Nb_alarmes "
"FROM TB_Batteries WHERE ID_Famille=:famille "
"ORDER BY Priorite_affichage_accueil"
);
q.bindValue(":type", TYPE_SOURCE_BATTERIE);
q.bindValue(":famille", idFamille);
q.exec();
while (q.next()) {
QJsonObject obj;
obj["id"] = q.value(0).toInt();
obj["etat_systeme"] = q.value(1).toInt();
obj["nom"] = q.value(2).toString();
obj["type_batterie"] = q.value(3).toInt();
obj["niveau"] = q.value(4).toDouble();
obj["tension"] = q.value(5).toDouble();
obj["tension_nominale"] = q.value(6).toDouble();
obj["etat"] = q.value(7).toInt();
elements.append(obj);
}
} else if (type == "capteurs") {
// Préférences d'unité du bateau (TB_Configuration) :
int uniteVolume = 0, uniteTemperature = 0, periodeObsolescenceNmea = 0;
{
QSqlQuery qc("SELECT Valeur FROM TB_Configuration WHERE Nom='Unite_volume'");
if (qc.next()) uniteVolume = qc.value(0).toInt();
}
{
QSqlQuery qc("SELECT Valeur FROM TB_Configuration WHERE Nom='Unite_temperature'");
if (qc.next()) uniteTemperature = qc.value(0).toInt();
}
{
QSqlQuery qc("SELECT Valeur FROM TB_Configuration WHERE Nom='Periode_obsolescence_capteur_nmea'");
if (qc.next()) periodeObsolescenceNmea = qc.value(0).toInt();
}
QSqlQuery q;
q.prepare(
"SELECT Cle_primaire, Etat_systeme, Nom, Type_entrees, Entrees, ID_Variable, Etat, "
"(SELECT COUNT(*) FROM TB_Events WHERE Validite=1 AND Type_source=:typeSource AND Alarme_source>0 AND Etat_test=1 "
"AND ID_Source=TB_Capteurs.Cle_primaire) AS Nb_alarmes, "
"Valeur, Precision_affichage_accueil, Liste_valeurs_textes, "
"IF(Type_entrees=:typeNmea, (SELECT (UNIX_TIMESTAMP() - UNIX_TIMESTAMP(Date_maj)) FROM TB_Nmea2000 "
"WHERE Pgn=SUBSTRING_INDEX(Entrees, ':', 1) AND Field=SUBSTRING_INDEX(Entrees, ':', -1)), 0) AS Age "
"FROM TB_Capteurs WHERE ID_Famille=:famille AND Priorite_affichage_accueil>0 "
"ORDER BY Priorite_affichage_accueil, Nom"
);
q.bindValue(":typeSource", TYPE_SOURCE_CAPTEUR);
q.bindValue(":typeNmea", TYPE_ENTREES_NMEA);
q.bindValue(":famille", idFamille);
q.exec();
while (q.next()) {
const int id = q.value(0).toInt();
const int etatSysteme = q.value(1).toInt();
const QString nom = q.value(2).toString();
const int typeEntrees = q.value(3).toInt();
const int idVariable = q.value(5).toInt();
const int etatCapteur = q.value(6).toInt();
const int nbAlarmes = q.value(7).toInt();
const double valeurBrute = q.value(8).toDouble();
const int precision = q.value(9).toInt();
const QString listeValeursTextes = q.value(10).toString();
const int age = q.value(11).toInt();
// Résolution de l'unité/conversion via TB_Variables :
QSqlQuery qv;
qv.prepare("SELECT Unite, Conversion_unite FROM TB_Variables WHERE Cle_primaire=:id");
qv.bindValue(":id", idVariable);
qv.exec();
QString uniteBrute;
int typeUnite = 0;
if (qv.next()) {
uniteBrute = qv.value(0).toString();
typeUnite = qv.value(1).toInt();
}
QJsonObject obj;
obj["id"] = id;
obj["etat_systeme"] = etatSysteme;
obj["nom"] = nom;
obj["etat"] = etatCapteur;
obj["etat_events"] = nbAlarmes;
if (typeUnite == TYPE_UNITE_LATITUDE) {
obj["valeur"] = chaine_latitude(valeurBrute);
obj["valeur_texte"] = true;
obj["unite"] = QString();
} else if (typeUnite == TYPE_UNITE_LONGITUDE) {
obj["valeur"] = chaine_longitude(valeurBrute);
obj["valeur_texte"] = true;
obj["unite"] = QString();
} else {
// Cas des listes de valeurs/textes (entrées AUX ou booléen calculé) :
QString texteListe;
if (typeEntrees == TYPE_ENTREES_AUX || typeEntrees == TYPE_ENTREES_CALCULEE_BOOLEEN) {
const auto paires = listeValeursTextes.split(';');
for (const auto &paire : paires) {
const auto champs = paire.split('/');
if (champs.size() == 2 && champs[0].toDouble() == valeurBrute) {
texteListe = champs[1];
break;
}
}
}
if (!texteListe.isEmpty()) {
obj["valeur"] = texteListe;
obj["valeur_texte"] = true;
obj["unite"] = QString();
} else {
double valeurConvertie = valeurBrute;
QString symboleUnite = uniteBrute;
if (typeUnite == TYPE_UNITE_VOLUME) {
valeurConvertie = conversion_volume(valeurBrute, uniteVolume);
symboleUnite = symbole_unite_volume(uniteVolume);
} else if (typeUnite == TYPE_UNITE_TEMPERATURE) {
valeurConvertie = conversion_temperature(valeurBrute, uniteTemperature);
symboleUnite = symbole_unite_temperature(uniteTemperature);
}
// Toujours une chaîne (même pour les nombres) - évite que QML
// ne fige le rôle 'valeur' en type numérique lors d'une famille
// visitée avant "Position" (latitude/longitude), qui tronquerait
// alors ces dernières à leur seul chiffre de tête.
obj["valeur"] = QString::number(valeurConvertie, 'f', precision);
obj["valeur_texte"] = false;
obj["unite"] = symboleUnite;
}
}
obj["obsolete"] = (typeEntrees == TYPE_ENTREES_NMEA && age > periodeObsolescenceNmea);
elements.append(obj);
}
} else if (type == "reservoirs") {
QSqlQuery q;
q.prepare(
"SELECT Cle_primaire, Nom, Etat_systeme, Niveau, Capacite, Logique, "
"(SELECT COUNT(*) FROM TB_Events WHERE Validite=1 AND Type_source=:type AND Alarme_source>0 AND Etat_test=1 "
"AND ID_Source=TB_Reservoirs.Cle_primaire) AS Nb_alarmes, "
"Etat_entrees "
"FROM TB_Reservoirs WHERE ID_Famille=:famille "
"ORDER BY Priorite_affichage_accueil"
);
q.bindValue(":type", TYPE_SOURCE_RESERVOIR);
q.bindValue(":famille", idFamille);
q.exec();
while (q.next()) {
const int etatSysteme = q.value(2).toInt();
const int nbAlarmes = q.value(6).toInt();
const int etatEntrees = q.value(7).toInt();
// État agrégé, même règle que le natif (mise_a_jour_reservoirs()) :
// rouge si Etat_systeme en défaut ou défaut d'ordre des sondes AUX,
// sinon orange si au moins une alarme, sinon vert.
int etat = 0;
if (etatSysteme < 0 || etatEntrees > 0) etat = 2;
else if (nbAlarmes > 0) etat = 1;
QJsonObject obj;
obj["id"] = q.value(0).toInt();
obj["nom"] = q.value(1).toString();
obj["etat_systeme"] = etatSysteme;
obj["niveau"] = q.value(3).toDouble();
obj["capacite"] = q.value(4).toDouble();
obj["logique"] = q.value(5).toInt();
obj["etat_events"] = nbAlarmes;
obj["etat_entrees"] = etatEntrees;
obj["etat"] = etat;
elements.append(obj);
}
}
QJsonObject reponse;
reponse["status"] = "ok";
reponse["elements"] = elements;
return withCors(QHttpServerResponse(QJsonDocument(reponse).toJson()));
});
// ------------------------------------------------------------------
// PATCH /elements/circuit/<id>
// Écriture rapide (bouton d'accueil) - PAS le formulaire de config
// complet (config_circuit.qml, avec orchestration esclave/maître,
// §20.7) qui reste un futur endpoint séparé. Reproduit fidèlement
// changement_consigne()/toggle_cdes_auto() (identiques dans circuit.qml
// et Shipheart_bouton_circuit.qml).
// ------------------------------------------------------------------
server.route("/elements/circuit/<arg>", QHttpServerRequest::Method::Patch,
[](int idCircuit, const QHttpServerRequest &request) {
assurer_connexion_bdd();
const QJsonDocument body = QJsonDocument::fromJson(request.body());
if (!body.isObject()) {
QJsonObject err;
err["status"] = "erreur";
err["message"] = "corps de requête JSON invalide";
return withCors(QHttpServerResponse(QJsonDocument(err).toJson(), QHttpServerResponder::StatusCode::BadRequest));
}
const QJsonObject obj = body.object();
bool ecritureEffectuee = false;
// Changement de consigne :
if (obj.contains("consigne")) {
int consigne = obj["consigne"].toInt();
if (consigne < 0) consigne = 0;
if (consigne > 100) consigne = 100;
QString req = QString("UPDATE TB_Circuits SET New_consigne=%1 ").arg(consigne);
if (obj.contains("old_consigne")) {
req += QString(", Old_consigne=%1 ").arg(obj["old_consigne"].toInt());
}
req += QString("WHERE Cle_primaire='%1'").arg(idCircuit);
QSqlQuery q(req);
ecritureEffectuee = true;
}
// Bascule du mode "commandes automatiques" (Events) :
if (obj.contains("validation_cdes_events")) {
const int mode = obj["validation_cdes_events"].toInt() != 0 ? 1 : 0;
QSqlQuery q(QString("UPDATE TB_Circuits SET Validation_cdes_events=%1 WHERE Cle_primaire='%2'").arg(mode).arg(idCircuit));
ecritureEffectuee = true;
}
if (!ecritureEffectuee) {
QJsonObject err;
err["status"] = "erreur";
err["message"] = "aucun champ reconnu (attendu : consigne, validation_cdes_events)";
return withCors(QHttpServerResponse(QJsonDocument(err).toJson(), QHttpServerResponder::StatusCode::BadRequest));
}
// Signale à l'Appli qu'un changement est en attente, pour un
// traitement rapide (100ms maxi) plutôt que son cycle normal
// (cf synchro_bdd(shipheart_type_synchro_data) du natif) :
QSqlQuery("UPDATE TB_Systeme SET Valeur=1 WHERE Nom=\"Synchro\"");
QJsonObject reponse;
reponse["status"] = "ok";
return withCors(QHttpServerResponse(QJsonDocument(reponse).toJson()));
});
// NB : la route OPTIONS ("preflight" CORS, avant un PATCH venant d'une autre origine) a été retirée - plus de
// requête entre origines depuis que l'API est servie sous /api, sur l'origine de la page (synthèse §49).
// ------------------------------------------------------------------
// GET /mesures?type=<capteurs|reservoirs>&id=<id>&periode=<0-3>
// Historique pour le graphique de la page de détail (0=1h, 1=24h,
// 2=7j, 3=30j) - regroupement TOUJOURS calculé côté serveur (jamais de
// donnée brute non agrégée transmise, cf §20.6/§21.6 synthèse).
// Capteurs : id_variable résolu depuis la config de l'élément lui-même.
// Réservoirs : id_variable résolu par CODE FIXE ("Res_niveau" dans
// TB_Variables) - le niveau est un concept universel, pas une config
// propre à chaque réservoir comme pour un capteur.
// Circuits/batteries (sélection de variable côté client) à venir.
// ------------------------------------------------------------------
server.route("/mesures", [](const QHttpServerRequest &request) {
assurer_connexion_bdd();
QUrlQuery query(request.query());
const QString type = query.queryItemValue("type");
const QString idStr = query.queryItemValue("id");
const QString periodeStr = query.queryItemValue("periode");
if (type != "capteurs" && type != "reservoirs" && type != "circuits" && type != "batteries") {
QJsonObject err;
err["status"] = "erreur";
err["message"] = "type non supporté pour l'instant (capteurs, reservoirs, circuits, batteries)";
return withCors(QHttpServerResponse(QJsonDocument(err).toJson(), QHttpServerResponder::StatusCode::NotImplemented));
}
bool okId = false, okPeriode = false;
const int id = idStr.toInt(&okId);
const int periode = periodeStr.toInt(&okPeriode);
if (!okId || !okPeriode || periode < 0 || periode > 3) {
QJsonObject err;
err["status"] = "erreur";
err["message"] = "paramètres 'id'/'periode' manquants ou invalides (periode: 0=1h, 1=24h, 2=7j, 3=30j)";
return withCors(QHttpServerResponse(QJsonDocument(err).toJson(), QHttpServerResponder::StatusCode::BadRequest));
}
// Durée de chaque période, en secondes :
static const qint64 DUREES_PERIODE[4] = { 3600, 86400, 604800, 2592000 }; // 1h, 24h, 7j, 30j
const qint64 dureeSecondes = DUREES_PERIODE[periode];
const QDateTime dateFin = QDateTime::currentDateTime();
const QDateTime dateDebut = dateFin.addSecs(-dureeSecondes);
// Regroupement calculé pour viser un nombre de points raisonnable à
// l'affichage, quelle que soit la période demandée - valeur à ajuster
// visuellement une fois le graphique réellement affiché (pas un
// chiffre figé arbitrairement bas, cf discussion §20.6).
static const int POINTS_CIBLE = 200;
const int regroupement = qMax(1, (int)(dureeSecondes / POINTS_CIBLE));
// Résolution de l'id_variable, de la précision d'affichage, et du nom
// de table de mesures - selon le domaine :
int idVariable = 0;
int precision = 1;
QString nomTable;
QString variableCircuit; // Utilisée aussi plus loin (valeur actuelle) si type == "circuits"
if (type == "capteurs") {
nomTable = "TB_Mesures_capteurs";
QSqlQuery qc;
qc.prepare("SELECT ID_Variable, Precision_affichage_mesures FROM TB_Capteurs WHERE Cle_primaire=:id");
qc.bindValue(":id", id);
qc.exec();
if (!qc.next()) {
QJsonObject err;
err["status"] = "erreur";
err["message"] = "capteur introuvable";
return withCors(QHttpServerResponse(QJsonDocument(err).toJson(), QHttpServerResponder::StatusCode::NotFound));
}
idVariable = qc.value(0).toInt();
precision = qc.value(1).toInt();
} else if (type == "reservoirs") {
nomTable = "TB_Mesures_reservoirs";
QSqlQuery qExiste;
qExiste.prepare("SELECT Cle_primaire FROM TB_Reservoirs WHERE Cle_primaire=:id");
qExiste.bindValue(":id", id);
qExiste.exec();
if (!qExiste.next()) {
QJsonObject err;
err["status"] = "erreur";
err["message"] = "réservoir introuvable";
return withCors(QHttpServerResponse(QJsonDocument(err).toJson(), QHttpServerResponder::StatusCode::NotFound));
}
QSqlQuery qv;
qv.prepare("SELECT Cle_primaire FROM TB_Variables WHERE Code='Res_niveau'");
qv.exec();
if (!qv.next()) {
QJsonObject err;
err["status"] = "erreur";
err["message"] = "configuration serveur incomplète (variable 'Res_niveau' introuvable)";
return withCors(QHttpServerResponse(QJsonDocument(err).toJson(), QHttpServerResponder::StatusCode::InternalServerError));
}
idVariable = qv.value(0).toInt();
// Pas de colonne de précision dédiée pour les réservoirs (à la
// différence des capteurs) - un pourcentage à 1 décimale est une
// valeur par défaut raisonnable.
precision = 1;
} else if (type == "circuits") {
nomTable = "TB_Mesures_circuits";
// Circuits : le client choisit QUELLE variable tracer (contrairement
// à réservoirs/capteurs, à valeur fixe) :
variableCircuit = query.queryItemValue("variable");
if (variableCircuit != "consigne" && variableCircuit != "tension"
&& variableCircuit != "intensite" && variableCircuit != "frequence") {
QJsonObject err;
err["status"] = "erreur";
err["message"] = "paramètre 'variable' manquant ou invalide (consigne, tension, intensite, frequence)";
return withCors(QHttpServerResponse(QJsonDocument(err).toJson(), QHttpServerResponder::StatusCode::BadRequest));
}
// Résolution du type de module du circuit - nécessaire car SP4AC
// utilise "tension_secteur" au lieu de "tension", et la précision
// d'affichage diffère selon le type de carte (cf circuit.qml natif) :
QSqlQuery qMod;
qMod.prepare(
"SELECT (SELECT Type FROM TB_Modules_config WHERE Adresse_k_bus=TB_Circuits.Adresse_module) "
"FROM TB_Circuits WHERE Cle_primaire=:id"
);
qMod.bindValue(":id", id);
qMod.exec();
if (!qMod.next()) {
QJsonObject err;
err["status"] = "erreur";
err["message"] = "circuit introuvable";
return withCors(QHttpServerResponse(QJsonDocument(err).toJson(), QHttpServerResponder::StatusCode::NotFound));
}
const int typeModule = qMod.value(0).toInt();
static const int TYPE_MODULE_SP4AC = 3;
const bool estSP4AC = (typeModule == TYPE_MODULE_SP4AC);
QString codeVariable;
if (variableCircuit == "consigne") { codeVariable = "Circ_consigne"; precision = 0; }
else if (variableCircuit == "intensite") { codeVariable = "Circ_courant"; precision = estSP4AC ? 1 : 2; }
else if (variableCircuit == "frequence") { codeVariable = "Circ_frequence"; precision = 1; }
else { codeVariable = estSP4AC ? "Circ_tension_secteur" : "Circ_tension"; precision = estSP4AC ? 0 : 2; }
QSqlQuery qv2;
qv2.prepare("SELECT Cle_primaire FROM TB_Variables WHERE Code=:code");
qv2.bindValue(":code", codeVariable);
qv2.exec();
if (!qv2.next()) {
QJsonObject err;
err["status"] = "erreur";
err["message"] = "configuration serveur incomplète (variable '" + codeVariable + "' introuvable)";
return withCors(QHttpServerResponse(QJsonDocument(err).toJson(), QHttpServerResponder::StatusCode::InternalServerError));
}
idVariable = qv2.value(0).toInt();
} else { // batteries
nomTable = "TB_Mesures_batteries";
// Batteries : le client choisit QUELLE variable tracer, comme pour
// les circuits. Valeur par défaut "soc" si non précisée (le vrai
// défaut natif est le comparatif d'énergie, hors périmètre ici).
variableCircuit = query.queryItemValue("variable");
if (variableCircuit.isEmpty()) variableCircuit = "soc";
static const QMap<QString, QString> CODES_BATTERIE = {
{"soc", "Batt_soc"}, {"tension", "Batt_tension"}, {"temperature", "Batt_temperature"},
{"courant_differentiel", "Batt_courant_differentiel"}, {"courant_charge", "Batt_courant_charge"},
{"courant_decharge", "Batt_courant_decharge"}, {"soh", "Batt_soh"}, {"autonomie", "Batt_autonomie"}
};
static const QMap<QString, int> PRECISIONS_BATTERIE = {
{"soc", 1}, {"tension", 2}, {"temperature", 1}, {"courant_differentiel", 2},
{"courant_charge", 2}, {"courant_decharge", 2}, {"soh", 1}, {"autonomie", 1}
};
if (!CODES_BATTERIE.contains(variableCircuit)) {
QJsonObject err;
err["status"] = "erreur";
err["message"] = "paramètre 'variable' invalide (soc, tension, temperature, courant_differentiel, courant_charge, courant_decharge, soh, autonomie)";
return withCors(QHttpServerResponse(QJsonDocument(err).toJson(), QHttpServerResponder::StatusCode::BadRequest));
}
precision = PRECISIONS_BATTERIE.value(variableCircuit);
QSqlQuery qv3;
qv3.prepare("SELECT Cle_primaire FROM TB_Variables WHERE Code=:code");
qv3.bindValue(":code", CODES_BATTERIE.value(variableCircuit));
qv3.exec();
if (!qv3.next()) {
QJsonObject err;
err["status"] = "erreur";
err["message"] = "configuration serveur incomplète (variable '" + CODES_BATTERIE.value(variableCircuit) + "' introuvable)";
return withCors(QHttpServerResponse(QJsonDocument(err).toJson(), QHttpServerResponder::StatusCode::InternalServerError));
}
idVariable = qv3.value(0).toInt();
}
QString uniteBrute;
int typeUnite = 0;
{
QSqlQuery qv;
qv.prepare("SELECT Unite, Conversion_unite FROM TB_Variables WHERE Cle_primaire=:id");
qv.bindValue(":id", idVariable);
qv.exec();
if (qv.next()) {
uniteBrute = qv.value(0).toString();
typeUnite = qv.value(1).toInt();
}
}
int uniteVolume = 0, uniteTemperature = 0;
{
QSqlQuery q1("SELECT Valeur FROM TB_Configuration WHERE Nom='Unite_volume'");
if (q1.next()) uniteVolume = q1.value(0).toInt();
}
{
QSqlQuery q2("SELECT Valeur FROM TB_Configuration WHERE Nom='Unite_temperature'");
if (q2.next()) uniteTemperature = q2.value(0).toInt();
}
QString symboleUnite = uniteBrute;
if (typeUnite == TYPE_UNITE_VOLUME) symboleUnite = symbole_unite_volume(uniteVolume);
else if (typeUnite == TYPE_UNITE_TEMPERATURE) symboleUnite = symbole_unite_temperature(uniteTemperature);
// Fonction de conversion, réutilisée à plusieurs endroits ci-dessous :
auto convertir = [&](double brut) {
if (typeUnite == TYPE_UNITE_VOLUME) return conversion_volume(brut, uniteVolume);
if (typeUnite == TYPE_UNITE_TEMPERATURE) return conversion_temperature(brut, uniteTemperature);
return brut;
};
// Valeur actuelle RÉELLE (TB_Capteurs.Valeur ou TB_Reservoirs.Niveau,
// toujours à jour) - PAS extrapolée depuis le dernier point de
// l'historique, qui peut être absent (aucune mesure récente) ou vieux
// de plusieurs minutes (valeur moyennée par bucket) :
QSqlQuery qActuel;
if (type == "capteurs") {
qActuel.prepare("SELECT Valeur FROM TB_Capteurs WHERE Cle_primaire=:id");
} else if (type == "reservoirs") {
qActuel.prepare("SELECT Niveau FROM TB_Reservoirs WHERE Cle_primaire=:id");
} else if (type == "circuits") {
QString colonne;
if (variableCircuit == "consigne") colonne = "Consigne";
else if (variableCircuit == "intensite") colonne = "Intensite";
else if (variableCircuit == "frequence") colonne = "Frequence";
else colonne = "Tension";
qActuel.prepare(QString("SELECT %1 FROM TB_Circuits WHERE Cle_primaire=:id").arg(colonne));
} else { // batteries
static const QMap<QString, QString> COLONNES_BATTERIE = {
{"soc", "Soc"}, {"tension", "Tension"}, {"temperature", "Temperature"},
{"courant_differentiel", "Courant_differentiel"}, {"courant_charge", "Courant_charge"},
{"courant_decharge", "Courant_decharge"}, {"soh", "Soh"}, {"autonomie", "Autonomie"}
};
qActuel.prepare(QString("SELECT %1 FROM TB_Batteries WHERE Cle_primaire=:id").arg(COLONNES_BATTERIE.value(variableCircuit)));
}
qActuel.bindValue(":id", id);
qActuel.exec();
double valeurActuelle = 0;
if (qActuel.next()) valeurActuelle = convertir(qActuel.value(0).toDouble());
// Point de départ synthétique : dernière valeur connue AVANT le début
// de la période, reportée exactement à la date de début - assure que
// la courbe reflète l'état réel dès le premier instant demandé, même
// si aucune mesure n'a été enregistrée DURANT la période elle-même
// (élément en mode "enregistrement sur changement" - une valeur
// stable ne crée aucune nouvelle ligne dans la table de mesures).
// Logique identique à celle déjà présente dans tb_mesures.cpp natif.
QJsonArray points;
double minVal = 0, maxVal = 0, sommeVal = 0;
qint64 minT = 0, maxT = 0;
int nb = 0;
double derniereValeurConnue = 0;
bool auMoinsUnePoint = false;
{
QSqlQuery qAvant;
qAvant.prepare(
QString("SELECT Valeur FROM %1 WHERE ID=:id AND ID_Variable=:idVariable "
"AND Date<=:debut AND Valeur IS NOT NULL ORDER BY Date DESC LIMIT 1").arg(nomTable)
);
qAvant.bindValue(":id", id);
qAvant.bindValue(":idVariable", idVariable);
qAvant.bindValue(":debut", dateDebut.toString("yyyy-MM-dd HH:mm:ss"));
qAvant.exec();
if (qAvant.next()) {
const double v = convertir(qAvant.value(0).toDouble());
const qint64 t = dateDebut.toMSecsSinceEpoch();
QJsonObject pt; pt["t"] = t; pt["v"] = v;
points.append(pt);
minVal = maxVal = v;
minT = maxT = t;
sommeVal = v;
nb = 1;
derniereValeurConnue = v;
auMoinsUnePoint = true;
}
}
// Récupération des points RÉELS de la période, regroupés côté SQL
// (moyenne par bucket) - même principe que la branche "barres
// d'énergie" déjà validée pour les circuits (§20.6), généralisé ici
// à toute courbe de mesure :
QSqlQuery qm;
qm.prepare(
QString("SELECT Date, AVG(Valeur) FROM %1 "
"WHERE ID=:id AND ID_Variable=:idVariable AND Date>:debut AND Date<=:fin AND Valeur IS NOT NULL "
"GROUP BY (UNIX_TIMESTAMP(Date) DIV :regroupement) ORDER BY Date").arg(nomTable)
);
qm.bindValue(":id", id);
qm.bindValue(":idVariable", idVariable);
qm.bindValue(":debut", dateDebut.toString("yyyy-MM-dd HH:mm:ss"));
qm.bindValue(":fin", dateFin.toString("yyyy-MM-dd HH:mm:ss"));
qm.bindValue(":regroupement", regroupement);
qm.exec();
while (qm.next()) {
const QDateTime dt = qm.value(0).toDateTime();
const double valeurConvertie = convertir(qm.value(1).toDouble());
const qint64 t = dt.toMSecsSinceEpoch();
QJsonObject pt;
pt["t"] = t;
pt["v"] = valeurConvertie;
points.append(pt);
if (nb == 0 || valeurConvertie < minVal) { minVal = valeurConvertie; minT = t; }
if (nb == 0 || valeurConvertie > maxVal) { maxVal = valeurConvertie; maxT = t; }
sommeVal += valeurConvertie;
nb++;
derniereValeurConnue = valeurConvertie;
auMoinsUnePoint = true;
}
// Point de fin synthétique : reporte la dernière valeur connue
// jusqu'à "maintenant", pour que la courbe occupe toute la période -
// purement visuel, ne modifie pas les statistiques min/max/moyenne
// (ce n'est pas une nouvelle mesure, juste la continuité visuelle de
// la précédente) :
if (auMoinsUnePoint) {
QJsonObject ptFin;
ptFin["t"] = dateFin.toMSecsSinceEpoch();
ptFin["v"] = derniereValeurConnue;
points.append(ptFin);
}
QJsonObject reponse;
reponse["status"] = "ok";
reponse["points"] = points;
reponse["unite"] = symboleUnite;
reponse["precision"] = precision;
reponse["valeur_actuelle"] = valeurActuelle;
if (nb > 0) {
QJsonObject minObj; minObj["v"] = minVal; minObj["t"] = minT;
QJsonObject maxObj; maxObj["v"] = maxVal; maxObj["t"] = maxT;
reponse["min"] = minObj;
reponse["max"] = maxObj;
reponse["moyenne"] = sommeVal / nb;
}
return withCors(QHttpServerResponse(QJsonDocument(reponse).toJson()));
});
// ------------------------------------------------------------------
// GET /energie?type=batteries&id=<id>&periode=<0-3>
// Consommation d'énergie des circuits DC rattachés à une batterie
// (Phase 4, cf synthèse §22). Version SIMPLIFIÉE par rapport au natif :
// pas d'intégrale de puissance (LEAD/LAG) pour rafraîchir l'intervalle
// "en cours" - la dernière barre reflète simplement le dernier
// regroupement déjà finalisé par l'Appli C, avec un léger retard
// (décision explicite, cf §22 - le calcul par intégrale représentait
// 90% du temps de traitement natif pour un gain marginal).
// ------------------------------------------------------------------
server.route("/energie", [](const QHttpServerRequest &request) {
assurer_connexion_bdd();
QUrlQuery query(request.query());
const QString type = query.queryItemValue("type");
const QString idStr = query.queryItemValue("id");
const QString periodeStr = query.queryItemValue("periode");
if (type != "batteries") {
QJsonObject err;
err["status"] = "erreur";
err["message"] = "type non supporté (batteries uniquement)";
return withCors(QHttpServerResponse(QJsonDocument(err).toJson(), QHttpServerResponder::StatusCode::NotImplemented));
}
bool okId = false, okPeriode = false;
const int id = idStr.toInt(&okId);
const int periode = periodeStr.toInt(&okPeriode);
if (!okId || !okPeriode || periode < 0 || periode > 3) {
QJsonObject err;
err["status"] = "erreur";
err["message"] = "paramètres 'id'/'periode' manquants ou invalides (periode: 0=1h, 1=24h, 2=7j, 3=30j)";
return withCors(QHttpServerResponse(QJsonDocument(err).toJson(), QHttpServerResponder::StatusCode::BadRequest));
}
QSqlQuery qExiste;
qExiste.prepare("SELECT Cle_primaire FROM TB_Batteries WHERE Cle_primaire=:id");
qExiste.bindValue(":id", id);
qExiste.exec();
if (!qExiste.next()) {
QJsonObject err;
err["status"] = "erreur";
err["message"] = "batterie introuvable";
return withCors(QHttpServerResponse(QJsonDocument(err).toJson(), QHttpServerResponder::StatusCode::NotFound));
}
// Variable "énergie" - globale, comme "Res_niveau" pour les réservoirs
// (une seule définition TB_Variables, pas une par circuit) :
QSqlQuery qv;
qv.prepare("SELECT Cle_primaire FROM TB_Variables WHERE Code='Circ_energie'");
qv.exec();
if (!qv.next()) {
QJsonObject err;
err["status"] = "erreur";
err["message"] = "configuration serveur incomplète (variable 'Circ_energie' introuvable)";
return withCors(QHttpServerResponse(QJsonDocument(err).toJson(), QHttpServerResponder::StatusCode::InternalServerError));
}
const int idVariableEnergie = qv.value(0).toInt();
static const qint64 DUREES_PERIODE[4] = { 3600, 86400, 604800, 2592000 };
const qint64 dureeSecondes = DUREES_PERIODE[periode];
// Le CM5 tourne en UTC, la BDD stocke les dates en UTC - mais les
// paliers de troncature (minuit, 6h...) doivent être calculés en
// heure LOCALE DU BATEAU (TB_Configuration.Fuseau_horaire +
// Heure_ete), pas en UTC brut, sous peine de désaligner les tranches
// de plusieurs heures par rapport à ce qu'affiche le natif :
double fuseauHoraire = 0;
int heureEte = 0;
{
QSqlQuery qf("SELECT Valeur FROM TB_Configuration WHERE Nom='Fuseau_horaire'");
if (qf.next()) fuseauHoraire = qf.value(0).toDouble();
}
{
QSqlQuery qe("SELECT Valeur FROM TB_Configuration WHERE Nom='Heure_ete'");
if (qe.next()) heureEte = qe.value(0).toInt();
}
const qint64 offsetSecondes = (qint64)((fuseauHoraire + heureEte) * 3600);
// Troncature à un palier propre (cf ajustement_periode_analyse()
// natif) - exclut naturellement l'intervalle "en cours" en arrondissant
// la fin vers le bas, et garantit des barres alignées sur des paliers
// réguliers plutôt que sur l'instant exact de la requête :
const QDateTime maintenantUTC = QDateTime::currentDateTime();
const QDateTime maintenantLocal = maintenantUTC.addSecs(offsetSecondes);
const QDateTime finLocale = tronquer_periode(maintenantLocal, periode);
const QDateTime debutLocal = tronquer_periode(maintenantLocal.addSecs(-dureeSecondes), periode);
// Reconversion en UTC - les dates stockées en base et utilisées dans
// les requêtes SQL sont toujours en UTC :
const QDateTime dateFin = finLocale.addSecs(-offsetSecondes);
const QDateTime dateDebut = debutLocal.addSecs(-offsetSecondes);
const QString chaineDebut = dateDebut.toString("yyyy-MM-dd HH:mm:ss");
const QString chaineFin = dateFin.toString("yyyy-MM-dd HH:mm:ss");
// "Type" de mesure et regroupement des barres - mêmes seuils que le
// natif (batterie.qml/mise_a_jour_courbes(), branche énergie) :
const int typeMesure = (periode == 0) ? 3 : 2;
qint64 regroupement = 1;
if (periode == 2) regroupement = 6 * 3600; // 7 jours : barres de 6h
else if (periode == 3) regroupement = 24 * 3600; // 30 jours : barres de 24h
// Largeur RÉELLE de chaque tranche, pour le calcul du "début" affiché -
// distincte de 'regroupement' (simple diviseur SQL, qui vaut 1 pour 1h/24h
// puisque l'Appli C pré-agrège déjà à la bonne granularité, sans qu'on
// ait besoin de regrouper davantage côté serveur). Correspond au nombre
// de graduations natif (13 pour 1h, 25 pour 24h) :
qint64 largeurTranche = regroupement;
if (periode == 0) largeurTranche = 5 * 60; // 1 heure : tranches de 5 min
else if (periode == 1) largeurTranche = 3600; // 24 heures : tranches de 1h
// Circuits DC rattachés à cette batterie (shipbase/SP812/SP8DC -
// pas les modules AC, qui n'ont pas de suivi d'énergie de ce type) :
const QString sousRequeteCircuits =
"SELECT Cle_primaire FROM TB_Circuits WHERE Adresse_module IN "
"(SELECT Adresse_k_bus FROM TB_Modules_config WHERE ID_Batterie=:idBatterie AND (Type=0 OR Type=1 OR Type=4))";
// 1) Total sur toute la période :
double totalWh = 0;
{
QSqlQuery qTotal;
qTotal.prepare(
"SELECT SUM(Valeur) FROM TB_Mesures_circuits WHERE Type=:typeMesure AND ID_Variable=:idVar "
"AND ID IN (" + sousRequeteCircuits + ") AND Date>=:debut AND Date<=:fin"
);
qTotal.bindValue(":typeMesure", typeMesure);
qTotal.bindValue(":idVar", idVariableEnergie);
qTotal.bindValue(":idBatterie", id);
qTotal.bindValue(":debut", chaineDebut);
qTotal.bindValue(":fin", chaineFin);
qTotal.exec();
if (qTotal.next()) totalWh = qTotal.value(0).toDouble();
}
// 2) Barres par intervalle (une par regroupement) :
QJsonArray barres;
{
QSqlQuery qBarres;
qBarres.prepare(
"SELECT SUM(Valeur), MAX(Date) FROM TB_Mesures_circuits WHERE Type=:typeMesure AND ID_Variable=:idVar "
"AND ID IN (" + sousRequeteCircuits + ") AND Date>=:debut AND Date<=:fin "
"GROUP BY (UNIX_TIMESTAMP(Date) - UNIX_TIMESTAMP(:debut2)) DIV :regroupement ORDER BY MAX(Date)"
);
qBarres.bindValue(":typeMesure", typeMesure);
qBarres.bindValue(":idVar", idVariableEnergie);
qBarres.bindValue(":idBatterie", id);
qBarres.bindValue(":debut", chaineDebut);
qBarres.bindValue(":fin", chaineFin);
qBarres.bindValue(":debut2", chaineDebut);
qBarres.bindValue(":regroupement", regroupement);
qBarres.exec();
while (qBarres.next()) {
// Les mesures d'énergie sont PRÉCISÉMENT calées sur xx:59:59
// par construction (intégrales dont la somme doit couvrir
// exactement la réalité, sans trou ni recouvrement) - +1s sur
// la dernière mesure réelle donne donc TOUJOURS la borne
// exacte de la tranche, sans calcul plus complexe nécessaire :
const QDateTime frontiere = qBarres.value(1).toDateTime().addSecs(1);
QJsonObject barre;
barre["fin"] = frontiere.toMSecsSinceEpoch();
barre["debut"] = frontiere.addSecs(-largeurTranche).toMSecsSinceEpoch();
barre["valeur_wh"] = qBarres.value(0).toDouble();
barres.append(barre);
}
}
// 3) Comparatif par circuit, trié par énergie décroissante :
QJsonArray comparatif;
{
QSqlQuery qComp;
qComp.prepare(
"SELECT ID, SUM(Valeur) AS energie FROM TB_Mesures_circuits WHERE Type=:typeMesure AND ID_Variable=:idVar "
"AND ID IN (" + sousRequeteCircuits + ") AND Date>=:debut AND Date<=:fin "
"GROUP BY ID ORDER BY energie DESC"
);
qComp.bindValue(":typeMesure", typeMesure);
qComp.bindValue(":idVar", idVariableEnergie);
qComp.bindValue(":idBatterie", id);
qComp.bindValue(":debut", chaineDebut);
qComp.bindValue(":fin", chaineFin);
qComp.exec();
QVector<QPair<int, double>> lignes;
double totalComparatif = 0;
while (qComp.next()) {
const int idCircuit = qComp.value(0).toInt();
const double energieCircuit = qComp.value(1).toDouble();
lignes.append({idCircuit, energieCircuit});
totalComparatif += energieCircuit;
}
for (const auto &ligne : lignes) {
QSqlQuery qInfoCircuit;
qInfoCircuit.prepare("SELECT Nom, Consigne FROM TB_Circuits WHERE Cle_primaire=:id");
qInfoCircuit.bindValue(":id", ligne.first);
qInfoCircuit.exec();
QJsonObject c;
c["id"] = ligne.first;
c["energie_wh"] = ligne.second;
c["pourcentage"] = (totalComparatif > 0) ? (ligne.second * 100.0 / totalComparatif) : 0;
if (qInfoCircuit.next()) {
c["nom"] = qInfoCircuit.value(0).toString();
c["consigne"] = qInfoCircuit.value(1).toInt();
}
comparatif.append(c);
}
}
QJsonObject reponse;
reponse["status"] = "ok";
reponse["total_wh"] = totalWh;
reponse["barres"] = barres;
reponse["comparatif"] = comparatif;
reponse["debug_debut"] = chaineDebut;
reponse["debug_fin"] = chaineFin;
reponse["debug_regroupement"] = (double)regroupement;
return withCors(QHttpServerResponse(QJsonDocument(reponse).toJson()));
});
// ------------------------------------------------------------------
// GET /alertes?depuis=AAAA-MM-JJ HH:MM:SS - déclenchements et fins des
// events d'ALERTE (Alarme_email ou Alarme_sms actif) enregistrés dans
// TB_Historique depuis cette date incluse (synthèse §51, lot Alertes).
// Appelée par le VPS via NetBird (port 8080) après un coup de sonnette.
// Dates en UTC. "depuis" inclus : deux enregistrements dans la même
// seconde ne sont jamais manqués - le VPS écarte les doublons.
// Au plus 1000 lignes, les plus anciennes d'abord (le VPS avance son
// curseur et relit la suite au passage suivant).
// Comme les autres routes /archives, réservée au VPS : bloquée dans le
// relais /api du nginx du CM5 (location ^~ /api/alertes).
// ------------------------------------------------------------------
server.route("/alertes", QHttpServerRequest::Method::Get, [](const QHttpServerRequest &request) {
assurer_connexion_bdd();
QUrlQuery query(request.query());
const QString depuis = query.queryItemValue("depuis", QUrl::FullyDecoded);
static const QRegularExpression format_date(QStringLiteral("^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}$"));
if (!format_date.match(depuis).hasMatch()) {
QJsonObject err;
err["status"] = "error";
err["message"] = "paramètre depuis attendu au format AAAA-MM-JJ HH:MM:SS (UTC)";
return withCors(QHttpServerResponse(QJsonDocument(err).toJson(), QHttpServerResponder::StatusCode::BadRequest));
}
// TB_Historique.Date est un TIMESTAMP : lecture forcée en UTC, quel que
// soit le fuseau par défaut du serveur MariaDB.
QSqlQuery fuseau;
fuseau.exec("SET time_zone = '+00:00'");
QSqlQuery q;
q.prepare("SELECT DATE_FORMAT(H.Date, '%Y-%m-%d %H:%i:%s'), H.Type, H.ID_Source, E.Nom,"
" E.Alarme_email, E.Alarme_sms, E.Texte_email, E.Texte_sms"
" FROM TB_Historique H"
" JOIN TB_Events E ON E.Cle_primaire = H.ID_Source"
" WHERE H.Type_source = 6 AND H.Type IN (1, 2)" // TYPE_SOURCE_HISTORIQUE_EVENTS ; EVENT_DECLENCHEMENT, EVENT_FIN_DECLENCHEMENT
" AND (E.Alarme_email > 0 OR E.Alarme_sms > 0)"
" AND H.Date >= ?"
" ORDER BY H.Date"
" LIMIT 1000");
q.addBindValue(depuis);
if (!q.exec()) {
QJsonObject err;
err["status"] = "error";
err["message"] = q.lastError().text();
return withCors(QHttpServerResponse(QJsonDocument(err).toJson(), QHttpServerResponder::StatusCode::InternalServerError));
}
QJsonArray evenements;
while (q.next()) {
QJsonObject ev;
ev["date"] = q.value(0).toString();
ev["type"] = q.value(1).toInt();
ev["id_event"] = q.value(2).toInt();
ev["nom"] = q.value(3).toString();
ev["alarme_email"] = q.value(4).toInt();
ev["alarme_sms"] = q.value(5).toInt();
ev["texte_email"] = q.value(6).toString();
ev["texte_sms"] = q.value(7).toString();
evenements.append(ev);
}
QJsonObject reponse;
reponse["status"] = "ok";
reponse["evenements"] = evenements;
return withCors(QHttpServerResponse(QJsonDocument(reponse).toJson()));
});
// ------------------------------------------------------------------
// POST /archives/demande - demande à l'Appli de produire une archive
// IMMÉDIATEMENT (synthèse §50, étape 3d) : TB_Systeme.Demande_sauvegarde_bdd
// passe à 1 ; l'Appli le lit, remet son compteur de période à 0 (archive
// produite dans la seconde), puis remet l'indicateur à 0.
// Appelée par le VPS via NetBird (port 8080), quand un utilisateur ouvre la
// page "Données du bateau" sur le portail. Comme les autres routes
// /archives, elle n'est PAS accessible via le relais /api du nginx du CM5.
// ------------------------------------------------------------------
server.route("/archives/demande", QHttpServerRequest::Method::Post, [](const QHttpServerRequest &request) {
Q_UNUSED(request)
assurer_connexion_bdd();
// Existence de la ligne vérifiée à part : un UPDATE qui ne change rien
// (indicateur déjà à 1) renvoie aussi 0 ligne modifiée.
QSqlQuery existe;
existe.prepare("SELECT COUNT(*) FROM TB_Systeme WHERE Nom='Demande_sauvegarde_bdd'");
if (!existe.exec() || !existe.next() || existe.value(0).toInt() == 0) {
QJsonObject err;
err["status"] = "error";
err["message"] = "paramètre TB_Systeme.Demande_sauvegarde_bdd introuvable";
return withCors(QHttpServerResponse(QJsonDocument(err).toJson(), QHttpServerResponder::StatusCode::NotImplemented));
}
QSqlQuery q;
q.prepare("UPDATE TB_Systeme SET Valeur='1' WHERE Nom='Demande_sauvegarde_bdd'");
if (!q.exec()) {
QJsonObject err;
err["status"] = "error";
err["message"] = q.lastError().text();
return withCors(QHttpServerResponse(QJsonDocument(err).toJson(), QHttpServerResponder::StatusCode::InternalServerError));
}
QJsonObject reponse;
reponse["status"] = "ok";
return withCors(QHttpServerResponse(QJsonDocument(reponse).toJson()));
});
// ------------------------------------------------------------------
// GET /archives - liste les archives en attente de traitement par le VPS
// (cf synthèse §32.5). Remplace le transport sshpass/scp existant.
// ------------------------------------------------------------------
server.route("/archives", [](const QHttpServerRequest &request) {
Q_UNUSED(request)
QDir dossier(REPERTOIRE_ARCHIVES);
QJsonArray archives;
const QFileInfoList fichiers = dossier.entryInfoList(QDir::Files, QDir::Name);
for (const QFileInfo &info : fichiers) {
// Tout fichier qui ne correspond pas au format attendu est
// ignoré silencieusement (ex: un .gitkeep, un fichier temporaire
// laissé par une écriture interrompue) :
if (!nom_archive_est_valide(info.fileName())) continue;
QJsonObject o;
o["nom"] = info.fileName();
o["taille"] = info.size();
archives.append(o);
}
QJsonObject reponse;
reponse["status"] = "ok";
reponse["archives"] = archives;
return withCors(QHttpServerResponse(QJsonDocument(reponse).toJson()));
});
// ------------------------------------------------------------------
// GET /archives/<nom> - contenu brut d'une archive précise.
// ------------------------------------------------------------------
server.route("/archives/<arg>", QHttpServerRequest::Method::Get,
[](const QString &nom, const QHttpServerRequest &request) {
Q_UNUSED(request)
if (!nom_archive_est_valide(nom)) {
QJsonObject err;
err["status"] = "erreur";
err["message"] = "nom d'archive invalide";
return withCors(QHttpServerResponse(QJsonDocument(err).toJson(), QHttpServerResponder::StatusCode::BadRequest));
}
QFile fichier(REPERTOIRE_ARCHIVES + "/" + nom);
if (!fichier.open(QIODevice::ReadOnly)) {
QJsonObject err;
err["status"] = "erreur";
err["message"] = "archive introuvable";
return withCors(QHttpServerResponse(QJsonDocument(err).toJson(), QHttpServerResponder::StatusCode::NotFound));
}
const QByteArray contenu = fichier.readAll();
fichier.close();
return withCors(QHttpServerResponse("application/octet-stream", contenu));
});
// ------------------------------------------------------------------
// DELETE /archives/<nom> - confirmation de traitement par le serveur,
// entraîne la suppression locale. Idempotent : supprimer une archive déjà
// absente renvoie "ok" (pas une erreur), pour ne pas gêner une
// confirmation qui arriverait en double (par exemple après une coupure
// réseau juste après un premier appel déjà réussi côté disque, mais dont
// la réponse ne serait jamais arrivée jusqu'au VPS).
// ------------------------------------------------------------------
server.route("/archives/<arg>", QHttpServerRequest::Method::Delete,
[](const QString &nom, const QHttpServerRequest &request) {
Q_UNUSED(request)
if (!nom_archive_est_valide(nom)) {
QJsonObject err;
err["status"] = "erreur";
err["message"] = "nom d'archive invalide";
return withCors(QHttpServerResponse(QJsonDocument(err).toJson(), QHttpServerResponder::StatusCode::BadRequest));
}
QFile fichier(REPERTOIRE_ARCHIVES + "/" + nom);
if (fichier.exists() && !fichier.remove()) {
QJsonObject err;
err["status"] = "erreur";
err["message"] = "suppression impossible";
return withCors(QHttpServerResponse(QJsonDocument(err).toJson(), QHttpServerResponder::StatusCode::InternalServerError));
}
QJsonObject reponse;
reponse["status"] = "ok";
return withCors(QHttpServerResponse(QJsonDocument(reponse).toJson()));
});
auto tcpServer = new QTcpServer();
if (!tcpServer->listen(QHostAddress::Any, 8080) || !server.bind(tcpServer)) {
qCritical() << "Impossible de démarrer le serveur HTTP sur le port 8080.";
delete tcpServer;
return 1;
}
qInfo() << "shipheart_api en écoute sur le port" << tcpServer->serverPort();
return app.exec();
}