// SPDX-License-Identifier: GPL-3.0-or-later // Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync #include "loadconfigstore.h" #include "plugininfo.h" #include #include #include #include #include #include #include #include #include #include namespace { //! L'identifiant tel qu'il entre dans un motif : entier s'il est bien formé (donc borné), sinon //! ses 64 premiers caractères — une configuration héritée peut porter n'importe quoi. QString idPourMotif(const QString &id) { if (LoadConfigStore::idIsWellFormed(id) || id.toUcs4().size() <= 64) return id; return LoadConfigStore::leftChars(id, 64) + QStringLiteral("…"); } //! Une valeur saisie citée dans un motif : assez pour la reconnaître, jamais en entier. QString valeurPourMotif(const QString &valeur) { return valeur.toUcs4().size() <= 16 ? valeur : LoadConfigStore::leftChars(valeur, 16) + QStringLiteral("…"); } //! Un libellé cité dans un motif : ses 64 premiers caractères. Celui d'une entrée héritée n'est //! pas borné, et l'ajout automatique d'une borne journalise ce motif (relecture q7-suite, m-1). QString libellePourMotif(const QString &libelle) { return libelle.toUcs4().size() <= 64 ? libelle : LoadConfigStore::leftChars(libelle, 64) + QStringLiteral("…"); } //! Une valeur citée au journal, entre guillemets et ÉCHAPPÉE par Qt (\c "\n" pour un saut de //! ligne) : une valeur persistée par un client ne fabrique pas de fausse ligne de journal. QString citee(const QString &valeur) { QString s; QDebug(&s).nospace() << valeur; return s; } //! Le motif publiable d'un refus PAR ENTRÉE : la cause, la position, et l'identifiant quand il //! est bien formé — un identifiant refusé ne se recopie pas. QVariantMap motifEntree(const char *cause, int index, const QString &id) { QVariantMap m{{QStringLiteral("cause"), QLatin1String(cause)}, {QStringLiteral("index"), index}}; if (LoadConfigStore::idIsWellFormed(id)) m.insert(QStringLiteral("id"), id); return m; } } LoadConfigStore::LoadConfigStore(QObject *parent) : QObject{parent} { if (load()) qCDebug(dcNymeaEnergy()) << "[LoadConfigStore]" << m_configs.count() << "config(s) de charge pilotée chargée(s) depuis" << filePath(); else qCDebug(dcNymeaEnergy()) << "[LoadConfigStore] Aucune config de charge pilotée — démarrage à vide."; } QString LoadConfigStore::filePath() const { const QString env = QString(qgetenv("NYMEA_ENERGY_LOAD_CONFIG")); if (!env.isEmpty()) return env; return NymeaSettings::storagePath() + "/energy-load-configuration.json"; } bool LoadConfigStore::load() { const QString path = filePath(); if (!QFileInfo::exists(path)) return false; QFile file(path); if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { qCWarning(dcNymeaEnergy()) << "[LoadConfigStore] Ouverture impossible:" << path << file.errorString(); return false; } const QByteArray data = file.readAll(); file.close(); QJsonParseError err; const QJsonDocument doc = QJsonDocument::fromJson(data, &err); if (err.error != QJsonParseError::NoError) { qCWarning(dcNymeaEnergy()) << "[LoadConfigStore] JSON invalide:" << err.errorString(); return false; } LoadConfigs loaded; const QJsonArray loads = doc.object().value("loads").toArray(); for (const QJsonValue &v : loads) { const LoadConfig c = LoadConfig::fromMap(v.toObject().toVariantMap()); QString why; if (!c.isValid(&why)) { // Tolérance ascendante : on ignore une entrée corrompue plutôt que de tout perdre. qCWarning(dcNymeaEnergy()) << "[LoadConfigStore] Entrée ignorée (invalide):" << why; continue; } // Conflit d'ensemble : on écarte l'entrée fautive (la PREMIÈRE lue garde son Thing) // plutôt que de rejeter tout le fichier — même tolérance ascendante que ci-dessus. // Écarter est ici plus sûr que charger : deux adaptateurs sur un organe se // contrediraient, alors qu'une charge absente ne fait que ne rien commander. LoadConfigs essai = loaded; essai.append(c); QString conflit; if (!validateSet(essai, &conflit)) { qCWarning(dcNymeaEnergy()) << "[LoadConfigStore] Entrée écartée (conflit d'ensemble):" << conflit; continue; } loaded.append(c); } // LM-1209-a — les doublons de rang sont SIGNALÉS, jamais écartés. Le contrôle de bonne // forme (validateRanks) ne s'applique qu'aux écritures : ici, écarter ferait disparaître // des charges de la configuration ET de l'arbitrage, sans applySafeState, sur la seule foi // d'une règle de contrat resserrée après coup. La première écriture cliente répare. { QString pourquoi; if (!validateRanks(loaded, &pourquoi)) qCWarning(dcNymeaEnergy()) << "[LoadConfigStore] CONFIGURATION CONSERVÉE MAIS MAL FORMÉE :" << pourquoi << "— l'ordre de service reste reproductible (tri sur rang PUIS identifiant)," << "mais il n'est PAS réglable tant que les rangs sont à égalité : il obéit à" << "des identifiants, donc à personne. À réparer par un SetLoadConfig."; } // Q7 — même règle pour un libellé trop long : CHARGÉ TEL QUEL, jamais tronqué ni écarté. Le // client d'optimiseur refuse d'émettre un GetPlan de plus de 1 Mio ; c'est lui le filet. // Chaque entrée est signalée, pas seulement la première : c'est la liste des réparations. // Les valeurs persistées sont citées ÉCHAPPÉES (citee(), ou `<<` sans noquote()) : avant Q7, // un client pouvait écrire un identifiant ou une échéance portant un saut de ligne. for (int i = 0; i < loaded.count(); ++i) { QString pourquoi; if (!validateLabels(LoadConfigs(QList{loaded.at(i)}), &pourquoi)) qCWarning(dcNymeaEnergy()) << "[LoadConfigStore] CONFIGURATION CONSERVÉE, LIBELLÉ TROP LONG :" << pourquoi << "— chargé tel quel ; toute écriture cliente sera refusée tant qu'il n'est pas raccourci."; if (!idIsWellFormed(loaded.at(i).id())) qCWarning(dcNymeaEnergy()).noquote() << QStringLiteral("[LoadConfigStore] CONFIGURATION CONSERVÉE, IDENTIFIANT HORS RÈGLE : " "charge %1 (%2 caractères ; attendu 1 à %3 caractères " "[A-Za-z0-9._{}-]) — chargée telle quelle ; toute écriture cliente " "sera refusée tant qu'elle porte cet identifiant.") .arg(citee(idPourMotif(loaded.at(i).id()))).arg(loaded.at(i).id().toUcs4().size()) .arg(MaxIdChars); // L'échéance, elle, est RETIRÉE de la mémoire — l'entrée reste. Une échéance illisible // ne se résout en aucun instant ; la garder la ferait partir brute dans GetPlan, où // l'optimiseur devrait deviner ce qu'elle veut dire. Sans elle, une obligation en énergie // n'a plus de cadence et rien n'est acheté pour elle : le côté sûr. Le fichier n'est pas // réécrit ici ; la première écriture cliente le remplacera. const QString echeance = loaded.at(i).needs().dailyDeadline(); if (!deadlineIsWellFormed(echeance)) { qCWarning(dcNymeaEnergy()).noquote() << QStringLiteral("[LoadConfigStore] CONFIGURATION CONSERVÉE, ÉCHÉANCE ILLISIBLE : " "charge %1, needs.dailyDeadline %2 (attendu HH:MM, de 00:00 " "à 23:59) — IGNORÉE : ni l'arbitre ni l'optimiseur ne la reçoivent, " "l'obligation n'a plus d'échéance.") .arg(citee(idPourMotif(loaded.at(i).id())), citee(valeurPourMotif(echeance))); LoadConfigNeeds besoins = loaded.at(i).needs(); besoins.setDailyDeadline(QString()); loaded[i].setNeeds(besoins); } } m_configs = loaded; return true; } bool LoadConfigStore::validateSet(const LoadConfigs &configs, QString *error, QVariantMap *fault) { auto fail = [error, fault](const QString &why, const QVariantMap &motif) { if (error) *error = why; if (fault) *fault = motif; return false; }; QSet ids; QHash proprietaire; // ThingId normalisé → index de la charge for (int i = 0; i < configs.count(); ++i) { const LoadConfig &c = configs.at(i); if (ids.contains(c.id())) return fail(QStringLiteral("l'identifiant de charge \"%1\" apparaît deux fois").arg(idPourMotif(c.id())), motifEntree("ID_DUPLICATE", i, c.id())); ids.insert(c.id()); if (!c.enabled()) continue; // ne construit aucun adaptateur : ne revendique rien // \rule{ECS-110-b} — un Thing n'appartient qu'à UNE charge active, et c'est ce qui // tient \rule{LM-400} : une machine physique est UNE seule charge, jamais déclarée // deux fois. \rule{LM-302-b} : // l'exclusivité ne peut PAS se valider dans une charge utile, aucune charge ne voyant // les autres — d'où ce contrôle d'ENSEMBLE, et d'où le fait qu'il soit distinct de // isValid(). for (const QString &thing : c.claimedThingIds()) { if (proprietaire.contains(thing)) { const int j = proprietaire.value(thing); return fail(QStringLiteral("le Thing %1 est commandé par deux charges actives, " "\"%2\" et \"%3\" : deux commandes contradictoires et " "une puissance comptée deux fois") .arg(thing, libellePourMotif(configs.at(j).label()), libellePourMotif(c.label())), QVariantMap{{QStringLiteral("cause"), QStringLiteral("THING_SHARED")}, {QStringLiteral("indexes"), QVariantList{j, i}}, {QStringLiteral("ids"), QVariantList{configs.at(j).id(), c.id()}}}); } proprietaire.insert(thing, i); } } return true; } bool LoadConfigStore::validateRanks(const LoadConfigs &configs, QString *error, QVariantMap *fault) { QHash parRang; // rang → index de la première charge qui le porte for (int i = 0; i < configs.count(); ++i) { const LoadConfig &c = configs.at(i); if (parRang.contains(c.priority())) { const int j = parRang.value(c.priority()); if (error) *error = QStringLiteral("les charges \"%1\" et \"%2\" portent toutes deux le rang " "%3 : leur ordre de service serait stable mais NON " "RÉGLABLE — il se déciderait sur leurs identifiants") .arg(libellePourMotif(configs.at(j).label()), libellePourMotif(c.label())) .arg(c.priority()); if (fault) *fault = QVariantMap{{QStringLiteral("cause"), QStringLiteral("RANK_DUPLICATE")}, {QStringLiteral("indexes"), QVariantList{j, i}}, {QStringLiteral("ids"), QVariantList{configs.at(j).id(), c.id()}}, {QStringLiteral("rank"), c.priority()}}; return false; } parRang.insert(c.priority(), i); } return true; } bool LoadConfigStore::reconcileRankOrigin(const LoadConfigs &incoming, LoadConfigs *out, QString *error, QVariantMap *fault) const { QHash stocke; for (const LoadConfig &c : m_configs) stocke.insert(c.id(), &c); LoadConfigs resultat; for (int i = 0; i < incoming.count(); ++i) { LoadConfig c = incoming.at(i); const LoadConfig *avant = stocke.value(c.id(), nullptr); const QString marqueAvant = avant ? avant->rankOrigin() : QString(); // Règle 3 — un client ne FABRIQUE pas un "auto". La seule tolérance est l'aller-retour // verbatim d'une entrée qui le porte déjà : sans elle, la neutralité Get→Set du // contrat serait rompue et tout client renvoyant ce qu'il a lu se verrait refusé. if (c.rankOrigin() == QLatin1String("auto") && marqueAvant != QLatin1String("auto")) { if (error) *error = QStringLiteral("la charge \"%1\" prétend un rankOrigin \"auto\" que le " "moteur n'a pas posé : c'est une affirmation sur un geste " "du moteur, elle ne s'écrit pas depuis un client") .arg(c.label()); if (fault) *fault = motifEntree("RANK_ORIGIN_AUTO", i, c.id()); return false; } if (avant && c.priority() != avant->priority()) { // Règle 1 — le rang a CHANGÉ : quelqu'un a classé la charge. La marque tombe, et // elle tombe vers le VIDE, pas vers "user" : le moteur constate un déplacement, il // ne sait pas si un humain ou un script l'a voulu. Ne rien affirmer est le // minimum honnête (LM-1209-c). c.setRankOrigin(QString()); } else if (avant && c.rankOrigin().isEmpty()) { // Règle 2 — rang INCHANGÉ et marque omise : on CONSERVE. SetLoadConfig remplace en // bloc, donc réordonner une AUTRE charge renvoie forcément celle-ci ; l'effacer à // cette occasion perdrait une marque encore vraie. L'effacement porte sur le RANG, // jamais sur la réception d'une écriture. c.setRankOrigin(marqueAvant); } // Reste le cas « rang inchangé, marque explicite » : elle est prise telle quelle — // c'est ainsi qu'un client lève la marque en envoyant "user", sans déplacer la charge. resultat.append(c); } if (out) *out = resultat; return true; } bool LoadConfigStore::validateLabels(const LoadConfigs &configs, QString *error, QVariantMap *fault, int from) { for (int i = qMax(0, from); i < configs.count(); ++i) { const LoadConfig &c = configs.at(i); const qsizetype caracteres = c.label().toUcs4().size(); if (caracteres > MaxLabelChars) { if (error) *error = QStringLiteral("le libellé de la charge \"%1\" compte %2 caractères, %3 au plus") .arg(idPourMotif(c.id())).arg(caracteres).arg(MaxLabelChars); if (fault) { *fault = motifEntree("LABEL", i, c.id()); fault->insert(QStringLiteral("length"), caracteres); fault->insert(QStringLiteral("max"), MaxLabelChars); } return false; } } return true; } bool LoadConfigStore::idIsWellFormed(const QString &id) { // Le jeu est ASCII : un caractère y vaut une unité UTF-16, et size() compte des caractères. static const QRegularExpression forme( QRegularExpression::anchoredPattern(QStringLiteral("[A-Za-z0-9._{}-]+"))); return id.size() <= MaxIdChars && forme.match(id).hasMatch(); } bool LoadConfigStore::validateIds(const LoadConfigs &configs, QString *error, QVariantMap *fault, int from) { static const QRegularExpression horsJeu(QStringLiteral("[^A-Za-z0-9._{}-]")); for (int i = qMax(0, from); i < configs.count(); ++i) { const QString id = configs.at(i).id(); if (idIsWellFormed(id)) continue; const qsizetype caracteres = id.toUcs4().size(); // La position du premier caractère hors jeu, en caractères — pas en unités UTF-16. const QRegularExpressionMatch m = horsJeu.match(id); const qsizetype position = m.hasMatch() ? id.left(m.capturedStart()).toUcs4().size() : -1; if (error) { *error = caracteres == 0 ? QStringLiteral("l'identifiant de la charge en position %1 est vide").arg(i + 1) : position >= 0 ? QStringLiteral("l'identifiant de la charge en position %1 contient un caractère hors " "[A-Za-z0-9._{}-] (caractère %2)").arg(i + 1).arg(position + 1) : QStringLiteral("l'identifiant de la charge en position %1 compte %2 caractères, %3 au plus") .arg(i + 1).arg(caracteres).arg(MaxIdChars); } if (fault) { *fault = QVariantMap{{QStringLiteral("cause"), QStringLiteral("ID")}, {QStringLiteral("index"), i}, {QStringLiteral("length"), caracteres}, {QStringLiteral("max"), MaxIdChars}}; if (position >= 0) fault->insert(QStringLiteral("position"), position); } return false; } return true; } bool LoadConfigStore::deadlineIsWellFormed(const QString &deadline) { // anchoredPattern, et non ^…$ : « $ » accepterait un saut de ligne final. static const QRegularExpression forme( QRegularExpression::anchoredPattern(QStringLiteral("([01][0-9]|2[0-3]):[0-5][0-9]"))); return deadline.isEmpty() || forme.match(deadline).hasMatch(); } bool LoadConfigStore::validateDeadlines(const LoadConfigs &configs, QString *error, QVariantMap *fault, int from) { for (int i = qMax(0, from); i < configs.count(); ++i) { const LoadConfig &c = configs.at(i); if (deadlineIsWellFormed(c.needs().dailyDeadline())) continue; if (error) *error = QStringLiteral("l'échéance quotidienne de la charge \"%1\" est illisible (\"%2\") : " "attendu HH:MM, de 00:00 à 23:59, ou absente") .arg(idPourMotif(c.id()), valeurPourMotif(c.needs().dailyDeadline())); if (fault) *fault = motifEntree("DEADLINE", i, c.id()); return false; } return true; } bool LoadConfigStore::validateBounds(const LoadConfigs &configs, QString *error, QVariantMap *fault, int from) { // L'identifiant D'ABORD : tous les motifs suivants le recopient. return validateIds(configs, error, fault, from) && validateLabels(configs, error, fault, from) && validateDeadlines(configs, error, fault, from); } QString LoadConfigStore::leftChars(const QString &texte, int n) { // Couper en points de code puis recomposer : une paire de substitution reste entière. const QList points = texte.toUcs4(); if (points.size() <= n) return texte; return QString::fromUcs4(reinterpret_cast(points.constData()), qMax(0, n)); } bool LoadConfigStore::setConfigs(const LoadConfigs &configs, QString *error, QVariantMap *fault) { return commit(configs, 0, error, fault); } bool LoadConfigStore::appendConfig(const LoadConfig &config, QString *error, QVariantMap *fault) { LoadConfigs configs = m_configs; configs.append(config); // Les bornes de saisie ne portent que sur l'entrée APPORTÉE — voir la déclaration. return commit(configs, configs.count() - 1, error, fault); } bool LoadConfigStore::commit(const LoadConfigs &configs, int premierBorne, QString *error, QVariantMap *fault) { // Les bornes de saisie D'ABORD : les motifs de refus suivants citent l'identifiant et le // libellé, et une saisie démesurée ne doit entrer ni dans la configuration ni dans le // journal (Q7). if (!validateBounds(configs, error, fault, premierBorne)) return false; // Validation EN BLOC : une seule entrée invalide → rejet total (rien persisté). for (int i = 0; i < configs.count(); ++i) { const LoadConfig &c = configs.at(i); QString why; QVariantMap motif; if (!c.isValid(&why, &motif)) { if (error) *error = QStringLiteral("Config \"%1\" invalide : %2").arg(c.label(), why); if (fault) { *fault = motif; fault->insert(QStringLiteral("index"), i); if (idIsWellFormed(c.id())) fault->insert(QStringLiteral("id"), c.id()); } return false; } } // …puis la cohérence d'ensemble, qu'aucune charge ne peut vérifier seule. if (!validateSet(configs, error, fault)) return false; // …puis la BONNE FORME, qui ne s'applique qu'ici. load() n'écarte rien sur sa foi : elle // refuse une écriture, elle n'écarte jamais une entrée déjà persistée (LM-1209-a). if (!validateRanks(configs, error, fault)) return false; const LoadConfigs previous = m_configs; m_configs = configs; QString echec; if (!save(&echec)) { m_configs = previous; // rollback mémoire si l'écriture échoue // La cause système est dite ICI : le refus de SetLoadConfig ne journalise que son motif, // et WRITE_FAILED ne dit pas pourquoi le disque a refusé. Aucune saisie n'y entre. qCWarning(dcNymeaEnergy()) << "[LoadConfigStore] Écriture de la configuration échouée :" << filePath() << echec; if (error) *error = echec; if (fault) *fault = QVariantMap{{QStringLiteral("cause"), QStringLiteral("WRITE_FAILED")}}; return false; } emit changed(); return true; } bool LoadConfigStore::save(QString *error) const { QJsonArray loads; for (const LoadConfig &c : m_configs) loads.append(QJsonObject::fromVariantMap(c.toMap())); QJsonObject root; root.insert("version", 1); root.insert("loads", loads); // Écriture atomique (QSaveFile = tmp + rename) — jamais de fichier mi-écrit. QSaveFile file(filePath()); if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { if (error) *error = QStringLiteral("Écriture impossible : %1").arg(file.errorString()); return false; } file.write(QJsonDocument(root).toJson(QJsonDocument::Indented)); if (!file.commit()) { if (error) *error = QStringLiteral("Commit échoué : %1").arg(file.errorString()); return false; } qCDebug(dcNymeaEnergy()) << "[LoadConfigStore]" << m_configs.count() << "config(s) persistée(s) dans" << filePath(); return true; }