Batterie : une consigne refusée est réessayée en s'espaçant, la levée jamais (lot e1, suite)
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 <noreply@anthropic.com>
This commit is contained in:
parent
2cde57ccf5
commit
b007aae720
@ -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
|
plugin, « décision D4 non validée »). Les tests la modélisent ; seule une mesure sur un GEN24
|
||||||
réel la prouve.
|
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 »)
|
### 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 +
|
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
|
- **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] …
|
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
|
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
|
- **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
|
`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
|
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`
|
- **Motifs** : `EV_WAITING_OFF_PEAK`, `STORAGE_DISCHARGE_BLOCKED`, `STORAGE_AUTOMATIC`
|
||||||
(`peak` | `evNotCharging` | `degraded`), `STORAGE_NOT_PROTECTED`. Aucun n'est émissible par
|
(`peak` | `evNotCharging` | `degraded`), `STORAGE_NOT_PROTECTED`. Aucun n'est émissible par
|
||||||
un optimiseur.
|
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 |
|
| `testABatteryWithoutControlIsReadAsBefore` | sans états, sans minuteur, absente |
|
||||||
| `testBlockedDischargeShowsAtTheMeterAndL4Decides` | invariant 8, soutirage mesuré, L4 coupe, levée |
|
| `testBlockedDischargeShowsAtTheMeterAndL4Decides` | invariant 8, soutirage mesuré, L4 coupe, levée |
|
||||||
| `testAMeterSilenceHandsTheBatteryBack` | L2 lève explicitement |
|
| `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 |
|
| `decisions` | IDENTIQUE à la référence sans tarif |
|
||||||
|
|
||||||
Les écritures sont lues **à la frontière nymea** (`ThingManager::actionExecuted`), et le GEN24 est
|
Les écritures sont lues **à la frontière nymea** (`ThingManager::actionExecuted`), et le GEN24 est
|
||||||
|
|||||||
@ -18,6 +18,17 @@ const QString EtatTaux = QStringLiteral("dischargingRate"); // OutWRte, %
|
|||||||
//! vite reviendrait à réécrire ce qui est en vol.
|
//! vite reviendrait à réécrire ce qui est en vol.
|
||||||
constexpr int ReecritureMinS = 55;
|
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.
|
//! La valeur de `dischargingRate` qui rend la décharge LIBRE : le maximum de l'état, 100 % à défaut.
|
||||||
double tauxLibre(Thing *t)
|
double tauxLibre(Thing *t)
|
||||||
{
|
{
|
||||||
@ -121,6 +132,10 @@ void StorageDischargeGuard::appliquer(bool configure, bool bloquer, const QDateT
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (bloquer) {
|
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 qint64 age = e.derniereEcriture.isValid() ? e.derniereEcriture.secsTo(now) : -1;
|
||||||
const int revertS = t->paramValue(t->thingClass().paramTypes()
|
const int revertS = t->paramValue(t->thingClass().paramTypes()
|
||||||
.findByName(QStringLiteral("revertTime")).id()).toInt();
|
.findByName(QStringLiteral("revertTime")).id()).toInt();
|
||||||
@ -154,7 +169,7 @@ void StorageDischargeGuard::leverTout(const QDateTime &now)
|
|||||||
bool StorageDischargeGuard::tientUneConsigne() const
|
bool StorageDischargeGuard::tientUneConsigne() const
|
||||||
{
|
{
|
||||||
for (const Etat &e : m_etats)
|
for (const Etat &e : m_etats)
|
||||||
if (e.tenue)
|
if (e.tenue && e.refus == 0)
|
||||||
return true;
|
return true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -169,11 +184,18 @@ QVariantList StorageDischargeGuard::vue() const
|
|||||||
const Etat e = m_etats.value(cap.thingId);
|
const Etat e = m_etats.value(cap.thingId);
|
||||||
QVariantMap m{{QStringLiteral("thingId"), cap.thingId},
|
QVariantMap m{{QStringLiteral("thingId"), cap.thingId},
|
||||||
{QStringLiteral("controllable"), cap.controllable},
|
{QStringLiteral("controllable"), cap.controllable},
|
||||||
{QStringLiteral("held"), e.tenue}};
|
{QStringLiteral("held"), e.tenue && e.refus == 0}};
|
||||||
if (!cap.cause.isEmpty())
|
if (!cap.cause.isEmpty())
|
||||||
m.insert(QStringLiteral("cause"), cap.cause);
|
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));
|
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())
|
if (e.derniereEcriture.isValid())
|
||||||
m.insert(QStringLiteral("lastWriteAt"), e.derniereEcriture.toUTC().toString(Qt::ISODate));
|
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.
|
// 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)
|
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()
|
qCDebug(dcNymeaEnergy()) << "[Batterie]" << t->name()
|
||||||
<< "— limite de décharge à 0 % rafraîchie (minuteur de retour réarmé).";
|
<< "— limite de décharge à 0 % rafraîchie (minuteur de retour réarmé).";
|
||||||
else
|
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
|
// 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
|
// 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.
|
// 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();
|
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)))
|
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)
|
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.tenue = false;
|
||||||
e.depuis = QDateTime();
|
e.depuis = QDateTime();
|
||||||
e.derniereEcriture = now;
|
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
|
// 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
|
// 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.
|
// 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,
|
void StorageDischargeGuard::ecrire(Thing *t, const QString &etat, const QVariant &valeur,
|
||||||
const std::function<void()> &suite)
|
const std::function<void()> &suite,
|
||||||
|
const std::function<void(const QString &, const QString &)> &refus)
|
||||||
{
|
{
|
||||||
const StateType st = t->thingClass().stateTypes().findByName(etat);
|
const StateType st = t->thingClass().stateTypes().findByName(etat);
|
||||||
Action action(st.id(), t->id(), Action::TriggeredByRule);
|
Action action(st.id(), t->id(), Action::TriggeredByRule);
|
||||||
action.setParams(ParamList() << Param(st.id(), valeur));
|
action.setParams(ParamList() << Param(st.id(), valeur));
|
||||||
ThingActionInfo *info = m_tm->executeAction(action);
|
ThingActionInfo *info = m_tm->executeAction(action);
|
||||||
if (!info) {
|
if (!info) {
|
||||||
qCWarning(dcNymeaEnergy()) << "[Batterie]" << t->name() << "— écriture de" << etat
|
const QString cause = QStringLiteral("aucune réponse du gestionnaire");
|
||||||
<< "impossible (aucune réponse du gestionnaire).";
|
if (refus)
|
||||||
|
refus(etat, cause);
|
||||||
|
else
|
||||||
|
qCWarning(dcNymeaEnergy()) << "[Batterie]" << t->name() << "— écriture de" << etat
|
||||||
|
<< "impossible (" << cause << ").";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const QString id = t->id().toString();
|
const QString id = t->id().toString();
|
||||||
++m_etats[id].enCours;
|
++m_etats[id].enCours;
|
||||||
// Aucune attente : verdict par signal, `this` en contexte (même modèle qu'ECS-414). Un REFUS
|
// 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).
|
// est au moins aussi visible que l'application (règle 7-d) — une fois par série pour la pose
|
||||||
connect(info, &ThingActionInfo::finished, this, [this, info, id, etat, valeur, suite]() {
|
// (\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))
|
if (m_etats.contains(id))
|
||||||
--m_etats[id].enCours;
|
--m_etats[id].enCours;
|
||||||
if (info->status() != Thing::ThingErrorNoError) {
|
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));
|
Thing *t = m_tm->findConfiguredThing(ThingId(id));
|
||||||
qCWarning(dcNymeaEnergy()) << "[Batterie]" << (t ? t->name() : id) << "— écriture de"
|
qCWarning(dcNymeaEnergy()) << "[Batterie]" << (t ? t->name() : id) << "— écriture de"
|
||||||
<< etat << "=" << valeur << "REFUSÉE, statut" << info->status();
|
<< etat << "=" << valeur << "REFUSÉE, statut" << info->status();
|
||||||
|
|||||||
@ -42,6 +42,12 @@ class ThingManager;
|
|||||||
* D'où le refus de commander une batterie SANS minuteur (\c noRevertTimer) ou avec un minuteur
|
* 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
|
* plus court que deux cycles (\c revertTimerTooShort) : la consigne y survivrait au moteur, ou
|
||||||
* expirerait entre deux rafraîchissements.
|
* 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 Générique
|
||||||
* Par l'interface \c energystorage et les états PRÉSENTS — jamais par une ThingClass Fronius.
|
* 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.
|
* 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
|
* - \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
|
* 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).
|
* 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
|
* \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
|
* 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);
|
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;
|
bool tientUneConsigne() const;
|
||||||
|
|
||||||
/*!
|
/*!
|
||||||
* \brief Ce qui a été LU et ÉCRIT, par batterie — pour la télémétrie.
|
* \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
|
* \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),
|
* 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.
|
* \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;
|
QVariantList vue() const;
|
||||||
|
|
||||||
@ -112,15 +126,25 @@ private:
|
|||||||
QDateTime depuis; //!< Début de la consigne tenue.
|
QDateTime depuis; //!< Début de la consigne tenue.
|
||||||
QDateTime derniereEcriture; //!< Dernière séquence écrite (temps de cycle).
|
QDateTime derniereEcriture; //!< Dernière séquence écrite (temps de cycle).
|
||||||
int enCours = 0; //!< Écritures dont l'issue n'est pas encore connue.
|
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.
|
//! Pose la limite à 0 % : \c dischargingRate puis \c enableDischarging, enchaînés.
|
||||||
void bloquerUne(Thing *t, Etat &e, const QDateTime &now, bool rafraichissement);
|
void bloquerUne(Thing *t, Etat &e, const QDateTime &now, bool rafraichissement);
|
||||||
//! Lève la limite : \c enableDischarging à faux puis \c dischargingRate au maximum.
|
//! Lève la limite : \c enableDischarging à faux puis \c dischargingRate au maximum.
|
||||||
void leverUne(Thing *t, Etat &e, const QDateTime &now);
|
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,
|
void ecrire(Thing *t, const QString &etat, const QVariant &valeur,
|
||||||
const std::function<void()> &suite = {});
|
const std::function<void()> &suite = {},
|
||||||
|
const std::function<void(const QString &, const QString &)> &refus = {});
|
||||||
|
|
||||||
ThingManager *m_tm = nullptr;
|
ThingManager *m_tm = nullptr;
|
||||||
QHash<QString, Etat> m_etats; //!< thingId → état de la commande.
|
QHash<QString, Etat> m_etats; //!< thingId → état de la commande.
|
||||||
|
|||||||
@ -1443,6 +1443,11 @@ QVariantMap NymeaEnergyJsonHandler::loadTelemetrySchema()
|
|||||||
batteryItem.insert("held", enumValueName(Bool));
|
batteryItem.insert("held", enumValueName(Bool));
|
||||||
batteryItem.insert("o:heldSince", enumValueName(String)); // ISO 8601 UTC
|
batteryItem.insert("o:heldSince", enumValueName(String)); // ISO 8601 UTC
|
||||||
batteryItem.insert("o:lastWriteAt", 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);
|
batteryItem.insert("o:observed", observedItem);
|
||||||
QVariantMap storageItem;
|
QVariantMap storageItem;
|
||||||
storageItem.insert("dischargeBlocked", enumValueName(Bool));
|
storageItem.insert("dischargeBlocked", enumValueName(Bool));
|
||||||
|
|||||||
@ -149,6 +149,15 @@ public:
|
|||||||
QString texte() const { return m_texte; }
|
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.
|
//! 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; }
|
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:
|
private:
|
||||||
static void collecte(QtMsgType type, const QMessageLogContext &ctx, const QString &msg)
|
static void collecte(QtMsgType type, const QMessageLogContext &ctx, const QString &msg)
|
||||||
@ -157,6 +166,7 @@ private:
|
|||||||
for (JournalCapture *c : s_pile) {
|
for (JournalCapture *c : s_pile) {
|
||||||
c->m_texte += msg + QLatin1Char('\n');
|
c->m_texte += msg + QLatin1Char('\n');
|
||||||
c->m_messages.append(msg);
|
c->m_messages.append(msg);
|
||||||
|
c->m_typeParMessage.append(type);
|
||||||
c->m_types.insert(type);
|
c->m_types.insert(type);
|
||||||
}
|
}
|
||||||
if (s_origine)
|
if (s_origine)
|
||||||
@ -164,6 +174,7 @@ private:
|
|||||||
}
|
}
|
||||||
QString m_texte;
|
QString m_texte;
|
||||||
QStringList m_messages;
|
QStringList m_messages;
|
||||||
|
QList<QtMsgType> m_typeParMessage;
|
||||||
QSet<QtMsgType> m_types;
|
QSet<QtMsgType> m_types;
|
||||||
static QList<JournalCapture *> s_pile;
|
static QList<JournalCapture *> s_pile;
|
||||||
static QtMessageHandler s_origine;
|
static QtMessageHandler s_origine;
|
||||||
@ -11154,3 +11165,155 @@ void Simulation::testAMeterSilenceHandsTheBatteryBack()
|
|||||||
QCOMPARE(snap.value("storage").toMap().value("dischargeBlocked").toBool(), false);
|
QCOMPARE(snap.value("storage").toMap().value("dischargeBlocked").toBool(), false);
|
||||||
#endif
|
#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<Tentative> 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<QDateTime> 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<QDateTime> 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<qint64> ecarts;
|
||||||
|
for (const QDateTime &d : p)
|
||||||
|
ecarts << premiere.secsTo(d);
|
||||||
|
QCOMPARE(ecarts, (QList<qint64>{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
|
||||||
|
}
|
||||||
|
|||||||
@ -271,6 +271,9 @@ private slots:
|
|||||||
void testBlockedDischargeShowsAtTheMeterAndL4Decides();
|
void testBlockedDischargeShowsAtTheMeterAndL4Decides();
|
||||||
//! L2 : compteur muet, la batterie est levée explicitement, la trame du repli le dit.
|
//! L2 : compteur muet, la batterie est levée explicitement, la trame du repli le dit.
|
||||||
void testAMeterSilenceHandsTheBatteryBack();
|
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.
|
//! TOUTE méthode Get* du namespace rend ce que sa déclaration promet.
|
||||||
void testEveryGetterReturnsWhatItDeclares();
|
void testEveryGetterReturnsWhatItDeclares();
|
||||||
//! Le snapshot sert la DÉCISION : le canal, jamais la table — et l'absence n'est pas zéro.
|
//! Le snapshot sert la DÉCISION : le canal, jamais la table — et l'absence n'est pas zéro.
|
||||||
|
|||||||
@ -607,6 +607,12 @@ void IntegrationPluginEnergyMocks::executeAction(ThingActionInfo *info)
|
|||||||
// Chaque état modifiable prend la valeur écrite, rien d'autre : la batterie qui
|
// Chaque état modifiable prend la valeur écrite, rien d'autre : la batterie qui
|
||||||
// obéit. Une écriture de `gridCharging`, `enableCharging` ou `chargingRate` passe
|
// obéit. Une écriture de `gridCharging`, `enableCharging` ou `chargingRate` passe
|
||||||
// aussi — c'est au TEST de dire qu'elle n'aurait jamais dû partir.
|
// 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();
|
const QString nom = actionType.name();
|
||||||
thing->setStateValue(nom, action.paramValue(actionType.paramTypes().findByName(nom).id()));
|
thing->setStateValue(nom, action.paramValue(actionType.paramTypes().findByName(nom).id()));
|
||||||
info->finish(Thing::ThingErrorNoError);
|
info->finish(Thing::ThingErrorNoError);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user