// 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 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. /*! * \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. */ void clearFault() override; //! \return Vrai si la PAC est en défaut (plus aucune commande émise). bool faulted() const { return m_faulted; } /*! * \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 Contacts que cet adaptateur revendique, tous états confondus. * * Sert à détecter qu'une PAC de configuration (LM-300) et une PAC enregistrée en dur * commandent le même matériel — situation où deux adaptateurs se disputeraient les mêmes * contacts et où le budget compterait la charge deux fois. */ QSet claimedRelays() const { return allRelays(); } /*! * \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; /*! * \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; 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; int m_currentState = 2; //!< Démarrage en NORMAL (mains off). // --- 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; QDateTime m_lastSwitch; //!< Dernier changement d'état (null = jamais). QDateTime m_lastActionAt; };