From b007aae72017936761d53ec2a85a9165eb311837 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sat, 24 Oct 2026 11:19:15 +0200 Subject: [PATCH] =?UTF-8?q?Batterie=20:=20une=20consigne=20refus=C3=A9e=20?= =?UTF-8?q?est=20r=C3=A9essay=C3=A9e=20en=20s'espa=C3=A7ant,=20la=20lev?= =?UTF-8?q?=C3=A9e=20jamais=20(lot=20e1,=20suite)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un onduleur qui refuse la consigne (GEN24 sans « Allow Control », exception 0x04) la voyait reposée à chaque cycle, avec un qCWarning à chaque fois, toute la nuit, et `held` la publiait comme tenue. - Une pose refusée (taux ou limite) ouvre une série de refus : la pose suivante attend 1, 2, 5, 10 puis 15 min au plus (marge de 5 s pour la gigue du cycle). La série se clôt à la première séquence acceptée jusqu'au bout (retour au tiers du minuteur) ou à la levée. - La levée n'est jamais espacée : elle part au cycle même où la décision tombe, série en cours ou non. - Journal : un seul qCWarning par série, les refus suivants en qCDebug, un qCInfo à l'acceptation qui clôt la série. - Télémétrie : pendant une série, `held` est faux (et `dischargeBlocked` avec lui), et la batterie publie o:refusedWrites, o:refusedSince, o:nextAttemptAt. `held` faux plutôt qu'un drapeau à côté : un client qui ignore le champ nouveau ne doit pas afficher une protection absente. Mock : propriété dynamique `refuserEcritures` sur la batterie pilotable. Test testRefusedWritesAreSpacedAndNeverDelayTheRelease ; les six contre-épreuves (espacement, avertissement, held, levée, deux fins de série) le font rougir. Co-Authored-By: Claude Opus 5.5 --- docs/DESIGN_HC_BATTERIE.md | 39 ++++- .../etm/adapters/storagedischargeguard.cpp | 114 ++++++++++-- .../etm/adapters/storagedischargeguard.h | 36 +++- energyplugin/nymeaenergyjsonhandler.cpp | 5 + tests/auto/simulation/simulation.cpp | 163 ++++++++++++++++++ tests/auto/simulation/simulation.h | 3 + .../integrationpluginenergymocks.cpp | 6 + 7 files changed, 346 insertions(+), 20 deletions(-) diff --git a/docs/DESIGN_HC_BATTERIE.md b/docs/DESIGN_HC_BATTERIE.md index 75c5d28..4558647 100644 --- a/docs/DESIGN_HC_BATTERIE.md +++ b/docs/DESIGN_HC_BATTERIE.md @@ -145,6 +145,38 @@ le constructeur au chapitre général et illustrée sur le modèle 123, pas sur plugin, « décision D4 non validée »). Les tests la modélisent ; seule une mesure sur un GEN24 réel la prouve. +### 3.4 bis Un onduleur qui refuse (suite du lot e1) + +Un GEN24 dont « Allow Control » est désactivé répond 0x04 à toute écriture. Avant cette suite, la +batterie ne rapportant jamais la limite, le garde reposait la séquence à chaque cycle, avec un +`qCWarning` à chaque fois, toute la nuit. + +- **Espacement** : une pose refusée (taux OU limite) ouvre une **série de refus**. La pose + suivante attend **1, 2, 5, 10 puis 15 min** au plus, à compter de la pose refusée, avec une + marge de 5 s pour la gigue d'un cycle par minute. Quinze minutes au plus : un réglage corrigé + en cours de nuit est repris dans le quart d'heure. +- **Fin de série** : à la première séquence **acceptée jusqu'au bout** (taux puis limite), le + rythme normal reprend (tiers du minuteur) ; ou à la **levée**, qui clôt la série — la pose + suivante est une consigne neuve. +- **La levée n'est jamais espacée** : rendre l'automatisme part au cycle même où la décision + tombe, série en cours ou non. Elle n'est pas répétée si elle est refusée (le minuteur de + l'onduleur la fera) ; son refus reste un `qCWarning`. +- **Journal** : un seul `qCWarning` par série (« … REFUSÉE (…) : la décharge n'est PAS + bloquée … »), les refus suivants et les nouvelles tentatives en `qCDebug`, et un `qCInfo` à + l'acceptation qui clôt la série (« … ACCEPTÉE après N refus »). +- **Télémétrie** : pendant une série, **`held` est faux** — et `dischargeBlocked` avec lui — et + la batterie publie `refusedWrites` (poses refusées d'affilée), `refusedSince` et + `nextAttemptAt`. La décision reste `STORAGE_DISCHARGE_BLOCKED` : c'est ce que le moteur a + décidé, l'exécution est ce qui échoue. + +**Pourquoi `held` faux plutôt que `held` vrai plus un drapeau** : `held` et `dischargeBlocked` +sont lus comme « la batterie ne se décharge pas ». Un client qui ignore un champ nouveau — +c'est-à-dire tous les clients d'aujourd'hui — continuerait d'afficher une protection qui +n'existe pas. Avec `held` faux, le client existant dit vrai sans changement, et le client à jour +lit en plus pourquoi et jusqu'à quand. Le passage de `dischargeBlocked` à faux notifie, ce qu'un +drapeau à côté n'aurait pas fait. Un refus se lit au cycle **suivant** la pose : le verdict de +l'onduleur arrive après la publication du cycle qui a écrit, comme `observed`. + ### 3.5 Sécurité et invariant 8 (« pas de boucle ») La décision ne lit **ni le budget ni la réserve**, et n'y verse rien. Le budget reste compteur + @@ -182,11 +214,13 @@ vide plus dans la voiture la nuit, elle passe plus souvent la réserve le matin. - **Journal** : `[Arbitre] Batterie → Décharge de la batterie bloquée : la voiture charge en heures creuses (1380 W, jusqu'à 06:00)`, à chaque cycle où la question se pose ; `[Batterie] … décharge BLOQUÉE` et `… RENDUE à l'automatisme` aux transitions (`qCInfo`), le rafraîchissement - en `qCDebug`, un refus d'écriture en `qCWarning` (règle 7-d). + en `qCDebug`, un refus d'écriture en `qCWarning` (règle 7-d) — une fois par série de refus + pour la pose (§3.4 bis). - **Télémétrie** (`GetLoadTelemetry` et `LoadTelemetryChanged`, même charge utile) : un bloc `storage`, **absent** sans heure creuse ou sans batterie — `dischargeBlocked` (ce que le moteur TIENT après le dispatch), `decision` `{code, params}`, et `batteries[]` avec ce qui est LU - (`observed.enableDischarging`, `observed.dischargingRate`). Une transition du bloc notifie. + (`observed.enableDischarging`, `observed.dischargingRate`) ; pendant une série de refus, + `held` faux et `refusedWrites`, `refusedSince`, `nextAttemptAt`. Une transition du bloc notifie. - **Motifs** : `EV_WAITING_OFF_PEAK`, `STORAGE_DISCHARGE_BLOCKED`, `STORAGE_AUTOMATIC` (`peak` | `evNotCharging` | `degraded`), `STORAGE_NOT_PROTECTED`. Aucun n'est émissible par un optimiseur. @@ -211,6 +245,7 @@ journée le vérifie sur toutes les écritures exécutées, et la contre-épreuv | `testABatteryWithoutControlIsReadAsBefore` | sans états, sans minuteur, absente | | `testBlockedDischargeShowsAtTheMeterAndL4Decides` | invariant 8, soutirage mesuré, L4 coupe, levée | | `testAMeterSilenceHandsTheBatteryBack` | L2 lève explicitement | +| `testRefusedWritesAreSpacedAndNeverDelayTheRelease` | onduleur qui refuse : poses à 0, 1, 3, 8, 18, 33, 48 min, un avertissement par série, `held` faux ; acceptation → rythme normal ; levée immédiate en pleine série, qui clôt la série | | `decisions` | IDENTIQUE à la référence sans tarif | Les écritures sont lues **à la frontière nymea** (`ThingManager::actionExecuted`), et le GEN24 est diff --git a/energyplugin/etm/adapters/storagedischargeguard.cpp b/energyplugin/etm/adapters/storagedischargeguard.cpp index cbc050e..e7651b0 100644 --- a/energyplugin/etm/adapters/storagedischargeguard.cpp +++ b/energyplugin/etm/adapters/storagedischargeguard.cpp @@ -18,6 +18,17 @@ const QString EtatTaux = QStringLiteral("dischargingRate"); // OutWRte, % //! vite reviendrait à réécrire ce qui est en vol. constexpr int ReecritureMinS = 55; +//! Après le n-ième refus d'affilée d'une pose, la suivante attend `EspacementRefusS[n - 1]` +//! secondes (le dernier palier ensuite) : 1, 2, 5, 10 puis 15 min. Un onduleur qui refuse (GEN24 +//! sans « Allow Control », exception Modbus 0x04) refusera encore au cycle suivant ; insister à +//! chaque minute toute la nuit n'apprend rien et charge le bus. Quinze minutes au plus : un +//! réglage corrigé en cours de nuit est repris dans le quart d'heure. +constexpr int EspacementRefusS[] = {60, 120, 300, 600, 900}; +constexpr int PaliersRefus = int(sizeof(EspacementRefusS) / sizeof(EspacementRefusS[0])); +//! Un cycle « par minute » arrive à quelques secondes près : sans cette marge, l'attente d'une +//! minute en coûterait deux (même raison que \c ReecritureMinS). +constexpr int MargeCycleS = 5; + //! La valeur de `dischargingRate` qui rend la décharge LIBRE : le maximum de l'état, 100 % à défaut. double tauxLibre(Thing *t) { @@ -121,6 +132,10 @@ void StorageDischargeGuard::appliquer(bool configure, bool bloquer, const QDateT } if (bloquer) { + // SÉRIE DE REFUS : pas de nouvelle pose avant l'échéance de l'espacement. La levée, + // elle, n'attend jamais (branche ci-dessous). + if (e.refus > 0 && now < e.prochainEssai.addSecs(-MargeCycleS)) + continue; const qint64 age = e.derniereEcriture.isValid() ? e.derniereEcriture.secsTo(now) : -1; const int revertS = t->paramValue(t->thingClass().paramTypes() .findByName(QStringLiteral("revertTime")).id()).toInt(); @@ -154,7 +169,7 @@ void StorageDischargeGuard::leverTout(const QDateTime &now) bool StorageDischargeGuard::tientUneConsigne() const { for (const Etat &e : m_etats) - if (e.tenue) + if (e.tenue && e.refus == 0) return true; return false; } @@ -169,11 +184,18 @@ QVariantList StorageDischargeGuard::vue() const const Etat e = m_etats.value(cap.thingId); QVariantMap m{{QStringLiteral("thingId"), cap.thingId}, {QStringLiteral("controllable"), cap.controllable}, - {QStringLiteral("held"), e.tenue}}; + {QStringLiteral("held"), e.tenue && e.refus == 0}}; if (!cap.cause.isEmpty()) m.insert(QStringLiteral("cause"), cap.cause); - if (e.tenue && e.depuis.isValid()) + if (e.tenue && e.refus == 0 && e.depuis.isValid()) m.insert(QStringLiteral("heldSince"), e.depuis.toUTC().toString(Qt::ISODate)); + // UNE SÉRIE DE REFUS : `held` est faux — l'onduleur ne tient pas ce qu'il refuse — et ces + // trois champs disent pourquoi, et quand le moteur réessaiera. + if (e.tenue && e.refus > 0) { + m.insert(QStringLiteral("refusedWrites"), e.refus); + m.insert(QStringLiteral("refusedSince"), e.refusDepuis.toUTC().toString(Qt::ISODate)); + m.insert(QStringLiteral("nextAttemptAt"), e.prochainEssai.toUTC().toString(Qt::ISODate)); + } if (e.derniereEcriture.isValid()) m.insert(QStringLiteral("lastWriteAt"), e.derniereEcriture.toUTC().toString(Qt::ISODate)); // CE QUI A ÉTÉ LU, pas ce qu'on a écrit : c'est la seule preuve que la batterie a suivi. @@ -191,7 +213,10 @@ QVariantList StorageDischargeGuard::vue() const void StorageDischargeGuard::bloquerUne(Thing *t, Etat &e, const QDateTime &now, bool rafraichissement) { - if (rafraichissement) + if (e.tenue && e.refus > 0) + qCDebug(dcNymeaEnergy()) << "[Batterie]" << t->name() << "— nouvelle tentative de pose après" + << e.refus << "refus."; + else if (rafraichissement) qCDebug(dcNymeaEnergy()) << "[Batterie]" << t->name() << "— limite de décharge à 0 % rafraîchie (minuteur de retour réarmé)."; else @@ -206,11 +231,59 @@ void StorageDischargeGuard::bloquerUne(Thing *t, Etat &e, const QDateTime &now, // inerte ; quand elle s'active, il vaut déjà 0. L'inverse activerait une limite au taux // précédent, peut-être 100 %, le temps d'une écriture. Si la première échoue, la seconde ne // part pas : la limite reste inactive, donc la batterie en automatique — le sens sûr. + // La séquence est ACCEPTÉE quand la seconde écriture l'est ; un refus de l'une OU de l'autre + // compte pour la série. const QString id = t->id().toString(); - ecrire(t, EtatTaux, 0, [this, id]() { + const auto refus = [this, id](const QString &etat, const QString &cause) { + noterRefus(id, etat, cause); + }; + ecrire(t, EtatTaux, 0, [this, id, refus]() { if (Thing *suite = m_tm->findConfiguredThing(ThingId(id))) - ecrire(suite, EtatLimite, true); - }); + ecrire(suite, EtatLimite, true, [this, id]() { noterAcceptation(id); }, refus); + }, refus); +} + +void StorageDischargeGuard::noterRefus(const QString &id, const QString &etat, const QString &cause) +{ + if (!m_etats.contains(id) || !m_etats.value(id).tenue) + return; // la consigne a été levée entre-temps : ce verdict tardif ne pèse plus + Etat &e = m_etats[id]; + Thing *t = m_tm->findConfiguredThing(ThingId(id)); + const QString nom = t ? t->name() : id; + const int attente = EspacementRefusS[qMin(e.refus + 1, PaliersRefus) - 1]; + // UN SEUL AVERTISSEMENT PAR SÉRIE : le premier refus est aussi visible que l'application + // (règle 7-d) ; les suivants ne disent rien de neuf, et la télémétrie les compte. + if (e.refus == 0) { + e.refusDepuis = e.derniereEcriture; + qCWarning(dcNymeaEnergy()).noquote() + << "[Batterie]" << nom << "— écriture de" << etat + << QStringLiteral("REFUSÉE (%1) : la décharge").arg(cause) + << "n'est PAS bloquée. Nouvelles tentatives espacées (1, 2, 5, 10 puis 15 min) ; ce" + << "refus n'est plus signalé qu'en débogage jusqu'à la première écriture acceptée."; + } else { + qCDebug(dcNymeaEnergy()).noquote() + << "[Batterie]" << nom << "— écriture de" << etat + << QStringLiteral("encore refusée (%1),").arg(cause) + << e.refus + 1 << "refus d'affilée ; prochain essai dans" << attente << "s."; + } + ++e.refus; + e.prochainEssai = e.derniereEcriture.addSecs(attente); +} + +void StorageDischargeGuard::noterAcceptation(const QString &id) +{ + if (!m_etats.contains(id)) + return; + Etat &e = m_etats[id]; + if (e.refus > 0) { + Thing *t = m_tm->findConfiguredThing(ThingId(id)); + qCInfo(dcNymeaEnergy()) << "[Batterie]" << (t ? t->name() : id) + << "— limite de décharge à 0 % ACCEPTÉE après" << e.refus + << "refus : décharge bloquée, retour au rythme normal."; + } + e.refus = 0; + e.refusDepuis = QDateTime(); + e.prochainEssai = QDateTime(); } void StorageDischargeGuard::leverUne(Thing *t, Etat &e, const QDateTime &now) @@ -221,6 +294,11 @@ void StorageDischargeGuard::leverUne(Thing *t, Etat &e, const QDateTime &now) e.tenue = false; e.depuis = QDateTime(); e.derniereEcriture = now; + // La levée CLÔT une série de refus : la prochaine pose est une nouvelle consigne, au rythme + // normal, et son premier refus sera de nouveau signalé. + e.refus = 0; + e.refusDepuis = QDateTime(); + e.prochainEssai = QDateTime(); // ORDRE INVERSE : lever la limite rend l'automatisme TOUT DE SUITE ; le taux suit, pour // qu'une limite réactivée plus tard par quiconque ne reparte pas à 0 %. La seconde part même // si la première échoue : remettre le taux au maximum ne bloque rien. @@ -229,25 +307,37 @@ void StorageDischargeGuard::leverUne(Thing *t, Etat &e, const QDateTime &now) } void StorageDischargeGuard::ecrire(Thing *t, const QString &etat, const QVariant &valeur, - const std::function &suite) + const std::function &suite, + const std::function &refus) { const StateType st = t->thingClass().stateTypes().findByName(etat); Action action(st.id(), t->id(), Action::TriggeredByRule); action.setParams(ParamList() << Param(st.id(), valeur)); ThingActionInfo *info = m_tm->executeAction(action); if (!info) { - qCWarning(dcNymeaEnergy()) << "[Batterie]" << t->name() << "— écriture de" << etat - << "impossible (aucune réponse du gestionnaire)."; + const QString cause = QStringLiteral("aucune réponse du gestionnaire"); + if (refus) + refus(etat, cause); + else + qCWarning(dcNymeaEnergy()) << "[Batterie]" << t->name() << "— écriture de" << etat + << "impossible (" << cause << ")."; return; } const QString id = t->id().toString(); ++m_etats[id].enCours; // Aucune attente : verdict par signal, `this` en contexte (même modèle qu'ECS-414). Un REFUS - // est au moins aussi visible que l'application (règle 7-d). - connect(info, &ThingActionInfo::finished, this, [this, info, id, etat, valeur, suite]() { + // est au moins aussi visible que l'application (règle 7-d) — une fois par série pour la pose + // (\c noterRefus), à chaque fois pour la levée, qui n'est jamais répétée. + connect(info, &ThingActionInfo::finished, this, [this, info, id, etat, valeur, suite, refus]() { if (m_etats.contains(id)) --m_etats[id].enCours; if (info->status() != Thing::ThingErrorNoError) { + if (refus) { + QString statut; + QDebug(&statut).nospace() << info->status(); + refus(etat, QStringLiteral("statut ") + statut); + return; + } Thing *t = m_tm->findConfiguredThing(ThingId(id)); qCWarning(dcNymeaEnergy()) << "[Batterie]" << (t ? t->name() : id) << "— écriture de" << etat << "=" << valeur << "REFUSÉE, statut" << info->status(); diff --git a/energyplugin/etm/adapters/storagedischargeguard.h b/energyplugin/etm/adapters/storagedischargeguard.h index 382c1db..8249321 100644 --- a/energyplugin/etm/adapters/storagedischargeguard.h +++ b/energyplugin/etm/adapters/storagedischargeguard.h @@ -42,6 +42,12 @@ class ThingManager; * D'où le refus de commander une batterie SANS minuteur (\c noRevertTimer) ou avec un minuteur * plus court que deux cycles (\c revertTimerTooShort) : la consigne y survivrait au moteur, ou * expirerait entre deux rafraîchissements. + * \par Un onduleur qui REFUSE (suite du lot e1, 2026-10-24) + * Un GEN24 dont « Allow Control » est désactivé répond 0x04 à chaque écriture. Une pose refusée + * (taux ou limite) ouvre une SÉRIE de refus : la pose suivante attend 1, 2, 5, 10 puis 15 min + * au plus ; un seul \c qCWarning par série, les suivants en \c qCDebug. La série se CLÔT à la + * première séquence acceptée (retour au rythme du tiers du minuteur) ou à la levée. La levée + * n'est JAMAIS espacée. Pendant la série, la consigne n'est pas publiée comme tenue (\c vue()). * \par Générique * Par l'interface \c energystorage et les états PRÉSENTS — jamais par une ThingClass Fronius. * Une batterie sans ces états est lue comme avant le lot. @@ -77,7 +83,9 @@ public: * - \p bloquer : sur chaque batterie pilotable, la limite est posée si elle ne l'est pas, ou * RAFRAÎCHIE dès que la dernière écriture a l'âge du tiers du minuteur de retour, ou si la * batterie ne rapporte plus la consigne (onduleur revenu seul à l'automatisme). - * - sinon : toute consigne TENUE est levée explicitement (jamais laissée expirer). + * Pendant une série de refus, aucune pose avant l'échéance de l'espacement. + * - sinon : toute consigne TENUE est levée explicitement (jamais laissée expirer), refusée + * ou non, et sans attendre l'espacement. * * \note Sans heures creuses (\p configure faux), le garde ne lit et n'écrit RIEN — sauf pour * lever une consigne qu'il tiendrait encore (tarif effacé en pleine charge). C'est ce qui @@ -94,14 +102,20 @@ public: */ void leverTout(const QDateTime &now); - //! \brief Une consigne est-elle tenue sur au moins une batterie ? \return Vrai si oui. + /*! + * \brief Une consigne est-elle tenue sur au moins une batterie ? + * \return Vrai si une limite est posée et que sa dernière pose n'a pas été refusée. + */ bool tientUneConsigne() const; /*! * \brief Ce qui a été LU et ÉCRIT, par batterie — pour la télémétrie. * \return Une carte par batterie : \c thingId, \c controllable, \c o:cause, \c held (consigne - * tenue par le moteur), \c o:heldSince, \c o:lastWriteAt (instants ISO UTC), - * \c observed {\c enableDischarging, \c dischargingRate} si la batterie les expose. + * posée et NON refusée), \c o:heldSince, \c o:lastWriteAt (instants ISO UTC), + * \c observed {\c enableDischarging, \c dischargingRate} si la batterie les expose ; pendant + * une série de refus, \c held est faux et \c o:refusedWrites (poses refusées d'affilée), + * \c o:refusedSince, \c o:nextAttemptAt (ISO UTC) sont publiés. + * \note Un verdict arrive après le cycle qui a écrit : un refus se lit au cycle suivant. */ QVariantList vue() const; @@ -112,15 +126,25 @@ private: QDateTime depuis; //!< Début de la consigne tenue. QDateTime derniereEcriture; //!< Dernière séquence écrite (temps de cycle). int enCours = 0; //!< Écritures dont l'issue n'est pas encore connue. + int refus = 0; //!< Poses refusées d'affilée (0 : pas de série en cours). + QDateTime refusDepuis; //!< Pose qui a ouvert la série de refus. + QDateTime prochainEssai; //!< Pas de nouvelle pose avant cet instant (série en cours). }; //! Pose la limite à 0 % : \c dischargingRate puis \c enableDischarging, enchaînés. void bloquerUne(Thing *t, Etat &e, const QDateTime &now, bool rafraichissement); //! Lève la limite : \c enableDischarging à faux puis \c dischargingRate au maximum. void leverUne(Thing *t, Etat &e, const QDateTime &now); - //! Écrit un état modifiable ; \p suite est appelée si l'écriture réussit. + //! Un refus de la pose de \p id (état \p etat, \p cause lisible) : avertit une fois par + //! série, compte, et fixe le prochain essai. + void noterRefus(const QString &id, const QString &etat, const QString &cause); + //! La pose de \p id est acceptée jusqu'au bout : clôt la série de refus. + void noterAcceptation(const QString &id); + //! Écrit un état modifiable ; \p suite est appelée si l'écriture réussit, \p refus (état, + //! cause) si elle échoue — sans \p refus, l'échec est un \c qCWarning. void ecrire(Thing *t, const QString &etat, const QVariant &valeur, - const std::function &suite = {}); + const std::function &suite = {}, + const std::function &refus = {}); ThingManager *m_tm = nullptr; QHash m_etats; //!< thingId → état de la commande. diff --git a/energyplugin/nymeaenergyjsonhandler.cpp b/energyplugin/nymeaenergyjsonhandler.cpp index 4cd9e99..dbba234 100644 --- a/energyplugin/nymeaenergyjsonhandler.cpp +++ b/energyplugin/nymeaenergyjsonhandler.cpp @@ -1443,6 +1443,11 @@ QVariantMap NymeaEnergyJsonHandler::loadTelemetrySchema() batteryItem.insert("held", enumValueName(Bool)); batteryItem.insert("o:heldSince", enumValueName(String)); // ISO 8601 UTC batteryItem.insert("o:lastWriteAt", enumValueName(String)); // ISO 8601 UTC + // Série de refus de l'onduleur : « held » est alors FAUX, et ces trois champs disent depuis + // quand, combien de poses d'affilée, et quand le moteur réessaiera (espacement 1 → 15 min). + batteryItem.insert("o:refusedWrites", enumValueName(Int)); + batteryItem.insert("o:refusedSince", enumValueName(String)); // ISO 8601 UTC + batteryItem.insert("o:nextAttemptAt", enumValueName(String)); // ISO 8601 UTC batteryItem.insert("o:observed", observedItem); QVariantMap storageItem; storageItem.insert("dischargeBlocked", enumValueName(Bool)); diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index a4ff877..da4a88f 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -149,6 +149,15 @@ public: QString texte() const { return m_texte; } //! Les messages un par un : une fausse ligne de journal ne se voit qu'À L'INTÉRIEUR d'un message. QStringList messages() const { return m_messages; } + //! Combien de messages de niveau \p type contiennent \p fragment. + int compte(QtMsgType type, const QString &fragment) const + { + int n = 0; + for (int i = 0; i < m_messages.size(); ++i) + if (m_typeParMessage.at(i) == type && m_messages.at(i).contains(fragment)) + ++n; + return n; + } private: static void collecte(QtMsgType type, const QMessageLogContext &ctx, const QString &msg) @@ -157,6 +166,7 @@ private: for (JournalCapture *c : s_pile) { c->m_texte += msg + QLatin1Char('\n'); c->m_messages.append(msg); + c->m_typeParMessage.append(type); c->m_types.insert(type); } if (s_origine) @@ -164,6 +174,7 @@ private: } QString m_texte; QStringList m_messages; + QList m_typeParMessage; QSet m_types; static QList s_pile; static QtMessageHandler s_origine; @@ -11154,3 +11165,155 @@ void Simulation::testAMeterSilenceHandsTheBatteryBack() QCOMPARE(snap.value("storage").toMap().value("dischargeBlocked").toBool(), false); #endif } + +void Simulation::testRefusedWritesAreSpacedAndNeverDelayTheRelease() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + // L'ONDULEUR QUI REFUSE — un GEN24 dont « Allow Control » est désactivé répond 0x04 à toute + // écriture. Avant cette suite, le garde reposait la consigne à chaque cycle, avec un + // avertissement à chaque fois, toute la nuit, et publiait `held` vrai. + FuseauDeTest fuseau("Europe/Paris"); + ScenarioHc s = preparerScenarioHc(QStringLiteral("refus"), BatterieHc::Pilotable, hphcT4()); + QVERIFY(s.arb && s.borne && s.batterie); + s.borne->setStateValue("pluggedIn", true); + s.batterie->setProperty("refuserEcritures", true); + + // TOUTES les tentatives, refusées comprises, lues à la frontière nymea : `s.ecrites` ne garde + // que les acceptées. + struct Tentative { QDateTime cycle; QString etat; QVariant valeur; bool acceptee; }; + QList tentatives; + QObject portee; + ThingManager *tm = NymeaCore::instance()->thingManager(); + const ThingId batterieId = s.batterie->id(); + connect(tm, &ThingManager::actionExecuted, &portee, [&](const Action &action, Thing::ThingError status) { + if (action.thingId() != batterieId) + return; + ActionTypes types = s.batterie->thingClass().actionTypes(); + const ActionType type = types.findById(action.actionTypeId()); + tentatives.append({*s.cycleCourant, type.name(), action.param(ParamTypeId(type.id())).value(), + status == Thing::ThingErrorNoError}); + }); + const auto poses = [&]() { + QList l; + for (const Tentative &t : tentatives) + if (t.etat == QLatin1String("dischargingRate") && qFuzzyIsNull(t.valeur.toDouble())) + l << t.cycle; + return l; + }; + const auto batterie = [](const QVariantMap &snap) { + return snap.value("storage").toMap().value("batteries").toList().value(0).toMap(); + }; + const QString serie = QStringLiteral("n'est PAS bloquée"); // l'avertissement d'une SÉRIE + JournalCapture journal; + + // 1. REFUS EN SÉRIE — 50 minutes en heures creuses, la voiture tire. + const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(21, 0)); // 23:00 à Paris + QVariantMap snap; + for (int m = 0; m <= 50; ++m) + snap = cycleHc(s, t0.addSecs(60 * m), 1380 + 300); + const QList p = poses(); + QVERIFY(!p.isEmpty()); + const QDateTime premiere = p.first(); + QVERIFY2(premiere <= t0.addSecs(120), qUtf8Printable(premiere.toString(Qt::ISODate))); + // L'ESPACEMENT : 1, 2, 5, 10, puis 15 min au plus entre deux poses. + QList ecarts; + for (const QDateTime &d : p) + ecarts << premiere.secsTo(d); + QCOMPARE(ecarts, (QList{0, 60, 180, 480, 1080, 1980, 2880})); + // Le taux est refusé : la limite n'est JAMAIS envoyée derrière (le sens sûr). + for (const Tentative &t : tentatives) + QVERIFY2(t.etat == QLatin1String("dischargingRate") && !t.acceptee, qUtf8Printable(t.etat)); + // UN SEUL avertissement pour la série, et une seule annonce de pose. + QCOMPARE(journal.compte(QtWarningMsg, serie), 1); + QCOMPARE(journal.compte(QtInfoMsg, QStringLiteral("décharge BLOQUÉE")), 1); + // LA TÉLÉMÉTRIE ne prétend pas tenir ce que l'onduleur refuse. La DÉCISION, elle, reste + // « bloquer » : c'est l'exécution qui échoue, et la batterie dit pourquoi. + QCOMPARE(snap.value("storage").toMap().value("decision").toMap().value("code").toString(), + QString(DecisionCode::StorageDischargeBlocked)); + QCOMPARE(snap.value("storage").toMap().value("dischargeBlocked").toBool(), false); + QVariantMap bat = batterie(snap); + QCOMPARE(bat.value("held").toBool(), false); + QVERIFY(!bat.contains("heldSince")); + QCOMPARE(bat.value("refusedWrites").toInt(), 7); + QCOMPARE(bat.value("refusedSince").toString(), premiere.toString(Qt::ISODate)); + QCOMPARE(bat.value("nextAttemptAt").toString(), premiere.addSecs(2880 + 900).toString(Qt::ISODate)); + // …et par la frontière RPC : les champs passent la validation du schéma déclaré. + const QVariantMap rpc = injectAndWait("NymeaEnergy.GetLoadTelemetry").toMap().value("params").toMap(); + QCOMPARE(rpc.value("storage").toMap().value("batteries").toList().value(0).toMap() + .value("refusedWrites").toInt(), 7); + + // 2. L'ONDULEUR ACCEPTE de nouveau (réglage corrigé) : la pose part à l'échéance prévue, pas + // avant, puis le rythme normal (tiers du minuteur) reprend. + s.batterie->setProperty("refuserEcritures", false); + const QDateTime reprise = premiere.addSecs(2880 + 900); + QDateTime a = t0.addSecs(60 * 50); + while (a < reprise) { + a = a.addSecs(60); + snap = cycleHc(s, a, 1380 + 300); + } + QCOMPARE(poses().size(), 8); + QCOMPARE(poses().last(), reprise); + QCOMPARE(tentatives.last().etat, QStringLiteral("enableDischarging")); + QVERIFY(tentatives.last().acceptee); + QCOMPARE(journal.compte(QtInfoMsg, QStringLiteral("ACCEPTÉE après 7 refus")), 1); + a = a.addSecs(60); + snap = cycleHc(s, a, 1380 + 300); + QCOMPARE(snap.value("storage").toMap().value("dischargeBlocked").toBool(), true); + bat = batterie(snap); + QCOMPARE(bat.value("held").toBool(), true); + QVERIFY(!bat.contains("refusedWrites") && !bat.contains("refusedSince") && !bat.contains("nextAttemptAt")); + // Le rythme normal : l'onduleur modélisé hors du moteur tient la limite à chaque cycle — + // un espacement de 15 min l'aurait laissée expirer au bout de 300 s. + const int poseesAvant = s.ecrites->size(); + for (int m = 0; m < 20; ++m) { + a = a.addSecs(60); + snap = cycleHc(s, a, 1380 + 300); + QVERIFY2(limiteTenue(*s.ecrites, a.addSecs(59), 300), + qUtf8Printable(QStringLiteral("limite expirée à %1").arg(a.toString(Qt::ISODate)))); + } + QVERIFY2(s.ecrites->size() - poseesAvant <= 2 * (20 / 2 + 1), qUtf8Printable(QString::number(s.ecrites->size()))); + + // 3. LA LEVÉE PENDANT UNE SÉRIE DE REFUS : immédiate. Une nouvelle série s'ouvre (un + // rafraîchissement refusé) et elle a son propre avertissement. + s.batterie->setProperty("refuserEcritures", true); + for (int garde = 0; batterie(snap).value("refusedWrites").toInt() < 3; ++garde) { + QVERIFY2(garde < 30, "la série de refus ne s'est pas ouverte"); + a = a.addSecs(60); + snap = cycleHc(s, a, 1380 + 300); + } + QCOMPARE(journal.compte(QtWarningMsg, serie), 2); + const QDateTime prochain = QDateTime::fromString(batterie(snap).value("nextAttemptAt").toString(), Qt::ISODate); + a = a.addSecs(60); + QVERIFY2(prochain > a, "la levée doit tomber AVANT le prochain essai, sinon elle ne prouve rien"); + s.borne->setStateValue("currentPower", 0); + s.borne->setStateValue("charging", false); + const int avantLevee = tentatives.size(); + snap = cycleHc(s, a, 300); + QCOMPARE(tentatives.size(), avantLevee + 2); + QCOMPARE(tentatives.at(avantLevee).cycle, a); + QCOMPARE(tentatives.at(avantLevee).etat, QStringLiteral("enableDischarging")); + QCOMPARE(tentatives.at(avantLevee).valeur.toBool(), false); + QCOMPARE(tentatives.at(avantLevee + 1).etat, QStringLiteral("dischargingRate")); + QCOMPARE(tentatives.at(avantLevee + 1).valeur.toDouble(), 100.0); + QCOMPARE(snap.value("storage").toMap().value("dischargeBlocked").toBool(), false); + bat = batterie(snap); + QCOMPARE(bat.value("held").toBool(), false); + QVERIFY(!bat.contains("refusedWrites") && !bat.contains("nextAttemptAt")); + + // 4. La levée a CLOS la série : la voiture tire de nouveau, la pose part tout de suite, et + // son premier refus est signalé comme celui d'une nouvelle série. + s.borne->setStateValue("currentPower", 1380); + s.borne->setStateValue("charging", true); + const int avantRepose = poses().size(); + a = a.addSecs(60); + snap = cycleHc(s, a, 1380 + 300); + QCOMPARE(poses().size(), avantRepose + 1); + QCOMPARE(poses().last(), a); + a = a.addSecs(60); + snap = cycleHc(s, a, 1380 + 300); + QCOMPARE(journal.compte(QtWarningMsg, serie), 3); + QCOMPARE(batterie(snap).value("refusedWrites").toInt(), 1); +#endif +} diff --git a/tests/auto/simulation/simulation.h b/tests/auto/simulation/simulation.h index 14b56ab..3fe2e15 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -271,6 +271,9 @@ private slots: void testBlockedDischargeShowsAtTheMeterAndL4Decides(); //! L2 : compteur muet, la batterie est levée explicitement, la trame du repli le dit. void testAMeterSilenceHandsTheBatteryBack(); + //! Onduleur qui REFUSE : poses espacées (1 → 15 min), un avertissement par série, `held` faux ; + //! une pose acceptée rend le rythme normal ; la levée n'attend jamais. + void testRefusedWritesAreSpacedAndNeverDelayTheRelease(); //! TOUTE méthode Get* du namespace rend ce que sa déclaration promet. void testEveryGetterReturnsWhatItDeclares(); //! Le snapshot sert la DÉCISION : le canal, jamais la table — et l'absence n'est pas zéro. diff --git a/tests/mocks/plugins/energymocks/integrationpluginenergymocks.cpp b/tests/mocks/plugins/energymocks/integrationpluginenergymocks.cpp index 2576beb..e90a4ab 100644 --- a/tests/mocks/plugins/energymocks/integrationpluginenergymocks.cpp +++ b/tests/mocks/plugins/energymocks/integrationpluginenergymocks.cpp @@ -607,6 +607,12 @@ void IntegrationPluginEnergyMocks::executeAction(ThingActionInfo *info) // Chaque état modifiable prend la valeur écrite, rien d'autre : la batterie qui // obéit. Une écriture de `gridCharging`, `enableCharging` ou `chargingRate` passe // aussi — c'est au TEST de dire qu'elle n'aurait jamais dû partir. + // Propriété dynamique `refuserEcritures`, posée par le test : l'onduleur qui REFUSE + // (GEN24 sans « Allow Control », exception Modbus 0x04) — rien n'est appliqué. + if (thing->property("refuserEcritures").toBool()) { + info->finish(Thing::ThingErrorHardwareFailure); + return; + } const QString nom = actionType.name(); thing->setStateValue(nom, action.paramValue(actionType.paramTypes().findByName(nom).id())); info->finish(Thing::ThingErrorNoError);