// SPDX-License-Identifier: GPL-3.0-or-later // Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync #pragma once #include "../smartchargingmanager.h" #include "scheduler/ischeduler.h" #include "types/surpluscontext.h" #include "types/plan.h" #include class QTimer; class EvAdapter; class SgReadyAdapter; class EtmVariableLoadAdapter; class RelayRouter; class ILoadAdapter; class LoadConfigStore; class RuleBasedScheduler; /*! * \brief Arbitre central ETM — remplace SmartChargingManager::update() (ETM_ARBITRATOR). * * Hérite de SmartChargingManager pour conserver la compatibilité API complète avec * NymeaEnergyJsonHandler sans modifier le code amont. * Seul update() est surchargé : préparation → sécurité → planificateur → adapters. * * \invariant UN seul arbitre : EnergyArbitrator décide, les EvAdapter exécutent (règle 1). * \invariant verifyOverloadProtection() est toujours appelée avant la planification (règle 4). * \invariant Toute LoadAction transmise aux adapters a un \c reason non vide (règle 7). * \invariant L'absence du root meter n'empêche pas le démarrage — cycle ignoré silencieusement. */ class EnergyArbitrator : public SmartChargingManager { Q_OBJECT public: explicit EnergyArbitrator(EnergyManager *energyManager, ThingManager *thingManager, SpotMarketManager *spotMarketManager, EnergyManagerConfiguration *configuration, QObject *parent = nullptr); /*! * \brief Déclenche planSurplusCharging() (protégée) — appelé par RuleBasedScheduler. * \param now Instant courant du cycle. */ void runSurplusPlanning(const QDateTime &now); /*! * \brief Déclenche planSpotMarketCharging() (protégée) — appelé par RuleBasedScheduler. * \param now Instant courant du cycle. */ void runSpotMarketPlanning(const QDateTime &now); /*! * \brief Actions planifiées (résultat de runSurplus/SpotMarket). * \return Référence constante vers la table EvCharger* → ChargingActions. * \note Valide seulement après runSurplusPlanning() / runSpotMarketPlanning(). */ const QHash &scheduledActions() const; /*! * \brief Pont d'exécution pour EvAdapter — délègue à executeChargingAction() protégée. * \param charger Borne EV cible. * \param action ChargingAction à appliquer. * \param now Instant de l'action (pour les locks anti-rebond). */ void doExecuteChargingAction(EvCharger *charger, const ChargingAction &action, const QDateTime &now); /*! * \brief Liste des EvCharger enregistrés (lecture seule). * \return Table ThingId → EvCharger*. */ const QHash ®isteredEvChargers() const; /*! * \brief Root meter courant. * \return Pointeur ou nullptr si aucun compteur principal n'est enregistré. */ RootMeter *registeredRootMeter() const; /*! * \brief Enregistre un SgReadyAdapter (PAC) pour inclusion dans le contexte et le dispatch. * \param adapter Adaptateur à enregistrer ; son \c descriptor().id doit être unique. * Adopté comme enfant Qt de l'arbitre. Appelé par le test (setup) ou la config production. */ void registerSgReadyAdapter(SgReadyAdapter *adapter); /*! * \brief Enregistre un EtmVariableLoadAdapter (ECS/routeur, interface \c etmvariableload) * pour inclusion dans le contexte et le dispatch \c Setpoint (W). * \param adapter Adaptateur à enregistrer ; son \c descriptor().id doit être unique. * Adopté comme enfant Qt de l'arbitre. Appelé par le test (setup) ou — en T4 — la * construction depuis \c LoadConfig. * \note Le dispatch distingue un \c Setpoint etmvariableload d'un \c Setpoint EV par le * \c loadId : seul l'EV n'est PAS dans \c m_loadAdapters (proxy amont jusqu'à 3g). */ void registerEtmVariableLoadAdapter(EtmVariableLoadAdapter *adapter); /*! * \brief Enregistre un RelayRouter (ECS multipalier, rév. 3) dans la même table d'adaptateurs. * \param adapter Routeur à enregistrer ; \c descriptor().id unique. Adopté enfant Qt. * Appelé par le test (setup) ou — en prod — la construction depuis \c LoadConfig (relays[]). */ void registerRelayRouter(RelayRouter *adapter); /*! * \brief Branche le store de config charge pilotée : construit les adaptateurs * \c etmvariableload depuis la config et les reconstruit à chaque \c changed(). * * Remplace l'enregistrement en dur (le « 3g »). Les charges \c enabled==false ne sont * PAS construites (exclues de l'arbitrage, contrat §9). La PAC SG-Ready reste hors config. * \param store Store persistant (propriété de l'appelant ; non adopté). */ void setLoadConfigStore(LoadConfigStore *store); /*! * \brief Mode dégradé L2 actif (compteur muet > 90 s) — override de SmartChargingManager. * \return \c true tant que les consignes de repli L2 tiennent ; \c false en régime normal. * \note Exposé dans la notification \c NymeaEnergy.ChargingSchedulesChanged (champ * \c degradedMode), émise aussi aux transitions de ce flag. */ bool degradedMode() const override { return m_degradedMode; } /*! * \brief Enregistre une mesure fraîche du compteur à l'instant \p now (logique L2). * * Met à jour \c m_lastMeterUpdate et, si le mode dégradé était actif, en sort * (\c degradedMode=false + notification). \p now = temps de cycle. * \note Logique injectable (temps en paramètre) — en production appelée par le * handler \c powerBalanceChanged ; en simulation/test appelée directement. Le * déclencheur réel (signal) est câblé sous \c \#ifndef ENERGY_SIMULATION. */ void recordMeterUpdate(const QDateTime &now); /*! * \brief Évalue la fraîcheur du compteur à \p now et bascule en mode dégradé si muet >90 s. * * Si \c now − \c m_lastMeterUpdate > 90 s et pas déjà dégradé → \c applyDegradedMode(). * Appliqué à la TRANSITION uniquement (idempotent ensuite). \p now = temps de cycle. * \note Logique injectable — en production appelée par \c onMeterWatchdogTick() (QTimer * horloge murale, indépendant car le compteur muet fige aussi \c update()) ; en * simulation/test appelée directement avec le temps simulé. Symétrique de * \c simulationCallUpdate : déclencheur réel en prod, logique testable par injection. */ void evaluateMeterFreshness(const QDateTime &now); protected: /*! * \brief Boucle principale ETM — surcharge SmartChargingManager::update(). * * Ordre garanti : * 1. updateManualSoCsWithoutMeter() * 2. prepareInformation() * 3. verifyOverloadProtection() + verifyOverloadProtectionRecovery() * (si \c m_degradedMode actif : retour immédiat — planification/dispatch suspendus, L2) * 4. m_scheduler->getPlan() → log des decisionReason * 5. applyActionsToAdapters() (etmvariableload Setpoint W + SG-Ready State) + adjustEvChargers() (EV) → dispatch * * \param currentDateTime Instant courant (timer ou simulation). */ void update(const QDateTime ¤tDateTime) override; private: /*! * \brief Construit le SurplusContext §5 : meter brut + loads EV + SG-Ready. * * \c ctx.meter.exportW = mesure brute du compteur (AGENTS invariant 8 — aucune * déduction interne). La déduction evReservedW est faite dans le scheduler. * \param now Temps de cycle (\c ctx.timestamp) : pose \c ctx.timestamp et sert de SOURCE * UNIQUE à la fenêtre de verrou PAC (\c minState/maxState) calculée par le SgReadyAdapter — * cohérence décision (scheduler) / exécution (applyAction). Les charges etmvariableload * (ECS/routeur) n'ont pas de fenêtre côté moteur (verrous dans le thing). */ SurplusContext buildContext(const QDateTime &now) const; /*! * \brief Synchronise m_adapters avec les EvCharger actuellement enregistrés. * Crée les adapters manquants, supprime les adapters obsolètes. * \note Découverte etmvariableload/SG-Ready via config — câblé en T4. */ void syncAdapters(); /*! * \brief Applique les actions d'un Slot aux LoadAdapters non-EV. * * Itère \c slot.actions et dispatche selon le kind : \c State → \c m_sgReadyAdapters * (SgReadyAdapter, PAC). Les actions EV (\c Setpoint) restent dispatchées par * \c adjustEvChargers() amont jusqu'à 3g ; les charges etmvariableload (\c Setpoint W) * seront dispatchées via leur adaptateur en T3/T4. L'adaptateur écrête/verrouille lui-même * et ignore toute action sans \c reason ou de kind non supporté — aucune décision ici (règle 2). * \param slot Créneau courant retourné par le scheduler. * \param now Temps de cycle (\c ctx.timestamp) — transmis aux adaptateurs pour une * évaluation des verrous cohérente avec celle vue par le scheduler. */ void applyActionsToAdapters(const Slot &slot, const QDateTime &now); /*! * \brief (Re)construit \c m_loadAdapters depuis \c m_loadConfigStore (rév. 3). * Purge les adaptateurs existants (deleteLater) puis crée, par config \c enabled==true, un * \c RelayRouter (si \c relays[]) ou un \c EtmVariableLoadAdapter (sinon). La distinction de * TYPE vit ici ; au-dessus tout est \c ILoadAdapter (Setpoint W). Appelé au branchement du * store et à chaque \c LoadConfigStore::changed(). */ void rebuildLoadAdapters(); /*! * \brief Déclencheur RÉEL du watchdog L2 (SAFETY.md §L2) — slot de \c m_meterWatchdog * (QTimer 30 s, horloge murale ; câblé sous \c \#ifndef ENERGY_SIMULATION). * * Délègue simplement à \c evaluateMeterFreshness(QDateTime::currentDateTime()) : la * LOGIQUE (seuil 90 s, bascule en dégradé) est dans cette méthode injectable, le QTimer * n'est que le battement. Indépendant des signaux compteur : reste actif précisément * quand le compteur est muet (le signal \c powerBalanceChanged ne fire plus, et * \c update() — piloté par le compteur — s'arrête aussi). Voir \c evaluateMeterFreshness(). */ void onMeterWatchdogTick(); /*! * \brief Applique les consignes de repli L2 (SAFETY.md §L2, Variante B). * * Repli CONSERVATEUR (n'initie aucune charge) : ECS → palier 0 \c force=true (bypass * anti-rebond) ; EV en charge → clamp courant minimum borne ; EV branché non chargeant * ou débranché → aucune action (planification en cours respectée ; "jamais 0 A si * branché" relève du failsafe L1). SG-Ready/Batterie : repli ajouté à l'arrivée de * leurs adaptateurs (3e/3f). Positionne \c m_degradedMode. * * \note Appelé une seule fois à la TRANSITION vers le mode dégradé. Ensuite \c update() * suspend la planification, donc les consignes tiennent sans ré-émission par tick. * \param now Instant courant (locks anti-rebond des bornes EV). */ void applyDegradedMode(const QDateTime &now); RuleBasedScheduler *m_scheduler = nullptr; QHash m_adapters; //!< loadId (ThingId string) → EvAdapter*. QHash m_sgReadyAdapters; //!< loadId → SgReadyAdapter* (PAC). QHash m_loadAdapters; //!< loadId → charge pilotée (relay-router | etmvariableload). ThingManager *m_tm = nullptr; //!< ThingManager (pour construire les adaptateurs config). LoadConfigStore *m_loadConfigStore = nullptr; //!< Store config charge pilotée (non adopté). // --- L2 watchdog fraîcheur compteur (SAFETY.md §L2) --- QTimer *m_meterWatchdog = nullptr; //!< Tick 30 s, indépendant des signaux compteur. QDateTime m_lastMeterUpdate; //!< Horodatage du dernier powerBalanceChanged. bool m_degradedMode = false; //!< Vrai si les consignes de repli L2 sont actives. };