Patrick Schurig b558df80b0 tools: l'index des règles, généré — et son garde-fou
Étape 2 du chantier de documentation. tools/gen-rules.py produit
doc-generated/RULES.md : chaque règle, son énoncé d'une ligne, son domicile, son
état de preuve DÉRIVÉ, et la liste de ce qui la cite. Sort en erreur sur un
identifiant cité et jamais défini, sur un identifiant qui n'existe qu'en ligne de
tableau, et sur un domicile ambigu.

L'état de preuve ne demande AUCUNE convention nouvelle : il se lit dans l'endroit
où l'identifiant apparaît — energyplugin/ pour le code, tests/ pour les tests,
docs/RELEVE_* pour la machine. Citer les règles en commentaire est déjà
l'habitude de la maison, et c'est cette habitude qui devient l'index.

« cité par », jamais « ✅ » : cet index prouve la CITATION, pas la couverture. Un
✅ automatique serait le même mensonge que celui qu'on corrige, en plus rapide.
C'est écrit en tête de la page générée, pas seulement dans un commit.

DEUX MESURES PRÉALABLES ÉTAIENT FAUSSES, et le générateur le montre. Un
identifiant admet des suffixes ALPHABÉTIQUES et NUMÉRIQUES — LM-1006-1,
LM-1105-b, LM-1209-a-bis. Un tiret ordinaire ne peut donc pas séparer
l'identifiant de l'énoncé : « **LM-1006-1** (…) », une citation, se lisait comme
une définition concurrente de LM-1006. D'où le séparateur cadratin, et d'où la
disparition des « 49 orphelins » et des « 20 domiciles ambigus » annoncés :
il en reste DIX, et ce sont les mêmes dans les deux catégories.

Et la première sortie a trouvé une faiblesse dans les citations écrites hier :
LES SOUS-RÈGLES N'HÉRITENT DE RIEN. Citer LM-1105 ne marque pas LM-1105-b, qui
sortait « spécifiée seule » alors qu'elle est implémentée et testée. Corrigé aux
quatre endroits concernés — la règle de convention est désormais : citer
l'identifiant EXACT, jamais le parent.

Aucun changement de comportement : les quatre citations touchées sont des
commentaires. Build 0/0, testMeasurementSourceIsPublished 3/3.

doc-generated/ reste ignoré par git, comme le reste de la sortie de gen-doc.sh :
versionner une page générée ferait un diff à chaque édition de spec. La garantie
qu'elle est à jour appartient au contrôle CI (étape 4).

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

249 lines
13 KiB
C++

// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#pragma once
#include <QObject>
#include <QDateTime>
#include <QList>
#include <QHash>
#include <QString>
#include <QSet>
#include "iloadadapter.h"
class ThingActionInfo;
class Thing;
class ThingManager;
/*!
* \brief Adaptateur SG-Ready (PAC) — interface "sg-ready", action \c kind:State.
*
* Pilote une pompe à chaleur via 2 contacts SG-Ready (encodage 2 bits → 4 états NORMÉS) :
* 1 = blocage (EVU-Sperre) · 2 = normal (mains off : la PAC décide)
* 3 = recommandation (surplus) · 4 = forcé (boost)
*
* Les 4 états ne sont PAS des paliers de puissance : ils sont qualitatifs, la PAC les
* interprète selon SA logique. \c m_stateRelays[état] = ThingIds powerswitch à mettre ON
* pour cet état (encodage câblé par l'installateur ; les autres relais sont OFF).
*
* \invariant applyAction() rejette silencieusement toute action dont \c reason est vide.
* \invariant applyAction() applique le verrou \c minStateHoldS (protection court-cycling
* compresseur) SAUF si \c action.force == true (réservé L2 watchdog → état 2).
* \invariant L'état est écrêté à l'ensemble \c m_states avant envoi matériel.
* \invariant Seul le kind State est traité ; les autres kinds retournent sans effet.
*
* \par Contrat d'atomicité (transport déporté Shelly/Modbus à venir)
* Une transition d'état commute parfois 2 relais (ex. 2→4 : 00→11). Les contacts
* doivent être écrits **aussi atomiquement que possible**, et l'ORDRE de commutation
* doit éviter tout **état actif parasite** : on passe par le transitoire le plus DOUX
* (neutre = état 2, sinon recommandation = état 3) plutôt que par blocage (1) ou forcé
* (4). \c applyStateRelays() choisit cet ordre. En GPIO local le transitoire dure des µs,
* mais l'intention est portée par l'adaptateur pour rester correcte sur un bus lent.
*/
class SgReadyAdapter : public QObject, public ILoadAdapter
{
Q_OBJECT
public:
/*!
* \brief Constructeur.
* \param thingManager Gestionnaire nymea pour résoudre les ThingIds.
* \param id Identifiant logique de la charge.
* \param label Nom lisible (logs, app).
* \param stateRelays état → liste de ThingIds powerswitch ON (encodage 2 bits SG-Ready).
* \param estimatedPowerW Puissance estimée (W) par état (déclaré installateur, approx.).
* \param minStateHoldS Durée minimale de maintien d'état (s) — protection court-cycling.
* \param priority Rang dans le waterfall (protocole §5 : valeur plus BASSE = servi en premier).
* \param parent Propriétaire Qt.
*/
explicit SgReadyAdapter(ThingManager *thingManager,
const QString &id,
const QString &label,
const QHash<int, QList<QString>> &stateRelays,
const QHash<int, double> &estimatedPowerW,
int minStateHoldS,
int priority,
QObject *parent = nullptr);
LoadDescriptor descriptor() const override;
/*!
* \brief Télémétrie runtime. \c currentPowerW = puissance ALLOUÉE de l'état courant
* (\c declared.estimatedPowerW : 0 pour états 1/2, P3/P4 pour 3/4). C'est la base du
* recrédit budget — PAS la conso mesurée de la PAC (l'état 2 autonome est déjà au
* compteur, invariant 8 : la recréditer double-compterait).
* \return LoadTelemetry ; \c available toujours vrai (l'encodage relais ne dépend pas
* d'une liaison montante), \c lastActionAt nul tant qu'aucune transition n'a eu lieu.
*/
LoadTelemetry telemetry() const override;
/*!
* \brief Construit l'entrée loads[] §5 (adapter="sg-ready").
* \param now Temps de cycle (\c ctx.timestamp) — source unique de la fenêtre minState/maxState.
* \return LoadContext incluant \c telemetry.state et la fenêtre \c minState/\c maxState
* évaluée à \p now : c'est sur elle que le scheduler clampe sa cible.
*/
LoadContext toLoadContext(const QDateTime &now) const override;
/*!
* \brief Applique un changement d'état SG-Ready (2 relais, transition atomique-douce).
* \param action LoadAction de kind State. Autres kinds : retour sans effet.
* \param now Temps de cycle — MÊME source que toLoadContext() (verrou + lastSwitch).
* \return L'action après écrêtage (state borné à l'ensemble déclaré).
*/
LoadAction applyAction(const LoadAction &action, const QDateTime &now) override;
//! \brief Sans effet : la PAC du banc est codée en dur, pas construite depuis
//! \c LoadConfig (à basculer avec la couche config).
//! \param label Nouveau libellé — repris DÈS ce cycle, il nomme la PAC au journal.
//! \param priority Ignoré. \param needs Ignoré.
void updateSoftConfig(const QString &label, int priority, const LoadNeeds &needs) override
{ m_label = label; Q_UNUSED(priority) Q_UNUSED(needs) }
//! \brief Sans effet : la PAC SG-Ready n'implémente pas encore l'échelle de défaut ECS-410.
//! La généralisation est portée par **ECS-414** (lot de mise en configuration
//! du SgReadyAdapter). Déclaré explicitement plutôt qu'hérité d'un défaut vide.
/*!
* \brief Lève le verrou de défaut (ECS-410/414, RPC \c ClearLoadFault).
* \note L'état réel est RELU depuis les contacts, jamais supposé : après un défaut, le
* motif 2 bits peut être valide mais non voulu.
*/
bool clearFault() override;
//! \return Vrai si la PAC est en défaut (plus aucune commande émise).
bool faulted() const { return m_faulted; }
/*!
* \brief Vue runtime pour la télémétrie RPC : état normé, estimation, défaut, verrou.
* \param now Temps de cycle — même source que \c lockWindow(), donc les secondes annoncées
* sont celles du verrou qui a réellement borné la décision.
* \return \c mechanismKind "sgReady" ; \c mechanism \c {state, estimatedPowerW}.
*/
LoadRuntimeView runtimeView(const QDateTime &now) const override;
/*!
* \brief Toujours « non mesurable » — et c'est une propriété du MÉCANISME (LM-1105-b).
*
* \return \c available toujours faux.
*
* \note Ce n'est pas un « pas encore » : un contact SG-Ready est un contact de **SIGNAL**.
* La PAC est alimentée ailleurs et son courant ne traverse jamais K1/K2 — un contact qui
* mesurerait quelque chose mesurerait **sa propre bobine**. La règle générale
* « le moteur lit l'appareil qu'il pilote » présuppose que l'appareil piloté est
* TRAVERSÉ par la puissance de la charge ; ici il ne l'est pas.
* \note C'est donc le seul mécanisme pour lequel \c meterThingId reste la **seule** voie
* vers une mesure, et cela doit se lire à l'écran plutôt que se deviner : d'où
* \c source: "none" publié, plutôt qu'une clé absente (LM-1106).
* \warning Ne PAS publier \c m_estimatedPowerW ici. C'est une puissance **déclarée** —
* le juge du recrédit budget, invariant 8 — et la faire passer pour une mesure
* produirait un écart commandé↔mesuré identiquement nul à tous les cycles : un « tout
* concorde » permanent, corollaire LM-104.
*/
LoadMeasurement deviceMeasurement() const override
{ return LoadMeasurement{}; }
/*!
* \brief État sûr de la PAC : **état 2** (normal, mains off) — JAMAIS l'état 1 (blocage).
* \param now Temps de cycle ; l'action passe en \c force = true (bypass minStateHold).
* \note Couper une PAC en la bloquant serait une régression de sécurité, pas une mise en
* sécurité (\c docs/SAFETY.md). C'est le même état que le repli du mode dégradé L2.
*/
void applySafeState(const QDateTime &now) override;
/*!
* \brief État SG-Ready courant (1-4).
* \return État commandé. À la CONSTRUCTION il est RELU depuis les contacts (ECS-411), pas
* supposé : un adaptateur neuf qui se croirait en état 2 alors que la PAC tourne en forcé
* annoncerait une puissance nulle qui circule, et — pire — refuserait comme idempotente
* une remise à l'état 2 qui n'a jamais été écrite (LM-104). Reflète le COMMANDÉ : la PAC
* reste seule juge de son fonctionnement.
*/
int currentState() const { return m_currentState; }
private:
/*!
* \brief Fenêtre d'états autorisée à \p now par le verrou minStateHold (symétrique).
* \param now Temps de cycle (\c ctx.timestamp) — jamais l'horloge.
* \param[out] minState Borne basse : \c m_currentState si \c minStateHold non écoulé
* (gel total), sinon le plus petit état déclaré.
* \param[out] maxState Borne haute : \c m_currentState si \c minStateHold non écoulé
* (gel total), sinon le plus grand état déclaré.
*/
void lockWindow(const QDateTime &now, int &minState, int &maxState) const;
bool lockActive(int newState, const QDateTime &now) const;
/*!
* \brief Amène les contacts à l'encodage de \p toState depuis un motif RÉEL quelconque.
* \param currentOn Ensemble des relais actuellement fermés — pas un index d'état.
* \param toState État visé.
* \note Prend un ENSEMBLE et non un état, parce que le chemin de REPLI (ECS-414) part
* d'un motif qui peut être valide mais non voulu — voire hors table — après une
* écriture partiellement échouée. Le contrat d'atomicité (\c transientHarm) doit
* protéger le repli exactement comme le chemin aller : sans quoi la récupération
* traverserait le blocage et serait plus dangereuse que la panne qu'elle corrige.
*/
void applyStateRelays(const QSet<QString> &currentOn, int toState);
//! \return Ensemble des relais RÉELLEMENT fermés, lu sur les Things.
QSet<QString> readActualOn() const;
//! Écrit un contact et suit son acquittement (ECS-414, modèle d'ECS-410).
void writeRelay(const QString &thingId, bool on);
//! Verdict quand toutes les écritures d'une étape sont acquittées ; fait avancer l'échelle.
void settleTransition();
//! Barreaux de l'échelle. Le plancher d'une PAC est l'ÉTAT 2, jamais « contacts ouverts ».
enum Phase { PhaseNominale, PhaseRepli, PhasePlancher, PhaseDefaut };
//! Rang de nocivité d'un état comme TRANSITOIRE (2 neutre < 3 reco < 1 blocage < 4 forcé).
static int transientHarm(int state);
//! État correspondant à un ensemble de relais ON (-1 si aucun état ne correspond).
int stateForRelays(const QList<QString> &onRelays) const;
/*!
* \brief Déduit l'état courant du motif RÉEL des contacts, au démarrage (ECS-411).
* \return État dont l'encodage correspond aux contacts fermés ; à défaut — encodage
* partiel, motif non déclaré — l'état déclaré de plus forte puissance estimée, jamais
* moins que ce qui peut circuler.
* \note Publie le relevé contact par contact AVEC l'issue, y compris quand l'issue est
* l'état neutre (ECS-411-b) : c'est précisément l'état qu'on vient vérifier après un
* démarrage, et s'en taire rendrait « contacts lus, tous ouverts » indistinguable de
* « reprise non exécutée ». Un contact injoignable passe l'issue en avertissement.
*/
int deduceStateFromRelays() const;
QSet<QString> allRelays() const;
ThingManager *m_thingManager;
QString m_id;
QString m_label;
QHash<int, QList<QString>> m_stateRelays; //!< état → ThingIds ON.
QHash<int, double> m_estimatedPowerW; //!< état → W estimés (déclaré).
QList<int> m_states; //!< États déclarés triés croissants.
int m_minStateHoldS;
int m_priority;
//! État commandé. Initialisé à 2 (neutre) puis RELU depuis les contacts par le
//! constructeur (ECS-411) : la valeur littérale n'est qu'un défaut de sécurité pour le cas
//! où l'encodage est inutilisable et où aucune commande ne sera jamais émise.
int m_currentState = 2;
// --- ECS-414 : suivi asynchrone des écritures (modèle d'ECS-410) ---------------------
int m_pending = 0;
bool m_writeFailed = false;
int m_statePrev = 2;
int m_stateTarget = 2;
Phase m_phase = PhaseNominale;
bool m_faulted = false;
//! Faux si l'encodage ne permet pas d'exprimer l'état 2 — repli sûr du mode dégradé L2.
//! L'adaptateur refuse alors TOUTE commande, et le dit (ECS-110 : jamais un Q_ASSERT,
//! qui disparaît du binaire release).
bool m_usable = true;
//! Dernière commutation. NUL = « on ne sait pas quand » (démarrage, reconstruction), ce qui
//! ARME le verrou pour sa durée configurée — pas l'inverse (ECS-412). L'armement est
//! transitoire : \c applyAction() estampille au premier \c now reçu.
QDateTime m_lastSwitch;
QDateTime m_lastActionAt;
};