#include #include #include #include #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); } // ---------------------------------------------------------------------------- // 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 : // __.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= // 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())); }); // 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=&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())); }); // ------------------------------------------------------------------ // 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/ - contenu brut d'une archive précise. // ------------------------------------------------------------------ server.route("/archives/", 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/ - 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/", 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(); }