fix(dérogation): setChargingInfo déclenche un update — pose après, restitution différée

setChargingInfo() appelle update() lui-même, et le lot le touche aux deux bouts.

À LA POSE, l'ordre décide d'une minute. L'écriture du mode vient désormais en
DERNIER, après l'insertion dans m_derogations : le cycle qu'elle déclenche
trouve la dérogation déjà posée et la sert immédiatement. L'ordre inverse la
lui faisait manquer d'un cycle — soixante secondes d'attente sur un mécanisme
dont la définition est « tout de suite ».

À LA FIN, la restitution est différée d'un tour de boucle. Les fins sont
évaluées DEPUIS update() ; y appeler setChargingInfo() imbriquait un update()
dans un update() — deux plans construits et deux dispatchs pour le même
instant, sur des charges qu'un seul des deux croirait avoir commandées.
update() n'a aucun verrou de ré-entrance : rien ne l'aurait signalé, et la
trace aurait montré une borne commandée deux fois sans cause lisible.

simulation 68/68, ci-quality 4/4, build 0 erreur 0 warning.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
This commit is contained in:
Patrick Schurig 2026-09-08 10:52:04 +02:00
parent 1eae99143b
commit 106806d8ce
3 changed files with 63 additions and 12 deletions

View File

@ -1,3 +1,15 @@
powersync-energy-plugin-nymea (1.15.2+etm60) trixie; urgency=medium
* Dérogation : deux effets de setChargingInfo(), qui déclenche lui-même un
update(). À la pose, l'écriture du mode vient EN DERNIER — le cycle qu'elle
déclenche trouve alors la dérogation déjà posée et la sert immédiatement, au
lieu de la manquer d'un cycle. À la fin, la restitution du mode est DIFFÉRÉE
d'un tour de boucle : les fins sont évaluées depuis update(), et y écrire le
mode imbriquait un update() dans un update() — deux plans et deux dispatchs
pour le même instant, sans qu'aucun verrou de ré-entrance le signale.
-- Patrick Schurig <etm.schurig@gmail.com> Tue, 08 Sep 2026 10:52:03 +0200
powersync-energy-plugin-nymea (1.15.2+etm59) trixie; urgency=medium
* Le BOOST, implémenté comme une DÉROGATION et non comme un mode. Elle se

View File

@ -295,3 +295,24 @@ sera reprise avec lui. C'est aussi pourquoi la première vérification au banc s
(`LM-1012-d`) — c'est précisément ce que le filet de durée couvre.
- `ClearLoadDerogation` publie la cause `cancelled`, qui n'est pas une des quatre fins par
mécanisme : c'est une fin **par décision**, et la distinguer est le but.
### Deux effets de `setChargingInfo()` trouvés en préparant l'essai — `+etm60`
`SmartChargingManager::setChargingInfo()` **déclenche lui-même un `update()`**. Écrire le mode
n'est donc pas un geste neutre, et le lot le touche aux deux bouts.
**À la pose, l'ordre décide d'une minute.** L'écriture du mode vient **en dernier**, après
l'insertion dans `m_derogations` : le cycle qu'elle déclenche trouve alors la dérogation déjà
posée et la sert immédiatement. L'ordre inverse la lui faisait manquer d'un cycle — soixante
secondes d'attente sur un mécanisme dont la définition est « tout de suite ».
**À la fin, la restitution est DIFFÉRÉE d'un tour de boucle.** Les fins sont évaluées *depuis*
`update()` ; y appeler `setChargingInfo()` imbriquerait un `update()` dans un `update()` — deux
plans construits et deux dispatchs pour le même instant, sur des charges qu'un seul des deux
croirait avoir commandées. `update()` n'a aucun verrou de ré-entrance, donc rien ne l'aurait
signalé. Le décalage ne se voit nulle part : l'événement de fin part dans la trame du cycle
courant, et le mode est rendu avant le suivant.
**Effet heureux du retour à `Normal`** : l'amont restaure alors les valeurs manuelles
(`manualChargingEnabled`, `manualMaxChargingCurrent`, `manualDesiredPhaseCount`). La borne
retrouve donc *ses* réglages, et pas seulement son mode.

