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>
148 lines
7.4 KiB
C++
148 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 <QHash>
|
|
#include <QString>
|
|
#include "iloadadapter.h"
|
|
|
|
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 priority Ignoré. \param needs Ignoré.
|
|
void updateSoftConfig(int priority, const LoadNeeds &needs) override
|
|
{ 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.
|
|
void clearFault() override {}
|
|
|
|
/*!
|
|
* \brief État SG-Ready courant (1-4).
|
|
* \return Dernier état réellement commuté ; valeur initiale tant qu'aucune transition
|
|
* n'a eu lieu. 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;
|
|
|
|
//! Applique l'ensemble de relais de \p toState en passant par le transitoire le plus
|
|
//! doux (cf. contrat d'atomicité). \p fromState = état courant (pour l'ordre).
|
|
void applyStateRelays(int fromState, int toState);
|
|
|
|
//! 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;
|
|
|
|
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;
|
|
|
|
int m_currentState = 2; //!< Démarrage en NORMAL (mains off).
|
|
QDateTime m_lastSwitch; //!< Dernier changement d'état (null = jamais).
|
|
QDateTime m_lastActionAt;
|
|
};
|