writeRelay() jetait le ThingActionInfo* : aucun acquittement, aucun retour arrière, available codé en dur à true, et m_currentStage mis à jour comme si tout avait réussi — on annonçait une puissance non appliquée. MODÈLE ASYNCHRONE. executeAction est asynchrone et update() ne doit jamais attendre (AGENTS règle 5). « Attendre le résultat » ne veut donc pas dire bloquer le cycle : l'écriture est émise, l'adaptateur retient combien d'acquittements il attend (m_pending), le verdict tombe quand le compteur retombe à zéro, et la conséquence est traitée au cycle suivant. Motif repris tel quel d'EvCharger (evcharger.cpp:293-299), y compris `this` en contexte de connexion — si l'adaptateur meurt, les callbacks sont coupés proprement. INDÉTERMINATION. Pendant une transition, telemetry() annonce max(m_stagePrev, m_stageTarget) : on ne sait pas ce qui est fermé, on annonce donc la plus haute des deux puissances possibles. Même direction qu'ECS-411 (relais injoignable supposé fermé) — ne jamais annoncer moins que ce qui peut être appliqué. Sous-estimer fait sur-allouer les charges suivantes ; surestimer ne fait que retarder une montée. ÉCHELLE BORNÉE à trois barreaux, une tentative chacun, aucune boucle : cible → retour arrière → arrêt total → défaut. Le retour arrière est asynchrone au même titre et passe par le même compteur. DÉFAUT COLLANT, pas clignotant. m_faulted est un verrou posé une seule fois, sans délai ni expiration : available ne peut pas osciller d'un cycle à l'autre. Seul NymeaEnergy.ClearLoadFault le lève — acte délibéré et journalisé de l'opérateur. La reconstruction le lève aussi, mais par construction : un adaptateur neuf n'a pas d'historique. À la levée, l'état matériel est RELU (ECS-411), pas supposé. CANAL OUVERT. LoadContext n'avait AUCUN champ available : le publier aurait été décoratif. Ajouté à LoadContextTelemetry, avec sa sémantique écrite noir sur blanc — il gouverne l'allocation, PAS la comptabilité. Une charge en défaut ne reçoit rien mais reste comptée : une puissance qu'on ne sait plus couper est de la conso fixe, au même titre que la base de la maison. Le figeage est porté par lockMin == lockMax == puissance crue engagée, jamais un plafond nul sous un plancher non nul. Le même canal servira ECS-601. OPTIMIZER_PROTOCOL.md mis à jour dans le MÊME lot, comme l'exige le §11 de la spec : available, lockMinPowerW et lockMaxPowerW documentés avec leur sémantique. clearFault() est PURE VIRTUELLE sur ILoadAdapter : les trois autres adaptateurs la déclarent sans effet plutôt que d'hériter d'un défaut vide. Leur généralisation est portée par ECS-414. Test testEcsPartialFailure : cas nominal sans défaut, puis échelle complète via un relais introuvable — défaut atteint, relais valide bien ramené à l'ouverture par la tentative d'arrêt total, available faux, plancher == plafond == puissance comptée, plus aucune commande une heure plus tard, puis levée délibérée. Build amd64 0 erreur. Simulation : 16/16. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
449 lines
19 KiB
C++
449 lines
19 KiB
C++
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
|
|
|
#include "energyarbitrator.h"
|
|
#include "adapters/evadapter.h"
|
|
#include "adapters/sgreadyadapter.h"
|
|
#include "adapters/etmvariableloadadapter.h"
|
|
#include "adapters/relayrouter.h"
|
|
#include "config/loadconfigstore.h"
|
|
#include "types/loadconfig.h"
|
|
#include "scheduler/rulebasedscheduler.h"
|
|
#include "types/surpluscontext.h"
|
|
#include "types/plan.h"
|
|
#include "../rootmeter.h"
|
|
#include "../evcharger.h"
|
|
|
|
#include "plugininfo.h"
|
|
|
|
#include <energymanager.h>
|
|
#include <QTimer>
|
|
|
|
namespace {
|
|
//! Période du watchdog L2 (SAFETY.md §L2) : tick indépendant des signaux compteur.
|
|
constexpr int MeterWatchdogPeriodMs = 30 * 1000; // 30 s
|
|
//! Seuil de silence compteur au-delà duquel le mode dégradé L2 est déclenché.
|
|
constexpr int MeterSilenceThresholdS = 90; // 90 s
|
|
}
|
|
|
|
EnergyArbitrator::EnergyArbitrator(EnergyManager *em, ThingManager *tm,
|
|
SpotMarketManager *sm,
|
|
EnergyManagerConfiguration *conf,
|
|
QObject *parent)
|
|
: SmartChargingManager(em, tm, sm, conf, parent)
|
|
, m_scheduler(new RuleBasedScheduler(this, this))
|
|
, m_tm(tm)
|
|
{
|
|
// --- L2 : watchdog fraîcheur compteur (SAFETY.md §L2) ---
|
|
// La LOGIQUE (recordMeterUpdate / evaluateMeterFreshness) prend le temps en paramètre
|
|
// et reste testable par injection (symétrique de simulationCallUpdate). Seuls les
|
|
// DÉCLENCHEURS RÉELS (signal + QTimer, horloge murale) sont câblés ici, et exclus en
|
|
// simulation — comme les connexions amont powerBalanceEntryAdded→update() (SCM l.108-130).
|
|
#ifndef ENERGY_SIMULATION
|
|
m_lastMeterUpdate = QDateTime::currentDateTime(); // grâce au démarrage (évite un dégradé immédiat)
|
|
// Fraîcheur picotée sur powerBalanceChanged (en plus de la connexion amont L4).
|
|
connect(em, &EnergyManager::powerBalanceChanged, this, [this]() {
|
|
recordMeterUpdate(QDateTime::currentDateTime());
|
|
});
|
|
// QTimer (et non signal) : doit rester actif quand le compteur est muet.
|
|
m_meterWatchdog = new QTimer(this);
|
|
m_meterWatchdog->setInterval(MeterWatchdogPeriodMs);
|
|
connect(m_meterWatchdog, &QTimer::timeout, this, &EnergyArbitrator::onMeterWatchdogTick);
|
|
m_meterWatchdog->start();
|
|
#else
|
|
Q_UNUSED(em)
|
|
#endif
|
|
|
|
qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] Arbitre ETM initialisé.";
|
|
}
|
|
|
|
void EnergyArbitrator::runSurplusPlanning(const QDateTime &now)
|
|
{
|
|
planSurplusCharging(now);
|
|
}
|
|
|
|
void EnergyArbitrator::runSpotMarketPlanning(const QDateTime &now)
|
|
{
|
|
planSpotMarketCharging(now);
|
|
}
|
|
|
|
const QHash<EvCharger *, ChargingActions> &EnergyArbitrator::scheduledActions() const
|
|
{
|
|
return internalChargingActions();
|
|
}
|
|
|
|
void EnergyArbitrator::doExecuteChargingAction(EvCharger *charger,
|
|
const ChargingAction &action,
|
|
const QDateTime &now)
|
|
{
|
|
executeChargingAction(charger, action, now);
|
|
}
|
|
|
|
const QHash<ThingId, EvCharger *> &EnergyArbitrator::registeredEvChargers() const
|
|
{
|
|
return internalEvChargers();
|
|
}
|
|
|
|
RootMeter *EnergyArbitrator::registeredRootMeter() const
|
|
{
|
|
return internalRootMeter();
|
|
}
|
|
|
|
void EnergyArbitrator::registerSgReadyAdapter(SgReadyAdapter *adapter)
|
|
{
|
|
const QString id = adapter->descriptor().id;
|
|
if (m_sgReadyAdapters.contains(id)) {
|
|
qCWarning(dcNymeaEnergy()) << "[EnergyArbitrator] SgReadyAdapter déjà enregistré:" << id;
|
|
return;
|
|
}
|
|
adapter->setParent(this);
|
|
m_sgReadyAdapters[id] = adapter;
|
|
qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] SgReadyAdapter enregistré:" << adapter->descriptor().label;
|
|
}
|
|
|
|
void EnergyArbitrator::registerEtmVariableLoadAdapter(EtmVariableLoadAdapter *adapter)
|
|
{
|
|
const QString id = adapter->descriptor().id;
|
|
if (m_loadAdapters.contains(id)) {
|
|
qCWarning(dcNymeaEnergy()) << "[EnergyArbitrator] charge pilotée déjà enregistrée:" << id;
|
|
return;
|
|
}
|
|
adapter->setParent(this);
|
|
m_loadAdapters[id] = adapter; // upcast EtmVariableLoadAdapter* → ILoadAdapter*
|
|
qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] etmvariableload enregistré:" << adapter->descriptor().label;
|
|
}
|
|
|
|
void EnergyArbitrator::registerRelayRouter(RelayRouter *adapter)
|
|
{
|
|
const QString id = adapter->descriptor().id;
|
|
if (m_loadAdapters.contains(id)) {
|
|
qCWarning(dcNymeaEnergy()) << "[EnergyArbitrator] charge pilotée déjà enregistrée:" << id;
|
|
return;
|
|
}
|
|
adapter->setParent(this);
|
|
m_loadAdapters[id] = adapter; // upcast RelayRouter* → ILoadAdapter*
|
|
qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] relay-router enregistré:" << adapter->descriptor().label;
|
|
}
|
|
|
|
void EnergyArbitrator::setLoadConfigStore(LoadConfigStore *store)
|
|
{
|
|
m_loadConfigStore = store;
|
|
if (!store)
|
|
return;
|
|
connect(store, &LoadConfigStore::changed, this, &EnergyArbitrator::rebuildLoadAdapters);
|
|
rebuildLoadAdapters(); // construction initiale depuis la config persistée
|
|
}
|
|
|
|
bool EnergyArbitrator::sameHardware(const LoadConfig &a, const LoadConfig &b)
|
|
{
|
|
// Champs dont un changement impose une VRAIE reconstruction : ils déterminent la table
|
|
// de paliers, le câblage ou les verrous. Tout le reste (rang, besoins, libellé) se met
|
|
// à jour en place.
|
|
if (a.adapter() != b.adapter() || a.mode() != b.mode())
|
|
return false;
|
|
if (a.minOnS() != b.minOnS() || a.minOffS() != b.minOffS())
|
|
return false;
|
|
if (a.maxPowerW() != b.maxPowerW() || a.powerLevelsInt() != b.powerLevelsInt())
|
|
return false;
|
|
|
|
const QList<LoadConfigRelay> ra = a.relaysList();
|
|
const QList<LoadConfigRelay> rb = b.relaysList();
|
|
if (ra.size() != rb.size())
|
|
return false;
|
|
for (int i = 0; i < ra.size(); ++i)
|
|
if (ra.at(i).thingId != rb.at(i).thingId || ra.at(i).powerW != rb.at(i).powerW)
|
|
return false;
|
|
return true;
|
|
}
|
|
|
|
bool EnergyArbitrator::clearLoadFault(const QString &loadId)
|
|
{
|
|
ILoadAdapter *adapter = m_loadAdapters.value(loadId, nullptr);
|
|
if (!adapter) {
|
|
qCWarning(dcNymeaEnergy()) << "[Arbitre] ClearLoadFault : charge inconnue" << loadId;
|
|
return false;
|
|
}
|
|
qCInfo(dcNymeaEnergy()) << "[Arbitre] ClearLoadFault demandé par l'opérateur pour" << loadId;
|
|
adapter->clearFault();
|
|
return true;
|
|
}
|
|
|
|
void EnergyArbitrator::rebuildLoadAdapters()
|
|
{
|
|
if (!m_loadConfigStore) {
|
|
for (ILoadAdapter *a : m_loadAdapters)
|
|
if (QObject *o = dynamic_cast<QObject *>(a))
|
|
o->deleteLater();
|
|
m_loadAdapters.clear();
|
|
m_builtFrom.clear();
|
|
return;
|
|
}
|
|
|
|
// ECS-412 — reconstruction INCRÉMENTALE. Détruire un adaptateur réarme ses verrous : sur
|
|
// un ballon thermodynamique à minOn de 300-600 s, un client qui réordonne ses priorités
|
|
// depuis l'app pourrait faire court-cycler son compresseur. On ne reconstruit donc que
|
|
// ce dont le MATÉRIEL a changé ; le reste est mis à jour en place.
|
|
QHash<QString, ILoadAdapter *> kept;
|
|
QHash<QString, LoadConfig> keptFrom;
|
|
int created = 0, updated = 0, reused = 0;
|
|
|
|
for (const LoadConfig &c : m_loadConfigStore->configs()) {
|
|
// enabled==false : rôle déclaré mais EXCLU de l'arbitrage (contrat §9) — pas d'adaptateur.
|
|
if (!c.enabled())
|
|
continue;
|
|
|
|
LoadNeeds needs;
|
|
needs.dailyDeadline = c.needs().dailyDeadline();
|
|
needs.minEnergyWhPerDay = c.needs().minEnergyWhPerDay();
|
|
|
|
ILoadAdapter *existing = m_loadAdapters.value(c.id(), nullptr);
|
|
if (existing && m_builtFrom.contains(c.id()) && sameHardware(m_builtFrom.value(c.id()), c)) {
|
|
// Matériel inchangé : on GARDE l'adaptateur — donc m_lastSwitch et le palier
|
|
// courant — et on ne met à jour que ce qui ne touche pas au matériel.
|
|
const LoadConfig &old = m_builtFrom[c.id()];
|
|
const bool soft = (old.priority() != c.priority())
|
|
|| (old.needs().dailyDeadline() != c.needs().dailyDeadline())
|
|
|| (old.needs().minEnergyWhPerDay() != c.needs().minEnergyWhPerDay());
|
|
if (soft) {
|
|
existing->updateSoftConfig(c.priority(), needs);
|
|
++updated;
|
|
} else {
|
|
++reused;
|
|
}
|
|
kept[c.id()] = existing;
|
|
keptFrom[c.id()] = c;
|
|
m_loadAdapters.remove(c.id()); // sorti de la table : ne sera pas détruit plus bas
|
|
continue;
|
|
}
|
|
|
|
// La distinction de TYPE vit ICI (rév. 3) ; au-dessus, tout est ILoadAdapter (Setpoint W).
|
|
ILoadAdapter *adapter = nullptr;
|
|
if (c.isRelayRouter()) {
|
|
adapter = new RelayRouter(m_tm, c.id(), c.label(), c.relaysList(),
|
|
c.minOnS(), c.minOffS(), c.priority(), needs, this);
|
|
qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] relay-router construit depuis config:"
|
|
<< c.label() << "(" << c.relaysList().count() << "relais)";
|
|
} else {
|
|
adapter = new EtmVariableLoadAdapter(m_tm, c.id(), c.label(), c.powerLevelsInt(),
|
|
c.maxPowerW(), c.priority(), needs, this);
|
|
qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] etmvariableload construit depuis config:"
|
|
<< c.label() << "(" << (c.isDynamic() ? "dynamic" : "fixed") << ")";
|
|
}
|
|
kept[c.id()] = adapter;
|
|
keptFrom[c.id()] = c;
|
|
++created;
|
|
}
|
|
|
|
// Ce qui reste dans m_loadAdapters n'est plus référencé par la config : à détruire.
|
|
for (ILoadAdapter *a : m_loadAdapters)
|
|
if (QObject *o = dynamic_cast<QObject *>(a))
|
|
o->deleteLater();
|
|
const int removed = m_loadAdapters.count();
|
|
|
|
m_loadAdapters = kept;
|
|
m_builtFrom = keptFrom;
|
|
|
|
qCInfo(dcNymeaEnergy()) << "[EnergyArbitrator]" << m_loadAdapters.count()
|
|
<< "charge(s) pilotée(s) active(s) (config) —" << created << "créée(s),"
|
|
<< updated << "mise(s) à jour en place," << reused << "inchangée(s),"
|
|
<< removed << "retirée(s).";
|
|
}
|
|
|
|
void EnergyArbitrator::update(const QDateTime ¤tDateTime)
|
|
{
|
|
qCDebug(dcNymeaEnergy()) << "Updating smart charging";
|
|
// Ordre IDENTIQUE à SmartChargingManager::update() — INTERDIT de réordonner.
|
|
// SCM : 1.updateManual 2.prepareInfo 3.verifyOverload 4.verifyRecovery
|
|
// 5.planSpot 6.planSurplus 7.adjustEv
|
|
// ETM : idem 1-4 ; insertions ETM entre 4 et 7 ;
|
|
// planSpot + planSurplus appelés via m_scheduler->getPlan() (position 5-6).
|
|
|
|
// 1-4 : préparation + sécurité (même ordre que l'amont)
|
|
updateManualSoCsWithoutMeter(currentDateTime);
|
|
prepareInformation(currentDateTime);
|
|
verifyOverloadProtection(currentDateTime);
|
|
verifyOverloadProtectionRecovery(currentDateTime);
|
|
|
|
// Mode dégradé L2 : la sécurité (L4 ci-dessus) reste active, mais on SUSPEND la
|
|
// planification et le dispatch. Replanifier sur le cache d'un compteur mort
|
|
// rallumerait les charges que le watchdog vient de couper → oscillation. Les
|
|
// consignes de repli (posées à la transition) tiennent jusqu'au retour du compteur.
|
|
if (m_degradedMode) {
|
|
qCDebug(dcNymeaEnergy()) << "[Arbitre] Mode dégradé L2 actif — planification suspendue.";
|
|
return;
|
|
}
|
|
|
|
// ETM-only : sync adapters + proxy planification → log [Arbitre]
|
|
// getPlan() appelle planSpotMarketCharging() + planSurplusCharging() (position 5-6 amont).
|
|
syncAdapters();
|
|
SurplusContext ctx = buildContext(currentDateTime);
|
|
Plan plan = m_scheduler->getPlan(ctx);
|
|
Slot slot = plan.slotCovering(currentDateTime);
|
|
|
|
for (const LoadAction &action : slot.actions) {
|
|
qCInfo(dcNymeaEnergy()) << "[Arbitre]"
|
|
<< action.loadId << "→" << action.reason
|
|
<< "| activé:" << action.chargingEnabled
|
|
<< "| courant:" << action.currentA << "A"
|
|
<< "| phases:" << action.phaseCount
|
|
<< "| stratégie:" << plan.strategy;
|
|
}
|
|
|
|
// 7 : dispatch matériel (même position que l'amont — m_chargingActions rempli par getPlan())
|
|
applyActionsToAdapters(slot, currentDateTime); // PAC (kind==State) → m_sgReadyAdapters
|
|
adjustEvChargers(currentDateTime); // EV (kind==Setpoint) → proxy amont jusqu'à 3g
|
|
}
|
|
|
|
SurplusContext EnergyArbitrator::buildContext(const QDateTime &now) const
|
|
{
|
|
SurplusContext ctx;
|
|
ctx.timestamp = now;
|
|
|
|
// --- Compteur principal (AGENTS invariant 8 : mesure brute, aucune déduction) ---
|
|
RootMeter *meter = internalRootMeter();
|
|
if (meter) {
|
|
// currentPower() < 0 → export ; > 0 → import (convention amont SCM l.1141)
|
|
const double p = meter->currentPower();
|
|
ctx.meter.importW = qMax(0.0, p);
|
|
ctx.meter.exportW = qMax(0.0, -p);
|
|
ctx.meter.perPhaseA = {
|
|
meter->currentPhaseA(),
|
|
meter->currentPhaseB(),
|
|
meter->currentPhaseC()
|
|
};
|
|
}
|
|
// SurplusPv : interface inverter — déféré (remplissage prévu en 3d)
|
|
// SurplusBattery : déféré 3f
|
|
|
|
// --- loads[] : EV adapters --- (now = ctx.timestamp : source unique des verrous)
|
|
for (auto it = m_adapters.constBegin(); it != m_adapters.constEnd(); ++it)
|
|
ctx.loads.append(it.value()->toLoadContext(now));
|
|
|
|
// --- loads[] : charges pilotées en watts (relay-router + etmvariableload), polymorphes ---
|
|
for (auto it = m_loadAdapters.constBegin(); it != m_loadAdapters.constEnd(); ++it)
|
|
ctx.loads.append(it.value()->toLoadContext(now));
|
|
|
|
// --- loads[] : SG-Ready adapters (PAC) ---
|
|
for (auto it = m_sgReadyAdapters.constBegin(); it != m_sgReadyAdapters.constEnd(); ++it)
|
|
ctx.loads.append(it.value()->toLoadContext(now));
|
|
|
|
return ctx;
|
|
}
|
|
|
|
void EnergyArbitrator::syncAdapters()
|
|
{
|
|
// Crée les adapters manquants
|
|
for (auto it = internalEvChargers().constBegin(); it != internalEvChargers().constEnd(); ++it) {
|
|
const QString id = it.key().toString();
|
|
if (!m_adapters.contains(id))
|
|
m_adapters[id] = new EvAdapter(it.value(), this);
|
|
}
|
|
// Supprime les adapters obsolètes
|
|
for (const QString &id : m_adapters.keys()) {
|
|
if (!internalEvChargers().contains(ThingId(id)))
|
|
m_adapters.take(id)->deleteLater();
|
|
}
|
|
}
|
|
|
|
void EnergyArbitrator::applyActionsToAdapters(const Slot &slot, const QDateTime &now)
|
|
{
|
|
for (const LoadAction &action : slot.actions) {
|
|
// L'adaptateur applique, écrête et verrouille — il ne décide pas (règle 2).
|
|
if (action.kind == LoadAction::State) {
|
|
SgReadyAdapter *adapter = m_sgReadyAdapters.value(action.loadId);
|
|
if (adapter)
|
|
adapter->applyAction(action, now);
|
|
else
|
|
qCWarning(dcNymeaEnergy()) << "[Arbitre] action State sans adaptateur SG-Ready:" << action.loadId;
|
|
|
|
} else if (action.kind == LoadAction::Setpoint) {
|
|
// Dispatch AGNOSTIQUE au type : routage par loadId, le polymorphisme ILoadAdapter
|
|
// absorbe (relay-router OU etmvariableload). Un Setpoint EV n'est PAS dans cette table
|
|
// → ignoré ici (dispatché par adjustEvChargers() amont).
|
|
ILoadAdapter *adapter = m_loadAdapters.value(action.loadId);
|
|
if (adapter)
|
|
adapter->applyAction(action, now);
|
|
}
|
|
}
|
|
}
|
|
|
|
void EnergyArbitrator::onMeterWatchdogTick()
|
|
{
|
|
// Déclencheur réel (QTimer, horloge murale) → délègue à la logique injectable.
|
|
evaluateMeterFreshness(QDateTime::currentDateTime());
|
|
}
|
|
|
|
void EnergyArbitrator::recordMeterUpdate(const QDateTime &now)
|
|
{
|
|
m_lastMeterUpdate = now;
|
|
if (m_degradedMode) {
|
|
qCInfo(dcNymeaEnergy()) << "[Arbitre] Compteur de nouveau actif — sortie du mode dégradé L2.";
|
|
m_degradedMode = false;
|
|
emit chargingSchedulesChanged(); // pousse degradedMode=false (planif reprend au cycle suivant)
|
|
}
|
|
}
|
|
|
|
void EnergyArbitrator::evaluateMeterFreshness(const QDateTime &now)
|
|
{
|
|
if (!m_lastMeterUpdate.isValid())
|
|
return; // Aucune mesure reçue (démarrage) — pas de dégradé (invariant root meter absent).
|
|
|
|
const qint64 silentS = m_lastMeterUpdate.secsTo(now);
|
|
if (silentS <= MeterSilenceThresholdS)
|
|
return;
|
|
|
|
if (m_degradedMode)
|
|
return; // Déjà en repli — les consignes tiennent, pas de ré-émission (anti-oscillation).
|
|
|
|
qCWarning(dcNymeaEnergy()) << "[Arbitre] Compteur muet depuis" << silentS
|
|
<< "s (>" << MeterSilenceThresholdS << "s) — mode dégradé L2.";
|
|
applyDegradedMode(now);
|
|
}
|
|
|
|
void EnergyArbitrator::applyDegradedMode(const QDateTime &now)
|
|
{
|
|
m_degradedMode = true;
|
|
emit chargingSchedulesChanged(); // pousse degradedMode=true (notification client L2)
|
|
const QString reason =
|
|
QStringLiteral("Compteur muet depuis >90 s — consigne de repli (L2 watchdog)");
|
|
|
|
// Charges pilotées en watts (relay-router ET etmvariableload) : repli Setpoint(0) force=true
|
|
// (contrat rév. 3 §9). Boucle sur TOUS les ILoadAdapter — ne PAS oublier un type, sinon des
|
|
// relais ECS resteraient allumés en compteur muet (trou T2 recréé). force=true → pour le
|
|
// relay-router, coupe TOUS les relais (bypass minOn/minOff) ; pour etmvariableload, setpoint 0.
|
|
for (ILoadAdapter *adapter : m_loadAdapters) {
|
|
LoadAction la;
|
|
la.loadId = adapter->descriptor().id;
|
|
la.kind = LoadAction::Setpoint;
|
|
la.powerW = 0;
|
|
la.force = true;
|
|
la.reason = reason;
|
|
adapter->applyAction(la, now);
|
|
}
|
|
|
|
// EV : repli CONSERVATEUR — n'initie aucune charge. On clampe seulement une charge
|
|
// DÉJÀ en cours au courant minimum (force=true, bypass lock). Une borne branchée mais
|
|
// non chargeante reste off (off volontaire possible : HC/spot à venir) ; débranchée →
|
|
// aucune action. La garantie "jamais 0 A si branché" relève du failsafe L1 de la borne.
|
|
for (auto it = internalEvChargers().constBegin(); it != internalEvChargers().constEnd(); ++it) {
|
|
EvCharger *ev = it.value();
|
|
if (ev->available() && ev->charging())
|
|
ev->setMaxChargingCurrent(ev->maxChargingCurrentMinValue(), now, true);
|
|
}
|
|
|
|
// SG-Ready (PAC) : repli en état 2 (NORMAL — mains off), JAMAIS état 1 (blocage).
|
|
// Sous compteur muet on cesse de piloter : la PAC chauffe selon son propre thermostat
|
|
// (la bloquer = maison qui ne chauffe plus sans raison visible). force=true → bypass minStateHold.
|
|
for (SgReadyAdapter *adapter : m_sgReadyAdapters) {
|
|
LoadAction la;
|
|
la.loadId = adapter->descriptor().id;
|
|
la.kind = LoadAction::State;
|
|
la.state = 2;
|
|
la.force = true;
|
|
la.reason = reason;
|
|
adapter->applyAction(la, now);
|
|
}
|
|
|
|
// Batterie (aucune charge réseau) : repli ajouté avec son adaptateur (3f).
|
|
}
|