View File

@ -1095,18 +1095,19 @@ bool EnergyArbitrator::poseDerogation(const QString &loadId, int dureeS, double
// baseWh reste à capturer : au moment de la pose, aucun cycle n'a construit de contexte et
// le cumul de session n'est lisible que là (cf. Derogation::baseCapturee).
ChargingInfo pendant;
bool aBasculer = false;
if (estBorne) {
const ThingId id(loadId);
const ChargingInfo avant = chargingInfo(id);
const ChargingInfo avant = chargingInfo(ThingId(loadId));
d.previousMode = static_cast<int>(avant.chargingMode());
// Une borne en MANUEL est hors waterfall : le moteur ne la commande pas, et une
// dérogation n'y produirait rien du tout. On la fait entrer dans l'arbitrage pour la
// durée — et c'est le SEUL cas où le mode est touché.
if (avant.chargingMode() == ChargingInfo::ChargingModeNormal) {
ChargingInfo pendant = avant;
pendant = avant;
pendant.setChargingMode(ChargingInfo::ChargingModeEco);
setChargingInfo(pendant);
d.modeForce = true;
aBasculer = true;
}
}
@ -1119,6 +1120,13 @@ bool EnergyArbitrator::poseDerogation(const QString &loadId, int dureeS, double
: QStringLiteral("sans cible"))
.arg(d.previousMode)
.arg(d.modeForce ? QStringLiteral(" (forcé en éco pour la durée)") : QString());
// L'ÉCRITURE DU MODE VIENT EN DERNIER, et l'ordre est décisif : setChargingInfo() déclenche
// lui-même un cycle. La dérogation est donc DÉJÀ posée quand ce cycle passe, et il la sert
// immédiatement. L'inverse la lui aurait fait manquer d'un cycle — soixante secondes
// d'attente sur un mécanisme dont la définition est « tout de suite ».
if (aBasculer)
setChargingInfo(pendant);
return true;
}
@ -1138,17 +1146,27 @@ bool EnergyArbitrator::leveDerogation(const QString &loadId)
void EnergyArbitrator::rendreModeMemorise(const Derogation &d)
{
// \rule{LM-1500-d} — RETOUR AU MODE MÉMORISÉ, et à lui seul. Revenir à un défaut sortirait
// une borne manuelle
// de la main de son utilisateur — le contraire exact de ce qu'une dérogation promet, qui
// est de rendre la main.
// une borne manuelle de la main de son utilisateur — le contraire exact de ce qu'une
// dérogation promet, qui est de rendre la main.
if (!d.modeForce || d.previousMode < 0)
return;
const ThingId id(d.loadId);
if (!internalEvChargers().contains(id))
return; // la borne a disparu : il n'y a plus de réglage à rendre
ChargingInfo info = chargingInfo(id);
info.setChargingMode(static_cast<ChargingInfo::ChargingMode>(d.previousMode));
setChargingInfo(info);
const int mode = d.previousMode;
// DIFFÉRÉ D'UN TOUR DE BOUCLE, et ce n'est pas une précaution de style : les fins sont
// évaluées DEPUIS update(), et setChargingInfo() déclenche lui-même un update(). Écrire ici
// imbriquerait un cycle dans un cycle — deux plans construits et deux dispatchs pour le
// même instant, sur des charges qu'un seul des deux croirait avoir commandées.
//
// Le décalage ne se voit nulle part : l'événement de fin est publié dans la trame de CE
// cycle, et le mode est rendu avant le suivant.
QTimer::singleShot(0, this, [this, id, mode]() {
if (!internalEvChargers().contains(id))
return; // la borne a disparu entre-temps : il n'y a plus de réglage à rendre
ChargingInfo info = chargingInfo(id);
info.setChargingMode(static_cast<ChargingInfo::ChargingMode>(mode));
setChargingInfo(info);
});
}
void EnergyArbitrator::evaluerDerogations(SurplusContext &ctx, const QDateTime &now)