[wip] 3c morceaux 0-2 compilés + plan 3c validé dans AGENTS.md

Morceaux 0-2 implémentés et compilés (0 erreur / 0 warning) :
- M0 : LoadAction.force=false (bypass verrous anti-rebond sécurité)
- M1 : EcsRelayAdapter (.h+.cpp) — N paliers powerswitch, anti-rebond, etm.pri
- M2 : buildContext() — SurplusMeter brut, loads EV+ECS, registerEcsAdapter()

AGENTS.md : section PLAN 3C ajoutée avec corrections A+B intégrées.
Corrections A (déduction EV unique dans scheduler) et B (recrédit conso
propre anti-clignotement) documentées avant implémentation morceau 3.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-06-08 13:34:42 +02:00
parent 5c2d5325e0
commit a239a324c8
6 changed files with 369 additions and 6 deletions

View File

@ -0,0 +1,191 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#include "ecsrelayadapter.h"
#include "plugininfo.h"
#include <QDateTime>
#include <integrations/thingmanager.h>
#include <integrations/thing.h>
#include <types/action.h>
#include <types/param.h>
EcsRelayAdapter::EcsRelayAdapter(ThingManager *thingManager,
const QString &id,
const QString &label,
const QList<int> &stages,
const QList<QList<QString>> &relayMapping,
int minOnS,
int minOffS,
int priority,
QObject *parent)
: QObject(parent)
, m_thingManager(thingManager)
, m_id(id)
, m_label(label)
, m_stages(stages)
, m_relayMapping(relayMapping)
, m_minOnS(minOnS)
, m_minOffS(minOffS)
, m_priority(priority)
{
Q_ASSERT(!m_stages.isEmpty() && m_stages.first() == 0);
Q_ASSERT(m_relayMapping.size() == m_stages.size());
}
LoadDescriptor EcsRelayAdapter::descriptor() const
{
LoadDescriptor d;
d.id = m_id;
d.label = m_label;
d.adapter = QStringLiteral("relay-stages");
d.priority = m_priority;
d.declared.stages = m_stages;
d.limits.minOnS = m_minOnS;
d.limits.minOffS = m_minOffS;
d.supportedKinds = { LoadAction::Stage };
return d;
}
LoadTelemetry EcsRelayAdapter::telemetry() const
{
LoadTelemetry t;
t.available = true;
t.lastActionAt = m_lastActionAt;
// Puissance mesurée = somme des relais actifs pour le stage courant
double power = 0;
const QList<QString> &activeRelays = m_currentStage < m_relayMapping.size()
? m_relayMapping.at(m_currentStage)
: QList<QString>();
for (const QString &thingId : activeRelays) {
Thing *t2 = m_thingManager->findConfiguredThing(ThingId(thingId));
if (t2)
power += t2->stateValue("currentPower").toDouble();
}
// Si pas de powermetering dans le mock, on estime depuis les stages déclarés
if (power == 0 && m_currentStage > 0 && m_currentStage < m_stages.size())
power = m_stages.at(m_currentStage);
t.currentPowerW = power;
return t;
}
LoadContext EcsRelayAdapter::toLoadContext() const
{
LoadContext ctx;
ctx.id = m_id;
ctx.adapter = QStringLiteral("relay-stages");
ctx.label = m_label;
ctx.priority = m_priority;
ctx.declared = descriptor().declared;
ctx.limits = descriptor().limits;
const LoadTelemetry tel = telemetry();
ctx.telemetry.currentPowerW = tel.currentPowerW;
ctx.telemetry.stage = m_currentStage;
ctx.telemetry.lastSwitch = m_lastSwitch;
return ctx;
}
LoadAction EcsRelayAdapter::applyAction(const LoadAction &action)
{
if (action.kind != LoadAction::Stage)
return action;
if (action.reason.isEmpty()) {
qCWarning(dcNymeaEnergy()) << "[EcsRelayAdapter]" << m_label
<< "— LoadAction sans reason rejetée.";
return action;
}
const int newStage = qBound(0, action.stage, m_stages.size() - 1);
if (newStage == m_currentStage)
return action; // Aucun changement → idempotent
// Verrous anti-rebond — bypassés si force == true (L2 watchdog)
if (!action.force && lockActive(newStage)) {
qCDebug(dcNymeaEnergy()) << "[EcsRelayAdapter]" << m_label
<< "— verrou anti-rebond actif, stage" << newStage << "ignoré.";
return action;
}
qCDebug(dcNymeaEnergy()) << "[EcsRelayAdapter]" << m_label
<< "→ stage" << newStage
<< "(" << (m_currentStage < m_stages.size() ? m_stages.at(m_currentStage) : 0) << "W"
<< "→" << m_stages.at(newStage) << "W)"
<< "|" << action.reason;
applyRelayStage(newStage);
m_currentStage = newStage;
m_lastSwitch = QDateTime::currentDateTime();
m_lastActionAt = m_lastSwitch;
LoadAction applied = action;
applied.stage = newStage;
applied.estimatedPowerW = m_stages.at(newStage);
return applied;
}
// ---- privé ---------------------------------------------------------------
bool EcsRelayAdapter::lockActive(int newStage) const
{
if (!m_lastSwitch.isValid())
return false;
const int elapsed = static_cast<int>(m_lastSwitch.secsTo(QDateTime::currentDateTime()));
if (newStage > m_currentStage) {
// Passage à un palier supérieur : minOffS si l'on quitte l'état OFF (stage 0→n)
// ou simplement le délai de stabilisation
if (m_currentStage == 0 && elapsed < m_minOffS)
return true;
} else {
// Réduction de palier : minOnS depuis le dernier ON
if (m_currentStage > 0 && elapsed < m_minOnS)
return true;
}
return false;
}
void EcsRelayAdapter::applyRelayStage(int stage)
{
// Ensemble des relais ON pour le nouveau stage
const QSet<QString> wantOn = [&]() {
QSet<QString> s;
if (stage < m_relayMapping.size())
for (const QString &id : m_relayMapping.at(stage))
s.insert(id);
return s;
}();
// Union de tous les relais connus
QSet<QString> allRelays;
for (const auto &list : m_relayMapping)
for (const QString &id : list)
allRelays.insert(id);
for (const QString &thingId : allRelays) {
Thing *relay = m_thingManager->findConfiguredThing(ThingId(thingId));
if (!relay) {
qCWarning(dcNymeaEnergy()) << "[EcsRelayAdapter]" << m_label
<< "— relais non trouvé:" << thingId;
continue;
}
const bool targetOn = wantOn.contains(thingId);
StateType powerStateType = relay->thingClass().stateTypes().findByName("power");
if (!powerStateType.id().isNull()) {
Action powerAction(powerStateType.id(), relay->id(), Action::TriggeredByRule);
powerAction.setParams(ParamList() << Param(powerStateType.id(), targetOn));
m_thingManager->executeAction(powerAction);
} else {
// Fallback mock : setStateValue direct (Things sans actionType "power")
relay->setStateValue("power", targetOn);
}
}
}

