Patrick Schurig dd28749b6b fix(etm): ECS-413 — désactiver une charge la laisse en état sûr
Constat de banc du 2026-08-09 : un SetLoadConfig posant enabled: false sur une
charge alors au palier 3500 W détruisait l'adaptateur en laissant les TROIS RELAIS
FERMÉS, juste avant une intervention de câblage. Plus personne ne les commandait ;
ils y seraient restés indéfiniment.

applySafeState(now) est ajouté à ILoadAdapter, PURE VIRTUELLE : l'état sûr est
propre à chaque adaptateur et une formulation « tout couper » serait fausse.
  - RelayRouter          : tous relais ouverts
  - EtmVariableLoadAdapter : consigne 0 W
  - SgReadyAdapter       : ÉTAT 2 (normal, mains off) — JAMAIS l'état 1. Bloquer
                           une PAC n'est pas la mettre en sécurité, c'est arrêter
                           le chauffage sans raison visible (SAFETY.md).
  - EvAdapter            : sans effet, il n'est pas construit depuis LoadConfig.

L'application passe par le chemin d'action NORMAL avec force = true, celui du
mode dégradé L2 : le mécanisme de contournement des verrous existait déjà.

ORDRE, et c'est le point qui dépendait d'ECS-410 : l'état sûr est appliqué AVANT
la destruction, et la destruction passe par deleteLater(). Les écritures d'ECS-410
sont asynchrones avec `this` en contexte de connexion — détruire immédiatement
couperait les acquittements en vol, et on ne saurait pas si la mise en sécurité a
abouti, précisément dans le cas où elle échoue.

PÉRIMÈTRE BORNÉ. Rien de tout cela à l'arrêt du plugin ni au redémarrage de
nymead : l'état doit y être CONSERVÉ, c'est ce qu'ECS-411 relit, et couper l'eau
chaude à chaque redémarrage de service serait une régression. La désactivation est
un acte délibéré de l'opérateur ; un redémarrage n'en est pas un. Les charges
CONSERVÉES par le rebuild incrémental ne passent pas par ce chemin.

Test testEcsDisableLeavesSafeState, avec son CAS NÉGATIF en premier : un rebuild
qui ne change que le rang ne coupe rien — sans lui, ECS-412 serait annulé et
chaque changement de priorité couperait la charge. Puis le cas positif :
désactivation, relais ouvert, et il le reste même sous surplus au cycle suivant.

Build amd64 0 erreur. Simulation : 17/17.

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

132 lines
6.8 KiB
C++

// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#pragma once
#include <QDateTime>
#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 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 \c reason 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 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 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 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(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**.
*/
virtual void clearFault() = 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;
};