Côté energymanager : EtmVariableLoadAdapter consomme l'interface etmvariableload (states currentPowerW read / powerSetpoint write) pour les charges à puissance pilotable (EV / ECS résistif / routeur PV). La combinatoire matérielle, l'anti-rebond et les phases vivent dans le thing (contrat §1/§3) — l'adaptateur n'écrit qu'un setpoint W et relit currentPowerW. - LoadDescriptor : champs additifs powerLevels / maxPowerW (déclaration §3/§5). - Contrat partagé versionné : docs/INTERFACE_etmvariableload.md (rév. 2). - La déclaration de l'interface nymee + le driver thing relèvent d'une session device. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
139 lines
4.8 KiB
C++
139 lines
4.8 KiB
C++
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
|
|
|
#include "etmvariableloadadapter.h"
|
|
#include "plugininfo.h"
|
|
|
|
#include <QDateTime>
|
|
#include <algorithm>
|
|
#include <integrations/thingmanager.h>
|
|
#include <integrations/thing.h>
|
|
#include <types/action.h>
|
|
#include <types/param.h>
|
|
|
|
EtmVariableLoadAdapter::EtmVariableLoadAdapter(ThingManager *thingManager,
|
|
const QString &id,
|
|
const QString &label,
|
|
const QList<int> &powerLevels,
|
|
int maxPowerW,
|
|
int priority,
|
|
const LoadNeeds &needs,
|
|
QObject *parent)
|
|
: QObject(parent)
|
|
, m_thingManager(thingManager)
|
|
, m_id(id)
|
|
, m_label(label)
|
|
, m_powerLevels(powerLevels)
|
|
, m_maxPowerW(maxPowerW)
|
|
, m_priority(priority)
|
|
, m_needs(needs)
|
|
{
|
|
// Re-tri par sécurité : l'UI émet trié, l'energymanager re-trie (contrat §6 cas limites).
|
|
std::sort(m_powerLevels.begin(), m_powerLevels.end());
|
|
// Contrat §2 : les paliers incluent 0. En *fixed* (liste non vide), 0 doit être présent.
|
|
Q_ASSERT(m_powerLevels.isEmpty() || m_powerLevels.first() == 0);
|
|
}
|
|
|
|
LoadDescriptor EtmVariableLoadAdapter::descriptor() const
|
|
{
|
|
LoadDescriptor d;
|
|
d.id = m_id;
|
|
d.label = m_label;
|
|
d.adapter = QStringLiteral("etmvariableload");
|
|
d.priority = m_priority;
|
|
|
|
d.declared.powerLevels = m_powerLevels;
|
|
d.declared.maxPowerW = m_maxPowerW;
|
|
d.needs = m_needs;
|
|
|
|
d.supportedKinds = { LoadAction::Setpoint };
|
|
return d;
|
|
}
|
|
|
|
LoadTelemetry EtmVariableLoadAdapter::telemetry() const
|
|
{
|
|
LoadTelemetry t;
|
|
Thing *thing = m_thingManager->findConfiguredThing(ThingId(m_id));
|
|
t.available = (thing != nullptr);
|
|
t.currentPowerW = readCurrentPowerW(); // juge runtime (contrat §4)
|
|
t.lastActionAt = m_lastActionAt;
|
|
return t;
|
|
}
|
|
|
|
LoadContext EtmVariableLoadAdapter::toLoadContext(const QDateTime &now) const
|
|
{
|
|
Q_UNUSED(now) // aucune fenêtre de verrou côté moteur : l'anti-rebond vit dans le thing.
|
|
|
|
LoadContext ctx;
|
|
ctx.id = m_id;
|
|
ctx.adapter = QStringLiteral("etmvariableload");
|
|
ctx.label = m_label;
|
|
ctx.priority = m_priority;
|
|
ctx.declared = descriptor().declared;
|
|
|
|
ctx.telemetry.currentPowerW = readCurrentPowerW();
|
|
return ctx;
|
|
}
|
|
|
|
LoadAction EtmVariableLoadAdapter::applyAction(const LoadAction &action, const QDateTime &now)
|
|
{
|
|
if (action.kind != LoadAction::Setpoint)
|
|
return action;
|
|
|
|
if (action.reason.isEmpty()) {
|
|
qCWarning(dcNymeaEnergy()) << "[EtmVariableLoadAdapter]" << m_label
|
|
<< "— LoadAction sans reason rejetée.";
|
|
return action;
|
|
}
|
|
|
|
// Second filet (invariant ILoadAdapter) : borner la consigne au plafond physique.
|
|
// L'arrondi au powerLevels (mode *fixed*) est fait par le scheduler (contrat §4, T3).
|
|
const double setpointW = qBound(0.0, action.powerW, static_cast<double>(m_maxPowerW));
|
|
|
|
qCInfo(dcNymeaEnergy()) << "[EtmVariableLoadAdapter]" << m_label
|
|
<< "→ setpoint" << qRound(setpointW) << "W"
|
|
<< (action.force ? "(force)" : "")
|
|
<< "|" << action.reason;
|
|
|
|
writeSetpoint(setpointW);
|
|
|
|
m_currentSetpointW = setpointW;
|
|
m_lastActionAt = now;
|
|
|
|
LoadAction applied = action;
|
|
applied.powerW = setpointW;
|
|
applied.estimatedPowerW = setpointW;
|
|
return applied;
|
|
}
|
|
|
|
// ---- privé ---------------------------------------------------------------
|
|
|
|
double EtmVariableLoadAdapter::readCurrentPowerW() const
|
|
{
|
|
Thing *thing = m_thingManager->findConfiguredThing(ThingId(m_id));
|
|
if (!thing)
|
|
return 0.0;
|
|
if (thing->thingClass().stateTypes().findByName("currentPowerW").id().isNull())
|
|
return 0.0;
|
|
return thing->stateValue("currentPowerW").toDouble();
|
|
}
|
|
|
|
void EtmVariableLoadAdapter::writeSetpoint(double powerW)
|
|
{
|
|
Thing *thing = m_thingManager->findConfiguredThing(ThingId(m_id));
|
|
if (!thing) {
|
|
qCWarning(dcNymeaEnergy()) << "[EtmVariableLoadAdapter]" << m_label
|
|
<< "— thing non trouvé:" << m_id;
|
|
return;
|
|
}
|
|
|
|
StateType setpointStateType = thing->thingClass().stateTypes().findByName("powerSetpoint");
|
|
if (!setpointStateType.id().isNull()) {
|
|
Action setpointAction(setpointStateType.id(), thing->id(), Action::TriggeredByRule);
|
|
setpointAction.setParams(ParamList() << Param(setpointStateType.id(), powerW));
|
|
m_thingManager->executeAction(setpointAction);
|
|
} else {
|
|
thing->setStateValue("powerSetpoint", powerW); // repli mock
|
|
}
|
|
}
|