etm-powersync-energy-plugin.../tests/mocks/plugins/energymocks/integrationpluginenergymocks.cpp
Patrick Schurig 119cffc745 test(mock): les deux derniers manques — compteur sans énergie, sonde thermique
`meterNoEnergy` remplace le `powerSwitch` détourné en instrument de mesure. Le
détour marchait — le moteur ne teste que `hasStateType("totalEnergyConsumed")` —
mais il modélisait un montage que personne ne fait, et faisait tenir DEUX rôles à
la même Thing dans le même scénario : relais d'une charge et compteur d'une autre.

La classe ne déclare PAS `energymeter` : cette interface EXIGE `totalEnergyConsumed`
et `totalEnergyProduced`. Promettre une interface qu'on ne tient pas ferait mentir
le mock exactement là où il doit être littéral. `smartmeter` est la base sans état
obligatoire, et c'est précisément ce qu'est ce matériel.

`thermalProbe` ouvre le volet thermique. La branche « sonde présente » de
attachMeasurements() était écrite depuis toujours et n'avait JAMAIS été parcourue :
aucune classe du mock ne portait l'état `temperature`. C'était la seule entrée du
volet thermique, et rien ne l'exerçait.

CE QUE L'OUVERTURE A RÉVÉLÉ, comme les trois précédentes. `setupThing()` du mock
n'avait AUCUNE sortie par défaut : une classe sans branche ne rencontrait jamais
d'`info->finish()`. Le setup ne échouait pas — il ne se terminait jamais. Le
symptôme est un Thing éternellement « en cours de configuration » et un test qui
expire ailleurs, très loin de la cause. Même mécanisme que le contrôleur
déréférencé pour toute classe : une hypothèse du mock que son homogénéité
protégeait. Une classe oubliée se dit maintenant, en qCWarning + SetupFailed.

UNE GARDE A MORDU sur son auteur le jour même. La première version du test
thermique lisait `loads[]` sans compteur racine — donc une liste vide, et trois
assertions qui ne se seraient jamais exécutées. `QCOMPARE(parId.count(), 3)` l'a
attrapé. C'est la parade écrite dans INVENTAIRE_MOCK.md, appliquée à elle-même.

RELEVÉ, NON CORRIGÉ : une sonde DÉSIGNÉE mais incapable produit la même charge
utile qu'aucune sonde du tout (`temperatureC` absent), alors que le geste de
réparation diffère — désigner une sonde, ou en changer. Silence de la famille
`progressUnmeasurableCause` ; le fermer demande un catalogue de causes thermiques
qui n'existe pas encore.

Suites : simulation 67/67 (+1), loadmodel 23/23, charging 48/48, spotmarket 32/32.
2026-09-05 08:52:24 +02:00

799 lines
40 KiB
C++

