From 2deaf8ad4d36eeb106ad14943192789296e7229a Mon Sep 17 00:00:00 2001 From: Pierre Date: Wed, 26 Aug 2026 17:26:33 +0200 Subject: [PATCH] =?UTF-8?q?Endpoint=20/mesures=20g=C3=A9n=C3=A9ralis=C3=A9?= =?UTF-8?q?=20aux=204=20domaines=20(capteurs,=20r=C3=A9servoirs,=20circuit?= =?UTF-8?q?s,=20batteries),=20valeur=20actuelle=20r=C3=A9elle=20+=20points?= =?UTF-8?q?=20de=20bord=20synth=C3=A9tiques?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dataprovider.cpp | 106 ++++++ dataprovider.h | 65 ++++ main.cpp | 380 ++++++++++++++++++++- main.cpp:Zone.Identifier | Bin 0 -> 94 bytes main_OLD.cpp | 656 +++++++++++++++++++++++++++++++++++++ shipheart_api_circuits.zip | Bin 2765 -> 0 bytes 6 files changed, 1194 insertions(+), 13 deletions(-) create mode 100755 dataprovider.cpp create mode 100755 dataprovider.h create mode 100644 main.cpp:Zone.Identifier create mode 100644 main_OLD.cpp delete mode 100755 shipheart_api_circuits.zip diff --git a/dataprovider.cpp b/dataprovider.cpp new file mode 100755 index 0000000..41eab2d --- /dev/null +++ b/dataprovider.cpp @@ -0,0 +1,106 @@ +#include "dataprovider.h" +#include +#include +#include +#include +#include +#include + +DataProvider::DataProvider(QObject *parent) : QObject(parent) +{ +} + +void DataProvider::signalerSucces() +{ + m_connecte = true; + m_derniereConnexionReussie = QDateTime::currentDateTime(); + emit etatConnexionChange(); +} + +void DataProvider::signalerEchec() +{ + // Ne touche JAMAIS m_derniereConnexionReussie ici : on veut garder la + // trace du dernier succès réel, pas la remplacer par l'instant de l'échec. + if (m_connecte) { + m_connecte = false; + emit etatConnexionChange(); + } +} + +void DataProvider::get_familles(const QString &type) +{ + QUrl url(m_baseUrl + "/elements/familles"); + QUrlQuery query; + query.addQueryItem("type", type); + url.setQuery(query); + + auto reply = m_manager.get(QNetworkRequest(url)); + connect(reply, &QNetworkReply::finished, this, [this, reply, type]() { + reply->deleteLater(); + if (reply->error() != QNetworkReply::NoError) { + signalerEchec(); + emit erreur(reply->errorString()); + return; + } + const auto doc = QJsonDocument::fromJson(reply->readAll()); + const auto obj = doc.object(); + if (obj["status"].toString() != "ok") { + signalerEchec(); + emit erreur(obj["message"].toString()); + return; + } + signalerSucces(); + emit famillesRecues(type, obj["familles"].toArray()); + }); +} + +void DataProvider::get_elements(const QString &type, int famille) +{ + QUrl url(m_baseUrl + "/elements"); + QUrlQuery query; + query.addQueryItem("type", type); + query.addQueryItem("famille", QString::number(famille)); + url.setQuery(query); + + auto reply = m_manager.get(QNetworkRequest(url)); + connect(reply, &QNetworkReply::finished, this, [this, reply, type, famille]() { + reply->deleteLater(); + if (reply->error() != QNetworkReply::NoError) { + signalerEchec(); + emit erreur(reply->errorString()); + return; + } + const auto doc = QJsonDocument::fromJson(reply->readAll()); + const auto obj = doc.object(); + if (obj["status"].toString() != "ok") { + signalerEchec(); + emit erreur(obj["message"].toString()); + return; + } + signalerSucces(); + emit elementsRecus(type, famille, obj["elements"].toArray()); + }); +} + +void DataProvider::set_circuit(int id, int consigne) +{ + QUrl url(m_baseUrl + "/elements/circuit/" + QString::number(id)); + QNetworkRequest request(url); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + + QJsonObject body; + body["consigne"] = consigne; + const QByteArray data = QJsonDocument(body).toJson(); + + auto reply = m_manager.sendCustomRequest(request, "PATCH", data); + connect(reply, &QNetworkReply::finished, this, [this, reply, id]() { + reply->deleteLater(); + const bool succes = (reply->error() == QNetworkReply::NoError); + if (succes) signalerSucces(); + else { + signalerEchec(); + emit erreur(reply->errorString()); + } + emit circuitModifie(id, succes); + }); +} diff --git a/dataprovider.h b/dataprovider.h new file mode 100755 index 0000000..2f09d6e --- /dev/null +++ b/dataprovider.h @@ -0,0 +1,65 @@ +#pragma once +#include +#include +#include +#include + +// ============================================================================ +// DataProvider - couche d'accès aux données du client WASM (cas 2/3). +// ---------------------------------------------------------------------------- +// Remplace, pour les domaines déjà portés, les appels bdd.* directs du cas 1 +// (qui reste inchangé, cf décision §21.1 de la synthèse). Toute la logique +// HTTP/JSON est encapsulée ici - le QML ne voit que des signaux avec des +// tableaux déjà prêts à l'emploi. +// +// Portée actuelle : les 4 domaines (circuit, batterie, capteur, reservoir) +// en lecture, écriture (consigne/mode auto) sur circuit uniquement. +// +// Suit également l'état de connexion (succès/échec de chaque requête) pour +// permettre à l'UI d'afficher un état "hors ligne" explicite plutôt que de +// laisser les listes silencieusement vides ou figées (cf synthèse, gestion +// du hors-ligne). +// ============================================================================ +class DataProvider : public QObject +{ + Q_OBJECT + + Q_PROPERTY(bool connecte READ connecte NOTIFY etatConnexionChange) + Q_PROPERTY(bool aDejaEteConnecte READ aDejaEteConnecte NOTIFY etatConnexionChange) + Q_PROPERTY(QDateTime derniereConnexionReussie READ derniereConnexionReussie NOTIFY etatConnexionChange) + +public: + explicit DataProvider(QObject *parent = nullptr); + + // TODO : rendre l'adresse de base configurable (IP locale du CM5 en cas 2, + // domaine VPS en cas 3 - cf QR code d'accès, §"Architecture cible"/Cas 2-3 + // de la synthèse) - en dur sur le relais VPS pour cette étape. + Q_INVOKABLE void get_familles(const QString &type); + Q_INVOKABLE void get_elements(const QString &type, int famille); + + // Écriture rapide sur un circuit (bouton d'accueil) - PATCH /elements/circuit/. + Q_INVOKABLE void set_circuit(int id, int consigne); + + bool connecte() const { return m_connecte; } + bool aDejaEteConnecte() const { return m_derniereConnexionReussie.isValid(); } + QDateTime derniereConnexionReussie() const { return m_derniereConnexionReussie; } + +signals: + // Le type (et, pour les éléments, la famille) sont désormais inclus dans + // chaque signal - indispensable avec plusieurs domaines actifs en + // parallèle, pour que le QML sache à quelle réponse il a affaire. + void famillesRecues(const QString &type, const QJsonArray &familles); + void elementsRecus(const QString &type, int famille, const QJsonArray &elements); + void circuitModifie(int id, bool succes); + void erreur(const QString &message); + void etatConnexionChange(); + +private: + void signalerSucces(); + void signalerEchec(); + + QNetworkAccessManager m_manager; + QString m_baseUrl = QStringLiteral("https://api.ship2net.fr"); + bool m_connecte = false; + QDateTime m_derniereConnexionReussie; // invalide (isValid()==false) tant qu'aucun succès n'a jamais eu lieu +}; diff --git a/main.cpp b/main.cpp index e14c6b5..c9d7468 100644 --- a/main.cpp +++ b/main.cpp @@ -9,6 +9,8 @@ #include #include #include +#include +#include #include // ============================================================================ @@ -170,10 +172,10 @@ int main(int argc, char *argv[]) const QString type = query.queryItemValue("type"); int typeSource; - if (type == "circuit") typeSource = TYPE_SOURCE_CIRCUIT; - else if (type == "batterie") typeSource = TYPE_SOURCE_BATTERIE; - else if (type == "capteur") typeSource = TYPE_SOURCE_CAPTEUR; - else if (type == "reservoir") typeSource = TYPE_SOURCE_RESERVOIR; + 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"; @@ -189,7 +191,7 @@ int main(int argc, char *argv[]) const QString nom = q1.value(1).toString(); int etat = 0; - if (type == "circuit") { + 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 " @@ -221,7 +223,7 @@ int main(int argc, char *argv[]) etat = 1; } } - } else if (type == "batterie") { + } 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. @@ -243,7 +245,7 @@ int main(int argc, char *argv[]) while (q3.next()) { if (q3.value(0).toInt() != 0) etat = 2; } - } else if (type == "capteur") { + } else if (type == "capteurs") { // Période d'obsolescence NMEA (TB_Configuration) : int periodeObsolescenceNmea = 0; { @@ -278,7 +280,7 @@ int main(int argc, char *argv[]) if (age > periodeObsolescenceNmea && etat < 1) etat = 1; if ((etatCapteur > 0 || etatSysteme != 0) && etat < 2) etat = 2; } - } else if (type == "reservoir") { + } 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 " @@ -326,7 +328,7 @@ int main(int argc, char *argv[]) const QString type = query.queryItemValue("type"); const QString familleStr = query.queryItemValue("famille"); - if (type != "circuit" && type != "batterie" && type != "capteur" && type != "reservoir") { + if (type != "circuits" && type != "batteries" && type != "capteurs" && type != "reservoirs") { QJsonObject err; err["status"] = "erreur"; err["message"] = "type non supporté (circuit, batterie, capteur, reservoir)"; @@ -344,7 +346,7 @@ int main(int argc, char *argv[]) QJsonArray elements; - if (type == "circuit") { + if (type == "circuits") { QSqlQuery q; q.prepare( "SELECT Cle_primaire, Etat_systeme, Nom, Etat_commande_carte, Mode_commande, Etat, Consigne, Old_consigne, " @@ -383,7 +385,7 @@ int main(int argc, char *argv[]) obj["etat_events"] = q.value(16).toInt(); elements.append(obj); } - } else if (type == "batterie") { + } 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. @@ -411,7 +413,7 @@ int main(int argc, char *argv[]) obj["etat"] = q.value(7).toInt(); elements.append(obj); } - } else if (type == "capteur") { + } else if (type == "capteurs") { // Préférences d'unité du bateau (TB_Configuration) : int uniteVolume = 0, uniteTemperature = 0, periodeObsolescenceNmea = 0; { @@ -524,7 +526,7 @@ int main(int argc, char *argv[]) obj["obsolete"] = (typeEntrees == TYPE_ENTREES_NMEA && age > periodeObsolescenceNmea); elements.append(obj); } - } else if (type == "reservoir") { + } else if (type == "reservoirs") { QSqlQuery q; q.prepare( "SELECT Cle_primaire, Nom, Etat_systeme, Niveau, Capacite, Logique, " @@ -644,6 +646,358 @@ int main(int argc, char *argv[]) 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) { + 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())); + }); + 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."; diff --git a/main.cpp:Zone.Identifier b/main.cpp:Zone.Identifier new file mode 100644 index 0000000000000000000000000000000000000000..ede892f68d6f9591e6f3fd81cc2cdf1999e800e1 GIT binary patch literal 94 zcmWm2!3sb?5C&jpwXfphrkuFAiVLf41-Pwe+1gpp2Xf!WVh0FK^W>z=q=FV<(Pe3)+0Uu literal 0 HcmV?d00001 diff --git a/main_OLD.cpp b/main_OLD.cpp new file mode 100644 index 0000000..32378ca --- /dev/null +++ b/main_OLD.cpp @@ -0,0 +1,656 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// ============================================================================ +// 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()). +// ---------------------------------------------------------------------------- +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); +} + +int main(int argc, char *argv[]) +{ + QCoreApplication app(argc, argv); + + // --- 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) { + 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) { + 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) { + 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; + }); + + 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(); +} diff --git a/shipheart_api_circuits.zip b/shipheart_api_circuits.zip deleted file mode 100755 index 535718d573355a19f37f31b43f5aa8f8616586bd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2765 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 T000310RT<_003zU00000tJ5O(