Le refus garde EnergyErrorInvalidParameter et gagne, comme SetTariff, un
`fault` LOAD_CONFIG_REFUSED et des `faultParams` : une cause par motif
existant (ID, LABEL, DEADLINE, MODE, DOMAIN, RANK_ORIGIN, RANK_ORIGIN_AUTO,
ADAPTER, FIELD_*, SG_READY_STATE_2, ID_DUPLICATE, THING_SHARED,
RANK_DUPLICATE, WRITE_FAILED, STORE_UNAVAILABLE) et l'endroit (index, id
quand il est bien formé, field, state, indexes/ids, rank, length/max,
position). Jamais le libellé. Les deux champs sont déclarés optionnels dans
le schéma ; un succès rend exactement ce qu'il rendait.
- id : 1 à 128 caractères de [A-Za-z0-9._{}-], refusé à l'écriture ;
hérité hors règle, chargé tel quel et signalé.
- needs.dailyDeadline : vide ou HH:MM (00:00-23:59), refusé à l'écriture ;
hérité illisible, l'entrée est chargée, l'échéance retirée de la mémoire
(ni l'arbitre ni l'optimiseur ne la reçoivent) et signalée.
- Les bornes de saisie passent avant la réconciliation de rankOrigin, dont le
motif cite le libellé.
- appendConfig() : l'ajout par le moteur ne borne que l'entrée ajoutée.
- leftChars() coupe en caractères sans couper une paire de substitution ;
l'id d'un motif n'est plus coupé par QString::left().
- Au chargement, chaque libellé trop long est signalé, pas seulement le
premier ; loadconfigstore.h dit « n'écarte rien au chargement ».
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
468 lines
21 KiB
C++
468 lines
21 KiB
C++
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
|
|
|
#include "loadconfigstore.h"
|
|
#include "plugininfo.h"
|
|
|
|
#include <nymeasettings.h>
|
|
|
|
#include <QFile>
|
|
#include <QFileInfo>
|
|
#include <QSaveFile>
|
|
#include <QJsonDocument>
|
|
#include <QJsonObject>
|
|
#include <QJsonArray>
|
|
#include <QJsonParseError>
|
|
#include <QRegularExpression>
|
|
|
|
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("…");
|
|
}
|
|
|
|
//! 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.
|
|
for (int i = 0; i < loaded.count(); ++i) {
|
|
QString pourquoi;
|
|
if (!validateLabels(LoadConfigs(QList<LoadConfig>{loaded.at(i)}), &pourquoi))
|
|
qCWarning(dcNymeaEnergy()).noquote()
|
|
<< "[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(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(idPourMotif(loaded.at(i).id()), 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<QString> ids;
|
|
QHash<QString, int> 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, configs.at(j).label(), 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<int, int> 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(configs.at(j).label(), 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<QString, const LoadConfig *> 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<uint> points = texte.toUcs4();
|
|
if (points.size() <= n)
|
|
return texte;
|
|
return QString::fromUcs4(reinterpret_cast<const char32_t *>(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;
|
|
|
|
if (!save(error)) {
|
|
m_configs = previous; // rollback mémoire si l'écriture échoue
|
|
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;
|
|
}
|