Patrick Schurig 010a7da0f4 ci: le contrôle de qualité documentaire — écrit, éprouvé, PAS en service
Étape 4 du chantier de documentation. tools/ci-quality.sh porte les trois
vérifications qui se posent au même endroit — elles partagent la même sortie
(doc-generated/) et la même commande de réparation, les séparer ferait trois jobs
qui régénèrent trois fois la même chose :

  1. l'index des règles est VRAI (code de retour de gen-rules.py) ;
  2. la doc Doxygen est complète — 0 avertissement, DoD §5 ;
  3. l'index est REPRODUCTIBLE : deux passes, un seul résultat.

PAS EN SERVICE, et l'extension le garantit : ci/gitea-workflow.yml.disabled ne
déclenche rien tant qu'il n'est pas déplacé dans .gitea/workflows/. La raison est
datée dans le fichier — forge muette, plus de trente commits en attente ici et
plus de soixante côté app ; un job activé à l'aveugle échouerait au moment précis
où tout le monde pousse, et le premier réflexe serait de le désactiver.

CHAQUE ÉCHEC DONNE LA COMMANDE À LANCER. Un job qui annonce « l'index est
périmé » sans dire quoi faire se contourne en le désactivant : c'est le chemin de
moindre effort, et il gagne toujours.

VÉRIFIÉ AVANT D'ÊTRE DÉCLARÉ PRÊT, et ça ne passait pas du premier coup.

— Les 26 avertissements Doxygen de ce matin sont CORRIGÉS, pas contournés par un
  cliquet sur un compte de référence : c'était de la dette DoD-5 (retours et
  paramètres non documentés dans relayrouter.h, energyarbitrator.h, loadconfig.h,
  les trois clearFault(), ILoadAdapter::updateSoftConfig), plus deux que j'avais
  introduits — un \rule{} dans un titre \par, et un \param energyLogs qui ne
  correspond à aucun argument. Le seuil est donc ZÉRO, le seul qui ne rote pas.

— Et le script lui-même échouait sur un dépôt SAIN : `grep -c ... || echo 0`
  affiche 0 PUIS sort en 1 quand il ne trouve rien, si bien que le « || » empile
  un second zéro et que le test entier casse. Corrigé ici et dans gen-doc.sh, qui
  portait le même piège. Une commande de mesure dont l'échec produit une lecture
  fausse — le motif de la semaine, appliqué à l'outil.

CE QUI N'EST PAS VÉRIFIÉ, et pourquoi : « régénérer et refuser si ça diffère de
ce qui est commité » n'a rien à comparer, doc-generated/ étant dans .gitignore.
Le risque qu'un tel diff attraperait — un artefact commité qui dérive de sa
source — a été SUPPRIMÉ en ne le commitant pas, pas déplacé. Ce qui reste à
garantir est que l'index soit vrai (contrôle 1) et déterministe (contrôle 3).

État au 2026-08-29 : ./tools/ci-quality.sh → RC=0, 113 règles, 0 avertissement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-08-29 11:33:46 +02:00

254 lines
14 KiB
C++

// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#pragma once
#include <QDateTime>
#include <QVariantMap>
#include "../types/loadaction.h"
#include "../types/loaddescriptor.h"
#include "../types/surpluscontext.h"
/*!
* \brief Vue runtime minimale exposée par un adaptateur à l'arbitre.
*/
struct LoadTelemetry {
double currentPowerW = 0; //!< Puissance mesurée (W).
bool available = true; //!< Faux si l'appareil nymea est absent ou en erreur.
QDateTime lastActionAt; //!< Dernier instant où applyAction() a produit un effet.
};
/*!
* \brief Mesure PROPRE de l'appareil piloté — §11, LM-1105/LM-1106.
*
* Distincte de \c LoadTelemetry::currentPowerW, et la distinction est tout l'objet du type.
* Ce champ-là est le **juge runtime du budget** : il vaut une mesure quand il en existe une et
* la puissance **commandée ou déclarée** sinon, parce que le recrédit anti-clignotement a
* besoin d'un nombre à chaque cycle. Celui-ci ne vaut **que** ce qu'un appareil a réellement
* mesuré, ou rien.
*
* \par Pourquoi une CAPACITÉ et non une valeur nulle
* \c available répond à « cette charge est-elle mesurable ? », question posée à la
* \c ThingClass, jamais à la valeur du moment. Un routeur au palier 0 mesure 0 W et reste
* mesurable ; sans ce booléen, les deux seraient indistinguables et la source publiée
* basculerait d'un cycle à l'autre sur une installation dont rien n'a changé.
*/
struct LoadMeasurement {
//! Vrai si les Things pilotés portent l'état de puissance — CAPACITÉ, pas valeur.
bool available = false;
//! Puissance réellement mesurée (W) ; sans objet quand \c available est faux.
double powerW = 0;
};
/*!
* \brief Vue runtime d'une charge destinée à la FRONTIÈRE DE TÉLÉMÉTRIE (RPC), et à elle seule.
*
* Distincte de \c LoadTelemetry (que l'arbitre lit pour DÉCIDER) et de \c LoadContext (ce que
* le scheduler reçoit). Elle porte ce qui n'a de sens que pour EXPLIQUER une décision à un
* humain : le défaut, le verrou en secondes restantes, et l'état du mécanisme.
*
* \par Des codes, jamais des phrases
* \c faultCode, \c lockKind et \c mechanismKind sont des CODES d'énumération fermée. La
* phrase est fabriquée par le client, dans la langue de son utilisateur — même raison que
* pour \c DecisionReason (cf. \c decisionreason.h).
*
* \par Des secondes, pas des watts
* \c lockMinPowerW / \c lockMaxPowerW (dans \c LoadContextTelemetry) sont un artefact du
* mécanisme d'allocation : illisibles pour un utilisateur. Le temps restant, lui, se lit.
* Ceci n'enfreint pas ECS-306 : cette exigence interdit qu'un index de PALIER franchisse la
* frontière d'ALLOCATION. Une valeur d'AFFICHAGE sortante est un autre chemin, et
* \c stageW / \c state sont des watts et des états normés, pas des index.
*
* \par Union discriminée par mécanisme
* \c mechanismKind discrimine ; \c mechanism porte la charge utile de la variante, et
* d'elle seule (LM-300). Ajouter un mécanisme = une variante ici et son test — rien d'autre.
*/
struct LoadRuntimeView {
//! Code de défaut ; VIDE = aucun défaut. Énumération fermée, cf. \c INTERFACE.md.
QString faultCode;
//! Verrou ACTIF maintenant : "minOn" | "minOff" | "minStateHold" ; vide = aucun.
QString lockKind;
//! Secondes restantes du verrou nommé par \c lockKind ; sans objet si \c lockKind est vide.
int lockRemainingS = 0;
//! Mécanisme : "relay" | "variable" | "sgReady" | "evcharger".
QString mechanismKind;
//! Charge utile du mécanisme (clés propres à la variante) ; jamais celle d'un autre.
QVariantMap mechanism;
};
/*!
* \brief Interface pure des adaptateurs de charge.
*
* Les implémentations concrètes héritent de QObject + ILoadAdapter et déclarent leurs
* propres signaux (telemetryChanged, descriptorChanged).
*
* \invariant Les adaptateurs EXÉCUTENT, ils ne décident pas (AGENTS règle 2).
* \invariant applyAction() écrête les valeurs selon les limites matérielles réelles
* (second filet après l'écrêtage de l'arbitre).
* \invariant applyAction() avec un \c reason.code vide doit être rejetée silencieusement.
* \invariant Les méthodes non-applyAction() retournent immédiatement (pas de I/O bloquant).
* \invariant **Temps = paramètre, jamais l'horloge.** Toute logique temporelle d'un
* adaptateur (verrous minOn/minOff, fenêtres, fraîcheur…) utilise EXCLUSIVEMENT le
* \c now (= \c ctx.timestamp) reçu en paramètre de \c toLoadContext()/applyAction().
* JAMAIS \c QDateTime::currentDateTime(). C'est cette source unique, partagée avec le
* scheduler, qui rend impossible toute divergence décision/exécution et qui rend la
* logique injectable en simulation. Contrat pour tout futur adaptateur (SgReady, Battery).
*/
class ILoadAdapter {
public:
virtual ~ILoadAdapter() = default;
/*!
* \brief Description statique de la charge : capacités, limites, priorité, needs.
* \return LoadDescriptor construit depuis la configuration matérielle.
* \note Peut être rappelé à chaque cycle — l'implémentation doit être légère.
*/
virtual LoadDescriptor descriptor() const = 0;
/*!
* \brief Télémétrie runtime (puissance, disponibilité, dernière action).
* \return LoadTelemetry issue de l'état courant de l'appareil nymea.
*/
virtual LoadTelemetry telemetry() const = 0;
/*!
* \brief Construit l'entrée §5 loads[] pour le SurplusContext.
* \param now Temps de cycle (\c ctx.timestamp). Source unique pour l'évaluation des
* verrous (minStage/maxStage) — JAMAIS \c QDateTime::currentDateTime() côté adaptateur,
* afin que décision (scheduler) et exécution (applyAction) partagent le même temps.
* \return LoadContext incluant declared, limits, needs et télémétrie type-spécifique.
*/
virtual LoadContext toLoadContext(const QDateTime &now) const = 0;
/*!
* \brief Applique l'action et retourne ce qui a réellement été envoyé au matériel.
*
* L'arbitre a déjà écrêté selon les limites et le budget — ceci est le second filet.
*
* \param action Action à appliquer. Doit avoir \c reason.code non vide.
* \param now Temps de cycle (\c ctx.timestamp) — MÊME source que toLoadContext(),
* pour que l'évaluation des verrous coïncide avec celle vue par le scheduler.
* \return L'action après écrêtage matériel (peut différer de l'entrée).
* \note Retour silencieux sans effet si \c action.reason.code est vide.
*/
virtual LoadAction applyAction(const LoadAction &action, const QDateTime &now) = 0;
/*!
* \brief Met à jour les champs de configuration qui ne touchent PAS le matériel.
*
* Sert au rebuild INCRÉMENTAL (ECS-412) : quand seul le rang de service ou les besoins
* changent, l'arbitre met à jour l'adaptateur en place au lieu de le détruire. Détruire
* réarmerait les verrous — sur un ballon thermodynamique à \c minOn de 300 à 600 s, un
* client qui réordonne ses priorités depuis l'app pourrait faire court-cycler son
* compresseur — et provoquerait des réécritures de relais inutiles.
*
* \param label Nouveau libellé d'affichage.
* \param priority Nouveau rang de service (ASC, 1 = premier servi).
* \param needs Nouveaux besoins déclarés.
* \warning NE DOIT jamais toucher au câblage, aux paliers ni aux verrous : ces
* changements-là exigent une vraie reconstruction.
* \note **Pure virtuelle à dessein**, sans implémentation par défaut. Un adaptateur qui
* n'est pas construit depuis \c LoadConfig n'a rien à mettre à jour, mais il doit le
* DÉCLARER : un défaut vide silencieux ferait qu'un futur adaptateur construit depuis
* la config ignorerait sans bruit les changements de rang.
*/
virtual void updateSoftConfig(const QString &label, int priority,
const LoadNeeds &needs) = 0;
/*!
* \brief Lève le verrou de défaut de la charge (ECS-410).
*
* Le défaut est COLLANT par conception — « cesser toute commande jusqu'à intervention ».
* Sa levée est un acte délibéré de l'opérateur, jamais une expiration : sans quoi
* \c available oscillerait d'un cycle à l'autre et le waterfall réagirait à chaque
* battement.
*
* \note **Pure virtuelle à dessein.** Tous les adaptateurs ne savent pas encore tomber en
* défaut — seul \c RelayRouter implémente l'échelle ECS-410 — mais chacun doit le
* DÉCLARER. La généralisation est portée par **ECS-414**.
*
* \return \c true si un défaut a RÉELLEMENT été levé, \c false s'il n'y en avait pas.
*
* \note **Pourquoi un booléen et non \c void** (règle 7-c d'AGENTS.md). C'est le seul
* levier de reprise à distance : ECS-410 pose un verrou collant que seuls ce RPC ou une
* reconstruction lèvent. Sans valeur de retour, l'arbitre ne peut pas distinguer
* « défaut levé » de « aucun défaut à lever », et un opérateur à distance ne peut pas
* savoir si son geste a agi — il relancera, puis conclura que le système est cassé.
* Le type de retour force chaque implémentation, présente et future, à répondre à la
* question ; une ligne de journal dans chaque adaptateur se serait oubliée à la
* cinquième.
*/
virtual bool clearFault() = 0;
/*!
* \brief Vue runtime destinée à la frontière de télémétrie RPC (\c GetLoadTelemetry).
*
* \param now Temps de cycle — MÊME source que \c toLoadContext()/\c applyAction(), afin
* que le verrou annoncé au client soit celui qui a réellement borné la décision.
* \return Défaut, verrou en secondes restantes, et charge utile du mécanisme.
*
* \note **Pure virtuelle à dessein**, sans implémentation par défaut. Un mécanisme ajouté
* qui hériterait d'un défaut vide sortirait de la télémétrie SANS BRUIT : le client
* verrait une charge sans mécanisme et sans verrou, indistinguable d'une charge libre.
* C'est précisément le silence ambigu de la règle 7-c. Le compilateur pose donc la
* question à chaque nouvel adaptateur.
* \note Cette méthode n'entre JAMAIS dans une décision : l'arbitre ne la lit que pour
* publier. Un adaptateur qui s'en servirait pour décider violerait la règle 2.
*/
virtual LoadRuntimeView runtimeView(const QDateTime &now) const = 0;
/*!
* \brief Cette charge est-elle mesurée par les appareils qu'elle pilote, et combien ?
*
* §11 — **LM-1105** : le moteur lit l'appareil qu'il pilote plutôt que d'exiger qu'on le
* désigne comme son propre compteur dans \c meterThingId. **LM-1106** : ce qui est publié
* est un couple {valeur, source}, et c'est cette méthode qui répond pour la source
* \c "device".
*
* \return \c available faux = **rien ne mesure cette charge** (\c source: "none" à la
* publication) ; vrai = \c powerW est une mesure réelle.
*
* \note **La question porte sur la ThingClass, jamais sur la valeur ni sur l'instant.** Un
* \c RelayRouter au palier 0 n'a aucun relais fermé : interroger les relais ACTIFS
* ferait osciller la source publiée d'un cycle à l'autre. Elle se pose sur TOUS les
* Things déclarés de la charge.
* \note **Et elle présuppose que le Thing piloté est TRAVERSÉ par la puissance.** Un relais
* de \c RelayRouter commute la charge, donc sa puissance EST celle de la charge ; un
* contact SG-Ready est un contact de SIGNAL — la PAC est alimentée ailleurs, et son
* courant ne passe jamais par lui. Un contact qui mesurerait quelque chose mesurerait sa
* propre bobine. \c SgReadyAdapter répond donc toujours « non mesurable », et c'est le
* seul mécanisme pour lequel \c meterThingId reste la seule voie.
* \note **Une mesure PARTIELLE n'est pas une mesure.** Là où plusieurs Things portent la
* charge, il faut qu'ils la portent TOUS : sommer les seuls relais mesurants
* publierait une puissance sous-évaluée, et l'écart commandé↔mesuré enverrait chercher
* une panne qui n'existe pas (LM-1102).
* \note **Pure virtuelle à dessein**, comme \c runtimeView() et pour la même raison : un
* mécanisme ajouté qui hériterait d'un défaut « non mesurable » sortirait de la mesure
* SANS BRUIT, et son écran afficherait « pas mesurable » avec l'autorité d'un constat.
* Le compilateur pose la question à chaque nouvel adaptateur.
* \note N'entre JAMAIS dans une décision : l'arbitre ne la lit que pour publier (règle 2).
*/
virtual LoadMeasurement deviceMeasurement() const = 0;
/*!
* \brief Amène le matériel dans l'ÉTAT SÛR de cet adaptateur (ECS-413).
*
* Appelé quand la charge est désactivée (\c enabled: false) ou retirée de la
* configuration — un acte DÉLIBÉRÉ de l'opérateur. Sans cela, l'adaptateur est détruit et
* le matériel reste dans son DERNIER ÉTAT COMMANDÉ : constaté au banc le 2026-08-09, trois
* relais laissés fermés juste avant une intervention de câblage.
*
* \param now Temps de cycle. Passe par le chemin d'action normal avec \c force = true,
* comme le mode dégradé L2 : l'arrêt prime sur les verrous.
*
* \warning **NE PAS appeler à l'arrêt du plugin ni au redémarrage de nymead.** L'état doit
* y être CONSERVÉ — c'est ce qu'ECS-411 relit au démarrage, et couper l'eau chaude à
* chaque redémarrage de service serait une régression. La distinction est
* intentionnelle : la désactivation est délibérée, un redémarrage ne l'est pas.
*
* \note L'état sûr est PROPRE à chaque adaptateur — relais ouverts pour un routeur,
* consigne 0 W pour une charge continue, mais **état 2** pour une PAC SG-Ready, jamais
* le blocage (\c docs/SAFETY.md). Une formulation « tout couper » serait fausse.
*/
virtual void applySafeState(const QDateTime &now) = 0;
};