View File

@ -0,0 +1,108 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#pragma once
#include <QObject>
#include <QDateTime>
#include <QList>
#include <QString>
#include "iloadadapter.h"
class Thing;
class ThingManager;
/*!
* \brief Adaptateur pour chauffe-eau ou tout relais N paliers (interface relay-stages).
*
* Pilote en production N Things powerswitch nymea : \c m_relayMapping[stage] contient
* la liste des ThingIds à mettre ON pour ce palier (les autres sont mis OFF).
*
* Exemple — chauffe-eau 2400W, 2 résistances Waveshare :
* stage 0 : {} → A=OFF, B=OFF
* stage 1 : {"thingId-A"} → A=ON, B=OFF (1200 W)
* stage 2 : {"thingId-A", "thingId-B"} → A=ON, B=ON (2400 W)
*
* \invariant applyAction() rejette silencieusement toute action dont \c reason est vide.
* \invariant applyAction() applique les verrous anti-rebond \c minOnS / \c minOffS
* SAUF si \c action.force == true (réservé L2 watchdog).
* \invariant Le stage est écrêté à [0, stages().size()-1] avant envoi matériel.
* \invariant Seul le kind Stage est traité ; les autres kinds retournent sans effet.
*/
class EcsRelayAdapter : public QObject, public ILoadAdapter
{
Q_OBJECT
public:
/*!
* \brief Constructeur.
* \param thingManager Gestionnaire nymea pour résoudre les ThingIds en Things.
* \param id Identifiant logique de la charge (ThingId de l'objet ECS dans nymea,
* ou identifiant arbitraire unique pour le mock).
* \param label Nom lisible affiché dans les logs et l'app.
* \param stages Puissances en W par palier, index 0 = off : [0, 1200, 2400].
* \param relayMapping relayMapping[i] = liste de ThingIds powerswitch ON pour le palier i.
* \param minOnS Durée minimale ON (s) — anti-rebond.
* \param minOffS Durée minimale OFF (s) — anti-rebond.
* \param priority Priorité dans le waterfall (200=deadline, 100=normal, 0=différable).
* \param parent Propriétaire Qt.
*/
explicit EcsRelayAdapter(ThingManager *thingManager,
const QString &id,
const QString &label,
const QList<int> &stages,
const QList<QList<QString>> &relayMapping,
int minOnS,
int minOffS,
int priority,
QObject *parent = nullptr);
/*!
* \brief Description statique de la charge.
* \return LoadDescriptor avec adapter="relay-stages", stages, minOnS/minOffS, priority.
*/
LoadDescriptor descriptor() const override;
/*!
* \brief Télémétrie runtime : puissance mesurée, stage courant, lastSwitch.
* \return LoadTelemetry avec currentPowerW issu de la somme des Things actifs.
*/
LoadTelemetry telemetry() const override;
/*!
* \brief Construit l'entrée loads[] §5 du SurplusContext.
* \return LoadContext incluant declared, limits et télémétrie ECS (stage, currentPowerW, lastSwitch).
*/
LoadContext toLoadContext() const override;
/*!
* \brief Applique un changement de palier sur les relais.
*
* \param action LoadAction de kind Stage. Autres kinds : retour sans effet.
* \return L'action après écrêtage (stage borné à [0, stages.size()-1]).
*
* \invariant Si \c action.reason est vide → retour sans effet (log warning).
* \invariant Si verrous anti-rebond actifs ET \c action.force == false → retour sans effet (log).
* \invariant Si \c action.force == true → bypass verrous (L2 watchdog uniquement).
* \invariant Toute modification de stage met à jour \c m_lastSwitch.
*/
LoadAction applyAction(const LoadAction &action) override;
/*! \brief Stage courant (0 = off). */
int currentStage() const { return m_currentStage; }
private:
bool lockActive(int newStage) const;
void applyRelayStage(int stage);
ThingManager *m_thingManager;
QString m_id;
QString m_label;
QList<int> m_stages; //!< Puissances W par palier, [0]=off.
QList<QList<QString>> m_relayMapping; //!< ThingIds ON par palier.
int m_minOnS;
int m_minOffS;
int m_priority;
int m_currentStage = 0;
QDateTime m_lastSwitch; //!< Dernier changement de palier (null = jamais).
QDateTime m_lastActionAt;
};

