fix(etm): ECS-412 — rebuild incrémental et armement du verrou à froid
Cause racine, pas symptôme. rebuildLoadAdapters() détruisait TOUS les adaptateurs à chaque SetLoadConfig, réarmant leurs verrous. Sur un ballon thermodynamique à minOn de 300-600 s, un client qui réordonne ses priorités depuis l'app pouvait faire court-cycler son compresseur. C'est de la protection matérielle. L'arbitre mémorise désormais la config ayant servi à construire chaque adaptateur (m_builtFrom) et ne reconstruit que si le MATÉRIEL a changé — type, câblage, paliers, plafond, verrous (sameHardware()). Un changement de rang ou de besoins passe par updateSoftConfig(), en place : ni m_currentStage ni m_lastSwitch ne bougent, aucun relais n'est réécrit. Le log distingue créées / mises à jour / inchangées / retirées. updateSoftConfig est PURE VIRTUELLE, sans implémentation par défaut. Un défaut vide silencieux ferait qu'un futur adaptateur construit depuis LoadConfig ignorerait sans bruit les changements de rang ; là, le compilateur force la décision. EvAdapter et SgReadyAdapter la déclarent sans effet, avec le motif. Démarrage à froid : après un redémarrage de nymead, m_lastSwitch est irrécupérable. lockWindow() traite désormais un horodatage nul comme « commutation venant d'avoir lieu » (elapsed = 0), donc verrou ARMÉ pour sa durée configurée. L'écriture naturelle (`valid && elapsed < minOnS`) fait l'inverse et laisserait une boucle de redémarrage court-circuiter la protection compresseur quand elle est la plus nécessaire. Armer n'est pas verrouiller inconditionnellement : avec une durée nulle, `0 < 0` est faux et le verrou reste inactif — un premier jet qui forçait le verrou a fait tomber trois tests existants, qui avaient raison. Test : testEcsRebuildPreservesLock — SetLoadConfig pendant une fenêtre de verrou active, le relais reste fermé ; le délestage reprend une fois minOn écoulé. Build amd64 0 erreur. Simulation : 12/12. Réf. specs/spec_ecs.md §3 ECS-412 (0.5.1). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1011a6ccd7
commit
c9b8e63f89
@ -106,6 +106,11 @@ public:
|
||||
*/
|
||||
LoadAction applyAction(const LoadAction &action, const QDateTime &now) override;
|
||||
|
||||
//! \brief Met à jour rang et besoins sans reconstruire (ECS-412).
|
||||
//! \param priority Nouveau rang de service. \param needs Nouveaux besoins.
|
||||
void updateSoftConfig(int priority, const LoadNeeds &needs) override
|
||||
{ m_priority = priority; m_needs = needs; }
|
||||
|
||||
/*!
|
||||
* \brief Dernière consigne (W) effectivement écrite (avant écrêtage thing).
|
||||
* \return Consigne commandée en W, bornée à \c maxPowerW ; 0 tant qu'aucune action
|
||||
|
||||
@ -71,6 +71,11 @@ public:
|
||||
*/
|
||||
LoadAction applyAction(const LoadAction &action, const QDateTime &now) override;
|
||||
|
||||
//! \brief Sans effet : l'EV n'est pas construit depuis \c LoadConfig (rang fixé à 100,
|
||||
//! cf. « Dette 3g »). \param priority Ignoré. \param needs Ignoré.
|
||||
void updateSoftConfig(int priority, const LoadNeeds &needs) override
|
||||
{ Q_UNUSED(priority) Q_UNUSED(needs) }
|
||||
|
||||
/*!
|
||||
* \brief Borne VE sous-jacente (lecture).
|
||||
* \return Pointeur non-propriétaire vers l'EvCharger ; jamais nul (fixé à la construction).
|
||||
|
||||
@ -72,4 +72,24 @@ public:
|
||||
* \note Retour silencieux sans effet si \c action.reason est vide.
|
||||
*/
|
||||
virtual LoadAction applyAction(const LoadAction &action, const QDateTime &now) = 0;
|
||||
|
||||
/*!
|
||||
* \brief Met à jour les champs de configuration qui ne touchent PAS le matériel.
|
||||
*
|
||||
* Sert au rebuild INCRÉMENTAL (ECS-412) : quand seul le rang de service ou les besoins
|
||||
* changent, l'arbitre met à jour l'adaptateur en place au lieu de le détruire. Détruire
|
||||
* réarmerait les verrous — sur un ballon thermodynamique à \c minOn de 300 à 600 s, un
|
||||
* client qui réordonne ses priorités depuis l'app pourrait faire court-cycler son
|
||||
* compresseur — et provoquerait des réécritures de relais inutiles.
|
||||
*
|
||||
* \param priority Nouveau rang de service (ASC, 1 = premier servi).
|
||||
* \param needs Nouveaux besoins déclarés.
|
||||
* \warning NE DOIT jamais toucher au câblage, aux paliers ni aux verrous : ces
|
||||
* changements-là exigent une vraie reconstruction.
|
||||
* \note **Pure virtuelle à dessein**, sans implémentation par défaut. Un adaptateur qui
|
||||
* n'est pas construit depuis \c LoadConfig n'a rien à mettre à jour, mais il doit le
|
||||
* DÉCLARER : un défaut vide silencieux ferait qu'un futur adaptateur construit depuis
|
||||
* la config ignorerait sans bruit les changements de rang.
|
||||
*/
|
||||
virtual void updateSoftConfig(int priority, const LoadNeeds &needs) = 0;
|
||||
};
|
||||
|
||||
@ -190,16 +190,34 @@ int RelayRouter::stageForPower(double powerW) const
|
||||
return stage;
|
||||
}
|
||||
|
||||
void RelayRouter::updateSoftConfig(int priority, const LoadNeeds &needs)
|
||||
{
|
||||
// ECS-412 — mise à jour EN PLACE : ni m_currentStage ni m_lastSwitch ne bougent,
|
||||
// aucun relais n'est réécrit.
|
||||
m_priority = priority;
|
||||
m_needs = needs;
|
||||
}
|
||||
|
||||
void RelayRouter::lockWindow(const QDateTime &now, int &minStage, int &maxStage) const
|
||||
{
|
||||
const int topStage = m_levels.size() - 1;
|
||||
const bool valid = m_lastSwitch.isValid();
|
||||
const qint64 elapsed = valid ? m_lastSwitch.secsTo(now) : 0;
|
||||
const int topStage = m_levels.size() - 1;
|
||||
|
||||
// ECS-412 — DÉMARRAGE À FROID. m_lastSwitch nul signifie « on ne sait pas quand la
|
||||
// dernière commutation a eu lieu » (redémarrage de nymead, reconstruction). Le défaut
|
||||
// SÛR est d'ARMER le verrou — traiter la commutation comme venant d'avoir lieu, donc
|
||||
// elapsed = 0 — et non de le purger. L'écriture naturelle (`valid && elapsed < minOnS`)
|
||||
// fait l'inverse et laisserait une boucle de redémarrage court-circuiter la protection
|
||||
// compresseur exactement quand elle est la plus nécessaire.
|
||||
// Armer ne signifie PAS verrouiller inconditionnellement : avec une durée nulle,
|
||||
// `0 < 0` est faux et le verrou reste inactif.
|
||||
const qint64 elapsed = m_lastSwitch.isValid() ? m_lastSwitch.secsTo(now) : 0;
|
||||
const bool onHeld = elapsed < m_minOnS;
|
||||
const bool offHeld = elapsed < m_minOffS;
|
||||
|
||||
// Plancher : si ON et minOn non écoulé → interdit de descendre (puissance engagée non-coupable).
|
||||
minStage = (m_currentStage > 0 && valid && elapsed < m_minOnS) ? m_currentStage : 0;
|
||||
minStage = (m_currentStage > 0 && onHeld) ? m_currentStage : 0;
|
||||
// Plafond : si à l'arrêt et minOff non écoulé → interdit de redémarrer.
|
||||
maxStage = (m_currentStage == 0 && valid && elapsed < m_minOffS) ? 0 : topStage;
|
||||
maxStage = (m_currentStage == 0 && offHeld) ? 0 : topStage;
|
||||
}
|
||||
|
||||
void RelayRouter::applyRelayStage(int stage)
|
||||
|
||||
@ -86,10 +86,24 @@ public:
|
||||
//! \brief Puissance (W) du palier courant.
|
||||
double currentSetpointW() const { return m_currentStage < m_levels.size() ? m_levels.at(m_currentStage) : 0.0; }
|
||||
|
||||
//! \brief Met à jour les champs qui ne touchent PAS le matériel (ECS-412).
|
||||
//! \param priority Nouveau rang de service.
|
||||
//! \param needs Nouveaux besoins déclarés.
|
||||
//! \note Permet à l'arbitre de refléter un changement de priorité SANS détruire
|
||||
//! l'adaptateur — donc sans réarmer les verrous ni recommuter les relais.
|
||||
void updateSoftConfig(int priority, const LoadNeeds &needs) override;
|
||||
|
||||
private:
|
||||
//! Palier le plus haut dont la puissance ≤ \p powerW (≥ 0).
|
||||
int stageForPower(double powerW) const;
|
||||
//! Fenêtre de paliers autorisée à \p now par minOn/minOff (verrou INTERNE, plus exposé).
|
||||
/*!
|
||||
* \brief Fenêtre de paliers autorisée à \p now par minOn/minOff (verrou INTERNE).
|
||||
* \param now Temps de cycle.
|
||||
* \param[out] minStage Palier plancher (puissance engagée non-coupable).
|
||||
* \param[out] maxStage Palier plafond (interdiction de redémarrer).
|
||||
* \note Un \c m_lastSwitch NUL vaut « commutation venant d'avoir lieu », donc verrou
|
||||
* **ARMÉ** pour sa durée configurée — cf. ECS-412, démarrage à froid.
|
||||
*/
|
||||
void lockWindow(const QDateTime &now, int &minStage, int &maxStage) const;
|
||||
void applyRelayStage(int stage);
|
||||
|
||||
|
||||
@ -89,6 +89,12 @@ public:
|
||||
*/
|
||||
LoadAction applyAction(const LoadAction &action, const QDateTime &now) override;
|
||||
|
||||
//! \brief Sans effet : la PAC du banc est codée en dur, pas construite depuis
|
||||
//! \c LoadConfig (à basculer avec la couche config).
|
||||
//! \param priority Ignoré. \param needs Ignoré.
|
||||
void updateSoftConfig(int priority, const LoadNeeds &needs) override
|
||||
{ Q_UNUSED(priority) Q_UNUSED(needs) }
|
||||
|
||||
/*!
|
||||
* \brief État SG-Ready courant (1-4).
|
||||
* \return Dernier état réellement commuté ; valeur initiale tant qu'aucune transition
|
||||
|
||||
@ -134,17 +134,46 @@ void EnergyArbitrator::setLoadConfigStore(LoadConfigStore *store)
|
||||
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;
|
||||
}
|
||||
|
||||
void EnergyArbitrator::rebuildLoadAdapters()
|
||||
{
|
||||
// Purge des adaptateurs construits depuis la config précédente (ILoadAdapter n'est pas QObject :
|
||||
// les concrets le sont — dynamic_cast pour deleteLater).
|
||||
for (ILoadAdapter *a : m_loadAdapters)
|
||||
if (QObject *o = dynamic_cast<QObject *>(a))
|
||||
o->deleteLater();
|
||||
m_loadAdapters.clear();
|
||||
|
||||
if (!m_loadConfigStore)
|
||||
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.
|
||||
@ -155,6 +184,26 @@ void EnergyArbitrator::rebuildLoadAdapters()
|
||||
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()) {
|
||||
@ -168,10 +217,24 @@ void EnergyArbitrator::rebuildLoadAdapters()
|
||||
qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] etmvariableload construit depuis config:"
|
||||
<< c.label() << "(" << (c.isDynamic() ? "dynamic" : "fixed") << ")";
|
||||
}
|
||||
m_loadAdapters[c.id()] = adapter;
|
||||
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).";
|
||||
<< "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)
|
||||
|
||||
@ -6,6 +6,9 @@
|
||||
#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 <QDateTime>
|
||||
|
||||
@ -195,6 +198,12 @@ private:
|
||||
*/
|
||||
void applyActionsToAdapters(const Slot &slot, const QDateTime &now);
|
||||
|
||||
//! \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.
|
||||
static bool sameHardware(const LoadConfig &a, const LoadConfig &b);
|
||||
|
||||
/*!
|
||||
* \brief (Re)construit \c m_loadAdapters depuis \c m_loadConfigStore (rév. 3).
|
||||
* Purge les adaptateurs existants (deleteLater) puis crée, par config \c enabled==true, un
|
||||
@ -235,6 +244,9 @@ private:
|
||||
QHash<QString, EvAdapter *> m_adapters; //!< loadId (ThingId string) → EvAdapter*.
|
||||
QHash<QString, SgReadyAdapter *> m_sgReadyAdapters; //!< loadId → SgReadyAdapter* (PAC).
|
||||
QHash<QString, ILoadAdapter *> m_loadAdapters; //!< loadId → charge pilotée (relay-router | etmvariableload).
|
||||
//! 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<QString, LoadConfig> m_builtFrom;
|
||||
ThingManager *m_tm = nullptr; //!< ThingManager (pour construire les adaptateurs config).
|
||||
LoadConfigStore *m_loadConfigStore = nullptr; //!< Store config charge pilotée (non adopté).
|
||||
|
||||
|
||||
@ -868,6 +868,79 @@ void Simulation::testLoadConfigRelayRouter()
|
||||
#endif
|
||||
}
|
||||
|
||||
void Simulation::testEcsRebuildPreservesLock()
|
||||
{
|
||||
#ifndef ETM_ARBITRATOR
|
||||
QSKIP("testEcsRebuildPreservesLock nécessite ETM_ARBITRATOR.");
|
||||
#else
|
||||
// [ECS-412] SetLoadConfig PENDANT une fenêtre de verrou active : un changement de rang
|
||||
// ne doit pas détruire l'adaptateur, donc ne doit pas réarmer le verrou. C'est de la
|
||||
// protection matérielle : sur un ballon thermodynamique à minOn de 300-600 s, réordonner
|
||||
// ses priorités depuis l'app ferait court-cycler le compresseur.
|
||||
cleanupTestCase();
|
||||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||||
initTestCase();
|
||||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||||
QVERIFY(arb);
|
||||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||||
|
||||
QUuid meterId = addMeter();
|
||||
m_experiencePlugin->energyManager()->setRootMeter(meterId);
|
||||
Thing *meter = tm->findConfiguredThing(meterId);
|
||||
QVERIFY(meter);
|
||||
meter->setStateValue("connected", true);
|
||||
|
||||
QUuid rA = addPowerSwitch(2000, 26661);
|
||||
Thing *tA = tm->findConfiguredThing(rA);
|
||||
QVERIFY(tA);
|
||||
|
||||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-rebuildlock.json";
|
||||
QFile::remove(cfgPath);
|
||||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||||
LoadConfigStore *store = new LoadConfigStore(arb);
|
||||
arb->setLoadConfigStore(store);
|
||||
|
||||
auto cfg = [&](int priority) {
|
||||
LoadConfigs cs;
|
||||
cs.append(LoadConfig::fromMap(QVariantMap{
|
||||
{"id", "ecs-verrou"}, {"label", "ECS verrouillé"}, {"adapter", "relay-router"},
|
||||
{"mode", "fixed"}, {"priority", priority}, {"enabled", true},
|
||||
{"relays", QVariantList() << QVariantMap{{"thingId", rA.toString()}, {"powerW", 2000}}},
|
||||
{"minOnS", 300}, {"minOffS", 0}}));
|
||||
return cs;
|
||||
};
|
||||
|
||||
QString err;
|
||||
QVERIFY2(store->setConfigs(cfg(1), &err), err.toUtf8());
|
||||
|
||||
// Cycle 1 : surplus large → le relais se ferme, le verrou minOn s'arme.
|
||||
meter->setStateValue("currentPower", -2500);
|
||||
arb->simulationCallUpdate(utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)));
|
||||
QCoreApplication::processEvents();
|
||||
QCOMPARE(tA->stateValue("power").toBool(), true);
|
||||
|
||||
// SetLoadConfig PENDANT la fenêtre de verrou : SEUL le rang change.
|
||||
QVERIFY2(store->setConfigs(cfg(2), &err), err.toUtf8());
|
||||
QCoreApplication::processEvents();
|
||||
|
||||
// Cycle 2, 60 s plus tard, surplus effondré. Si le rebuild avait détruit l'adaptateur,
|
||||
// le verrou serait réarmé sur un palier 0 et le relais s'ouvrirait. Il doit RESTER fermé :
|
||||
// minOn court toujours depuis le cycle 1.
|
||||
meter->setStateValue("currentPower", 100); // import : budget négatif
|
||||
arb->simulationCallUpdate(utcDateTime(QDate(2026, 6, 8), QTime(13, 1, 0)));
|
||||
QCoreApplication::processEvents();
|
||||
QCOMPARE(tA->stateValue("power").toBool(), true);
|
||||
|
||||
// Au-delà de minOn, le délestage reprend normalement.
|
||||
arb->simulationCallUpdate(utcDateTime(QDate(2026, 6, 8), QTime(13, 6, 0)));
|
||||
QCoreApplication::processEvents();
|
||||
QCOMPARE(tA->stateValue("power").toBool(), false);
|
||||
|
||||
qunsetenv("NYMEA_ENERGY_LOAD_CONFIG");
|
||||
QFile::remove(cfgPath);
|
||||
#endif
|
||||
}
|
||||
|
||||
void Simulation::run_data()
|
||||
{
|
||||
// Simulation infos
|
||||
|
||||
@ -85,6 +85,10 @@ private slots:
|
||||
// (EnergyRatiosInterim.compute) — seed, normal, clamp, den≤0→n/a, non-monotone, nouveau jour.
|
||||
void testEnergyRatiosAlignment();
|
||||
|
||||
// [étape 1 / ECS-412] SetLoadConfig pendant une fenêtre de verrou active ne réarme pas
|
||||
// le verrou : seul le matériel modifié est reconstruit. Couvre aussi l'armement à froid.
|
||||
void testEcsRebuildPreservesLock();
|
||||
|
||||
void printStates(Thing *thing);
|
||||
void updateChargerMeter(Thing *thing);
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user