// SPDX-License-Identifier: GPL-3.0-or-later // Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync #pragma once #include #include #include #include #include #include "iloadadapter.h" class ThingActionInfo; #include "../types/loadconfig.h" // LoadConfigRelay class Thing; class ThingManager; /*! * \brief ROUTEUR de relais (contrat rév. 3) — traduit un \c Setpoint(W) de l'optimiseur en * combinaison de things \c power, côté experience-plugin. * * \par Frontière rév. 3 * L'optimiseur (scheduler) est **watt-pur** : il émet \c LoadAction{Setpoint, powerW}. Le * `RelayRouter` est la couche **sous** l'optimiseur qui connaît les relais : il mappe * \c powerW → la combinaison atteignable la plus haute ≤ \c powerW, applique l'anti-rebond * (\c minOn/minOff) **ici** (plus dans le scheduler), commute les relais via * \c ThingManager::executeAction (interface \c power), et agrège \c currentPowerW. **Aucun * relais, aucun index de combinaison ne remonte dans le \c LoadContext** (seuls les watts * dérivés `powerLevels`/`maxPowerW` y figurent). * * \par Paliers DÉRIVÉS * Les \c powerLevels (et \c maxPowerW) sont **calculés** depuis la liste de relais : toutes * les sommes de sous-ensembles atteignables, dédupliquées, triées, \c 0 inclus. **Source de * vérité unique = les relais** (l'app affiche le même calcul, l'optimiseur les lit pour * l'arrondi/résidu). * * \invariant \c supportedKinds == { Setpoint }. Autres kinds : retour sans effet. * \invariant applyAction() rejette silencieusement toute action dont \c reason est vide. * \invariant Verrous \c minOn/minOff appliqués en INTERNE (clamp), bypassés si \c force==true * (repli L2). **Temps = paramètre** (cf. \c ILoadAdapter) : \c now reçu, jamais l'horloge. * \invariant Transition relais en **off-before-on** : coupe d'abord les relais hors-cible. */ /*! * \brief Table de paliers dérivée d'une liste de relais — ce que le routeur SAIT faire. * * Deux listes de relais qui produisent la même table sont **matériellement identiques** : * mêmes puissances atteignables, mêmes contacts fermés pour chacune. C'est la seule * comparaison qui ait un sens, et elle est vraie par construction plutôt que par pari. * * \note L'ordre de déclaration des relais n'est PAS inerte, et c'est pourquoi comparer * les listes comme des ensembles serait faux : à somme égale, la table retient la * **première** combinaison rencontrée. Deux relais de même puissance échangés dans la * liste donnent donc les mêmes paliers mais un **contact différent** pour ce palier — * autre contacteur qui s'use, autre résistance qui chauffe. La table le voit ; un * ensemble ne le verrait pas. */ struct RelayStageTable { QList levels; //!< Paliers W triés croissants, \c levels[0] == 0. QList> mapping; //!< ThingIds fermés pour chaque palier de \c levels. /*! * \brief Égalité MATÉRIELLE : mêmes paliers, mêmes contacts fermés pour chacun. * * Les ThingIds d'une combinaison sont comparés comme un **ensemble**, pas comme une * liste. Ils sont collectés dans l'ordre de déclaration des relais, si bien que * fermer {K1,K2} et fermer {K2,K1} produit deux listes différentes pour un geste * électrique identique. Comparer les listes ferait reconstruire la charge sur un * simple réordonnancement — exactement le défaut que cette table corrige. * * L'ordre reste significatif là où il l'est vraiment : dans le CHOIX de la * combinaison retenue à somme égale, que \c levels et l'appariement position par * position capturent. */ //! \param o Table comparée. \return Vrai si les deux tables décrivent le MÊME câblage. bool operator==(const RelayStageTable &o) const { if (levels != o.levels || mapping.size() != o.mapping.size()) return false; for (int i = 0; i < mapping.size(); ++i) { const QList &a = mapping.at(i); const QList &b = o.mapping.at(i); if (QSet(a.begin(), a.end()) != QSet(b.begin(), b.end())) return false; } return true; } //! \brief Négation de \c operator==. \param o Table comparée. \return Vrai si elles diffèrent. bool operator!=(const RelayStageTable &o) const { return !(*this == o); } }; /*! * \brief Adaptateur d'une charge commandée par une COMBINAISON de relais (rév. 3). * * Traduit une enveloppe en watts en un palier, et le palier en contacts à fermer. Toute la * combinatoire vit ici : l'optimiseur ne voit que des watts, jamais un relais ni un index de * palier (ECS-306). Les paliers sont DÉRIVÉS des sous-ensembles de relais (\c deriveStages), * ce qui couvre les topologies non cascadées et les puissances quelconques. * * \invariant Coupure avant fermeture à chaque transition — jamais deux contacts en conflit. * \invariant Les verrous \c minOn / \c minOff sont INTERNES à l'adaptateur, évalués sur le * \c now reçu en paramètre, jamais sur l'horloge. * \invariant Un échec d'écriture pose un défaut COLLANT (ECS-410) : plus aucune commande * n'est émise jusqu'à \c clearFault(). */ class RelayRouter : public QObject, public ILoadAdapter { Q_OBJECT public: /*! * \brief Constructeur. * \param thingManager Résout les ThingIds des relais et exécute les actions \c power. * \param id Identifiant LOGIQUE de la charge (rév. 3 : plus un thingId unique). * \param label Nom lisible (logs, app). * \param relays Liste \c {thingId, powerW} des relais \c power. Les paliers atteignables * sont dérivés de toutes leurs combinaisons. * \param minOnS/minOffS Verrous anti-rebond (protection relais/compresseur), appliqués ici. * \rule{LM-303} — les verrous appartiennent au MÉCANISME, pas au domaine : c'est le * relais qui craint le court-cycle, pas le service rendu. * \param priority Rang dans le waterfall (1 = servi en premier). * \param needs Besoins déclarés (exposés via descriptor().needs). * \param parent Propriétaire Qt. */ explicit RelayRouter(ThingManager *thingManager, const QString &id, const QString &label, const QList &relays, int minOnS, int minOffS, int priority, const LoadNeeds &needs = LoadNeeds(), QObject *parent = nullptr); //! \return LoadDescriptor : adapter="relay-router", powerLevels/maxPowerW DÉRIVÉS, needs. LoadDescriptor descriptor() const override; //! \return currentPowerW = somme des \c currentPower des relais ON (mesuré), sinon nominal commandé. LoadTelemetry telemetry() const override; //! \param now Temps de cycle — source unique des verrous (contrat \c ILoadAdapter). //! \return LoadContext §5 (watts uniquement : powerLevels, currentPowerW — aucun relais). LoadContext toLoadContext(const QDateTime &now) const override; /*! * \brief Applique un \c Setpoint(W) : mappe en combinaison de relais, clampe par les verrous, * commute (off-before-on), publie l'état. * \param action LoadAction kind \c Setpoint (\c powerW). Autres kinds : retour sans effet. * \param now Temps de cycle (verrous + estampille). * \return L'action après écrêtage (\c powerW = palier réellement appliqué). */ LoadAction applyAction(const LoadAction &action, const QDateTime &now) override; //! \brief Palier courant. \return Index du palier, 0 = tout coupé. int currentStage() const { return m_currentStage; } //! \brief Puissance du palier courant. \return Watts du palier appliqué ; 0 si hors table. double currentSetpointW() const { return m_currentStage < m_levels.size() ? m_levels.at(m_currentStage) : 0.0; } //! \brief Met à jour les champs qui ne touchent PAS le matériel (ECS-412). //! \param label Nouveau libellé d'affichage. //! \param priority Nouveau rang de service. //! \param needs Nouveaux besoins déclarés. //! \note Permet à l'arbitre de refléter un changement de priorité SANS détruire //! l'adaptateur — donc sans réarmer les verrous ni recommuter les relais. void updateSoftConfig(const QString &label, int priority, const LoadNeeds &needs) override; /*! * \brief Dérive la table de paliers d'une liste de relais. **Implémentation unique.** * * Trois appelants, une seule règle : la construction du routeur, la comparaison de * \c EnergyArbitrator::sameHardware(), et la publication en télémétrie. Dupliquer * cette combinatoire ailleurs — y compris côté app — expose à une divergence que * personne ne verrait passer. * * \param relays Relais déclarés, dans leur ordre de configuration (l'ordre compte, * cf. \c RelayStageTable). * \return La table dérivée. Toujours non vide : le sous-ensemble vide donne le palier 0. */ static RelayStageTable deriveStages(const QList &relays); /*! * \brief Lève le verrou de défaut (ECS-410, RPC \c NymeaEnergy.ClearLoadFault). * \return Vrai si un défaut a RÉELLEMENT été levé, faux s'il n'y en avait pas. * \note **Lever le défaut ne rend pas forcément la charge disponible.** \rule{ECS-111} : * un relais déclaré qui manque au registre la garde indisponible, et ClearLoadFault n'y * peut rien — il ne fait pas exister un Thing. Les deux causes sont distinctes et se * lisent séparément : \c faulted() pour le défaut d'écriture collant, * \c telemetry().available pour l'aptitude réelle à être commandée. * \note Le défaut est COLLANT par conception — « cesser toute commande jusqu'à * intervention ». Sa levée est donc un acte délibéré et tracé de l'opérateur, jamais * une expiration. L'état matériel réel étant inconnu après un défaut, il est RELU * (même principe qu'ECS-411) et non supposé. */ bool clearFault() override; /*! * \brief Vue runtime pour la télémétrie RPC : palier en watts, défaut, verrou en secondes. * \param now Temps de cycle — même source que \c lockWindow(), donc le verrou annoncé est * celui qui a réellement borné la décision. * \return \c mechanismKind "relay" ; \c mechanism \c {stageW, maxStageW} — des WATTS, * jamais un index de palier ni un ThingId (la frontière rév. 3 tient aussi en sortie). */ LoadRuntimeView runtimeView(const QDateTime &now) const override; /*! * \brief Mesure propre des relais qui portent la charge (§11 / LM-1105). * * \return Somme des \c currentPower des relais FERMÉS au palier courant, à condition que * **tous** les relais déclarés portent l'état ; « non mesurable » sinon. * * \note **La capacité se juge sur TOUS les relais déclarés, la valeur se lit sur les relais * FERMÉS.** Juger la capacité sur les relais fermés ferait répondre « non mesurable » au * palier 0, où aucun ne l'est — la source publiée basculerait à chaque extinction, sur * une installation dont rien n'a changé (LM-1106). * \note **Tous, et pas au moins un (LM-1105-a).** Une topologie mixte — un relais mesurant, deux nus — * produirait une somme SOUS-ÉVALUÉE présentée comme la mesure de la charge, et l'écart * commandé↔mesuré enverrait chercher une panne qui n'existe pas (LM-1102). Une mesure * partielle n'est pas une mesure ; c'est le cas où \c meterThingId garde tout son sens. * \note Distincte de \c telemetry(), qui accepte « au moins un » et retombe sur le palier * NOMINAL sinon : elle doit rendre un nombre à chaque cycle pour le recrédit. Ici, un * nominal n'est pas une mesure. */ LoadMeasurement deviceMeasurement() const override; //! \brief État sûr du routeur : TOUS relais ouverts (ECS-413). //! \param now Temps de cycle — l'action passe en \c force = true, verrous bypassés. void applySafeState(const QDateTime &now) override; //! \return Vrai tant qu'une écriture émise n'est pas acquittée (ECS-410). bool writesPending() const { return m_pending > 0; } //! \return Vrai si la charge est en défaut (plus aucune commande émise). bool faulted() const { return m_faulted; } private: //! Barreaux de l'échelle ECS-410. Une tentative par barreau, jamais de boucle. enum Phase { PhaseNominale, PhaseRepli, PhaseArretTotal, PhaseDefaut }; void writeRelay(const QString &thingId, bool on); void emitRelayWrites(int stage); //! Verdict pris quand toutes les écritures d'une étape sont acquittées. void settleTransition(); //! Palier le plus haut dont la puissance ≤ \p powerW (≥ 0). int stageForPower(double powerW) const; /*! * \brief Fenêtre de paliers autorisée à \p now par minOn/minOff (verrou INTERNE). * \param now Temps de cycle. * \param[out] minStage Palier plancher (puissance engagée non-coupable). * \param[out] maxStage Palier plafond (interdiction de redémarrer). * \note Un \c m_lastSwitch NUL vaut « commutation venant d'avoir lieu », donc verrou * **ARMÉ** pour sa durée configurée — cf. ECS-412, démarrage à froid. */ void lockWindow(const QDateTime &now, int &minStage, int &maxStage) const; //! \brief Déduit le palier courant de l'état RÉEL des Things relais (ECS-411). //! \return Palier dont l'encodage correspond aux relais fermés ; à défaut, celui de //! même PUISSANCE. Ne retourne jamais un palier inférieur à ce qui est appliqué. int deduceStageFromThings() const; void applyRelayStage(int stage); /*! * \brief Un relais DÉCLARÉ manque-t-il au registre nymea ? \rule{ECS-111} * \return Vrai dès qu'un seul \c thingId déclaré ne résout plus. * \note Jugé sur TOUS les relais déclarés, jamais sur ceux du palier courant : au palier 0 * aucun n'est fermé, et juger là-dessus ferait basculer la disponibilité au rythme du * thermostat. Même règle que \c deviceMeasurement(). */ bool aUnRelaisManquant() const; ThingManager *m_thingManager; QString m_id; QString m_label; QList m_levels; //!< Paliers W dérivés, triés, [0]=0. QList> m_relayMapping; //!< ThingIds ON par palier (dérivé des combinaisons). //! Puissance nominale DÉCLARÉE de chaque relais. Ne pas la redéduire de //! m_relayMapping : la déduplication par puissance peut évincer l'encodage solo d'un //! relais (ex. 500/1000/1500 — le palier 1500 est encodé {500,1000}, et le relais 1500 //! n'apparaît alors jamais seul), ce qui lui donnerait une nominale nulle. Voir ECS-411-b. QHash m_relayNominalW; int m_minOnS; int m_minOffS; int m_priority; LoadNeeds m_needs; int m_currentStage = 0; QDateTime m_lastSwitch; //!< Dernier changement (null = jamais). QDateTime m_lastActionAt; // --- ECS-410 : suivi asynchrone des écritures --------------------------------------- int m_pending = 0; //!< Écritures émises non encore acquittées. bool m_writeFailed = false; //!< Au moins un acquittement en erreur sur l'étape courante. int m_stagePrev = 0; //!< Palier avant la transition en cours. int m_stageTarget = 0; //!< Palier visé par la transition en cours. Phase m_phase = PhaseNominale; bool m_faulted = false; //!< Verrou de défaut. COLLANT : seul clearFault() le lève. };