Patrick Schurig 2a4659ff8f fix(etm): ECS-414 — échelle d'échec généralisée à SgReady et EtmVariableLoad
Même modèle qu'ECS-410, rien de nouveau conçu : m_pending, verdict à zéro,
échelle bornée à trois barreaux, `this` en contexte de connexion.

PLANCHER PROPRE À CHAQUE ADAPTATEUR. Pour EtmVariableLoadAdapter, consigne 0 W.
Pour SgReadyAdapter, le barreau 3 est l'ÉTAT 2 — pas « tous contacts ouverts ».
Ouvrir les deux contacts d'une PAC est une COMMANDE, et selon l'encodage câblé
c'est potentiellement le BLOCAGE, donc l'inverse d'une mise en sécurité.

ATOMICITÉ DU REPLI — le point qui n'existait pas sur le routeur relais. Un état
SG-Ready est porté par deux bits ; si une écriture échoue, on est dans un motif
valide mais NON VOULU, et le retour depuis ce motif peut exiger de traverser le
blocage. applyStateRelays() prend désormais un ENSEMBLE de relais réellement
fermés au lieu d'un index d'état, de sorte que transientHarm ordonne le repli
exactement comme il ordonne l'aller. Sans cela, la récupération serait plus
dangereuse que la panne qu'elle corrige.

Le repli écrit les contacts directement, sans repasser par applyAction : le verrou
minStateHoldS n'est donc jamais consulté — équivalent d'un force = true, comme le
repli L2. À 900 s sur une PAC, attendre un quart d'heure pour sortir d'un état non
voulu ne serait pas défendable.

DEUX INCOHÉRENCES TROUVÉES PAR LE TEST, et corrigées :

  1. readActualOn() traitait un contact injoignable comme OUVERT, alors qu'ECS-411
     le suppose FERMÉ. Conséquence : un repli ne demandant aucune écriture
     « réussissait » à vide alors qu'on ne savait rien du matériel.

  2. Corollaire inverse : une fois supposé fermé, un contact injoignable n'était
     plus jamais écrit quand la cible le voulait fermé — l'échec était masqué et
     la transition paraissait réussie sans qu'aucune écriture n'ait été tentée.
     Un contact non vérifiable est désormais TOUJOURS commandé.

Test testSgReadyPartialFailure. Ce qu'il verrouille n'est pas le défaut mais le
PLANCHER : le repli ramène K1 à l'ouverture, soit l'état 2 sur cet encodage. Un
plancher « contacts ouverts par principe » aurait pu, sur un autre câblage, valoir
l'état 1 — le blocage. Vérifie aussi que minStateHoldS = 900 s ne retarde pas le
repli, que la charge figée a plancher == plafond, et que la levée relit l'état.

Une correction de mon propre scénario au passage : j'avais écrit un cas
« récupération au barreau 2 » qui n'est pas constructible avec un contact
définitivement injoignable — rien ne peut alors être confirmé, et le défaut est
l'issue honnête.

Build amd64 0 erreur. Simulation : 18/18.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 12:07:39 +02:00

