Patrick Schurig d6dc9dcd4e fix(ECS-415): un refus doit se distinguer d'une application, par le retour
SgReadyAdapter::applyAction() renvoyait, quand le verrou minStateHold
refusait la transition, l'action DEMANDÉE telle quelle. Un appelant qui
relisait `state` y retrouvait l'état qu'il venait de demander et concluait
au succès — alors que rien n'avait été écrit et que les contacts n'avaient
pas bougé.

Ce n'est pas un défaut du délestage, c'est un défaut de contrat. N'importe
quelle descente d'état pouvait échouer en silence, et `available` restait
vrai : la charge avait l'air saine pendant que la PAC tirait 3 kW. Même
famille qu'ECS-111 — une issue indiscernable de son contraire — transposée
au retour d'une commande au lieu de la télémétrie.

Toute sortie décrit désormais l'état RÉELLEMENT tenu, `estimatedPowerW`
compris. L'idempotence n'y échappe pas : demander « état 4, 0 W » à une PAC
qui en tire 3000 doit rendre 3000, pas le zéro de l'appelant — sinon il lit
son enveloppe, pas une réponse. Même correction sur la branche « motif
vide » du RelayRouter et de l'EtmVariableLoadAdapter.

Le défaut était LATENT : aucun appelant ne lisait ce retour, le dispatch
l'ignore. Le premier à s'y fier fut le délestage L4 en cours d'écriture, qui
a publié 3 kW rendus par une PAC restée en état 4. Un contrat qu'aucun
appelant n'exerce n'est pas un contrat tenu, c'est un contrat non testé.

Deux pistes écartées, mesurées et non supposées. m_currentState n'était pas
en cause : il est écrit synchroniquement, sans attendre d'acquittement.
ECS-411-b non plus : la relecture des contacts répond à une divergence entre
état interne et matériel, et il n'y en avait aucune — l'état interne était
juste, c'est le retour qui mentait.

Les trois pièges rencontrés à l'étape 4 sont consignés dans
DESIGN_DELESTAGE §6bis, les deux placements écartés compris. L'étape 4
elle-même reste non livrée.

Suite complète : simulation 53/53, charging 48/48, loadmodel 21/21,
spotmarket 32/32, doxygen 0 avertissement. Contre-épreuve : sur l'ancien
retour, refuse.state vaut 2 — l'état demandé — au lieu de 4.

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

257 lines
14 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.
*/
//! \return Vrai si un défaut a réellement été levé ; faux s'il n'y en avait pas.
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 Retour décrivant l'état RÉELLEMENT tenu — forme unique de tout refus (ECS-415).
* \param action Action refusée, dont l'enveloppe (motif, kind) est conservée.
* \return La même action, mais avec \c state et \c estimatedPowerW pris sur l'adaptateur.
*/
LoadAction inchange(const LoadAction &action) const;
/*!
* \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;
};