arbitre : borne ajoutée d'office même au nom trop long, libellé coupé à 256 caractères (Q7)

Écart « important » de la relecture d'etm72. provisionEvChargerConfig()
prenait le nom du Thing pour libellé et réécrivait l'ensemble complet : un
nom de plus de 256 caractères, ou un libellé hérité trop long sur une AUTRE
entrée, refusait l'ajout, et la borne restait arbitrée sans rang réglable.

- Le libellé est le nom coupé à 256 points de code (LoadConfigStore::
  leftChars), jamais au milieu d'une paire de substitution.
- L'ajout passe par appendConfig() : les bornes de saisie ne portent que
  sur l'entrée apportée. L'écriture cliente, elle, reste refusée tant que
  l'hérité n'est pas raccourci (règle d'etm72 inchangée).
- Le journal nomme la borne par ce libellé coupé.

Preuve : testAutoProvisionedLabelIsCutNeverRefused (nom de 300 caractères,
emoji en 256e position, hérité de 300 caractères sur une autre entrée).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-10-22 20:23:37 +02:00
parent f7aa370acb
commit a728a3c8da
3 changed files with 92 additions and 5 deletions

View File

@ -1662,7 +1662,7 @@ void EnergyArbitrator::provisionEvChargerConfig(EvCharger *ev)
return;
const QString id = ev->thing()->id().toString();
LoadConfigs configs = m_loadConfigStore->configs();
const LoadConfigs configs = m_loadConfigStore->configs();
// LM-1209 — EN QUEUE, jamais en tête. L'auto-provisionnement REMPLIT UN TROU : sans entrée,
// la borne serait arbitrée sans rang réglable. Il ne s'ensuit rien sur sa PLACE — le rang
@ -1688,7 +1688,10 @@ void EnergyArbitrator::provisionEvChargerConfig(EvCharger *ev)
LoadConfig c;
c.setId(id);
c.setLabel(ev->name());
// Q7 — le libellé est le nom du Thing, COUPÉ à la borne de saisie (en caractères, jamais au
// milieu d'une paire de substitution). Un nom plus long n'est pas une raison de laisser la
// borne sans rang réglable : le libellé n'est qu'un nom, l'installateur le renomme.
c.setLabel(LoadConfigStore::leftChars(ev->name(), LoadConfigStore::MaxLabelChars));
c.setAdapter(QStringLiteral("evcharger"));
c.setMode(QStringLiteral("dynamic"));
// Le domaine d'une borne n'est pas une devinette : c'est le mécanisme lui-même. La réserve
@ -1704,10 +1707,12 @@ void EnergyArbitrator::provisionEvChargerConfig(EvCharger *ev)
// rang est un choix » — un client ne peut pas le deviner, rien dans le nombre ne le dit.
c.setRankOrigin(QStringLiteral("auto"));
c.setEnabled(true);
configs.append(c);
// appendConfig() et non setConfigs() : les bornes de saisie ne portent que sur l'entrée
// AJOUTÉE. Un libellé hérité trop long sur une AUTRE charge refuserait sinon l'ajout — le
// moteur ne sait pas le réparer, et c'est l'installateur qu'il attend (relecture etm72).
QString erreur;
if (!m_loadConfigStore->setConfigs(configs, &erreur)) {
if (!m_loadConfigStore->appendConfig(c, &erreur)) {
// 7-c — l'échec ne doit pas être plus discret que le succès : sans entrée, la borne
// reste arbitrée mais absente de GetLoadConfig, et c'est précisément le trou qu'on
// vient refermer.
@ -1720,7 +1725,7 @@ void EnergyArbitrator::provisionEvChargerConfig(EvCharger *ev)
if (!m_provisionRefuse.contains(id)) {
m_provisionRefuse.insert(id);
qCWarning(dcNymeaEnergy()) << "[EnergyArbitrator] Configuration automatique de la borne"
<< ev->name() << "REFUSÉE :" << erreur
<< c.label() << "REFUSÉE :" << erreur
<< "— elle sera arbitrée sans rang configurable. Réessayé"
<< "à chaque cycle ; cet avertissement ne sera pas répété.";
}

View File

@ -6250,6 +6250,83 @@ void Simulation::testSetLoadConfigRefusalIsPublished()
#endif
}
void Simulation::testAutoProvisionedLabelIsCutNeverRefused()
{
#ifndef ETM_ARBITRATOR
QSKIP("testAutoProvisionedLabelIsCutNeverRefused nécessite ETM_ARBITRATOR.");
#else
// [Q7, suite — relecture etm72, écart « important »] La borne prenait le nom de son Thing pour
// libellé et passait par l'écriture complète : un nom de plus de 256 caractères, ou un libellé
// hérité trop long sur une AUTRE entrée, refusait l'ajout, et la borne restait arbitrée sans
// rang réglable. Le nom est désormais coupé, et l'ajout ne borne que ce qu'il apporte.
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-nomlong.json";
QFile::remove(cfgPath);
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
const auto nettoyer = qScopeGuard([&] {
qunsetenv("NYMEA_ENERGY_LOAD_CONFIG");
QFile::remove(cfgPath);
});
// Une configuration HÉRITÉE dont une autre entrée porte un libellé de 300 caractères.
const QString libelleHerite(300, QLatin1Char('L'));
{
QFile f(cfgPath);
QVERIFY(f.open(QIODevice::WriteOnly | QIODevice::Text));
f.write(QJsonDocument(QJsonObject{{"version", 1}, {"loads", QJsonArray{QJsonObject::fromVariantMap({
{"id", "ballon-herite"}, {"label", libelleHerite}, {"adapter", "relay-router"}, {"mode", "fixed"},
{"priority", 1}, {"enabled", false}, {"minOnS", 0}, {"minOffS", 0},
{"relays", QVariantList{QVariantMap{{"thingId", "{aaaaaaaa-0000-4000-8000-0000000000e1}"},
{"powerW", 1500}}}}})}}}).toJson());
}
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);
meter->setStateValue("currentPower", -4000);
QUuid borneId = addCharger("A", 16, 26657);
Thing *borne = tm->findConfiguredThing(borneId);
QVERIFY(borne);
// 300 caractères, un emoji en 256e position : couper en unités UTF-16 le briserait en deux.
const QString prise = QString::fromUcs4(U"\U0001F50C");
const QString nom = QString(255, QLatin1Char('a')) + prise + QString(44, QLatin1Char('b'));
QCOMPARE(nom.toUcs4().size(), qsizetype(300));
borne->setName(nom);
armerBorne(borne);
arb->simulationCallUpdate(utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)));
QCoreApplication::processEvents();
const QHash<QString, QVariantMap> cfg = configsParId(injectAndWait("NymeaEnergy.GetLoadConfig"));
QVERIFY2(cfg.contains(borneId.toString()), "la borne au nom de 300 caractères n'a pas été ajoutée");
const QString libelle = cfg.value(borneId.toString()).value("label").toString();
QCOMPARE(libelle, QString(255, QLatin1Char('a')) + prise); // la paire est ENTIÈRE
QCOMPARE(libelle.toUcs4().size(), qsizetype(LoadConfigStore::MaxLabelChars));
QCOMPARE(cfg.value(borneId.toString()).value("rankOrigin").toString(), QStringLiteral("auto"));
QCOMPARE(cfg.value(borneId.toString()).value("priority").toInt(), 2); // en queue
// L'entrée héritée est intacte : le moteur n'a rien « réparé » d'office.
QCOMPARE(cfg.value("ballon-herite").value("label").toString(), libelleHerite);
// CONTRE-ÉPREUVE en place : le libellé hérité dépasse BIEN la borne — l'écho client de ce
// même ensemble est refusé, sur lui. C'est l'ajout du moteur qui en est exempté, pas l'hérité
// qui serait passé inaperçu.
const QVariantList ensemble = injectAndWait("NymeaEnergy.GetLoadConfig").toMap()
.value("params").toMap().value("loadConfigs").toList();
const QVariantMap p = injectAndWait("NymeaEnergy.SetLoadConfig", {{"loadConfigs", ensemble}})
.toMap().value("params").toMap();
QCOMPARE(p.value("energyError").toString(), QStringLiteral("EnergyErrorInvalidParameter"));
QCOMPARE(p.value("faultParams").toMap().value("cause").toString(), QStringLiteral("LABEL"));
QCOMPARE(p.value("faultParams").toMap().value("id").toString(), QStringLiteral("ballon-herite"));
#endif
}
void Simulation::testVanishedRelayFreezesTheLoad()
{
#ifndef ETM_ARBITRATOR

View File

@ -254,6 +254,11 @@ private slots:
// faultParams), déclarée dans le schéma, sans rien modifier — mémoire, GetLoadConfig, disque.
void testSetLoadConfigRefusalIsPublished();
// [Q7, suite — relecture etm72] Une borne au nom de 300 caractères est AJOUTÉE, son libellé
// coupé à 256 caractères sans couper de paire de substitution ; un libellé hérité trop long
// sur une autre entrée ne bloque pas l'ajout.
void testAutoProvisionedLabelIsCutNeverRefused();
// [ECS-111] Un relais DÉCLARÉ qui disparaît du registre nymea : la charge sort de
// l'arbitrage (available faux, THING_MISSING) et reste dans la comptabilité, figée à la
// puissance crue engagée. Avant, elle publiait le nominal du palier entier — part du