#include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #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= // 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(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(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); } // ---------------------------------------------------------------------------- // Ajoute l'en-tête CORS à une réponse - indispensable puisque le client WASM // (servi par nginx sur un port distinct) et cette API (port 8080) constituent // deux origines différentes du point de vue du navigateur. Sans cet en-tête, // les requêtes aboutissent bien côté serveur (visibles dans les logs) mais // leur réponse est bloquée silencieusement côté client. // TODO : restreindre à l'origine réelle du client en production plutôt que // '*', une fois l'authentification (cf "Architecture cible") en place. // ---------------------------------------------------------------------------- static QHttpServerResponse withCors(QHttpServerResponse &&resp) { QHttpHeaders headers = resp.headers(); headers.append(QHttpHeaders::WellKnownHeader::AccessControlAllowOrigin, "*"); // 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); } // ---------------------------------------------------------------------------- // 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= // 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/ // É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/", 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())); }); // Réponse au "preflight" CORS (requête OPTIONS automatique du navigateur // avant tout PATCH avec Content-Type: application/json) - sans elle, le // navigateur abandonne silencieusement la vraie requête PATCH avant même // de l'envoyer. server.route("/elements/circuit/", QHttpServerRequest::Method::Options, [](int) { QHttpServerResponse resp(QHttpServerResponder::StatusCode::NoContent); QHttpHeaders headers = resp.headers(); headers.append(QHttpHeaders::WellKnownHeader::AccessControlAllowOrigin, "*"); headers.append(QHttpHeaders::WellKnownHeader::AccessControlAllowMethods, "GET, PATCH, OPTIONS"); headers.append(QHttpHeaders::WellKnownHeader::AccessControlAllowHeaders, "Content-Type"); resp.setHeaders(std::move(headers)); return resp; }); // ------------------------------------------------------------------ // GET /mesures?type=&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 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 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 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=&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> 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())); }); 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(); }