View File

@ -3,9 +3,11 @@
#include "energyarbitrator.h" #include "energyarbitrator.h"
#include "adapters/evadapter.h" #include "adapters/evadapter.h"
#include "adapters/ecsrelayadapter.h"
#include "scheduler/rulebasedscheduler.h" #include "scheduler/rulebasedscheduler.h"
#include "types/surpluscontext.h" #include "types/surpluscontext.h"
#include "types/plan.h" #include "types/plan.h"
#include "../rootmeter.h"
#include "plugininfo.h" #include "plugininfo.h"
@ -51,6 +53,18 @@ RootMeter *EnergyArbitrator::registeredRootMeter() const
return internalRootMeter(); return internalRootMeter();
} }
void EnergyArbitrator::registerEcsAdapter(EcsRelayAdapter *adapter)
{
const QString id = adapter->descriptor().id;
if (m_ecsAdapters.contains(id)) {
qCWarning(dcNymeaEnergy()) << "[EnergyArbitrator] EcsRelayAdapter déjà enregistré:" << id;
return;
}
adapter->setParent(this);
m_ecsAdapters[id] = adapter;
qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] EcsRelayAdapter enregistré:" << adapter->descriptor().label;
}
void EnergyArbitrator::update(const QDateTime &currentDateTime) void EnergyArbitrator::update(const QDateTime &currentDateTime)
{ {
qCDebug(dcNymeaEnergy()) << "Updating smart charging"; qCDebug(dcNymeaEnergy()) << "Updating smart charging";
@ -88,10 +102,33 @@ void EnergyArbitrator::update(const QDateTime &currentDateTime)
SurplusContext EnergyArbitrator::buildContext(const QDateTime &now) const SurplusContext EnergyArbitrator::buildContext(const QDateTime &now) const
{ {
// 3b stub — seul le timestamp est renseigné.
// Contexte §5 complet (site/meter/pv/battery/loads) prévu en 3d.
SurplusContext ctx; SurplusContext ctx;
ctx.timestamp = now; 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 ---
for (auto it = m_adapters.constBegin(); it != m_adapters.constEnd(); ++it)
ctx.loads.append(it.value()->toLoadContext());
// --- loads[] : ECS relay adapters ---
for (auto it = m_ecsAdapters.constBegin(); it != m_ecsAdapters.constEnd(); ++it)
ctx.loads.append(it.value()->toLoadContext());
return ctx; return ctx;
} }

View File

@ -8,6 +8,7 @@
#include "types/plan.h" #include "types/plan.h"
class EvAdapter; class EvAdapter;
class EcsRelayAdapter;
class RuleBasedScheduler; class RuleBasedScheduler;
/*! /*!
@ -70,6 +71,15 @@ public:
*/ */
RootMeter *registeredRootMeter() const; RootMeter *registeredRootMeter() const;
/*!
* \brief Enregistre un EcsRelayAdapter pour inclusion dans le contexte et le dispatch.
*
* Appelé par le test (setup) ou la configuration de production.
* L'adaptateur est adopté comme enfant Qt de l'arbitre.
* \param adapter Adaptateur à enregistrer. Son \c descriptor().id doit être unique.
*/
void registerEcsAdapter(EcsRelayAdapter *adapter);
protected: protected:
/*! /*!
* \brief Boucle principale ETM — surcharge SmartChargingManager::update(). * \brief Boucle principale ETM — surcharge SmartChargingManager::update().
@ -87,17 +97,22 @@ protected:
private: private:
/*! /*!
* \brief Construit un SurplusContext minimal (3b stub — timestamp seul). * \brief Construit le SurplusContext §5 : meter brut + loads EV + loads ECS.
* \note Contexte complet prévu en 3d (§5 complet avec site/meter/pv/battery/loads). *
* \c ctx.meter.exportW = mesure brute du compteur (AGENTS invariant 8 — aucune
* déduction interne). La déduction evReservedW est faite dans le scheduler.
*/ */
SurplusContext buildContext(const QDateTime &now) const; SurplusContext buildContext(const QDateTime &now) const;
/*! /*!
* \brief Synchronise m_adapters avec les EvCharger actuellement enregistrés. * \brief Synchronise m_adapters avec les EvCharger actuellement enregistrés.
* Crée les adapters manquants, supprime les adapters obsolètes. * Crée les adapters manquants, supprime les adapters obsolètes.
* \note Découverte ECS via interface 'ecsrelay' ThingManager — déféré 3g config.
* En beta, les EcsRelayAdapters sont enregistrés via registerEcsAdapter().
*/ */
void syncAdapters(); void syncAdapters();
RuleBasedScheduler *m_scheduler = nullptr; RuleBasedScheduler *m_scheduler = nullptr;
QHash<QString, EvAdapter *> m_adapters; //!< loadId (ThingId string) → EvAdapter*. QHash<QString, EvAdapter *> m_adapters; //!< loadId (ThingId string) → EvAdapter*.
QHash<QString, EcsRelayAdapter *> m_ecsAdapters; //!< loadId → EcsRelayAdapter*.
}; };