170 lines
8.4 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 <QString>
#include "iloadadapter.h"
class ThingActionInfo;
class Thing;
class ThingManager;
/*!
* \brief Adaptateur charge à puissance pilotable — interface \c etmvariableload (contrat
* INTERFACE_etmvariableload.md). Action \c kind:Setpoint en WATTS.
*
* Couvre les charges dont la consigne est une puissance : ECS multipalier, routeur PV /
* triac, résistance modulable. \b Pas la PAC SG-Ready (modèle à états, \c SgReadyAdapter).
*
* \par Frontière (contrat §1 / §5) — non négociable
* L'energymanager raisonne UNIQUEMENT en watts. \b Aucune résistance, relais, combinaison
* ni index de palier matériel ne traverse cet adaptateur. La connaissance matérielle (quelle
* résistance, quel triac, sur quelle phase), l'anti-rebond (minOn/minOff) et l'équilibrage
* de phases vivent ENTIÈREMENT dans le \b thing qui implémente l'interface. Cet adaptateur
* se contente d'écrire une consigne \c powerSetpoint (W) et de relire \c currentPowerW.
*
* \par States nymea consommés sur le thing cible (\c m_id)
* - \c currentPowerW (read) : puissance réellement appliquée — juge runtime (contrat §4).
* - \c powerSetpoint (write) : consigne demandée par l'energymanager.
* - \c maxPowerW / \c powerLevels (read) : déclarés par le thing pour le wizard ; la
* planification utilise la DÉCLARATION de config (passée au constructeur, contrat §4),
* pas ces states — l'adaptateur n'écrase jamais la déclaration.
*
* \invariant applyAction() rejette silencieusement toute action dont \c reason est vide.
* \invariant Seul le kind \c Setpoint est traité ; les autres kinds retournent sans effet.
* \invariant La consigne est écrêtée à \c [0, maxPowerW] avant envoi matériel (second filet
* après l'écrêtage de l'arbitre). L'arrondi au \c powerLevels (mode *fixed*) est la
* responsabilité du SCHEDULER (contrat §4), pas de l'adaptateur.
* \invariant **Temps = paramètre, jamais l'horloge** (cf. \c ILoadAdapter) : aucune logique
* temporelle ici (les verrous vivent dans le thing) — \c now sert uniquement aux estampilles.
*
* \note T1 (squelette) : l'adaptateur consomme l'interface côté moteur. La déclaration de
* l'interface nymea \c etmvariableload et le driver thing relèvent d'une session device dédiée.
*/
class EtmVariableLoadAdapter : public QObject, public ILoadAdapter
{
Q_OBJECT
public:
/*!
* \brief Constructeur.
* \param thingManager Gestionnaire nymea pour résoudre \p id en Thing.
* \param id ThingId (string) du thing implémentant \c etmvariableload, et
* identifiant logique de la charge.
* \param label Nom lisible (logs, app).
* \param powerLevels Paliers atteignables en W (DÉCLARÉS, triés croissants, \c 0 inclus)
* en mode *fixed* ; liste vide ⇒ *dynamic* (modulation 0..maxPowerW).
* \param maxPowerW Plafond physique (W) — contrat §2.
* \param priority Rang dans le waterfall (contrat §5 / OPTIMIZER_PROTOCOL §5) : valeur
* plus BASSE = servi en premier (rang 1 = premier servi).
* \param needs Besoins déclarés (échéances, énergie min) — exposés au scheduler
* via \c descriptor().needs. Vide par défaut.
* \param parent Propriétaire Qt.
*/
explicit EtmVariableLoadAdapter(ThingManager *thingManager,
const QString &id,
const QString &label,
const QList<int> &powerLevels,
int maxPowerW,
int priority,
const LoadNeeds &needs = LoadNeeds(),
QObject *parent = nullptr);
/*!
* \brief Description statique : adapter="etmvariableload", powerLevels, maxPowerW, priority.
* \return LoadDescriptor déclaratif (référence de planification, contrat §4).
*/
LoadDescriptor descriptor() const override;
/*!
* \brief Télémétrie runtime. \c currentPowerW = state \c currentPowerW MESURÉ du thing
* (juge de quantification, contrat §4) ; \c available faux si le thing est absent.
* \return LoadTelemetry ; \c currentPowerW vaut 0 si le thing est absent ou n'expose
* pas l'état \c currentPowerW (jamais de puissance fantôme).
*/
LoadTelemetry telemetry() const override;
/*!
* \brief Construit l'entrée loads[] §5 (adapter="etmvariableload").
* \param now Temps de cycle (\c ctx.timestamp) — estampille uniquement ; aucune fenêtre de
* verrou côté moteur (l'anti-rebond vit dans le thing, contrat §2).
* \return LoadContext incluant declared (powerLevels/maxPowerW) et \c currentPowerW.
*/
LoadContext toLoadContext(const QDateTime &now) const override;
/*!
* \brief Écrit la consigne \c powerSetpoint (W) sur le thing.
* \param action LoadAction de kind \c Setpoint ; consigne lue dans \c action.powerW. Autres
* kinds : retour sans effet.
* \param now Temps de cycle (\c ctx.timestamp) — estampille \c m_lastActionAt.
* \return L'action après écrêtage matériel (\c powerW borné à [0, maxPowerW]).
*
* \invariant Si \c action.reason est vide → retour sans effet (log warning).
* \invariant \c action.force == true (repli L2 : \c setPowerSetpoint(0)) est transmis tel
* quel ; le bypass anti-rebond est honoré par le thing, pas ici.
*/
LoadAction applyAction(const LoadAction &action, const QDateTime &now) override;
//! \brief Met à jour rang et besoins sans reconstruire (ECS-412).
//! \param priority Nouveau rang de service. \param needs Nouveaux besoins.
void updateSoftConfig(int priority, const LoadNeeds &needs) override
{ m_priority = priority; m_needs = needs; }
//! \brief Sans effet : cet adaptateur 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-414). La consigne réelle est RELUE, pas supposée.
void clearFault() override;
//! \return Vrai si la charge est en défaut (plus aucune consigne écrite).
bool faulted() const { return m_faulted; }
//! \brief État sûr : consigne **0 W**, en \c force = true (ECS-413). \param now Temps de cycle.
void applySafeState(const QDateTime &now) override;
/*!
* \brief Dernière consigne (W) effectivement écrite (avant écrêtage thing).
* \return Consigne commandée en W, bornée à \c maxPowerW ; 0 tant qu'aucune action
* n'a été appliquée. C'est le COMMANDÉ, pas le mesuré.
*/
double currentSetpointW() const { return m_currentSetpointW; }
private:
//! Vrai si aucun palier déclaré n'est fourni ⇒ modulation continue (contrat §2).
bool isDynamic() const { return m_powerLevels.isEmpty(); }
//! Écrit le state \c powerSetpoint (W) via executeAction (repli \c setStateValue pour mock),
//! et suit son acquittement (ECS-414, modèle d'ECS-410).
void writeSetpoint(double powerW);
//! Verdict quand l'écriture est acquittée ; fait avancer l'échelle d'un cran.
void settleTransition();
//! Barreaux de l'échelle. Plancher de cet adaptateur : consigne 0 W.
enum Phase { PhaseNominale, PhaseRepli, PhasePlancher, PhaseDefaut };
//! Lit le state \c currentPowerW du thing cible (0 si absent / non exposé).
double readCurrentPowerW() const;
ThingManager *m_thingManager;
QString m_id;
QString m_label;
QList<int> m_powerLevels; //!< Paliers W déclarés (vide = dynamic).
int m_maxPowerW;
int m_priority;
LoadNeeds m_needs;
double m_currentSetpointW = 0; //!< Dernière consigne écrite (W).
// --- ECS-414 : suivi asynchrone (modèle d'ECS-410) ----------------------------------
int m_pending = 0;
bool m_writeFailed = false;
double m_setpointPrev = 0;
double m_setpointTarget = 0;
Phase m_phase = PhaseNominale;
bool m_faulted = false;
QDateTime m_lastActionAt;
};