diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index 4c69651..1731cd5 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -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é."; } diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index 7e32c9b..66a93fc 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -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(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 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 diff --git a/tests/auto/simulation/simulation.h b/tests/auto/simulation/simulation.h index 12782e4..edfacb8 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -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