// 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" // Inclus (et non déclaré en avant) : m_builtFrom stocke des LoadConfig PAR VALEUR — c'est ce // qui permet au rebuild incrémental (ECS-412) de comparer l'ancienne et la nouvelle config. #include "types/loadconfig.h" #include #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: /*! * \brief Construit l'arbitre central. Mêmes dépendances que \c SmartChargingManager amont, * dont il hérite (justification : AGENTS.md, « 3b-iii »). * \param energyManager Gestionnaire d'énergie nymea (compteur racine, notifications). * \param thingManager Registre des Things — résolution des appareils pilotés/mesurés. * \param spotMarketManager Fournisseur de tarifs dynamiques (proxy EV). * \param configuration Réglages persistés (limites de phase, tolérance, réserve). * \param parent Parent QObject. */ 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 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(). * * Les charges \c enabled==false ne sont PAS construites (exclues de l'arbitrage, * contrat §9). Depuis le lot B-bis, **toutes** les charges arbitrées passent par ici — y * compris la PAC SG-Ready, qui n'a plus d'exemplaire codé en dur. * \param store Store persistant (propriété de l'appelant ; non adopté). */ void setLoadConfigStore(LoadConfigStore *store); /*! * \brief Lève le verrou de défaut d'une charge pilotée (ECS-410). * \param loadId Identifiant logique de la charge. * \return Vrai si la charge existe ; faux si l'identifiant est inconnu. * \note Acte délibéré de l'opérateur, exposé par \c NymeaEnergy.ClearLoadFault. Le défaut * ne se lève jamais seul : c'est ce qui empêche \c available de clignoter. */ bool clearLoadFault(const QString &loadId); /*! * \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. * \param now Temps de cycle — jamais l'horloge, pour que la logique reste injectable. */ 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. * \param now Temps de cycle — jamais l'horloge, pour que la logique reste injectable. * \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); /*! * \brief Dernier instantané de télémétrie d'arbitrage — CE QUI A ÉTÉ PUBLIÉ, pas un recalcul. * * \return La charge utile de \c NymeaEnergy.GetLoadTelemetry : \c timestamp (fin du dernier * cycle d'arbitrage), \c degradedMode, \c budget, \c loads[]. Vide tant qu'aucun cycle * n'a eu lieu (démarrage) — voir la note ci-dessous. * * \note **Snapshot ≡ notification, par construction.** C'est le MÊME objet que celui émis * par \c loadTelemetryChanged() ; il n'est pas recalculé à la demande. Un recalcul * donnerait des verrous rafraîchis sous un \c timestamp périmé — un état qui n'a jamais * existé, et que le client ne pourrait rapprocher d'aucune notification. * \note **Vide avant le premier cycle.** Un instantané forgé au démarrage (budget nul, * aucune charge) se lirait comme « cycle exécuté, rien à allouer » — exactement le * mensonge que \c timestamp existe pour empêcher. La carte vide est la seule réponse * vraie ; le handler la rend telle quelle, sans \c timestamp (règle « omis, jamais nul »). */ QVariantMap loadTelemetry() const { return m_telemetry; } /*! * \brief Battement de cœur : republie l'instantané COURANT sans condition ni recalcul. * * C'est ce qui rend un arbitre FIGÉ détectable : les notifications continuent d'arriver * tandis que leur \c timestamp cesse d'avancer. Sans battement, « installation stable » et * « moteur arrêté » se ressemblent — un silence, donc un silence ambigu (règle 7-c). * * \note Logique injectable (symétrique de \c evaluateMeterFreshness) : en production * appelée par un \c QTimer d'horloge murale câblé sous \c \#ifndef ENERGY_SIMULATION ; * en simulation/test appelée directement. Le \c QTimer ne peut pas dépendre du cycle * d'arbitrage : c'est précisément l'arrêt de ce cycle qu'il sert à révéler. * \note Le battement ne double pas une notification fraîche : chaque publication RELANCE * le timer. La règle vit donc dans le timer, et cette méthode reste inconditionnelle — * testable sans attendre une période entière. */ void publishTelemetryHeartbeat(); /*! * \brief Vrai si deux configs décrivent le MÊME matériel (câblage, paliers, verrous). * * \param a Config ayant servi à construire l'adaptateur. \param b Nouvelle config. * \return Vrai si l'adaptateur peut être conservé — ECS-412. Rang, besoins et libellé * sont volontairement ignorés : ils se mettent à jour en place. * * \note **Publique à dessein.** Ce prédicat décide si une écriture coûte un geste * MATÉRIEL — contacts ouverts par l'état sûr, verrous réarmés à froid — ou une simple * mise à jour en place. C'est à ce titre un élément de contrat, que le client annonce * à l'installateur avant d'enregistrer, et il est éprouvé comme tel * (\c tests/auto/loadmodel). Statique et sans état : l'exposer n'ouvre rien. */ static bool sameHardware(const LoadConfig &a, const LoadConfig &b); /*! * \brief [3g-1] Cette borne participe-t-elle au waterfall de surplus ? * * \param ev Borne interrogée ; \c nullptr rend faux. * \return Vrai si la borne entre dans le waterfall de surplus ce cycle. * * **Deux** exclusions, et elles ne disent pas la même chose : le **mode manuel** * (\c ChargingModeNormal) est une INTENTION — l'utilisateur pilote, l'arbitre s'abstient ; * **aucun véhicule branché** (\c pluggedIn) est un CONSTAT — la borne ne peut rien tirer * de ce qu'on lui allouerait. * * Les exclure du contexte est plus juste que leur donner un rang : leur consommation est * déjà dans le compteur racine, donc dans le budget, sans qu'on ait à la leur allouer * (LM-1208 — sortir de la décision n'est pas sortir de la comptabilité). * * \note **`pluggedIn` a été ajouté après le banc du 2026-08-27** : les exclusions * d'origine venaient du filtre d'entrée du proxy, qui n'avait pas besoin de celle-ci — * son allocation n'était pas une cascade, donc gaspiller ne privait personne. En * cascade, une borne débranchée absorbe le budget des charges suivantes (4 853 W * mesurés, \c docs/RELEVE_3g2.md §3.1). * \note **« Pas de voiture assignée » a été RETIRÉ des exclusions le 2026-08-28** * (LM-1208-b) : le critère contredisait LM-1206 — le rang et le plancher appartiennent à * la borne, pas au véhicule — il n'était pas vérifiable, et il excluait la borne d'un * invité au moment précis où elle consomme. Ce qu'on perd sans voiture assignée est une * FONCTION (échéance, cible d'énergie, toutes deux restées au proxy), pas l'arbitrage. * \warning Corollaire à ne pas perdre : une borne sans voiture n'a **pas** de * \c ChargingProcessInfo — \c SmartChargingManager::prepareInformation() l'écarte * toujours. Tout code qui lit \c internalProcessInfo() pour une borne du waterfall doit * d'abord interroger \c internalHasProcessInfo() ; voir \c evTargetPhases(). */ bool evParticipatesInWaterfall(EvCharger *ev) const; /*! * \brief [3g-1] Nombre de phases visé pour une borne, à un courant donné. * * Passe-plat vers la règle de bascule 1↔3 du manager, PLUS le plafond du véhicule. * L'arbitre est la porte d'entrée de l'adaptateur vers cette règle : la réécrire dans * l'adaptateur en ferait deux versions, et elles divergeraient au premier ajustement. * * \note **[3g-2] Le plafond véhicule fait partie de la règle.** \c getBestPhaseCount() * ne le connaît pas : le proxy appliquait `qMin(carPhaseCount, …)` juste après l'appel. * 3g-1 n'a repris que l'appel, si bien qu'une voiture monophasée pouvait se voir viser * 3 phases — et le plancher publié triplait avec elle, sans qu'aucune allocation ne * puisse jamais la démarrer. Le plafond est donc rendu ici, au seul endroit qui parle * de phases pour le waterfall. * * \note **[LM-1208-b] Sans \c ChargingProcessInfo, la règle du manager n'existe pas** — * c'est le cas d'une borne sans voiture assignée, que \c prepareInformation() écarte * alors qu'elle est désormais arbitrée. La capacité du véhicule étant inconnue, on ne * bascule pas et on retient le nombre de phases de la BORNE. Laisser * \c internalProcessInfo() rendre son objet par défaut annoncerait « monophasée » une * borne triphasée, et diviserait le budget du cycle par trois — silencieusement. * * \param ev Borne concernée. * \param referenceA Courant de référence, en ampères TOTAUX toutes phases confondues — * voir \c evReferenceCurrentA(). Peut être négatif en soutirage. * \return Nombre de phases visé, dans [1, 3], jamais 0. */ uint evTargetPhases(EvCharger *ev, double referenceA); /*! * \brief [3g-2] Courant de référence (A) pour choisir le nombre de phases d'une borne. * * \param ev Borne. \return Somme du courant qu'elle tire DÉJÀ et du surplus disponible, * en ampères totaux (toutes phases confondues). Négatif possible : en soutirage, la * référence descend, et la règle de bascule vise le monophasé. * * \par Pourquoi la référence n'est pas le seul courant courant * 3g-1 passait \c currentPower / 230, au motif que l'allocation n'est pas encore connue. * Mais la bascule 1↔3 phases ne se décide pas sur ce que la borne tire : elle se décide * sur ce qu'elle POURRAIT tirer — c'est la formule du proxy (`currentPower/230 + * allowance`), et c'est la seule qui puisse répondre « redescends en monophasé, il n'y a * plus que 800 W ». Avec le seul courant courant, une borne en triphasé restait en * triphasé quel que soit l'effondrement du surplus, et son plancher publié restait à * 4,1 kW — donc plus rien ne pouvait la servir. * * \note Le terme de stockage de \c planSurplusCharging() (tampon 500 W, réinjection au- * dessus du seuil) n'entre PAS ici : il déplace la référence à la marge et n'a d'effet * que sur le choix 1↔3, jamais sur l'allocation, qui est faite par le waterfall avec un * budget qui, lui, tient compte du stockage. */ double evReferenceCurrentA(EvCharger *ev) const; /*! * \brief [3g-1] Plancher de modulation d'une borne, en watts, pour le nombre de phases VISÉ. * * \param ev Borne. \param phaseCount Nombre de phases visé — **pas** le nombre courant. * \return Puissance minimale sous laquelle la borne ne démarre pas, 0 si indéterminable. * * \warning Le nombre de phases change le plancher du simple au triple (6 A × 230 V = 1,4 kW * en monophasé, 4,1 kW en triphasé). Publier le plancher du nombre COURANT alors que * l'exécution basculera vers un autre produirait un défaut silencieux : le waterfall * allouerait 2 kW en croyant démarrer une borne qui en exige 4,1, et se croirait servi. * * \note Valeur **runtime**, pas un réglage : \c minimalChargingCurrent vaut * `max(borne, voiture)` et dépend donc du véhicule branché. */ double evFloorW(EvCharger *ev, uint phaseCount) const; /*! * \brief [3g-2] Puissance maximale (W) que la protection de surcharge laisse à cette borne. * * \param ev Borne. \param phaseCount Nombre de phases VISÉ. * \return Plafond en watts, ou **−1 si aucune limite** — \c phasePowerLimit à 0 signifie * « non réglé » (contrat amont), et la protection est alors désactivée. * * \par Pourquoi cette méthode existe * L'écrêtage par la limite de phase vivait dans \c planSurplusCharging() * (\c calculateAllowanceAmpere, `qMin(newRawValue, allowance)`). En transplantant la * décision de surplus dans le waterfall, 3g-1 a laissé cet écrêtage derrière lui — sans * conséquence tant que rien n'était exécuté, mais 3g-2 exécute. Sans ce plafond, le * waterfall pourrait allouer un surplus que le branchement ne supporte pas, et la seule * chose qui rattraperait serait \c verifyOverloadProtection() **après coup**. * * \note Le plafond est un **plafond de charge**, pas une marge : l'allowance du compteur * est la place LIBRE, à laquelle on rend ce que la borne tire déjà — sans quoi une borne * en charge se verrait interdire ce qu'elle consomme déjà. * \note Valeur de CYCLE : elle dépend de ce que le reste de la maison consomme à cet * instant. Elle est donc relue à chaque \c toLoadContext(), jamais mémorisée. */ double evPhaseAllowanceW(EvCharger *ev, uint phaseCount) const; /*! * \brief [3g-2] Rang configuré d'une borne dans le waterfall. * * \param chargerId ThingId de la borne, sous sa forme chaîne. * \return \c priority de son entrée \c LoadConfig \c evcharger. En l'absence d'entrée — * fenêtre entre la détection du Thing et l'auto-provisionnement, ou store absent — un * rang de queue plutôt qu'un rang de tête : une borne dont la configuration n'est pas * encore posée ne doit pas passer devant des charges déjà classées. * * \note Lu à CHAQUE \c descriptor() : c'est ce qui rend le réordonnancement effectif à * chaud sans reconstruire l'adaptateur, donc sans toucher aux verrous de la borne. */ int evPriority(const QString &chargerId) const; /*! \brief Crochet amont — cette borne a-t-elle été commandée par le waterfall ce cycle ? * \param evCharger Borne interrogée par \c adjustEvChargers(). * \return Vrai si elle figure dans \c Plan::waterfallEvIds du cycle courant. */ bool evCommandedByWaterfall(EvCharger *evCharger) const override; /*! \brief Crochet amont — le surplus de cette borne appartient-il au waterfall ? * \param evCharger Borne interrogée par \c planSurplusCharging(). * \return Vrai si elle passe le filtre d'entrée du waterfall (voiture assignée, mode non * manuel). Interrogé AVANT que le waterfall n'ait décidé, d'où sa réponse plus large que * celle de \c evCommandedByWaterfall(). */ bool evSurplusPlannedByWaterfall(EvCharger *evCharger) const override; signals: /*! * \brief Émise quand l'état runtime d'arbitrage a CHANGÉ, ou au battement de cœur. * \param telemetry Charge utile identique à celle de \c loadTelemetry(). * \note « Changé » exclut les grandeurs qui bougent en continu : les secondes restantes * d'un verrou et les paramètres numériques d'un motif ne déclenchent rien, et une * variation d'allocation inférieure au seuil non plus. Voir * \c telemetrySignificantlyDiffers(). */ void loadTelemetryChanged(const QVariantMap &telemetry); 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 Les charges non-EV, elles, viennent toutes de \c LoadConfigStore. */ void syncAdapters(); /*! * \brief Applique les actions d'un Slot aux LoadAdapters non-EV. * * Itère \c slot.actions et dispatche par \c loadId dans la table UNIQUE des charges * arbitrées ; le polymorphisme \c ILoadAdapter absorbe le mécanisme. Les actions EV * (\c Setpoint) restent dispatchées par \c adjustEvChargers() amont jusqu'à 3g — l'EV * n'est pas dans cette table. 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). * Crée, par config \c enabled==true, un \c SgReadyAdapter (si \c sgReady), un * \c RelayRouter (si \c relays[]) ou un \c EtmVariableLoadAdapter (sinon) — une branche * de fabrique par mécanisme, LM-301. La distinction de TYPE vit ici ; au-dessus tout est * \c ILoadAdapter. Reconstruction INCRÉMENTALE (ECS-412) ; ce qui sort de la config passe * par \c applySafeState() avant destruction (ECS-413). Appelé au branchement du store et * à chaque \c LoadConfigStore::changed(). */ void rebuildLoadAdapters(); /*! * \brief [3g-2] Crée l'entrée de configuration manquante d'une borne détectée. * * Une borne qui apparaît dans nymea n'a aucune entrée \c LoadConfig, donc aucun rang — * et jusqu'à 3g-2 elle était arbitrée et publiée en télémétrie sans figurer dans * \c GetLoadConfig. C'est l'invariant « \c loads[] ⊆ \c GetLoadConfig » du lot B-bis, * rompu par 3g-1 : l'app s'y appuie pour afficher une charge, et une charge publiée sans * configuration est exactement la charge fantôme que le lot B-bis avait supprimée. * * \par Ce que l'entrée contient, et ce qu'elle ne contient pas * Le rang, le libellé, le domaine \c ev, \c enabled. Aucune charge utile : les limites de * la borne viennent du Thing (voir \c LoadConfig::isEvCharger()). * * \par Rang par defaut — EN QUEUE (LM-1209) * `1 + max(rangs existants)`, donc libre par construction et sans bousculer personne. Ce * n'est pas une politique du plugin : le rang est un choix de l'installateur, et une * détection ne réordonne pas ce qu'un humain a décidé. Une borne détectée un mois après la * mise en service passerait sinon devant l'ECS que quelqu'un a réglé exprès. * * \par Appelée à CHAQUE cycle, pas seulement à la naissance de l'adaptateur * \rule{LM-1209-e}. Rend la main aussitôt si l'entrée existe : la condition est « cette * borne n'a pas d'entrée », jamais « l'adaptateur vient de naître ». C'est ce qui **rattrape * une suppression depuis l'app** — l'adaptateur, construit depuis le Thing, survit à la * disparition de l'entrée, et sans ce rattrapage la borne restait arbitrée mais absente de * \c GetLoadConfig, à un rang non réglable. * * \note **Une entrée recréée revient en \c rankOrigin "auto"**, et c'est voulu : personne * ne l'a classée, son rang est de nouveau un défaut. L'installateur qui vient de la * supprimer la voit donc réapparaître **en queue, avec son badge** — ce qui se dit, au * journal et dans la charge utile, plutôt que de se subir. * \note **Journalisée, une ligne par borne à la création.** Une entrée créée d'office est * une écriture que personne n'a demandée : sans trace, un installateur verrait apparaître * des charges dans son écran sans savoir d'où elles viennent. Un REFUS, lui, n'est * annoncé qu'une fois par borne (\c m_provisionRefuse) : le rattrapage étant cyclique, * le répéter produirait un avertissement par minute et noierait le vrai signal. * \note L'entrée n'est JAMAIS supprimée d'office quand le Thing disparaît — c'est une * configuration, et l'installateur a pu la classer. Elle se lit alors comme une charge * dont l'appareil manque (\c LOAD_UNAVAILABLE, \c faultCode \c THING_MISSING). * \param ev Borne détectée, ou borne connue dont l'entrée manque. */ void provisionEvChargerConfig(EvCharger *ev); /*! \brief [3g-2] Vrai si \p loadId désigne une borne DÉCLARÉE en configuration et active. * \param loadId Identifiant de charge. * \return Vrai même si le Thing correspondant a disparu — c'est précisément le cas qu'il * sert à reconnaître, pour publier la charge comme « appareil manquant » plutôt que de * l'omettre. */ bool estUneBorneConfiguree(const QString &loadId) const; /*! * \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); /*! * \brief Construit l'instantané de télémétrie du cycle qui vient de s'exécuter. * * \param plan Plan retourné par le scheduler — source de \c budget (jamais recalculé ici). * \param slot Créneau courant : ses actions donnent l'ordre du waterfall ET les motifs. * \param now Temps de cycle, publié tel quel comme \c timestamp. * \note Les charges EV sont EXCLUES : décidées par l'amont en amont du waterfall (décision * B), leur puissance est réservée dans \c budget.evReservedW, pas allouée depuis le * budget publié. Les inclure ferait une somme \c loads[] qui ne retomberait pas sur * \c budget.allocatedW. */ void buildTelemetry(const Plan &plan, const Slot &slot, const QDateTime &now); /*! * \brief Construit l'instantané du mode dégradé L2 et le publie sans condition. * \param now Instant de la bascule — c'est bien une décision, elle se date. * \note Sans cet appel, un client qui se connecte APRÈS la bascule ne lirait jamais * \c degradedMode : \c update() rend la main avant toute publication tant que le repli * tient. C'est la reprise à froid, et c'est la famille ECS-411. */ void buildDegradedTelemetry(const QDateTime &now); /*! * \brief Ajoute à une entrée de télémétrie les MESURES de la charge, si elle en déclare. * * §11 (LM-1101, LM-1103). Deux champs optionnels de \c LoadConfig — \c meterThingId et * \c sensorThingId — sont résolus ici, au moment de PUBLIER, et nulle part ailleurs. * * \note **Les adaptateurs ne voient jamais ces Things.** C'est ce qui rend « aucun effet * sur les décisions » vrai par construction plutôt que par discipline : la mesure ne * traverse aucun chemin d'arbitrage, elle est lue au dernier moment et publiée. * \note Omis, jamais nul : un Thing absent de la configuration nymea, ou un état que le * Thing ne porte pas, ne produit **aucune clé** — pas un zéro, qui se lirait « mesuré 0 W » * là où la vérité est « pas de mesure ». * * \param entry Entrée de charge en cours de construction, modifiée en place. * \param loadId Identifiant de la charge, clé dans le magasin de configuration. */ void attachMeasurements(QVariantMap &entry, const QString &loadId) const; /*! * \brief Publie \c m_telemetry si elle diffère significativement de la dernière publiée. * \param force Vrai pour publier sans condition (bascule de mode dégradé, battement). */ void publishTelemetry(bool force); /*! * \brief Vrai si deux instantanés diffèrent assez pour mériter une notification. * \param a Dernier instantané publié. \param b Instantané courant. * \return Vrai sur tout changement d'état QUALITATIF (mode dégradé, jeu de charges, * disponibilité, code de défaut, nature du verrou, mécanisme, CODE de motif) ou sur une * variation d'allocation dépassant le seuil. * \note Sont volontairement IGNORÉS : les secondes restantes d'un verrou (elles décroissent * à chaque seconde), et les paramètres numériques d'un motif (le budget bouge à chaque * cycle). Les comparer ferait notifier en permanence et le seuil ne servirait à rien. */ static bool telemetrySignificantlyDiffers(const QVariantMap &a, const QVariantMap &b); RuleBasedScheduler *m_scheduler = nullptr; QHash m_adapters; //!< loadId (ThingId string) → EvAdapter*. /*! * \brief [3g-2] Bornes commandées par le waterfall AU CYCLE COURANT. * * Recopié de \c Plan::waterfallEvIds à chaque cycle, et lu par le crochet * \c evCommandedByWaterfall() que \c adjustEvChargers() amont interroge. C'est la * mémoire qui empêche le proxy de re-commander ce que l'arbitre vient de commander. * * \note **Vidé à l'entrée du mode dégradé L2**, où la planification est suspendue : sans * cela, le jeu du dernier cycle sain continuerait d'exclure ces bornes du proxy alors * que plus personne ne les commande — elles resteraient figées sur leur dernière * consigne, compteur muet, ce que SAFETY.md interdit. */ QSet m_waterfallEvIds; //! Bornes dont la configuration automatique a été REFUSÉE — pour n'avertir qu'une fois. //! Depuis LM-1209-e le rattrapage est tenté à chaque cycle ; répéter l'avertissement en //! produirait un par minute et noierait le vrai signal. Vidée au premier succès. QSet m_provisionRefuse; //! loadId → charge ARBITRÉE, tous mécanismes confondus (relay-router | etmvariableload | //! sg-ready). Table UNIQUE, et c'est un invariant de contrat : toutes ses entrées viennent //! de \c LoadConfigStore, donc l'ensemble publié par \c NymeaEnergy.GetLoadTelemetry est //! exactement celui de \c GetLoadConfig. Une seconde table existait pour la PAC de banc //! codée en dur ; elle a disparu avec elle (lot B-bis), et avec elle la possibilité qu'une //! charge soit arbitrée sans être configurable. QHash m_loadAdapters; //! loadId → config AYANT SERVI à construire l'adaptateur. Permet le rebuild incrémental //! (ECS-412) : on ne reconstruit que si le matériel a changé. QHash m_builtFrom; 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. // --- Frontière de télémétrie (NymeaEnergy.GetLoadTelemetry / LoadTelemetryChanged) --- QVariantMap m_telemetry; //!< Instantané courant ; vide avant le premier cycle. QVariantMap m_publishedTelemetry; //!< Dernier instantané ÉMIS — base de comparaison du seuil. QDateTime m_lastTelemetryPublish;//!< Horloge murale du dernier envoi (cadence du battement). QTimer *m_telemetryHeartbeat = nullptr; //!< Battement 60 s, indépendant du cycle d'arbitrage. };