Patrick Schurig 92d0bef4ac fix(etm): ECS-410 — échec d'écriture relais, échelle bornée et défaut collant
writeRelay() jetait le ThingActionInfo* : aucun acquittement, aucun retour
arrière, available codé en dur à true, et m_currentStage mis à jour comme si tout
avait réussi — on annonçait une puissance non appliquée.

MODÈLE ASYNCHRONE. executeAction est asynchrone et update() ne doit jamais
attendre (AGENTS règle 5). « Attendre le résultat » ne veut donc pas dire bloquer
le cycle : l'écriture est émise, l'adaptateur retient combien d'acquittements il
attend (m_pending), le verdict tombe quand le compteur retombe à zéro, et la
conséquence est traitée au cycle suivant. Motif repris tel quel d'EvCharger
(evcharger.cpp:293-299), y compris `this` en contexte de connexion — si
l'adaptateur meurt, les callbacks sont coupés proprement.

INDÉTERMINATION. Pendant une transition, telemetry() annonce
max(m_stagePrev, m_stageTarget) : on ne sait pas ce qui est fermé, on annonce donc
la plus haute des deux puissances possibles. Même direction qu'ECS-411 (relais
injoignable supposé fermé) — ne jamais annoncer moins que ce qui peut être
appliqué. Sous-estimer fait sur-allouer les charges suivantes ; surestimer ne fait
que retarder une montée.

ÉCHELLE BORNÉE à trois barreaux, une tentative chacun, aucune boucle : cible →
retour arrière → arrêt total → défaut. Le retour arrière est asynchrone au même
titre et passe par le même compteur.

DÉFAUT COLLANT, pas clignotant. m_faulted est un verrou posé une seule fois, sans
délai ni expiration : available ne peut pas osciller d'un cycle à l'autre. Seul
NymeaEnergy.ClearLoadFault le lève — acte délibéré et journalisé de l'opérateur.
La reconstruction le lève aussi, mais par construction : un adaptateur neuf n'a
pas d'historique. À la levée, l'état matériel est RELU (ECS-411), pas supposé.

CANAL OUVERT. LoadContext n'avait AUCUN champ available : le publier aurait été
décoratif. Ajouté à LoadContextTelemetry, avec sa sémantique écrite noir sur
blanc — il gouverne l'allocation, PAS la comptabilité. Une charge en défaut ne
reçoit rien mais reste comptée : une puissance qu'on ne sait plus couper est de la
conso fixe, au même titre que la base de la maison. Le figeage est porté par
lockMin == lockMax == puissance crue engagée, jamais un plafond nul sous un
plancher non nul. Le même canal servira ECS-601.

OPTIMIZER_PROTOCOL.md mis à jour dans le MÊME lot, comme l'exige le §11 de la
spec : available, lockMinPowerW et lockMaxPowerW documentés avec leur sémantique.

clearFault() est PURE VIRTUELLE sur ILoadAdapter : les trois autres adaptateurs la
déclarent sans effet plutôt que d'hériter d'un défaut vide. Leur généralisation est
portée par ECS-414.

Test testEcsPartialFailure : cas nominal sans défaut, puis échelle complète via un
relais introuvable — défaut atteint, relais valide bien ramené à l'ouverture par la
tentative d'arrêt total, available faux, plancher == plafond == puissance comptée,
plus aucune commande une heure plus tard, puis levée délibérée.

Build amd64 0 erreur. Simulation : 16/16.

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

147 lines
7.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 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.
void clearFault() 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).
void writeSetpoint(double powerW);
//! 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).
QDateTime m_lastActionAt;
};