// SPDX-License-Identifier: GPL-3.0-or-later
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
*
* Copyright (C) 2013 - 2024, nymea GmbH
* Copyright (C) 2024 - 2025, chargebyte austria GmbH
*
* This file is part of nymea-energy-plugin-nymea.
*
* nymea-energy-plugin-nymea.s free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* nymea-energy-plugin-nymea.s distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with nymea-energy-plugin-nymea. If not, see <https://www.gnu.org/licenses/>.
*
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
#include "integrationpluginenergymocks.h"
#include "plugininfo.h"
#include "mockcontroller.h"
#include <QTimer>
#include <hardware/electricity.h>
IntegrationPluginEnergyMocks::IntegrationPluginEnergyMocks(QObject *parent) : IntegrationPlugin(parent)
{
}
void IntegrationPluginEnergyMocks::integrerEnergie()
{
const QDateTime maintenant = QDateTime::currentDateTime();
for (Thing *thing : myThings()) {
if (!thing->thingClass().hasStateType("totalEnergyConsumed")
|| !thing->thingClass().hasStateType("currentPower"))
continue;
const QDateTime avant = m_derniereIntegration.value(thing);
m_derniereIntegration.insert(thing, maintenant);
// Première passe : aucune durée derrière elle. Intégrer y fabriquerait de l'énergie.
if (!avant.isValid())
continue;
const double heures = avant.msecsTo(maintenant) / 3600000.0;
const double puissanceW = thing->stateValue("currentPower").toDouble();
// Un compteur de CONSOMMATION n'intègre que ce qui est consommé : en export, il ne
// recule pas — il ne bouge pas. Le faire reculer produirait un cas que le terrain ne
// produit pas, et que la garde de sémantique traiterait comme une anomalie.
if (puissanceW <= 0 || heures <= 0)
continue;
const double avantKWh = thing->stateValue("totalEnergyConsumed").toDouble();
thing->setStateValue("totalEnergyConsumed", avantKWh + puissanceW * heures / 1000.0);
}
}
void IntegrationPluginEnergyMocks::setupThing(ThingSetupInfo *info)
{
// Intégrateur d'énergie, armé une fois. Une seconde : assez fin pour qu'un cycle
// d'arbitrage (à la minute) voie le compteur BOUGER, ce qui est tout l'objet.
if (!m_integrateur) {
m_integrateur = new QTimer(this);
m_integrateur->setInterval(1000);
connect(m_integrateur, &QTimer::timeout, this, &IntegrationPluginEnergyMocks::integrerEnergie);
m_integrateur->start();
}
Thing *thing = info->thing();
qCDebug(dcEnergyMocks()) << "Setting up" << thing << thing->params();
if (thing->thingClassId() == chargerThingClassId) {
EnergyMockController *controller = new EnergyMockController(thing, this);
ParamType paramType = thing->thingClass().paramTypes().findByName("port");
quint16 port = thing->paramValue(paramType.id()).toUInt();
if (!controller->listen(QHostAddress::Any, port)) {
qCWarning(dcEnergyMocks()) << "Failed to start mock controller on port" << controller->errorString();
delete controller;
info->finish(Thing::ThingErrorThingInUse);
return;
}
connect(controller, &EnergyMockController::updateStateRequestReceived, thing, [=](const QUrlQuery &query){
if (query.hasQueryItem("connected"))
thing->setStateValue("connected", QVariant(query.queryItemValue("connected")).toBool());
if (query.hasQueryItem("power"))
thing->setStateValue("power", QVariant(query.queryItemValue("power")).toBool());
if (query.hasQueryItem("pluggedIn"))
thing->setStateValue("pluggedIn", QVariant(query.queryItemValue("pluggedIn")).toBool());
if (query.hasQueryItem("usedPhases")) {
thing->setStateValue("usedPhases", query.queryItemValue("usedPhases"));
thing->setStateValue("phaseCount", Electricity::getPhaseCount(Electricity::convertPhasesFromString(query.queryItemValue("usedPhases"))));
}
if (query.hasQueryItem("maxChargingCurrent"))
thing->setStateValue("maxChargingCurrent", QVariant(query.queryItemValue("maxChargingCurrent")).toInt());
if (query.hasQueryItem("maxChargingCurrentMaxValue"))
thing->setStateMaxValue("maxChargingCurrent", QVariant(query.queryItemValue("maxChargingCurrentMaxValue")).toInt());
updateChargerMeter(thing);
qCDebug(dcEnergyMocks()) << "--> States" << thing->name();
foreach (const State &state, thing->states())
qCDebug(dcEnergyMocks()) << " -" << thing->thingClass().stateTypes().findById(state.stateTypeId()).displayName() << ":" << state.value();
});
qCDebug(dcEnergyMocks()) << "Setting up charger" << thing->name() << "finished successfully";
m_controllers.insert(thing, controller);
info->finish(Thing::ThingErrorNoError);
thing->setStateValue("usedPhases", thing->paramValue("phases").toString());
thing->setStateValue("phaseCount", Electricity::getPhaseCount(Electricity::convertPhasesFromString(thing->stateValue("usedPhases").toString())));
thing->setStateMaxValue("maxChargingCurrent", QVariant(thing->paramValue("maxChargingCurrentUpperLimit")).toInt());
updateChargerMeter(thing);
return;
} else if (thing->thingClassId() == chargerPhaseSwitchingThingClassId) {
EnergyMockController *controller = new EnergyMockController(thing, this);
ParamType paramType = thing->thingClass().paramTypes().findByName("port");
quint16 port = thing->paramValue(paramType.id()).toUInt();
if (!controller->listen(QHostAddress::Any, port)) {
qCWarning(dcEnergyMocks()) << "Failed to start mock controller on port" << controller->errorString();
delete controller;
info->finish(Thing::ThingErrorThingInUse);
return;
}
connect(controller, &EnergyMockController::updateStateRequestReceived, thing, [=](const QUrlQuery &query){
if (query.hasQueryItem("connected"))
thing->setStateValue("connected", QVariant(query.queryItemValue("connected")).toBool());
if (query.hasQueryItem("power"))
thing->setStateValue("power", QVariant(query.queryItemValue("power")).toBool());
if (query.hasQueryItem("pluggedIn"))
thing->setStateValue("pluggedIn", QVariant(query.queryItemValue("pluggedIn")).toBool());
if (query.hasQueryItem("usedPhases")) {
thing->setStateValue("usedPhases", query.queryItemValue("usedPhases"));
thing->setStateValue("phaseCount", Electricity::getPhaseCount(Electricity::convertPhasesFromString(query.queryItemValue("usedPhases"))));
}
if (query.hasQueryItem("maxChargingCurrent"))
thing->setStateValue("maxChargingCurrent", QVariant(query.queryItemValue("maxChargingCurrent")).toInt());
if (query.hasQueryItem("maxChargingCurrentMaxValue"))
thing->setStateMaxValue("maxChargingCurrent", QVariant(query.queryItemValue("maxChargingCurrentMaxValue")).toInt());
if (query.hasQueryItem("desiredPhaseCount"))
thing->setStateValue("desiredPhaseCount", QVariant(query.queryItemValue("desiredPhaseCount")).toUInt());
updateChargerMeter(thing);
qCDebug(dcEnergyMocks()) << "--> States" << thing->name();
foreach (const State &state, thing->states())
qCDebug(dcEnergyMocks()) << " -" << thing->thingClass().stateTypes().findById(state.stateTypeId()).displayName() << ":" << state.value();
});
qCDebug(dcEnergyMocks()) << "Setting up charger" << thing->name() << "finished successfully";
m_controllers.insert(thing, controller);
info->finish(Thing::ThingErrorNoError);
thing->setStateValue("usedPhases", thing->paramValue("phases").toString());
thing->setStateValue("phaseCount", Electricity::getPhaseCount(Electricity::convertPhasesFromString(thing->stateValue("usedPhases").toString())));
thing->setStateMaxValue("maxChargingCurrent", QVariant(thing->paramValue("maxChargingCurrentUpperLimit")).toInt());
updateChargerMeter(thing);
return;
} else if (thing->thingClassId() == simpleChargerThingClassId) {
EnergyMockController *controller = new EnergyMockController(thing, this);
ParamType paramType = thing->thingClass().paramTypes().findByName("port");
quint16 port = thing->paramValue(paramType.id()).toUInt();
if (!controller->listen(QHostAddress::Any, port)) {
qCWarning(dcEnergyMocks()) << "Failed to start mock controller on port" << controller->errorString();
delete controller;
info->finish(Thing::ThingErrorThingInUse);
return;
}
connect(controller, &EnergyMockController::updateStateRequestReceived, thing, [=](const QUrlQuery &query){
if (query.hasQueryItem("connected"))
thing->setStateValue("connected", QVariant(query.queryItemValue("connected")).toBool());
if (query.hasQueryItem("power"))
thing->setStateValue("power", QVariant(query.queryItemValue("power")).toBool());
if (query.hasQueryItem("pluggedIn"))
thing->setStateValue("pluggedIn", QVariant(query.queryItemValue("pluggedIn")).toBool());
if (query.hasQueryItem("phaseCount"))
thing->setStateValue("phaseCount", QVariant(query.queryItemValue("phaseCount")).toInt());
if (query.hasQueryItem("maxChargingCurrent"))
thing->setStateValue("maxChargingCurrent", QVariant(query.queryItemValue("maxChargingCurrent")).toInt());
if (query.hasQueryItem("maxChargingCurrentMaxValue"))
thing->setStateMaxValue("maxChargingCurrent", QVariant(query.queryItemValue("maxChargingCurrentMaxValue")).toInt());
qCDebug(dcEnergyMocks()) << "--> States" << thing->name();
foreach (const State &state, thing->states())
qCDebug(dcEnergyMocks()) << " -" << thing->thingClass().stateTypes().findById(state.stateTypeId()).displayName() << ":" << state.value();
});
m_controllers.insert(thing, controller);
qCDebug(dcEnergyMocks()) << "Setting up simple charger" << thing->name() << "finished successfully";
info->finish(Thing::ThingErrorNoError);
return;
} else if (thing->thingClassId() == meterThingClassId
|| thing->thingClassId() == meterPowerOnlyThingClassId) {
// `meterPowerOnly` partage tout le comportement du compteur : seule sa ThingClass diffère
// (pas de `currentPhaseB/C`, pas de `voltagePhaseC`). C'est l'ABSENCE d'états qui est
// l'objet de la classe, pas un comportement particulier.
EnergyMockController *controller = new EnergyMockController(thing, this);
ParamType paramType = thing->thingClass().paramTypes().findByName("port");
quint16 port = thing->paramValue(paramType.id()).toUInt();
if (!controller->listen(QHostAddress::Any, port)) {
qCWarning(dcEnergyMocks()) << "Failed to start mock controller on port" << controller->errorString();
delete controller;
info->finish(Thing::ThingErrorThingInUse);
return;
}
connect(controller, &EnergyMockController::updateStateRequestReceived, thing, [=](const QUrlQuery &query){
if (query.hasQueryItem("connected"))
thing->setStateValue("connected", QVariant(query.queryItemValue("connected")).toBool());
if (query.hasQueryItem("originalPower")) {
// Split the original power on the 3 phases and add the current power (depending on the phases) from the charger
// double originalPhasePower = query.queryItemValue("originalPower").toInt() / 3.0;
// double phaseA = originalPhasePower;
// double phaseB = originalPhasePower;
// double phaseC = originalPhasePower;
// // Get charger phases, set each phase and then the sum
// foreach (Thing *chargerThing, myThings().filterByThingClassId(chargerThingClassId)) {
// Electricity::Phases phases = Electricity::convertPhasesFromString(thing->paramValue("phases").toString());
// //
// }
}
if (query.hasQueryItem("currentPowerPhaseA") && query.hasQueryItem("currentPowerPhaseB") && query.hasQueryItem("currentPowerPhaseC")) {
// We directly set the current power on the 3 phases, no need to perform calculations (for tests to emulate a certain situation)
int currentPowerPhaseA = query.queryItemValue("currentPowerPhaseA").toInt();
int currentPowerPhaseB = query.queryItemValue("currentPowerPhaseB").toInt();
int currentPowerPhaseC = query.queryItemValue("currentPowerPhaseC").toInt();
// TENSION ET COURANT PAR PHASE, réglables INDÉPENDAMMENT des puissances.
//
// Sans cela, le mock posait `currentPhaseX = currentPowerPhaseX / 230` : les deux
// familles d'états étaient COHÉRENTES PAR CONSTRUCTION, et aucun scénario ne
// pouvait produire une tension réelle ≠ 230 V, un déséquilibre entre phases, ni
// un compteur qui ne publierait qu'une des deux familles. C'est ce qui a rendu le
// défaut d'asymétrie de `calculateAllowanceAmpere()` invisible à la suite
// pendant des mois (docs/INVENTAIRE_MOCK.md, manque n°2).
//
// Les paramètres sont OPTIONNELS : sans eux, le comportement dérivé d'origine est
// conservé, et aucun scénario existant ne bouge.
auto tension = [&](const char *nom) {
return query.hasQueryItem(nom) ? query.queryItemValue(nom).toDouble() : 230.0;
};
const double uA = tension("voltagePhaseA");
const double uB = tension("voltagePhaseB");
const double uC = tension("voltagePhaseC");
// N'écrire que ce que la ThingClass PORTE : `meterPowerOnly` n'a ni
// `voltagePhaseC` ni les courants de B et C, et c'est tout son objet.
auto poser = [&](const char *n, double v) {
if (thing->thingClass().hasStateType(n)) thing->setStateValue(n, v);
};
poser("voltagePhaseA", uA);
poser("voltagePhaseB", uB);
poser("voltagePhaseC", uC);
// Le COURANT peut être imposé directement — un compteur réel le mesure, il ne le
// dérive pas de la puissance.
poser("currentPhaseA", query.hasQueryItem("currentPhaseA")
? query.queryItemValue("currentPhaseA").toDouble() : currentPowerPhaseA / uA);
poser("currentPhaseB", query.hasQueryItem("currentPhaseB")
? query.queryItemValue("currentPhaseB").toDouble() : currentPowerPhaseB / uB);
poser("currentPhaseC", query.hasQueryItem("currentPhaseC")
? query.queryItemValue("currentPhaseC").toDouble() : currentPowerPhaseC / uC);
thing->setStateValue("currentPowerPhaseA", currentPowerPhaseA);
thing->setStateValue("currentPowerPhaseB", currentPowerPhaseB);
thing->setStateValue("currentPowerPhaseC", currentPowerPhaseC);
// Note: set the current power at the end since that triggers caclulations....
thing->setStateValue("currentPower", currentPowerPhaseA + currentPowerPhaseB + currentPowerPhaseC);
}
qCDebug(dcEnergyMocks()) << "--> States" << thing->name();
foreach (const State &state, thing->states())
qCDebug(dcEnergyMocks()) << " -" << thing->thingClass().stateTypes().findById(state.stateTypeId()).displayName() << ":" << state.value();
});
m_controllers.insert(thing, controller);
qCDebug(dcEnergyMocks()) << "Setting up meter" << thing->name() << "finished successfully";
info->finish(Thing::ThingErrorNoError);
return;
} else if (thing->thingClassId() == carThingClassId) {
EnergyMockController *controller = new EnergyMockController(thing, this);
ParamType paramType = thing->thingClass().paramTypes().findByName("port");
quint16 port = thing->paramValue(paramType.id()).toUInt();
if (!controller->listen(QHostAddress::Any, port)) {
qCWarning(dcEnergyMocks()) << "Failed to start mock controller on port" << controller->errorString();
delete controller;
info->finish(Thing::ThingErrorThingInUse);
return;
}
connect(controller, &EnergyMockController::updateStateRequestReceived, thing, [=](const QUrlQuery &query){
if (query.hasQueryItem("batteryLevel"))
thing->setStateValue("batteryLevel", QVariant(query.queryItemValue("batteryLevel")).toInt());
if (query.hasQueryItem("capacity"))
thing->setStateValue("capacity", QVariant(query.queryItemValue("capacity")).toInt());
if (query.hasQueryItem("minChargingCurrent"))
thing->setStateValue("minChargingCurrent", QVariant(query.queryItemValue("minChargingCurrent")).toInt());
if (query.hasQueryItem("phaseCount")) {
thing->setSettingValue(carSettingsPhaseCountParamTypeId, QVariant(query.queryItemValue("phaseCount")).toInt());
thing->setStateValue("phaseCount", QVariant(query.queryItemValue("phaseCount")).toInt());
}
});
// Set the min charging current state if the settings value changed
connect(thing, &Thing::settingChanged, controller, [thing](const ParamTypeId &paramTypeId, const QVariant &value){
if (paramTypeId == carSettingsCapacityParamTypeId) {
qCDebug(dcEnergyMocks()) << "Car capacity settings changed" << value << "kWh";
thing->setStateValue(carCapacityStateTypeId, value);
} else if (paramTypeId == carSettingsMinChargingCurrentParamTypeId) {
qCDebug(dcEnergyMocks()) << "Car minimum charging current settings changed" << value.toUInt() << "A";
thing->setStateValue(carMinChargingCurrentStateTypeId, value);
} else if (paramTypeId == carSettingsPhaseCountParamTypeId) {
qCDebug(dcEnergyMocks()) << "Car phase count settings changed" << value.toUInt();
thing->setStateValue(carPhaseCountStateTypeId, value);
}
qCDebug(dcEnergyMocks()) << "--> States" << thing->name();
foreach (const State &state, thing->states())
qCDebug(dcEnergyMocks()) << " -" << thing->thingClass().stateTypes().findById(state.stateTypeId()).displayName() << ":" << state.value();
});
qCDebug(dcEnergyMocks()) << "Setting car" << thing->name() << "finished successfully";
m_controllers.insert(thing, controller);
info->finish(Thing::ThingErrorNoError);
thing->setStateValue(carMinChargingCurrentStateTypeId, thing->setting(carSettingsMinChargingCurrentParamTypeId));
return;
} else if (thing->thingClassId() == notificationThingClassId) {
EnergyMockController *controller = new EnergyMockController(thing, this);
ParamType paramType = thing->thingClass().paramTypes().findByName("port");
quint16 port = thing->paramValue(paramType.id()).toUInt();
if (!controller->listen(QHostAddress::Any, port)) {
qCWarning(dcEnergyMocks()) << "Failed to start mock controller on port" << controller->errorString();
delete controller;
info->finish(Thing::ThingErrorThingInUse);
return;
}
connect(controller, &EnergyMockController::updateStateRequestReceived, thing, [=](const QUrlQuery &query){
Q_UNUSED(query)
});
m_controllers.insert(thing, controller);
qCDebug(dcEnergyMocks()) << "Setting up notification" << thing->name() << "finished successfully";
info->finish(Thing::ThingErrorNoError);
return;
} else if (thing->thingClassId() == energyStorageThingClassId) {
EnergyMockController * controller = new EnergyMockController(thing, this);
ParamType paramType = thing->thingClass().paramTypes().findByName("port");
quint16 port = thing->paramValue(paramType.id()).toUInt();
if (!controller->listen(QHostAddress::Any, port)) {
qWarning(dcEnergyMocks()) << "Failed to start mock controller on port" << controller->errorString();
delete controller;
info->finish(Thing::ThingErrorThingInUse);
return;
}
connect(controller, &EnergyMockController::updateStateRequestReceived, thing, [=](const QUrlQuery &query){
if (query.hasQueryItem("batteryLevel")) {
thing->setStateValue("batteryLevel", QVariant(query.queryItemValue("batteryLevel")).toInt());
thing->setStateValue("batteryCritical", thing->stateValue("batteryLevel").toInt() < 10);
}
if (query.hasQueryItem("currentPower"))
thing->setStateValue("currentPower", QVariant(query.queryItemValue("currentPower")).toInt());
});
qCDebug(dcEnergyMocks()) << "Setting battery storage" << thing->name() << "finished successfully";
m_controllers.insert(thing, controller);
info->finish(Thing::ThingErrorNoError);
thing->setStateValue("capacity", thing->paramValue(energyStorageThingCapacityParamTypeId));
return;
} else if (thing->thingClassId() == dryRelayThingClassId) {
// RELAIS SEC — il commute, il ne mesure pas. C'est toute sa raison d'être : le mock ne
// savait produire aucune charge pilotable SANS puissance, si bien qu'une charge « non
// mesurée mais saine » était impossible à fabriquer, alors qu'un relais sec est banal
// sur le terrain. Sans lui, aucun test ne peut distinguer « non mesurée » de « en
// défaut » (docs/INVENTAIRE_MOCK.md).
info->finish(Thing::ThingErrorNoError);
return;
} else if (thing->thingClassId() == powerSwitchThingClassId) {
EnergyMockController *controller = new EnergyMockController(thing, this);
ParamType paramType = thing->thingClass().paramTypes().findByName("port");
quint16 port = thing->paramValue(paramType.id()).toUInt();
if (!controller->listen(QHostAddress::Any, port)) {
qCWarning(dcEnergyMocks()) << "Failed to start mock controller on port" << controller->errorString();
delete controller;
info->finish(Thing::ThingErrorThingInUse);
return;
}
connect(controller, &EnergyMockController::updateStateRequestReceived, thing, [=](const QUrlQuery &query){
// Permet au test d'imposer power / currentPower directement (ex. émuler une
// mesure dérivée, ou un thermostat coupé : power=true mais currentPower=0).
if (query.hasQueryItem("power"))
thing->setStateValue("power", QVariant(query.queryItemValue("power")).toBool());
if (query.hasQueryItem("currentPower"))
thing->setStateValue("currentPower", QVariant(query.queryItemValue("currentPower")).toDouble());
});
m_controllers.insert(thing, controller);
qCDebug(dcEnergyMocks()) << "Setting up power switch" << thing->name() << "finished successfully";
info->finish(Thing::ThingErrorNoError);
return;
} else if (thing->thingClassId() == etmVariableLoadThingClassId) {
// Mock etmvariableload : un controller (nécessaire pour logActionExecuted dans
// executeAction) + injection éventuelle de currentPowerW. Le test impose plutôt
// currentPowerW directement via setStateValue (mesure simulée de la charge).
EnergyMockController *controller = new EnergyMockController(thing, this);
ParamType paramType = thing->thingClass().paramTypes().findByName("port");
quint16 port = thing->paramValue(paramType.id()).toUInt();
if (!controller->listen(QHostAddress::Any, port)) {
qCWarning(dcEnergyMocks()) << "Failed to start mock controller on port" << controller->errorString();
delete controller;
info->finish(Thing::ThingErrorThingInUse);
return;
}
connect(controller, &EnergyMockController::updateStateRequestReceived, thing, [=](const QUrlQuery &query){
if (query.hasQueryItem("currentPowerW"))
thing->setStateValue("currentPowerW", QVariant(query.queryItemValue("currentPowerW")).toDouble());
});
m_controllers.insert(thing, controller);
qCDebug(dcEnergyMocks()) << "Setting up etmVariableLoad" << thing->name() << "finished successfully";
info->finish(Thing::ThingErrorNoError);
return;
} else if (thing->thingClassId() == meterNoEnergyThingClassId) {
// COMPTEUR SANS CUMUL D'ÉNERGIE — il publie une puissance, il ne totalise rien.
//
// Le cas était jusqu'ici fabriqué en désignant un `powerSwitch` comme compteur : un
// INTERRUPTEUR dans le rôle d'un instrument de mesure, et de surcroît la même Thing
// jouant deux rôles dans le même scénario. Le moteur ne teste que
// `hasStateType("totalEnergyConsumed")`, donc le détour marchait — mais il modélisait
// un montage que personne ne fait, et il empêchait de voir ce que le vrai cas produit.
//
// La classe ne déclare PAS `energymeter` : cette interface EXIGE `totalEnergyConsumed`
// et `totalEnergyProduced`. Promettre une interface qu'on ne tient pas ferait mentir le
// mock exactement là où il doit être littéral. `smartmeter` est la base sans état
// obligatoire, et c'est précisément ce qu'est ce matériel — mesuré sur l'ECS du banc
// le 2026-09-01.
EnergyMockController *controller = new EnergyMockController(thing, this);
ParamType paramType = thing->thingClass().paramTypes().findByName("port");
quint16 port = thing->paramValue(paramType.id()).toUInt();
if (!controller->listen(QHostAddress::Any, port)) {
qCWarning(dcEnergyMocks()) << "Failed to start mock controller on port" << controller->errorString();
delete controller;
info->finish(Thing::ThingErrorThingInUse);
return;
}
connect(controller, &EnergyMockController::updateStateRequestReceived, thing, [=](const QUrlQuery &query){
for (const QString &etat : {QStringLiteral("currentPower"),
QStringLiteral("voltagePhaseA"),
QStringLiteral("currentPhaseA")}) {
if (query.hasQueryItem(etat))
thing->setStateValue(etat, QVariant(query.queryItemValue(etat)).toDouble());
}
});
m_controllers.insert(thing, controller);
info->finish(Thing::ThingErrorNoError);
return;
} else if (thing->thingClassId() == thermalProbeThingClassId) {
// SONDE DE TEMPÉRATURE — la seule entrée du volet thermique, et rien ne l'exerçait.
// `EnergyArbitrator::attachMeasurements()` teste `hasState("temperature")` sur le Thing
// désigné par `sensorThingId` : aucune classe du mock ne portait cet état, donc la
// branche « sonde présente » était écrite et n'avait JAMAIS été exécutée.
EnergyMockController *controller = new EnergyMockController(thing, this);
ParamType paramType = thing->thingClass().paramTypes().findByName("port");
quint16 port = thing->paramValue(paramType.id()).toUInt();
if (!controller->listen(QHostAddress::Any, port)) {
qCWarning(dcEnergyMocks()) << "Failed to start mock controller on port" << controller->errorString();
delete controller;
info->finish(Thing::ThingErrorThingInUse);
return;
}
connect(controller, &EnergyMockController::updateStateRequestReceived, thing, [=](const QUrlQuery &query){
if (query.hasQueryItem("temperature"))
thing->setStateValue("temperature", QVariant(query.queryItemValue("temperature")).toDouble());
});
m_controllers.insert(thing, controller);
info->finish(Thing::ThingErrorNoError);
return;
}
// SORTIE PAR DÉFAUT, et elle manquait. Une classe sans branche ne tombait sur AUCUN
// `info->finish()` : le setup ne échouait pas, il ne se terminait jamais. Le symptôme est
// un Thing éternellement « en cours de configuration » et un test qui expire ailleurs, très
// loin de la cause. Une classe oubliée doit se dire, pas se taire.
qCWarning(dcEnergyMocks()) << "Aucune branche de setup pour la classe"
<< thing->thingClass().name() << "— Thing non configurable.";
info->finish(Thing::ThingErrorSetupFailed);
}
void IntegrationPluginEnergyMocks::thingRemoved(Thing *thing)
{
qCDebug(dcEnergyMocks()) << "Removing" << thing;
if (m_controllers.contains(thing)) {
delete m_controllers.take(thing);
}
}
void IntegrationPluginEnergyMocks::executeAction(ThingActionInfo *info)
{
Thing *thing = info->thing();
ThingClass thingClass = info->thing()->thingClass();
Action action = info->action();
ActionType actionType = thingClass.actionTypes().findById(action.actionTypeId());
qCDebug(dcEnergyMocks()) << "Executing action" << actionType.displayName() << "on" << thing->name() << action.params();
// Toutes les classes n'ont pas de contrôleur HTTP : le relais SEC n'en a pas besoin,
// n'ayant rien à mesurer ni à piloter à distance. Déréférencer sans vérifier plantait —
// le mock supposait que chaque Thing en possédait un.
if (EnergyMockController *c = m_controllers.value(thing))
c->logActionExecuted(actionType, action);
// Update states
if (thing->thingClassId() == dryRelayThingClassId) {
if (actionType.name() == "power") {
// Aucune écriture de `currentPower` : la classe ne porte pas l'état. C'est
// l'absence qui est l'objet du mécanisme, pas une valeur à zéro.
thing->setStateValue("power",
action.paramValue(actionType.paramTypes().findByName("power").id()).toBool());
}
info->finish(Thing::ThingErrorNoError);
return;
}
if (thing->thingClassId() == chargerThingClassId) {
// Note do this before every action execution since it might hase changed directly within the simulation
Electricity::Phases usedPhases = Electricity::convertPhasesFromString(thing->stateValue("usedPhases").toString());
uint phaseCount = Electricity::getPhaseCount(usedPhases);
thing->setStateValue("phaseCount", phaseCount);
if (actionType.name() == "maxChargingCurrent") {
uint maxChargingCurrent = action.paramValue(actionType.paramTypes().findByName("maxChargingCurrent").id()).toUInt();
thing->setStateValue("maxChargingCurrent", maxChargingCurrent);
updateChargerMeter(thing);
}
if (actionType.name() == "power") {
bool power = action.paramValue(actionType.paramTypes().findByName("power").id()).toBool();
qCDebug(dcEnergyMocks()) << "Setting charger power to" << power;
thing->setStateValue("power", power);
updateChargerMeter(thing);
}
if (actionType.name() == "update") {
updateChargerMeter(thing);
}
}
if (thing->thingClassId() == chargerPhaseSwitchingThingClassId) {
// Note do this before every action execution since it might has changed directly within the simulation
Electricity::Phases usedPhases = Electricity::convertPhasesFromString(thing->stateValue("usedPhases").toString());
uint phaseCount = Electricity::getPhaseCount(usedPhases);
thing->setStateValue("phaseCount", phaseCount);
if (actionType.name() == "maxChargingCurrent") {
uint maxChargingCurrent = action.paramValue(actionType.paramTypes().findByName("maxChargingCurrent").id()).toUInt();
thing->setStateValue("maxChargingCurrent", maxChargingCurrent);
updateChargerMeter(thing);
}
if (actionType.name() == "connected") {
bool connected = action.paramValue(actionType.paramTypes().findByName("connected").id()).toBool();
thing->setStateValue("connected", connected);
updateChargerMeter(thing);
}
if (actionType.name() == "pluggedIn") {
bool pluggedIn = action.paramValue(actionType.paramTypes().findByName("pluggedIn").id()).toBool();
thing->setStateValue("pluggedIn", pluggedIn);
updateChargerMeter(thing);
}
if (actionType.name() == "desiredPhaseCount") {
uint desiredPhaseCount = action.paramValue(actionType.paramTypes().findByName("desiredPhaseCount").id()).toUInt();
thing->setStateValue("desiredPhaseCount", desiredPhaseCount);
updateChargerMeter(thing);
}
if (actionType.name() == "power") {
bool power = action.paramValue(actionType.paramTypes().findByName("power").id()).toBool();
qCDebug(dcEnergyMocks()) << "Setting charger power to" << power;
thing->setStateValue("power", power);
updateChargerMeter(thing);
}
if (actionType.name() == "update") {
updateChargerMeter(thing);
}
}
if (thing->thingClassId() == simpleChargerThingClassId) {
if (actionType.name() == "maxChargingCurrent") {
uint maxChargingCurrent = action.paramValue(actionType.paramTypes().findByName("maxChargingCurrent").id()).toUInt();
thing->setStateValue("maxChargingCurrent", action.paramValue(actionType.paramTypes().findByName("maxChargingCurrent").id()).toUInt());
qCDebug(dcEnergyMocks()) << "Setting max charging current to" << maxChargingCurrent << "A";
}
if (actionType.name() == "power") {
bool power = action.paramValue(actionType.paramTypes().findByName("power").id()).toBool();
qCDebug(dcEnergyMocks()) << "Setting charger power to" << power;
thing->setStateValue("power", power);
}
}
if (thing->thingClassId() == carThingClassId) {
if (action.actionTypeId() == carBatteryLevelActionTypeId) {
thing->setStateValue(carBatteryLevelStateTypeId, action.paramValue(carBatteryLevelActionBatteryLevelParamTypeId));
thing->setStateValue(carBatteryCriticalStateTypeId, action.paramValue(carBatteryLevelActionBatteryLevelParamTypeId).toInt() < 10);
info->finish(Thing::ThingErrorNoError);
return;
} else {
Q_ASSERT_X(false, "executeAction", QString("Unhandled action: %1").arg(action.actionTypeId().toString()).toUtf8());
}
}
if (thing->thingClassId() == powerSwitchThingClassId) {
if (actionType.name() == "power") {
bool power = action.paramValue(actionType.paramTypes().findByName("power").id()).toBool();
double nominal = thing->paramValue(thing->thingClass().paramTypes().findByName("nominalPower").id()).toDouble();
// Relais ON → consomme sa puissance nominale ; OFF → 0 W. Le test peut écraser
// currentPower via /setstates (ex. émuler une mesure dérivée).
thing->setStateValue("power", power);
thing->setStateValue("currentPower", power ? nominal : 0.0);
qCDebug(dcEnergyMocks()) << "Mock power switch" << thing->name() << "power" << power
<< "currentPower" << thing->stateValue("currentPower");
}
}
if (thing->thingClassId() == etmVariableLoadThingClassId) {
if (actionType.name() == "powerSetpoint") {
// L'EtmVariableLoadAdapter écrit la consigne ; le mock l'enregistre dans le state.
// currentPowerW (puissance réellement appliquée) reste piloté par le test —
// c'est le thing réel qui choisirait la combinaison matérielle (contrat §3).
double setpoint = action.paramValue(actionType.paramTypes().findByName("powerSetpoint").id()).toDouble();
thing->setStateValue("powerSetpoint", setpoint);
qCDebug(dcEnergyMocks()) << "Mock etmVariableLoad" << thing->name() << "powerSetpoint" << setpoint;
}
}
info->finish(Thing::ThingErrorNoError);
}
void IntegrationPluginEnergyMocks::updateChargerMeter(Thing *thing)
{
Electricity::Phases connectedPhases = Electricity::convertPhasesFromString(thing->paramValue("phases").toString());
bool canSwitchPhases = thing->hasState("desiredPhaseCount");
Electricity::Phases usedPhases = Electricity::PhaseNone;
if (canSwitchPhases) {
uint desiredPhaseCount = thing->stateValue("desiredPhaseCount").toUInt();
if (desiredPhaseCount == 1) {
usedPhases = Electricity::PhaseA;
} else {
usedPhases = Electricity::PhaseAll;
}
thing->setStateValue("usedPhases", Electricity::convertPhasesToString(usedPhases));
} else {
usedPhases = Electricity::convertPhasesFromString(thing->stateValue("usedPhases").toString());
}
uint phaseCount = Electricity::getPhaseCount(usedPhases);
thing->setStateValue("phaseCount", phaseCount);
if (connectedPhases.testFlag(Electricity::PhaseA)) {
thing->setStateValue("voltagePhaseA", 230);
} else {
thing->setStateValue("voltagePhaseA", 0);
}
if (connectedPhases.testFlag(Electricity::PhaseB)) {
thing->setStateValue("voltagePhaseB", 230);
} else {
thing->setStateValue("voltagePhaseB", 0);
}
if (connectedPhases.testFlag(Electricity::PhaseC)) {
thing->setStateValue("voltagePhaseC", 230);
} else {
thing->setStateValue("voltagePhaseC", 0);
}
double maxChargingCurrent = thing->stateValue("maxChargingCurrent").toDouble();
if (thing->stateValue("power").toBool() && thing->stateValue("connected").toBool() && thing->stateValue("pluggedIn").toBool()) {
double currentPower = maxChargingCurrent * 230 * phaseCount;
double phasePower = currentPower / phaseCount;
double phaseAmpere = maxChargingCurrent;
if (usedPhases.testFlag(Electricity::PhaseA)) {
thing->setStateValue("currentPhaseA", phaseAmpere);
thing->setStateValue("currentPowerPhaseA", phasePower);
} else {
thing->setStateValue("currentPhaseA", 0);
thing->setStateValue("currentPowerPhaseA", 0);
}
if (usedPhases.testFlag(Electricity::PhaseB)) {
thing->setStateValue("currentPhaseB", phaseAmpere);
thing->setStateValue("currentPowerPhaseB", phasePower);
} else {
thing->setStateValue("currentPhaseB", 0);
thing->setStateValue("currentPowerPhaseB", 0);
}
if (usedPhases.testFlag(Electricity::PhaseC)) {
thing->setStateValue("currentPhaseC", phaseAmpere);
thing->setStateValue("currentPowerPhaseC", phasePower);
} else {
thing->setStateValue("currentPhaseC", 0);
thing->setStateValue("currentPowerPhaseC", 0);
}
thing->setStateValue("currentPower", currentPower);
thing->setStateValue("charging", true);
} else {
thing->setStateValue("currentPhaseA", 0);
thing->setStateValue("currentPowerPhaseA", 0);
thing->setStateValue("currentPhaseB", 0);
thing->setStateValue("currentPowerPhaseB", 0);
thing->setStateValue("currentPhaseC", 0);
thing->setStateValue("currentPowerPhaseC", 0);
thing->setStateValue("currentPower", 0);
thing->setStateValue("charging", false);
}
}