View File

@ -6,10 +6,12 @@ HEADERS += \
$$PWD/adapters/iloadadapter.h \ $$PWD/adapters/iloadadapter.h \
$$PWD/scheduler/ischeduler.h \ $$PWD/scheduler/ischeduler.h \
$$PWD/adapters/evadapter.h \ $$PWD/adapters/evadapter.h \
$$PWD/adapters/ecsrelayadapter.h \
$$PWD/scheduler/rulebasedscheduler.h \ $$PWD/scheduler/rulebasedscheduler.h \
$$PWD/energyarbitrator.h \ $$PWD/energyarbitrator.h \
SOURCES += \ SOURCES += \
$$PWD/adapters/evadapter.cpp \ $$PWD/adapters/evadapter.cpp \
$$PWD/adapters/ecsrelayadapter.cpp \
$$PWD/scheduler/rulebasedscheduler.cpp \ $$PWD/scheduler/rulebasedscheduler.cpp \
$$PWD/energyarbitrator.cpp \ $$PWD/energyarbitrator.cpp \

View File

@ -59,4 +59,14 @@ struct LoadAction {
* Rempli par le scheduler ; peut être 0 si inconnu. * Rempli par le scheduler ; peut être 0 si inconnu.
*/ */
double estimatedPowerW = 0; double estimatedPowerW = 0;
/*!
* \brief Forçage sécurité — bypasse les verrous anti-rebond (minOn/minOff).
*
* Positionné à \c true uniquement par \c applyDegradedMode() (L2 watchdog)
* et les contraintes de sécurité. Les adaptateurs doivent appliquer l'action
* immédiatement sans vérifier les verrous temporels.
* \warning Réservé à la sécurité. Ne jamais mettre à \c true dans un scheduler.
*/
bool force = false;
}; };