// SPDX-License-Identifier: GPL-3.0-or-later // Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync #pragma once #include #include #include #include #include #include #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> &stateRelays, const QHash &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 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 ¤tOn, int toState); //! \return Ensemble des relais RÉELLEMENT fermés, lu sur les Things. QSet 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 &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 allRelays() const; ThingManager *m_thingManager; QString m_id; QString m_label; QHash> m_stateRelays; //!< état → ThingIds ON. QHash m_estimatedPowerW; //!< état → W estimés (déclaré). QList 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; };