From 0a2ec261ecc87e1a5d79856854b0590bd9a9e00c Mon Sep 17 00:00:00 2001 From: Pierre Date: Tue, 6 Oct 2026 16:25:29 +0000 Subject: [PATCH] =?UTF-8?q?Mise=20=C3=A0=20jour=20des=20fichiers=20CSS,=20?= =?UTF-8?q?ajout=20des=20m=C3=A9canismes=20de=20mise=20=C3=A0=20jour=20de?= =?UTF-8?q?=20Ship2net=20avec=20info=20sur=20la=20page=20du=20bateau.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + config/definitions.php | 1 + config/fonctions.php | 1 + config/fonctions_bdd.php | 2 +- config/fonctions_consultation.php | 584 ++++++++ config/fonctions_html.php | 91 +- config/fonctions_utilitaires.php | 35 +- cron_archivage.php | 14 + fonctions_archivage.php | 11 +- html/bateau_etat.php | 43 + html/bateau_mesures.php | 69 + html/bateaux.php | 443 ++++++- html/format.css | 1843 +++++++++++--------------- html/format__telephone.css | 411 +----- html/format_bateaux.css | 317 ++--- html/format_contact.css | 200 +-- html/format_shipmembers.css | 628 +++++---- html/vendor/chartjs/LICENSE.md | 9 + html/vendor/chartjs/chart.umd.min.js | 14 + purger_css.py | 100 ++ sql/delai_demande_archive.sql | 5 + systemd/ship2net-demandes.path | 9 + systemd/ship2net-demandes.service | 12 + traiter_demandes_archivage.php | 102 ++ 24 files changed, 2682 insertions(+), 2263 deletions(-) create mode 100644 config/fonctions_consultation.php create mode 100644 html/bateau_etat.php create mode 100644 html/bateau_mesures.php create mode 100644 html/vendor/chartjs/LICENSE.md create mode 100644 html/vendor/chartjs/chart.umd.min.js create mode 100644 purger_css.py create mode 100644 sql/delai_demande_archive.sql create mode 100644 systemd/ship2net-demandes.path create mode 100644 systemd/ship2net-demandes.service create mode 100644 traiter_demandes_archivage.php diff --git a/.gitignore b/.gitignore index 2ccc8f8..591ddd8 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ vendor/* !vendor/phpqrcode/ vendor/phpqrcode/cache/ html/vendor_OLD/ +demandes/ diff --git a/config/definitions.php b/config/definitions.php index 43489d9..dea465f 100644 --- a/config/definitions.php +++ b/config/definitions.php @@ -59,6 +59,7 @@ define("ANNEE_CREATION", 2019); //===================================================================== // Répertoires et fichiers : define("REPERTOIRE_TEMP", REPERTOIRE_HOME."/temp"); // Pas de '/' de terminaison +define("REPERTOIRE_LOGS", REPERTOIRE_HOME."/logs"); // Pas de '/' de terminaison if (PRODUCTION) { diff --git a/config/fonctions.php b/config/fonctions.php index 0799f9c..b566e00 100644 --- a/config/fonctions.php +++ b/config/fonctions.php @@ -9,5 +9,6 @@ require_once(__DIR__.'/fonctions_mail.php'); // Contient les fonctions d'émi require_once(__DIR__.'/fonctions_pdf.php'); // Contient les fonctions de création de fichiers PDF require_once(__DIR__.'/fonctions_traductions.php'); require_once(__DIR__.'/fonctions_utilitaires.php'); +require_once(__DIR__.'/fonctions_consultation.php'); // Consultation des données d'un bateau (bases de génération) diff --git a/config/fonctions_bdd.php b/config/fonctions_bdd.php index e369d71..31711ef 100644 --- a/config/fonctions_bdd.php +++ b/config/fonctions_bdd.php @@ -119,7 +119,7 @@ function Ouverture_bdd_bateau($id_bateau) return false; } - $requete = "SELECT Nom_base FROM TB_Generations_bateau WHERE ID_Bateau=? AND Date_fin IS NULL"; + $requete = "SELECT Nom_base FROM TB_Generations_bateaux WHERE ID_Bateau=? AND Date_fin IS NULL"; $stmt = $db_principale->prepare($requete); $stmt->bind_param('i', $id_bateau); $stmt->execute(); diff --git a/config/fonctions_consultation.php b/config/fonctions_consultation.php new file mode 100644 index 0000000..7e6c59a --- /dev/null +++ b/config/fonctions_consultation.php @@ -0,0 +1,584 @@ + 1, 'capteurs' => 2, 'reservoirs' => 3, 'batteries' => 4); + +// États d'un circuit (TB_Circuits.Etat), hors 0 = OK : +const CONSULTATION_ETATS_CIRCUIT = array(1 => "Limitation", 2 => "Protection", 3 => "Disjoncté", 8 => "Absence de terre", 9 => "Absence de masse"); + +// Types d'entrées capteur (TB_Capteurs.Type_entrees) et conversions d'unités (TB_Variables.Conversion_unite) : +const CONSULTATION_TYPE_ENTREES_AUX = 1; +const CONSULTATION_TYPE_ENTREES_CALCULEE_BOOLEEN = 20; +const CONSULTATION_TYPE_UNITE_VOLUME = 1; +const CONSULTATION_TYPE_UNITE_TEMPERATURE = 2; +const CONSULTATION_TYPE_UNITE_LATITUDE = 3; +const CONSULTATION_TYPE_UNITE_LONGITUDE = 4; + +// Historique des déclenchements d'events (Appli du CM5, bdd.h : TYPE_SOURCE_HISTORIQUE_EVENTS, EVENT_DECLENCHEMENT - +// ID_Source = clé primaire de l'event). NB : le commentaire de la colonne TB_Historique.Type_source dans le schéma +// ("6=usb") ne correspond pas à bdd.h - c'est bdd.h qui fait foi. +const CONSULTATION_HISTORIQUE_TYPE_SOURCE_EVENTS = 6; +const CONSULTATION_HISTORIQUE_EVENT_DECLENCHEMENT = 1; +const CONSULTATION_HISTORIQUE_EVENT_FIN_DECLENCHEMENT = 2; // Fin de la condition (ajouté dans l'Appli en octobre 2026) + +// Période d'archivage par défaut (secondes) si TB_Configuration.Periode_sauvegarde_bdd est absente ou invalide : +const CONSULTATION_PERIODE_ARCHIVAGE_DEFAUT = 7200; + + +//************************************************************************************************************ +// Fonction : Générations d'un bateau (base principale), de la plus récente à la plus ancienne. +// Reçoit : La clé primaire du bateau +// Retour : Tableau de lignes (Cle_primaire, Numero_generation, Nom_base, Motif, Version_soft, Date_debut, Date_fin) +//************************************************************************************************************ +function Generations_bateau($id_bateau) +{ + $generations = array(); + $reponse = Requete_preparee_bdd("SELECT Cle_primaire, Numero_generation, Nom_base, Motif, Version_soft, Date_debut, Date_fin + FROM TB_Generations_bateaux WHERE ID_Bateau=? ORDER BY Numero_generation DESC", + array(array('i', $id_bateau)), __FILE__, __LINE__); + while ($donnees = Recherche_ligne_bdd($reponse, MYSQLI_ASSOC)) $generations[] = $donnees; + Liberation_jeu_de_resultat_bdd($reponse); + return $generations; +} + + +//************************************************************************************************************ +// Fonction : Connexion DÉDIÉE à la base d'une génération (lecture), sans toucher à $_SESSION['db']. +// Reçoit : Le nom de la base - TOUJOURS issu de TB_Generations_bateaux, jamais d'une saisie +// Retour : L'objet mysqli, ou null en cas d'échec (journalisé) +//************************************************************************************************************ +function Connexion_generation($nom_base) +{ + if (preg_match('/^[A-Za-z0-9_]+$/', (string)$nom_base) !== 1) return null; + // Serveur : celui de la base principale (BDD_HOST), joignable depuis le conteneur du site - les bases des + // générations sont sur le même serveur MariaDB. BDD_BATEAUX_HOST/PORT (127.0.0.1:3307) ne conviennent qu'aux + // scripts NATIFS du VPS (archivage) : dans le conteneur, 127.0.0.1 désigne le conteneur lui-même (synthèse §50). + // Compte : celui des bases des bateaux (à remplacer par un compte en lecture seule - lot transverse). + mysqli_report(MYSQLI_REPORT_OFF); + $hote = BDD_HOST; + $port = null; + if (preg_match('/^(.+):(\d+)$/', $hote, $m) === 1) { $hote = $m[1]; $port = (int)$m[2]; } + $db = @new mysqli($hote, BDD_BATEAUX_USER, BDD_BATEAUX_PASSWORD, $nom_base, $port); + if ($db->connect_errno) + { + Enregistrement_log("\tConnexion_generation : échec de connexion à ".$nom_base." (".$db->connect_error.")", __FILE__, __LINE__); + return null; + } + $db->set_charset('utf8mb4'); + // Dates des colonnes TIMESTAMP (TB_Historique...) lues en UTC, comme elles sont produites par le CM5, quel que soit + // le fuseau par défaut du serveur MariaDB : + $db->query("SET time_zone = '+00:00'"); + return $db; +} + + +//************************************************************************************************************ +// Fonction : Valeur d'un paramètre de TB_Configuration de la base du bateau (telle qu'à la dernière archive). +// Retour : La valeur (chaîne), ou $defaut si absente +//************************************************************************************************************ +function Configuration_bateau($db, $nom, $defaut = null) +{ + $stmt = $db->prepare("SELECT Valeur FROM TB_Configuration WHERE Nom=?"); + if (!$stmt) return $defaut; + $stmt->bind_param('s', $nom); + $stmt->execute(); + $ligne = $stmt->get_result()->fetch_row(); + $stmt->close(); + return $ligne ? $ligne[0] : $defaut; +} + + +//************************************************************************************************************ +// Fonction : Période d'archivage du bateau, en secondes (TB_Configuration.Periode_sauvegarde_bdd) - base des +// seuils de l'indicateur de fraîcheur (orange jusqu'à 1,25 fois la période, rouge au-delà). +//************************************************************************************************************ +function Periode_archivage_bateau($db) +{ + $periode = (int)Configuration_bateau($db, 'Periode_sauvegarde_bdd', 0); + return ($periode > 0) ? $periode : CONSULTATION_PERIODE_ARCHIVAGE_DEFAUT; +} + + +//************************************************************************************************************ +// Conversions et formats - traduction de l'API du CM5 (conversion_volume, conversion_temperature, +// symbole_unite_volume, symbole_unite_temperature, chaine_latitude, chaine_longitude). +//************************************************************************************************************ +function Conversion_volume($valeur, $unite) +{ + switch ((int)$unite) { case 1: return $valeur / 3.785411784; case 2: return $valeur / 4.546090; default: return $valeur; } +} +function Symbole_unite_volume($unite) +{ + switch ((int)$unite) { case 1: return "US Ga"; case 2: return "Imp. Ga"; default: return "lit."; } +} +function Conversion_temperature($valeur, $unite) +{ + return ((int)$unite == 1) ? ($valeur * 1.8) + 32.0 : $valeur; +} +function Symbole_unite_temperature($unite) +{ + return ((int)$unite == 1) ? "°F" : "°C"; +} +function Chaine_coordonnee($valeur, $positif, $negatif) +{ + // Le signe est porté par la lettre (N/S, E/W) : degrés et minutes en valeur absolue. (L'API du CM5 garde le signe, + // ce qui donne par exemple -12°-30.000' S pour -12,5 - à corriger aussi de son côté.) + $absolue = abs((float)$valeur); + $degres = (int)$absolue; + $minutes = ($absolue - $degres) * 60.0; + return $degres."°".number_format($minutes, 3, '.', '')."' ".(($valeur >= 0) ? $positif : $negatif); +} +function Nombre_affiche($valeur, $precision) +{ + return number_format((float)$valeur, max(0, (int)$precision), ',', ' '); +} + + +//************************************************************************************************************ +// Fonction : Éléments d'un domaine, regroupés par famille, avec leurs dernières valeurs (telles qu'à la dernière +// archive), prêts à afficher. Mêmes éléments que l'écran du bord : circuits et capteurs dont +// Priorite_affichage_accueil > 0, toutes les batteries et tous les réservoirs. +// Reçoit : La connexion à la base de génération, le domaine ('circuits', 'capteurs', 'batteries', 'reservoirs') +// Retour : Tableau de familles : ['nom' => ..., 'elements' => [['nom'=>..., 'valeurs'=>[...], 'defaut'=>texte|null]]] +// Les textes sont BRUTS (à échapper à l'affichage). +//************************************************************************************************************ +function Elements_domaine($db, $domaine) +{ + if (!isset(CONSULTATION_TYPE_SOURCE[$domaine])) return array(); + $familles = array(); + + $stmt = $db->prepare("SELECT Cle_primaire, Nom FROM TB_Familles WHERE Type_source=? ORDER BY Priorite_affichage, Cle_primaire"); + $type_source = CONSULTATION_TYPE_SOURCE[$domaine]; + $stmt->bind_param('i', $type_source); + $stmt->execute(); + $resultat = $stmt->get_result(); + while ($famille = $resultat->fetch_assoc()) $familles[(int)$famille['Cle_primaire']] = array('nom' => $famille['Nom'], 'elements' => array()); + $stmt->close(); + if (count($familles) == 0) return array(); + + switch ($domaine) + { + case 'circuits': + $requete = "SELECT Cle_primaire, ID_Famille, Nom, Etat_systeme, Etat, Consigne, Tension, Intensite + FROM TB_Circuits WHERE Priorite_affichage_accueil > 0 ORDER BY Priorite_affichage_accueil, Cle_primaire"; + break; + case 'capteurs': + $requete = "SELECT C.Cle_primaire, C.ID_Famille, C.Nom, C.Etat_systeme, C.Etat, C.Type_entrees, C.Valeur, C.Precision_affichage_accueil, + C.Liste_valeurs_textes, V.Unite, V.Conversion_unite + FROM TB_Capteurs C LEFT JOIN TB_Variables V ON V.Cle_primaire = C.ID_Variable + WHERE C.Priorite_affichage_accueil > 0 ORDER BY C.Priorite_affichage_accueil, C.Nom"; + $unite_volume = Configuration_bateau($db, 'Unite_volume', 0); + $unite_temperature = Configuration_bateau($db, 'Unite_temperature', 0); + break; + case 'batteries': + $requete = "SELECT Cle_primaire, ID_Famille, Nom, Etat_systeme, Soc, Tension FROM TB_Batteries ORDER BY Priorite_affichage_accueil, Cle_primaire"; + break; + case 'reservoirs': + $requete = "SELECT Cle_primaire, ID_Famille, Nom, Etat_systeme, Etat_entrees, Niveau, Capacite FROM TB_Reservoirs ORDER BY Priorite_affichage_accueil, Cle_primaire"; + $unite_volume = Configuration_bateau($db, 'Unite_volume', 0); + break; + } + $resultat = $db->query($requete); + if (!$resultat) return array(); + + while ($e = $resultat->fetch_assoc()) + { + $id_famille = (int)$e['ID_Famille']; + if (!isset($familles[$id_famille])) continue; // Élément rattaché à une famille d'un autre domaine ou inexistante + $valeurs = array(); + $defaut = ((int)$e['Etat_systeme'] != 0) ? t("Défaut système") : null; + $courbe = true; // Courbe historique disponible (pas pour une latitude ou une longitude) + + switch ($domaine) + { + case 'circuits': + // Consigne en % (100 = marche pleine ; une valeur intermédiaire = variateur) : + $consigne = (int)$e['Consigne']; + if ($consigne <= 0) $valeurs[] = t("Arrêt"); + else if ($consigne >= 100) $valeurs[] = t("Marche"); + else $valeurs[] = t("Marche")." (".$consigne." %)"; + if ((float)$e['Intensite'] != 0) $valeurs[] = Nombre_affiche($e['Intensite'], 1)." A"; + if ((float)$e['Tension'] != 0) $valeurs[] = Nombre_affiche($e['Tension'], 1)." V"; + if ($defaut === null && (int)$e['Etat'] != 0) $defaut = t(CONSULTATION_ETATS_CIRCUIT[(int)$e['Etat']] ?? "Défaut"); + break; + + case 'capteurs': + $brute = (float)$e['Valeur']; + $type_unite = (int)$e['Conversion_unite']; + $texte_liste = null; + if ((int)$e['Type_entrees'] == CONSULTATION_TYPE_ENTREES_AUX || (int)$e['Type_entrees'] == CONSULTATION_TYPE_ENTREES_CALCULEE_BOOLEEN) + { + foreach (explode(';', (string)$e['Liste_valeurs_textes']) as $paire) + { + $champs = explode('/', $paire); + if (count($champs) == 2 && (float)$champs[0] == $brute) { $texte_liste = $champs[1]; break; } + } + } + if ($type_unite == CONSULTATION_TYPE_UNITE_LATITUDE) { $valeurs[] = Chaine_coordonnee($brute, "N", "S"); $courbe = false; } + else if ($type_unite == CONSULTATION_TYPE_UNITE_LONGITUDE) { $valeurs[] = Chaine_coordonnee($brute, "E", "W"); $courbe = false; } + else if ($texte_liste !== null) $valeurs[] = $texte_liste; + else if ($type_unite == CONSULTATION_TYPE_UNITE_VOLUME) $valeurs[] = Nombre_affiche(Conversion_volume($brute, $unite_volume), $e['Precision_affichage_accueil'])." ".Symbole_unite_volume($unite_volume); + else if ($type_unite == CONSULTATION_TYPE_UNITE_TEMPERATURE) $valeurs[] = Nombre_affiche(Conversion_temperature($brute, $unite_temperature), $e['Precision_affichage_accueil'])." ".Symbole_unite_temperature($unite_temperature); + else $valeurs[] = trim(Nombre_affiche($brute, $e['Precision_affichage_accueil'])." ".$e['Unite']); + if ($defaut === null && (int)$e['Etat'] > 0) $defaut = t("Défaut capteur"); + break; + + case 'batteries': + $valeurs[] = Nombre_affiche($e['Soc'], 0)." %"; + $valeurs[] = Nombre_affiche($e['Tension'], 2)." V"; + break; + + case 'reservoirs': + $valeurs[] = Nombre_affiche($e['Niveau'], 0)." %"; + if ((float)$e['Capacite'] > 0) + { + $valeurs[] = "≈ ".Nombre_affiche(Conversion_volume((float)$e['Niveau'] * (float)$e['Capacite'] / 100.0, $unite_volume), 0)." ".Symbole_unite_volume($unite_volume); + } + if ($defaut === null && (int)$e['Etat_entrees'] > 0) $defaut = t("Défaut sondes"); + break; + } + $familles[$id_famille]['elements'][] = array('id' => (int)$e['Cle_primaire'], 'nom' => $e['Nom'], 'valeurs' => $valeurs, 'defaut' => $defaut, 'courbe' => $courbe); + } + $resultat->free(); + + // Familles sans élément affiché : retirées + return array_values(array_filter($familles, fn($f) => count($f['elements']) > 0)); +} + + +//************************************************************************************************************ +// Fonction : Derniers ÉPISODES d'alarmes (synthèse §50), à partir des déclenchements (EVENT_DECLENCHEMENT) et des fins +// de condition (EVENT_FIN_DECLENCHEMENT) enregistrés dans TB_Historique. +// - Alarme = event ayant au moins un indicateur Alarme_... actif (son, LED, soutien, source, message, SMS, +// e-mail), selon sa définition ACTUELLE - un event sans indicateur (bouton poussoir...) n'apparaît jamais. +// - Event à condition sur une source (Type_source 1 à 4) : un épisode = un déclenchement et la fin +// suivante du même event, avec sa durée. Cas particuliers signalés : déclenchement le plus récent sans fin +// ('en_cours' : en cours à la dernière archive), déclenchement suivi d'un autre déclenchement sans fin +// entre les deux ('fin_non_enregistree' : données antérieures à l'enregistrement des fins), fin dont le +// déclenchement est hors de la période lue ('debut_anterieur'). +// - Event horaire (Type_source 0) : ponctuel, sans durée ni fin ('ponctuel'). +// Reçoit : La connexion à la base de génération, le nombre maximal d'épisodes +// Retour : Tableau, du plus récent au plus ancien, de ['debut_utc', 'fin_utc' (ISO 8601 Z ou null), 'duree' (secondes +// ou null), 'statut' (termine, en_cours, fin_non_enregistree, debut_anterieur, ponctuel), 'nom', +// 'message'] - textes bruts, à échapper à l'affichage +//************************************************************************************************************ +function Episodes_alarmes($db, $limite = 20) +{ + // Les dernières lignes (déclenchements et fins), lues en ordre décroissant puis traitées dans l'ordre chronologique. + // Marge de lecture : plusieurs lignes par épisode, et des events qui s'entremêlent. + $stmt = $db->prepare("SELECT H.Date, H.Type, H.ID_Source, E.Nom, E.Texte_message, E.Type_source + FROM TB_Historique H + JOIN TB_Events E ON E.Cle_primaire = H.ID_Source + WHERE H.Type_source = ? AND H.Type IN (?, ?) + AND (E.Alarme_son > 0 OR E.Alarme_led > 0 OR E.Alarme_soutien > 0 OR E.Alarme_source > 0 + OR E.Alarme_message > 0 OR E.Alarme_sms > 0 OR E.Alarme_email > 0) + ORDER BY H.Date DESC + LIMIT ?"); + if (!$stmt) return array(); + $type_source = CONSULTATION_HISTORIQUE_TYPE_SOURCE_EVENTS; + $debut = CONSULTATION_HISTORIQUE_EVENT_DECLENCHEMENT; + $fin = CONSULTATION_HISTORIQUE_EVENT_FIN_DECLENCHEMENT; + $nb_lignes = $limite * 10; + $stmt->bind_param('iiii', $type_source, $debut, $fin, $nb_lignes); + $stmt->execute(); + $lignes = array_reverse($stmt->get_result()->fetch_all(MYSQLI_ASSOC)); + $stmt->close(); + + $iso = function($date) { return str_replace(' ', 'T', $date).'Z'; }; + $episodes = array(); + $ouverts = array(); // id_event => index de l'épisode ouvert (déclenchement sans fin pour l'instant) + foreach ($lignes as $l) + { + $id = (int)$l['ID_Source']; + $base = array('nom' => $l['Nom'], 'message' => trim((string)$l['Texte_message'])); + if ((int)$l['Type_source'] == 0) // Event horaire : ponctuel + { + if ((int)$l['Type'] == $debut) $episodes[] = $base + array('debut_utc' => $iso($l['Date']), 'fin_utc' => null, 'duree' => null, 'statut' => 'ponctuel', 'tri' => $l['Date']); + continue; + } + if ((int)$l['Type'] == $debut) + { + if (isset($ouverts[$id])) $episodes[$ouverts[$id]]['statut'] = 'fin_non_enregistree'; + $episodes[] = $base + array('debut_utc' => $iso($l['Date']), 'fin_utc' => null, 'duree' => null, 'statut' => 'en_cours', 'tri' => $l['Date']); + $ouverts[$id] = count($episodes) - 1; + } + else + { + if (isset($ouverts[$id])) + { + $i = $ouverts[$id]; + $episodes[$i]['fin_utc'] = $iso($l['Date']); + $episodes[$i]['duree'] = max(0, strtotime($l['Date'].' UTC') - strtotime(str_replace(array('T', 'Z'), array(' ', ''), $episodes[$i]['debut_utc']).' UTC')); + $episodes[$i]['statut'] = 'termine'; + $episodes[$i]['tri'] = $l['Date']; + unset($ouverts[$id]); + } + else $episodes[] = $base + array('debut_utc' => null, 'fin_utc' => $iso($l['Date']), 'duree' => null, 'statut' => 'debut_anterieur', 'tri' => $l['Date']); + } + } + + // Du plus récent au plus ancien (date de la dernière ligne connue de l'épisode), limité : + usort($episodes, fn($a, $b) => strcmp($b['tri'], $a['tri'])); + return array_slice($episodes, 0, $limite); +} + + +//************************************************************************************************************ +// Fonction : Durée lisible ("45 s", "27 min", "2 h 05 min", "3 j 4 h"). +//************************************************************************************************************ +function Duree_lisible($secondes) +{ + $secondes = (int)$secondes; + if ($secondes < 60) return $secondes." s"; + if ($secondes < 3600) return intdiv($secondes, 60)." min"; + if ($secondes < 86400) return intdiv($secondes, 3600)." h ".sprintf("%02d", intdiv($secondes % 3600, 60))." min"; + return intdiv($secondes, 86400)." j ".intdiv($secondes % 86400, 3600)." h"; +} + + +//************************************************************************************************************ +// COURBES HISTORIQUES (synthèse §50, étape 3c) - logique reprise de la route /mesures de l'API du CM5 (variable +// selon le domaine, conversions d'unités, dernière valeur connue reportée au début de la période), avec trois +// différences voulues : +// - la courbe s'arrête à la DERNIÈRE ARCHIVE (ou à la fin de la génération), pas à "maintenant" : au-delà, il n'y a +// pas de données, et prolonger la dernière valeur en inventerait ; +// - périodes étendues (24 h à 1 an), le VPS conservant tout l'historique ; +// - regroupement par tranche avec MOYENNE, MINIMUM et MAXIMUM : la courbe moyenne est entourée d'une bande min-max, +// pour qu'un pic bref (30 V pendant quelques secondes sur une tension de 26 V) reste visible même sur une longue +// période - une simple moyenne par tranche l'effaçait. Les statistiques min/max/moyenne sont calculées sur les +// mesures BRUTES de la période (et non sur les moyennes par tranche, qui les sous-estimaient). +//************************************************************************************************************ + +const CONSULTATION_PERIODES_COURBE = array('24h' => 86400, '7j' => 604800, '30j' => 2592000, '3m' => 7776000, '1a' => 31536000); +const CONSULTATION_POINTS_CIBLE = 300; // Nombre de tranches visé, quelle que soit la période + +// Variables traçables pour les circuits et les batteries (le client choisit) : code TB_Variables, colonne de la table +// permanente (valeur actuelle), précision. Capteurs et réservoirs : variable unique. +const CONSULTATION_VARIABLES_CIRCUIT = array( + 'consigne' => array('code' => 'Circ_consigne', 'precision' => 0), + 'intensite' => array('code' => 'Circ_courant', 'precision' => 2), // 1 décimale sur SP4AC + 'tension' => array('code' => 'Circ_tension', 'precision' => 2), // Circ_tension_secteur et 0 décimale sur SP4AC + 'frequence' => array('code' => 'Circ_frequence', 'precision' => 1)); +const CONSULTATION_VARIABLES_BATTERIE = array( + 'soc' => array('code' => 'Batt_soc', 'precision' => 1), + 'tension' => array('code' => 'Batt_tension', 'precision' => 2), + 'temperature' => array('code' => 'Batt_temperature', 'precision' => 1), + 'courant_differentiel' => array('code' => 'Batt_courant_differentiel', 'precision' => 2), + 'courant_charge' => array('code' => 'Batt_courant_charge', 'precision' => 2), + 'courant_decharge' => array('code' => 'Batt_courant_decharge', 'precision' => 2), + 'soh' => array('code' => 'Batt_soh', 'precision' => 1), + 'autonomie' => array('code' => 'Batt_autonomie', 'precision' => 1)); +const CONSULTATION_TYPE_MODULE_SP4AC = 3; + + +//************************************************************************************************************ +// Fonction : Données d'une courbe historique. +// Reçoit : La connexion à la base de génération, le domaine, l'élément (clé primaire), la variable (circuits, +// batteries ; ignorée sinon), la période (clé de CONSULTATION_PERIODES_COURBE), la date de fin (UTC, +// 'Y-m-d H:i:s' : dernière archive, ou fin de la génération) +// Retour : ['debut', 'fin' (ms), 'unite', 'precision', 'points' => [['t' (ms), 'v', 'min', 'max']], 'stats' (ou null) +// => ['min' => ['v', 't'], 'max' => ['v', 't'], 'moyenne']] ou ['erreur' => texte] +//************************************************************************************************************ +function Courbe_element($db, $type, $id_element, $variable, $periode, $date_fin_utc) +{ + if (!isset(CONSULTATION_PERIODES_COURBE[$periode])) return array('erreur' => "période invalide"); + $fin = strtotime($date_fin_utc.' UTC'); + if ($fin === false) return array('erreur' => "date de fin invalide"); + $duree = CONSULTATION_PERIODES_COURBE[$periode]; + $debut = $fin - $duree; + $regroupement = max(1, intdiv($duree, CONSULTATION_POINTS_CIBLE)); + + $valeur_unique = function($requete, $types, ...$parametres) use ($db) { + $stmt = $db->prepare($requete); + if (!$stmt) return null; + $stmt->bind_param($types, ...$parametres); + $stmt->execute(); + $ligne = $stmt->get_result()->fetch_row(); + $stmt->close(); + return $ligne; + }; + $id_variable_code = function($code) use ($valeur_unique) { + $ligne = $valeur_unique("SELECT Cle_primaire FROM TB_Variables WHERE Code=?", 's', $code); + return $ligne ? (int)$ligne[0] : 0; + }; + + // Variable et table selon le domaine : + switch ($type) + { + case 'capteurs': + $table = "TB_Mesures_capteurs"; + $ligne = $valeur_unique("SELECT ID_Variable, Precision_affichage_mesures FROM TB_Capteurs WHERE Cle_primaire=?", 'i', $id_element); + if (!$ligne) return array('erreur' => "capteur introuvable"); + $id_variable = (int)$ligne[0]; + $precision = (int)$ligne[1]; + break; + case 'reservoirs': + $table = "TB_Mesures_reservoirs"; + if (!$valeur_unique("SELECT 1 FROM TB_Reservoirs WHERE Cle_primaire=?", 'i', $id_element)) return array('erreur' => "réservoir introuvable"); + $id_variable = $id_variable_code('Res_niveau'); + $precision = 1; + break; + case 'circuits': + $table = "TB_Mesures_circuits"; + if (!isset(CONSULTATION_VARIABLES_CIRCUIT[$variable])) return array('erreur' => "variable invalide"); + $ligne = $valeur_unique("SELECT (SELECT Type FROM TB_Modules_config WHERE Adresse_k_bus=TB_Circuits.Adresse_module) FROM TB_Circuits WHERE Cle_primaire=?", 'i', $id_element); + if (!$ligne) return array('erreur' => "circuit introuvable"); + $sp4ac = ((int)$ligne[0] == CONSULTATION_TYPE_MODULE_SP4AC); + $code = CONSULTATION_VARIABLES_CIRCUIT[$variable]['code']; + $precision = CONSULTATION_VARIABLES_CIRCUIT[$variable]['precision']; + if ($sp4ac && $variable == 'tension') { $code = 'Circ_tension_secteur'; $precision = 0; } + if ($sp4ac && $variable == 'intensite') $precision = 1; + $id_variable = $id_variable_code($code); + break; + case 'batteries': + $table = "TB_Mesures_batteries"; + if (!isset(CONSULTATION_VARIABLES_BATTERIE[$variable])) return array('erreur' => "variable invalide"); + if (!$valeur_unique("SELECT 1 FROM TB_Batteries WHERE Cle_primaire=?", 'i', $id_element)) return array('erreur' => "batterie introuvable"); + $id_variable = $id_variable_code(CONSULTATION_VARIABLES_BATTERIE[$variable]['code']); + $precision = CONSULTATION_VARIABLES_BATTERIE[$variable]['precision']; + break; + default: + return array('erreur' => "domaine invalide"); + } + if ($id_variable == 0) return array('erreur' => "variable introuvable dans la configuration du bateau"); + + // Unité et conversion (unités du bateau) : + $ligne = $valeur_unique("SELECT Unite, Conversion_unite FROM TB_Variables WHERE Cle_primaire=?", 'i', $id_variable); + $unite = $ligne ? (string)$ligne[0] : ""; + $type_unite = $ligne ? (int)$ligne[1] : 0; + if ($type_unite == CONSULTATION_TYPE_UNITE_LATITUDE || $type_unite == CONSULTATION_TYPE_UNITE_LONGITUDE) return array('erreur' => "pas de courbe pour une coordonnée"); + $unite_volume = (int)Configuration_bateau($db, 'Unite_volume', 0); + $unite_temperature = (int)Configuration_bateau($db, 'Unite_temperature', 0); + if ($type_unite == CONSULTATION_TYPE_UNITE_VOLUME) $unite = Symbole_unite_volume($unite_volume); + else if ($type_unite == CONSULTATION_TYPE_UNITE_TEMPERATURE) $unite = Symbole_unite_temperature($unite_temperature); + $convertir = function($brut) use ($type_unite, $unite_volume, $unite_temperature) { + if ($brut === null) return null; + if ($type_unite == CONSULTATION_TYPE_UNITE_VOLUME) return Conversion_volume((float)$brut, $unite_volume); + if ($type_unite == CONSULTATION_TYPE_UNITE_TEMPERATURE) return Conversion_temperature((float)$brut, $unite_temperature); + return (float)$brut; + }; + + $date_debut = gmdate('Y-m-d H:i:s', $debut); + $date_fin = gmdate('Y-m-d H:i:s', $fin); + $points = array(); + $stats_min = null; $stats_max = null; + + // Dernière valeur connue AVANT la période, reportée au premier instant (élément enregistré seulement sur changement) : + $ligne = $valeur_unique("SELECT Valeur FROM ".$table." WHERE ID=? AND ID_Variable=? AND Date<=? AND Valeur IS NOT NULL ORDER BY Date DESC LIMIT 1", + 'iis', $id_element, $id_variable, $date_debut); + if ($ligne) + { + $v = $convertir($ligne[0]); + $points[] = array('t' => $debut * 1000, 'v' => $v, 'min' => $v, 'max' => $v); + $stats_min = array('v' => $v, 't' => $debut * 1000); + $stats_max = array('v' => $v, 't' => $debut * 1000); + } + + // Tranches : moyenne, minimum, maximum. + $stmt = $db->prepare("SELECT UNIX_TIMESTAMP(MIN(Date)), AVG(Valeur), MIN(Valeur), MAX(Valeur) FROM ".$table." + WHERE ID=? AND ID_Variable=? AND Date>? AND Date<=? AND Valeur IS NOT NULL + GROUP BY UNIX_TIMESTAMP(Date) DIV ? ORDER BY 1"); + $stmt->bind_param('iissi', $id_element, $id_variable, $date_debut, $date_fin, $regroupement); + $stmt->execute(); + $resultat = $stmt->get_result(); + while ($l = $resultat->fetch_row()) + { + $points[] = array('t' => (int)$l[0] * 1000, 'v' => $convertir($l[1]), 'min' => $convertir($l[2]), 'max' => $convertir($l[3])); + } + $stmt->close(); + + // Statistiques sur les mesures BRUTES de la période (avec la valeur reportée au début) : + $ligne = $valeur_unique("SELECT AVG(Valeur), COUNT(*) FROM ".$table." WHERE ID=? AND ID_Variable=? AND Date>? AND Date<=? AND Valeur IS NOT NULL", + 'iiss', $id_element, $id_variable, $date_debut, $date_fin); + $moyenne = ($ligne && (int)$ligne[1] > 0) ? $convertir($ligne[0]) : (($stats_min !== null) ? $stats_min['v'] : null); + if ($ligne && (int)$ligne[1] > 0) + { + foreach (array('ASC' => 'min', 'DESC' => 'max') as $ordre => $cle) + { + $extreme = $valeur_unique("SELECT Valeur, UNIX_TIMESTAMP(Date) FROM ".$table." WHERE ID=? AND ID_Variable=? AND Date>? AND Date<=? AND Valeur IS NOT NULL ORDER BY Valeur ".$ordre.", Date DESC LIMIT 1", + 'iiss', $id_element, $id_variable, $date_debut, $date_fin); + $candidat = array('v' => $convertir($extreme[0]), 't' => (int)$extreme[1] * 1000); + if ($cle == 'min' && ($stats_min === null || $candidat['v'] < $stats_min['v'])) $stats_min = $candidat; + if ($cle == 'max' && ($stats_max === null || $candidat['v'] > $stats_max['v'])) $stats_max = $candidat; + } + } + + // Point de fin : dernière valeur MESURÉE (et non la moyenne de la dernière tranche) prolongée jusqu'à la dernière + // archive, pas au-delà : + if (count($points) > 0 && end($points)['t'] < $fin * 1000) + { + $ligne = $valeur_unique("SELECT Valeur FROM ".$table." WHERE ID=? AND ID_Variable=? AND Date<=? AND Valeur IS NOT NULL ORDER BY Date DESC LIMIT 1", + 'iis', $id_element, $id_variable, $date_fin); + $v = $ligne ? $convertir($ligne[0]) : end($points)['v']; + $points[] = array('t' => $fin * 1000, 'v' => $v, 'min' => $v, 'max' => $v); + } + + return array('debut' => $debut * 1000, 'fin' => $fin * 1000, 'unite' => $unite, 'precision' => $precision, 'points' => $points, + 'stats' => ($stats_min !== null) ? array('min' => $stats_min, 'max' => $stats_max, 'moyenne' => $moyenne) : null); +} + + +//************************************************************************************************************ +// DEMANDE D'ARCHIVE À L'OUVERTURE DE LA PAGE (synthèse §50, étape 3d). +// Le portail (conteneur) ne peut pas lancer le traitement des archives (natif : client mysql, openssl, dossiers +// d'archives) : il DÉPOSE une demande - un fichier nommé d'après le numéro du bateau dans /var/www/demandes/a_traiter - +// que l'unité systemd ship2net-demandes.path fait traiter aussitôt (traiter_demandes_archivage.php : archive immédiate +// demandée au CM5, puis rapatriement et chargement). +// Garde-fous : pas de demande si les données ont moins de Delai_demande_archive secondes (TB_Configuration, 300 par +// défaut), ni si une demande pour ce bateau date de moins de ce même délai (marqueur /var/www/demandes/dernieres/). +//************************************************************************************************************ +const REPERTOIRE_DEMANDES = "/var/www/demandes"; +const DELAI_DEMANDE_ARCHIVE_DEFAUT = 300; // secondes + +function Delai_demande_archive() +{ + $reponse = Requete_preparee_bdd("SELECT Valeur FROM TB_Configuration WHERE Nom=? AND Validite=1", + array(array('s', 'Delai_demande_archive')), __FILE__, __LINE__); + $donnees = Recherche_ligne_bdd($reponse, MYSQLI_ASSOC); + Liberation_jeu_de_resultat_bdd($reponse); + $delai = $donnees ? (int)$donnees['Valeur'] : 0; + return ($delai > 0) ? $delai : DELAI_DEMANDE_ARCHIVE_DEFAUT; +} + +// Retour : ['statut' => 'demandee' | 'donnees_recentes' | 'deja_demandee' | 'impossible', 'attente' => secondes avant +// qu'une nouvelle demande soit possible (0 si sans objet)] - la page l'affiche, pour que l'utilisateur sache +// pourquoi aucune mise à jour n'est demandée. +function Demander_archive_si_besoin($id_bateau, $date_derniere_maj_utc) +{ + $id_bateau = (int)$id_bateau; + if ($id_bateau <= 0) return array('statut' => 'impossible', 'attente' => 0); + $delai = Delai_demande_archive(); + + // Données déjà récentes : + if ($date_derniere_maj_utc !== null && $date_derniere_maj_utc >= "2020-01-02") + { + $age = time() - strtotime($date_derniere_maj_utc.' UTC'); + if ($age < $delai) return array('statut' => 'donnees_recentes', 'attente' => $delai - $age); + } + + // Demande récente pour ce bateau (quel que soit le nombre de pages ouvertes ou de rechargements) : + $marqueur = REPERTOIRE_DEMANDES."/dernieres/".$id_bateau; + if (is_file($marqueur)) + { + $age = time() - filemtime($marqueur); + if ($age < $delai) return array('statut' => 'deja_demandee', 'attente' => $delai - $age); + } + + if (!@touch($marqueur) || @file_put_contents(REPERTOIRE_DEMANDES."/a_traiter/".$id_bateau, gmdate('Y-m-d H:i:s')." UTC\n") === false) + { + Enregistrement_log("\tDemander_archive_si_besoin : dépôt impossible dans ".REPERTOIRE_DEMANDES." (bateau ".$id_bateau.")", __FILE__, __LINE__); + return array('statut' => 'impossible', 'attente' => 0); + } + return array('statut' => 'demandee', 'attente' => 0); +} diff --git a/config/fonctions_html.php b/config/fonctions_html.php index 3d160b1..6dbb5b2 100644 --- a/config/fonctions_html.php +++ b/config/fonctions_html.php @@ -108,12 +108,11 @@ function Entete_page_html() echo ''; - echo ""; - echo ""; - echo ""; - - echo ""; - echo ""; + // NB (synthèse §50) : les polices Montserrat et Oswald ne sont plus chargées depuis fonts.googleapis.com - aucune + // feuille de style ne les utilisait (police du système), et chaque visite transmettait l'adresse IP du visiteur à + // Google (problème RGPD). + // NB (synthèse §50) : Leaflet (cartographie, chargé depuis unpkg.com) et noUiSlider (curseur de dates) ne sont plus + // chargés - ils servaient aux onglets du modèle CM4, retirés au §47 ; aucune page ne les utilise plus. // gestion des fichiers CSS (et de scripts spécifiques à certains fichiers) : switch(basename($_SERVER['SCRIPT_FILENAME'])) @@ -130,10 +129,13 @@ function Entete_page_html() echo ''; // CSS de Bootstrap echo ''; // CSS général, tous écrans. - echo ''; // CSS pour les petits écrans (doit être placé à la fin car il override les CSS globaux) + // NB (synthèse §50) : "max-width" (largeur de la FENÊTRE) au lieu de "max-device-width" (taille physique de l'écran, + // condition dépréciée) - même effet sur un téléphone, et cohérent avec les règles "petit écran" de format.css. + echo ''; // CSS pour les petits écrans (doit être placé à la fin car il override les CSS globaux) // Chargement de srcipts JS généraux : - echo ''; // Pour Google Charts (graphiques) + // NB (synthèse §50) : Google Charts n'est plus chargé (depuis les serveurs de Google, sur toutes les pages) - les + // graphiques utilisent Chart.js, hébergé sur le VPS (vendor/chartjs) et chargé seulement par la page qui en a besoin. echo ''; @@ -167,11 +169,9 @@ function Entete_page($message_erreur, $message_information) //---------------------------------- // Bandeau gauche : echo ''; @@ -209,68 +209,11 @@ function Entete_page($message_erreur, $message_information) echo ''; - //-------------------------------------------------------------------------------------------- - // Menu afficher / masquer -- Mobile - //-------------------------------------------------------------------------------------------- - echo ''; + // NB (synthèse §50) : le script du menu mobile de l'ancien site (nav_bar_global, menu_mobile, img_type, portail_pro, + // portail_par) a été retiré. Ces éléments n'existent plus : il provoquait une erreur JavaScript à chaque chargement de + // page et à chaque clic, et redirigeait le logo vers bienvenue.php / accueil.php, pages qui n'existent plus. + - //-------------------------------------------------------------------------------------------- // Script d'affichage/masquage du menu : //-------------------------------------------------------------------------------------------- diff --git a/config/fonctions_utilitaires.php b/config/fonctions_utilitaires.php index 3f1b0c3..620cb57 100644 --- a/config/fonctions_utilitaires.php +++ b/config/fonctions_utilitaires.php @@ -316,33 +316,36 @@ function Conversion_DECtoDMS($lat_lon, $valeur) //************************************************************************************************************ // Fonction : Enregistrement dans le fichier de log de l'évènement +// Ne doit JAMAIS interrompre la page qui l'appelle (synthèse §50) : si le répertoire ou le fichier de log +// est inaccessible (cas du conteneur ship2net-php, où /home/koriolan/logs n'était pas monté), le message +// part dans le journal d'erreurs de PHP (visible par "docker logs ship2net-php") au lieu de provoquer une +// erreur fatale qui masquait l'erreur d'origine. // Reçoit : Le texte à afficher dans le log, le fichier d'origine et la ligne d'origine // Retour : Rien //************************************************************************************************************ function Enregistrement_log($texte, $fichier, $ligne) { $repertoire_logs = REPERTOIRE_LOGS."/www_shipheart"; - if (!file_exists($repertoire_logs) OR !is_dir($repertoire_logs)) // On vérifie d'abord que le répertoire existe, sinon on le créé. - { - mkdir($repertoire_logs); - $message_information .= "Le répertoire de logs [".$repertoire_logs."] n'existait pas. Il vient d'êtrer crééé
"; - } - - $fichier_log = $repertoire_logs.'/log_shipheart_'.date("Y-m-d").'.txt'; $prefixe_ligne = date("d/m/Y H:i:s")." [".basename($fichier)." - ".$ligne."] \t: "; $suffixe_ligne = "\r\n"; - - $log = fopen($fichier_log, "a+"); // Ouverture en lecture/écriture en mode append... - - fputs($log, $prefixe_ligne); // Entete de ligne - fputs($log, $texte); // Ecriture du texte de log - fputs($log, $suffixe_ligne); // Fin de ligne - - fclose($log); // Fermetue du fichier. + + // Répertoire créé au besoin (y compris les répertoires parents), sans avertissement en cas d'échec : + if (!is_dir($repertoire_logs)) @mkdir($repertoire_logs, 0775, true); + + $log = @fopen($repertoire_logs.'/log_shipheart_'.date("Y-m-d").'.txt', "a"); + if ($log !== false) + { + fputs($log, $prefixe_ligne.$texte.$suffixe_ligne); + fclose($log); + } + else + { + error_log("Ship2net (fichier de log inaccessible : ".$repertoire_logs.") ".$prefixe_ligne.$texte); + } if(SHOW_DEBUG) { - echo $prefixe_ligne . $texte . $suffixe_ligne; + echo htmlspecialchars($prefixe_ligne.$texte).$suffixe_ligne; } } diff --git a/cron_archivage.php b/cron_archivage.php index 8ef75cc..3d96e64 100644 --- a/cron_archivage.php +++ b/cron_archivage.php @@ -315,9 +315,23 @@ function Executer_cron_archivage() $boitiers = Lister_boitiers_connus($db); echo count($boitiers) . " boîtier(s) connu(s) avec une adresse NetBird renseignée.\n\n"; + // Option --bateau=N (synthèse §50, traitement à la demande du portail) : seul le boîtier actuellement affecté au + // bateau N est traité. Sans option : tous les boîtiers (passage régulier du timer). + $bateau_filtre = 0; + foreach ($GLOBALS['argv'] ?? array() as $argument) + { + if (preg_match('/^--bateau=([1-9][0-9]{0,9})$/', $argument, $m) === 1) $bateau_filtre = (int)$m[1]; + } + if ($bateau_filtre > 0) echo "Traitement limité au bateau {$bateau_filtre}.\n\n"; + foreach ($boitiers as $boitier) { $adresse_mac = $boitier['Adresse_mac']; + if ($bateau_filtre > 0) + { + $affectation = Resoudre_affectation_active($db, $adresse_mac); + if (!$affectation['existe'] || (int)$affectation['id_bateau'] != $bateau_filtre) continue; + } $verrou = Verrouiller_boitier($adresse_mac); if (!$verrou['ok']) { diff --git a/fonctions_archivage.php b/fonctions_archivage.php index fbd2afd..65cced9 100644 --- a/fonctions_archivage.php +++ b/fonctions_archivage.php @@ -275,7 +275,7 @@ function Creer_generation($db_principale, $id_bateau, $motif, $contenu_sql, $ver $fichier_temp = tempnam(sys_get_temp_dir(), 'usine_'); file_put_contents($fichier_temp, $sql_adapte); - $commande = "mysql --host=" . escapeshellarg(BDD_BATEAUX_HOST) . " --port=" . escapeshellarg(BDD_BATEAUX_PORT) + $commande = "mysql --default-character-set=utf8mb4 --host=" . escapeshellarg(BDD_BATEAUX_HOST) . " --port=" . escapeshellarg(BDD_BATEAUX_PORT) . " --user=" . escapeshellarg(BDD_BATEAUX_USER) . " --password=" . escapeshellarg(BDD_BATEAUX_PASSWORD) . " < " . escapeshellarg($fichier_temp) . " 2>&1"; exec($commande, $sortie, $code_retour); @@ -449,6 +449,11 @@ function Migrer_generation($db_principale, $generation_active, $version_cible, $ return ['ok' => true, 'message' => "schéma migré {$version_actuelle} -> {$version_cible} ({$nb_requetes} requête(s))"]; } +// ---------------------------------------------------------------------------- +// NB (toutes les commandes mysql de ce fichier, synthèse §50) : --default-character-set=utf8mb4 - sans lui, le +// jeu de caractères du client dépend de la configuration du système (latin1 par défaut sur certaines +// installations) et un texte UTF-8 sans "SET NAMES" (zone usine, segments de migration) serait stocké +// doublement encodé ("Électricité" au lieu de "Électricité"). // ---------------------------------------------------------------------------- // Exécute un bloc SQL sur une base bateau via le client mysql en ligne de // commande (même mécanique que Creer_generation/Charger_contenu_archive - un @@ -459,7 +464,7 @@ function Executer_sql_base($nom_base, $sql) { $fichier_temp = tempnam(sys_get_temp_dir(), 'migr_'); file_put_contents($fichier_temp, $sql); - $commande = "mysql --host=" . escapeshellarg(BDD_BATEAUX_HOST) . " --port=" . escapeshellarg(BDD_BATEAUX_PORT) + $commande = "mysql --default-character-set=utf8mb4 --host=" . escapeshellarg(BDD_BATEAUX_HOST) . " --port=" . escapeshellarg(BDD_BATEAUX_PORT) . " --user=" . escapeshellarg(BDD_BATEAUX_USER) . " --password=" . escapeshellarg(BDD_BATEAUX_PASSWORD) . " " . escapeshellarg($nom_base) . " < " . escapeshellarg($fichier_temp) . " 2>&1"; exec($commande, $sortie, $code_retour); @@ -543,7 +548,7 @@ function Charger_contenu_archive($nom_base, $sql_archive) $fichier_temp = tempnam(sys_get_temp_dir(), 'charge_'); file_put_contents($fichier_temp, $script); - $commande = "mysql --host=" . escapeshellarg(BDD_BATEAUX_HOST) . " --port=" . escapeshellarg(BDD_BATEAUX_PORT) + $commande = "mysql --default-character-set=utf8mb4 --host=" . escapeshellarg(BDD_BATEAUX_HOST) . " --port=" . escapeshellarg(BDD_BATEAUX_PORT) . " --user=" . escapeshellarg(BDD_BATEAUX_USER) . " --password=" . escapeshellarg(BDD_BATEAUX_PASSWORD) . " " . escapeshellarg($nom_base) . " < " . escapeshellarg($fichier_temp) . " 2>&1"; exec($commande, $sortie, $code_retour); diff --git a/html/bateau_etat.php b/html/bateau_etat.php new file mode 100644 index 0000000..3efe414 --- /dev/null +++ b/html/bateau_etat.php @@ -0,0 +1,43 @@ +"} ou {"date": null}. Mêmes droits que la page du bateau : administrateur, ou accès actif à ce bateau. +//************************************************************************************************************ +// Réponse JSON : aucun avertissement PHP ne doit s'y écrire (il la rendrait illisible) - ils vont dans le journal. +ini_set('display_errors', '0'); +require_once("../config/config.php"); +Init_session(basename(__FILE__)); + +header('Content-Type: application/json; charset=UTF-8'); +header('Cache-Control: no-store'); + +if (empty($_SESSION['acces_valide'])) +{ + http_response_code(401); + echo '{}'; + exit(); +} + +$id_bateau = isset($_GET['id']) ? (int)$_GET['id'] : 0; +$niveau_admin = NIVEAU_ACCES_MINI_ADMINISTRATEUR; +$est_administrateur = ((int)$_SESSION['shipmember']['Niveau_acces'] >= $niveau_admin) ? 1 : 0; + +Ouverture_bdd(); +$reponse = Requete_preparee_bdd("SELECT B.Date_derniere_maj FROM TB_Bateaux B + WHERE B.Cle_primaire=? AND B.Validite=1 + AND (? = 1 OR EXISTS (SELECT 1 FROM TB_Lien_bateau_shipmember L + WHERE L.ID_Bateau=B.Cle_primaire AND L.ID_Shipmember=? AND L.Statut='actif' AND L.Fin_validite >= NOW()))", + array(array('i', $id_bateau), array('i', $est_administrateur), array('i', $_SESSION['shipmember']['Cle_primaire'])), + __FILE__, __LINE__); +$donnees = Recherche_ligne_bdd($reponse, MYSQLI_ASSOC); +Liberation_jeu_de_resultat_bdd($reponse); +Fermeture_bdd(); + +if (!$donnees) +{ + http_response_code(403); + echo '{}'; + exit(); +} +echo json_encode(array('date' => $donnees['Date_derniere_maj']), JSON_INVALID_UTF8_SUBSTITUTE); diff --git a/html/bateau_mesures.php b/html/bateau_mesures.php new file mode 100644 index 0000000..8e036ed --- /dev/null +++ b/html/bateau_mesures.php @@ -0,0 +1,69 @@ + "session expirée")); + +$id_bateau = (int)($_GET['bateau'] ?? 0); +$id_generation = (int)($_GET['generation'] ?? 0); +$type = (string)($_GET['type'] ?? ''); +$id_element = (int)($_GET['element'] ?? 0); +$variable = (string)($_GET['variable'] ?? ''); +$periode = (string)($_GET['periode'] ?? ''); +$est_administrateur = ((int)$_SESSION['shipmember']['Niveau_acces'] >= NIVEAU_ACCES_MINI_ADMINISTRATEUR) ? 1 : 0; + +Ouverture_bdd(); + +// Bateau accessible (et date de la dernière archive) : +$reponse = Requete_preparee_bdd("SELECT B.Date_derniere_maj FROM TB_Bateaux B + WHERE B.Cle_primaire=? AND B.Validite=1 + AND (? = 1 OR EXISTS (SELECT 1 FROM TB_Lien_bateau_shipmember L + JOIN TB_Types_acces_bateau_shipmember T ON T.Cle_primaire = L.ID_Type_acces_bateau_shipmember + WHERE L.ID_Bateau=B.Cle_primaire AND L.ID_Shipmember=? AND L.Statut='actif' + AND L.Fin_validite >= NOW() AND T.Consultation_donnees >= 1))", + array(array('i', $id_bateau), array('i', $est_administrateur), array('i', $_SESSION['shipmember']['Cle_primaire'])), + __FILE__, __LINE__); +$bateau = Recherche_ligne_bdd($reponse, MYSQLI_ASSOC); +Liberation_jeu_de_resultat_bdd($reponse); +if (!$bateau) { Fermeture_bdd(); Repondre(403, array('erreur' => "accès refusé")); } + +// Génération : celle demandée si elle appartient à ce bateau, sinon la génération active : +$generation = null; +foreach (Generations_bateau($id_bateau) as $g) +{ + if ((int)$g['Cle_primaire'] == $id_generation) { $generation = $g; break; } + if ($g['Date_fin'] === null && $generation === null) $generation = $g; +} +Fermeture_bdd(); +if ($generation === null) Repondre(404, array('erreur' => "aucune donnée pour ce bateau")); + +// Fin de la courbe : dernière archive (génération active) ou fin de la génération (génération antérieure) : +$date_fin = ($generation['Date_fin'] === null) ? $bateau['Date_derniere_maj'] : $generation['Date_fin']; +if ($date_fin === null) Repondre(404, array('erreur' => "aucune archive reçue")); + +$db = Connexion_generation($generation['Nom_base']); +if ($db === null) Repondre(503, array('erreur' => "données momentanément inaccessibles")); +$courbe = Courbe_element($db, $type, $id_element, $variable, $periode, $date_fin); +$db->close(); + +if (isset($courbe['erreur'])) Repondre(400, $courbe); +Repondre(200, $courbe); diff --git a/html/bateaux.php b/html/bateaux.php index a0f9f44..f3e7d09 100644 --- a/html/bateaux.php +++ b/html/bateaux.php @@ -135,6 +135,7 @@ Init_session(basename($_SERVER['SCRIPT_NAME'])); // Fonction ouvrant la session { $_SESSION['Bateaux']['bateau']['Cle_primaire'] = (int)$_GET['id_bateau']; $_SESSION['Bateaux']['type_details_bateau'] = "resume"; + $_SESSION['Bateaux']['generation'] = 0; // Nouveau bateau : génération active $_SESSION['Bateaux']['page'] = "Details_bateau"; if (isset($_GET['origine'])) $_SESSION['Bateaux']['origine'] = $_GET['origine']; @@ -152,6 +153,16 @@ Init_session(basename($_SERVER['SCRIPT_NAME'])); // Fonction ouvrant la session break; } + //=================================== + // Génération consultée (synthèse §50) : 0 = génération active. La valeur n'est utilisée qu'après vérification + // qu'elle appartient bien au bateau affiché (préparation de la page). + case "choix_generation": + { + $_SESSION['Bateaux']['generation'] = isset($_GET['generation']) ? (int)$_GET['generation'] : 0; + $_SESSION['Bateaux']['type_details_bateau'] = "resume"; + break; + } + //=================================== case "modification_bateau": { @@ -370,7 +381,7 @@ $requete .= ' TB_Bateaux.Date_derniere_maj AS Date_derniere_maj, '; // Mise en forme de certaines variables : $bateaux[$nb_bateaux]['version'] = ($donnees['version'] == "") ? 'inconnue' : htmlspecialchars($donnees['version']); if ($donnees['Date_derniere_maj'] < "2020-01-02") $bateaux[$nb_bateaux]['Date_derniere_maj'] = "Jamais"; - else $bateaux[$nb_bateaux]['Date_derniere_maj'] = Formatage_date($donnees['Date_derniere_maj']); + else $bateaux[$nb_bateaux]['Date_derniere_maj'] = ''.htmlspecialchars($donnees['Date_derniere_maj']).' UTC'; $nb_bateaux ++; } @@ -400,6 +411,7 @@ $requete .= ' TB_Bateaux.Date_derniere_maj AS Date_derniere_maj, '; while ($donnees = Recherche_ligne_bdd($reponse)) { $bateau = $donnees; + $bateau['Date_derniere_maj_utc'] = $donnees['Date_derniere_maj']; // Brute (UTC), pour l'indicateur de fraîcheur // Mise en forme de textes : if ($donnees['Date_derniere_maj'] < "2020-01-02") $bateau['Date_derniere_maj'] = "".t("Jamais").""; @@ -632,7 +644,41 @@ $requete .= ' TB_Bateaux.Date_derniere_maj AS Date_derniere_maj, '; Liberation_jeu_de_resultat_bdd($reponse); break; } - default: break; + //------------------------------------------------------------------------------------------------------- + // Données du bateau (synthèse §50) : lues dans la base de la génération consultée (active par défaut), par + // une connexion dédiée - la connexion principale du site n'est pas touchée. + default: + { + $consultation = array('autorisee' => ($est_administrateur || (int)($acces_profil['Consultation_donnees'] ?? 0) >= 1), + 'generations' => array(), 'generation' => null, 'domaines' => array(), 'erreur' => null, + 'periode_archivage' => CONSULTATION_PERIODE_ARCHIVAGE_DEFAUT); + if (!$consultation['autorisee']) break; + + $consultation['generations'] = Generations_bateau($bateau['Cle_primaire']); + foreach ($consultation['generations'] as $generation) + { + // Génération demandée si elle appartient bien à ce bateau, sinon la génération active : + if ((int)$generation['Cle_primaire'] == (int)($_SESSION['Bateaux']['generation'] ?? 0)) { $consultation['generation'] = $generation; break; } + if ($generation['Date_fin'] === null && $consultation['generation'] === null) $consultation['generation'] = $generation; + } + if ($consultation['generation'] === null) break; // Aucune archive reçue pour ce bateau + + $db_generation = Connexion_generation($consultation['generation']['Nom_base']); + if ($db_generation === null) { $consultation['erreur'] = t("Les données de ce bateau sont momentanément inaccessibles."); break; } + $consultation['periode_archivage'] = Periode_archivage_bateau($db_generation); + $consultation['alarmes'] = Episodes_alarmes($db_generation, 20); + foreach (array('circuits' => t("Circuits"), 'capteurs' => t("Capteurs"), 'batteries' => t("Batteries"), 'reservoirs' => t("Réservoirs")) as $domaine => $titre) + { + $consultation['domaines'][$domaine] = array('titre' => $titre, 'familles' => Elements_domaine($db_generation, $domaine)); + } + $db_generation->close(); + + // Génération active : demande d'archive immédiate (si les données ne sont pas déjà récentes - étape 3d) : + $consultation['demande'] = ($consultation['generation']['Date_fin'] === null) + ? Demander_archive_si_besoin($bateau['Cle_primaire'], $bateau['Date_derniere_maj_utc']) : null; + $consultation['demande_archive'] = (($consultation['demande']['statut'] ?? '') == 'demandee'); + break; + } } // Fin du switch 'type_details_bateau' @@ -741,7 +787,7 @@ $requete .= ' TB_Bateaux.Date_derniere_maj AS Date_derniere_maj, '; // echo 'ShipBase'; echo 'Adresse MAC'; echo 'Version'; - echo 'Dernière mise à jour [UTC]'; + echo ''.t("Dernières données reçues").''; // echo 'Commentaires'; echo ' @@ -808,7 +854,30 @@ $requete .= ' TB_Bateaux.Date_derniere_maj AS Date_derniere_maj, '; else echo ''.t("Live indisponible").''; echo ''; - echo '
'.t("Dernières données reçues").' : '.$bateau['Date_derniere_maj'].' [UTC]
'; + // Indicateur de fraîcheur (synthèse §44.6, §50) : date UTC transmise au navigateur, qui l'affiche dans le fuseau + // de l'utilisateur et colore selon l'ancienneté (seuil orange/rouge = 1,25 x la période d'archivage du bateau) : + echo '
'.t("Dernières données reçues").' : '; + if ($bateau['Date_derniere_maj_utc'] === null || $bateau['Date_derniere_maj_utc'] < "2020-01-02") echo ''.t("Jamais").''; + else echo ''.htmlspecialchars($bateau['Date_derniere_maj_utc']).' UTC'; + + // Mise à jour à l'ouverture (étape 3d) : ce qui a été fait, et pourquoi (synthèse §50) : + $minutes = function($secondes) { return max(1, (int)ceil($secondes / 60)); }; + switch ($consultation['demande']['statut'] ?? '') + { + case 'demandee': + echo ''.t("Mise à jour demandée au bateau...").''; + break; + case 'donnees_recentes': + echo ''.str_replace("[[M]]", $minutes($consultation['demande']['attente']), + t("Données récentes : pas de nouvelle demande au bateau avant [[M]] min.")).''; + break; + case 'deja_demandee': + echo ''.str_replace("[[M]]", $minutes($consultation['demande']['attente']), + t("Mise à jour déjà demandée récemment : nouvelle demande possible dans [[M]] min.")).''; + break; + } + echo '
'; echo ''; //-------------------------------- // Boutons de haut de page (les anciens onglets Carte/Batteries/Circuits/Réservoirs/Capteurs/Système, liés au @@ -968,7 +1037,120 @@ $requete .= ' TB_Bateaux.Date_derniere_maj AS Date_derniere_maj, '; // 'resume' (et toute ancienne valeur de session) : default: { - echo '

'.t("La consultation des données du bateau sera disponible prochainement.").'

'; + if (!$consultation['autorisee']) { echo '

'.t("Votre type d'accès ne permet pas de consulter les données de ce bateau.").'

'; break; } + if ($consultation['generation'] === null) { echo '

'.t("Aucune donnée n'a encore été reçue de ce bateau.").'

'; break; } + if ($consultation['erreur'] !== null) { echo '

'.htmlspecialchars($consultation['erreur']).'

'; break; } + + echo '
'; + + // Rafraîchissement à l'arrivée d'une nouvelle archive (synthèse §50) - génération active seulement : + if ($consultation['generation']['Date_fin'] === null) + { + echo ''; + } + + //-------------------------------- + // Génération consultée, et choix d'une génération antérieure : + $format_date = function($date) { return ($date === null) ? '' : date_format(date_create($date), 'd/m/Y'); }; + echo '
'; + echo ''; + echo ''; + echo ''; + echo ''; + echo '
'; + if ($consultation['generation']['Date_fin'] !== null) + { + echo '

'.t("Vous consultez une génération antérieure : les valeurs ci-dessous sont celles de sa dernière archive, pas l'état actuel du bateau.").'

'; + } + + //-------------------------------- + // Dernières alarmes (synthèse §50) : déclenchements, du plus récent au plus ancien, dates dans le fuseau du + // navigateur. (Pas d'état "alarme en cours" : vieux de jusqu'à une période d'archivage, il ne serait ni + // fiable ni exploitable.) + echo '
'; + echo '

'.t("Dernières alarmes").'

'; + if (count($consultation['alarmes'] ?? array()) == 0) echo '

'.t("Aucune alarme enregistrée.").'

'; + else + { + $date_affichee = function($iso) { return ''.htmlspecialchars($iso).''; }; + echo ''; + echo ''; + foreach ($consultation['alarmes'] as $alarme) + { + switch ($alarme['statut']) + { + case 'termine': $fin = $date_affichee($alarme['fin_utc']); break; + case 'en_cours': $fin = ''.t("en cours à la dernière archive").''; break; + case 'fin_non_enregistree': $fin = ''.t("fin non enregistrée").''; break; + case 'debut_anterieur': $fin = $date_affichee($alarme['fin_utc']); break; + default: $fin = ''.t("ponctuel").''; break; + } + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + } + echo '
'.t("Début").''.t("Fin").''.t("Durée").''.t("Alarme").'
'.(($alarme['debut_utc'] !== null) ? $date_affichee($alarme['debut_utc']) : ''.t("début antérieur").'').''.$fin.''.(($alarme['duree'] !== null) ? htmlspecialchars(Duree_lisible($alarme['duree'])) : '').''.htmlspecialchars($alarme['nom']).''.htmlspecialchars($alarme['message']).'
'; + } + echo '
'; + + //-------------------------------- + // Les quatre domaines, par famille, avec leurs dernières valeurs : + foreach ($consultation['domaines'] as $domaine => $donnees_domaine) + { + if (count($donnees_domaine['familles']) == 0) continue; + echo '
'; + echo '

'.htmlspecialchars($donnees_domaine['titre']).'

'; + foreach ($donnees_domaine['familles'] as $famille) + { + echo '
'; + echo '

'.htmlspecialchars($famille['nom']).'

'; + echo ''; + foreach ($famille['elements'] as $element) + { + // Élément avec courbe historique : ligne cliquable (synthèse §50, étape 3c) + $classes = array(); + if ($element['defaut'] !== null) $classes[] = 'en_defaut'; + if ($element['courbe']) $classes[] = 'avec_courbe'; + echo ' 0) ? ' class="'.implode(' ', $classes).'"' : '') + .($element['courbe'] ? ' data-type="'.$domaine.'" data-element="'.(int)$element['id'].'" data-nom="'.htmlspecialchars($element['nom']).'" title="'.t("Afficher la courbe").'"' : '').'>'; + echo ''; + echo ''; + echo ''; + echo ''; + } + echo '
'.htmlspecialchars($element['nom']).''.htmlspecialchars(implode(' ', $element['valeurs'])).''.(($element['defaut'] !== null) ? htmlspecialchars($element['defaut']) : '').'
'; + echo '
'; + } + echo '
'; + } + //-------------------------------- + // Panneau de courbe historique (synthèse §50, étape 3c), ouvert au clic sur un élément : + echo ''; + echo ''; + + echo '
'; // Fin de class="consultation" break; } } @@ -1139,6 +1321,257 @@ Pied_de_page();