Constat d'origine, rejoué et daté au banc : 90 s d'écoute, arbitre actif, ~2 kW exportés
→ ZÉRO notification NymeaEnergy.*. L'état runtime de l'arbitre (allocation, budget, verrous,
paliers, disponibilité, motifs) ne franchissait aucune frontière RPC : il vivait exclusivement
dans qCInfo(dcNymeaEnergy). Expliquer les décisions au client n'avait donc aucun transport.
Après déploiement, même geste : 13 notifications en 480 s.
Aucune phrase composée par la box. La locale nymea est PAR CONNEXION (JSONRPC.Hello o:locale),
et NymeaEnergyJsonHandler est un JsonHandler sans pluginId — donc hors du catalogue de
traduction : rien ne rattraperait une phrase française émise là. La box transporte un CODE et
des PARAMÈTRES ; la phrase est fabriquée par le client.
LoadAction::reason devient DecisionReason{code, params}. La ligne française du journal est
RENDUE depuis ce couple par renderFr(), source unique — jamais maintenue en parallèle. Les 19
codes sont EXTRAITS des sites existants, pas inventés ; les motifs d'ECS-309 et ECS-309-b sont
repris mot pour mot. Prouvé par comparaison de traces (1726 lignes de journal identiques en
simulation), par une table d'or qui échoue au premier code sans phrase attendue, et au banc sur
7 motifs distincts. Le libellé de la charge n'entre pas dans la charge utile : il est substitué
au RENDU, par l'appelant qui le connaît.
Les verrous sortent en SECONDES RESTANTES, pas en watts. Ceci n'enfreint pas ECS-306 : cette
exigence interdit qu'un index de PALIER franchisse la frontière d'ALLOCATION ; une valeur
d'AFFICHAGE sortante est un autre chemin. Le mécanisme sort en union discriminée
(relay|variable|sgReady), toutes clés internes optionnelles au schéma (LM-302).
ILoadAdapter::runtimeView() est PURE VIRTUELLE à dessein — mais la pure virtuelle force à
RÉPONDRE, pas à répondre quelque chose. testRuntimeViewIsSubstantial() est le second verrou :
les quatre adaptateurs, amenés dans un état non trivial, doivent rendre les valeurs runtime
réelles, les verrous en secondes ET leur expiration, et les codes de défaut. Deux mutants
« vue vide » introduits puis retirés : le test échoue sur chacun.
budget publié : surplusW, evReservedW, recreditedW, allocatedW, remainingW, avec l'identité
surplus + recrédit − alloué == restant. recreditedW n'est pas décoratif : sans lui l'identité
serait fausse DÈS QU'UNE CHARGE CONSOMME, et le client chercherait un écart inexplicable. Le
banc l'a montré aussitôt — surplusW −250 W avec recreditedW 3000 W — rendant lisible l'artefact
de banc documenté dans AGENTS.md (relais sans mesure + rootmeter aveugle).
Champs omis, jamais nuls : timestamp absent = aucun cycle depuis le démarrage ; budget absent =
mode dégradé, planification suspendue. Publier un zéro se lirait « exécuté, rien à allouer ».
Cadence : changement qualitatif ou variation d'allocation > 50 W, plus un battement de 60 s.
C'est le battement qui rend un arbitre FIGÉ détectable — les notifications continuent tandis que
le timestamp cesse d'avancer. Observé 3 fois au banc. Volume mesuré : ≈ 1,7 Mo/jour, un client.
GetChargingSchedules rend enfin degradedMode : il était POUSSÉ en notification seule, donc
illisible par un client connecté après la bascule (famille ECS-411). Règles absolues 11 et 12
ajoutées à AGENTS.md.
LoadConfig : champ optionnel domain (ecs·ev·heating·hvac·battery), PURE métadonnée d'intention
que l'arbitre ne lit pas. Absente ou vide = non classée. Forme exacte découverte au banc et
corrigée dans la doc ET le test : GetLoadConfig sérialise via le méta-objet, donc la propriété
sort TOUJOURS, à "" — comme sgReady:{} ou powerLevels:[]. C'est le corollaire LM-302.
Règle 7-c, deux silences fermés : les retours idempotents du RelayRouter et du SgReadyAdapter
étaient MUETS — « consigne reçue, palier déjà bon » ne se distinguait pas de « l'action n'est
jamais parvenue à l'adaptateur », et c'est le cas ordinaire qu'on vient vérifier.
⚠️ Porté au contrat : loads[] n'est PAS en correspondance 1:1 avec GetLoadConfig. « pac-terrain »
est la PAC SG-Ready CODÉE EN DUR d'energypluginnymea.cpp (K1/K2 = Relay4/Relay5, {3:1500,
4:3000}) — arbitrée et publiée, mais inconfigurable : ni rang, ni domain, ni enabled:false.
INTERFACE.md porte la règle de détection pour l'app. Son sort reste À TRANCHER (RELEVE_LOTB §8-bis b).
Déploiement : la recette de TEST_TERRAIN.md §b portait un glob « dpkg -i /tmp/…_*_arm64.deb »,
de la même famille que celui qui a rétrogradé .75 de +etm12 à +etm9. Remplacée par
etm-powersync-deploy/deploy.sh (archivage avant dépôt, installation par nom, refus de
rétrogradation, contrôle de version après coup).
Vérifié sur .75 (1.15.2+etm13 puis +etm14) : critères 1, 2, 4, 5, 6, 7, 8 du brief. Critère 3
(épisode dégradé) vérifié en simulation seulement — le déclencher demande de faire taire un
simulateur SunSpec partagé, hors périmètre. Relevé complet, critère par critère, avec ce qui n'a
PAS été vérifié sur la machine et pourquoi : docs/RELEVE_LOTB.md.
Suites : simulation 38/38, charging 46/46, spotmarket 32/32, y compris en build DEBUG où les
Q_ASSERT_X du validateur JSON-RPC sont actifs (le schéma déclaré correspond donc à la charge
utile émise, retours ET notifications). Cross-arm64 : 0 avertissement. Doxygen : 10, inchangé.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MfvSskkYrrFriYcj4683D
588 lines
26 KiB
C++
588 lines
26 KiB
C++
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
|
|
|
#include "relayrouter.h"
|
|
#include "plugininfo.h"
|
|
|
|
#include <QDateTime>
|
|
#include <QMap>
|
|
#include <QSet>
|
|
#include <integrations/thingmanager.h>
|
|
#include <integrations/thing.h>
|
|
#include <types/action.h>
|
|
#include <types/param.h>
|
|
#include <integrations/thingactioninfo.h>
|
|
|
|
namespace {
|
|
//! Au-delà, 2^N combinaisons explose. 16 relais = 65536 combos — bien au-delà de tout ECS réel.
|
|
constexpr int MaxRelays = 16;
|
|
}
|
|
|
|
RelayRouter::RelayRouter(ThingManager *thingManager,
|
|
const QString &id,
|
|
const QString &label,
|
|
const QList<LoadConfigRelay> &relays,
|
|
int minOnS,
|
|
int minOffS,
|
|
int priority,
|
|
const LoadNeeds &needs,
|
|
QObject *parent)
|
|
: QObject(parent)
|
|
, m_thingManager(thingManager)
|
|
, m_id(id)
|
|
, m_label(label)
|
|
, m_minOnS(minOnS)
|
|
, m_minOffS(minOffS)
|
|
, m_priority(priority)
|
|
, m_needs(needs)
|
|
{
|
|
int n = relays.size();
|
|
if (n > MaxRelays) {
|
|
qCWarning(dcNymeaEnergy()) << "[RelayRouter]" << m_label << "—" << n
|
|
<< "relais > max" << MaxRelays << ": tronqué.";
|
|
n = MaxRelays;
|
|
}
|
|
|
|
// Nominale par relais, prise à la DÉCLARATION. Voir m_relayNominalW dans l'en-tête :
|
|
// la redéduire des encodages solo la perd pour tout relais dont la puissance est
|
|
// aussi atteignable par une combinaison.
|
|
for (int i = 0; i < n; ++i)
|
|
m_relayNominalW.insert(relays.at(i).thingId, relays.at(i).powerW);
|
|
|
|
// Paliers DÉRIVÉS : toutes les sommes de sous-ensembles, dédupliquées par puissance, triées
|
|
// (QMap = clés croissantes), 0 inclus (sous-ensemble vide = masque 0). Pour des puissances
|
|
// identiques (ex. deux relais 1000 W), on garde la première combinaison rencontrée.
|
|
QMap<int, QList<QString>> byPower;
|
|
for (int mask = 0; mask < (1 << n); ++mask) {
|
|
int sum = 0;
|
|
QList<QString> set;
|
|
for (int i = 0; i < n; ++i) {
|
|
if (mask & (1 << i)) {
|
|
sum += relays.at(i).powerW;
|
|
set.append(relays.at(i).thingId);
|
|
}
|
|
}
|
|
if (!byPower.contains(sum))
|
|
byPower.insert(sum, set);
|
|
}
|
|
for (auto it = byPower.constBegin(); it != byPower.constEnd(); ++it) {
|
|
m_levels.append(it.key());
|
|
m_relayMapping.append(it.value());
|
|
}
|
|
// byPower contient toujours la clé 0 (masque vide) → m_levels[0] == 0.
|
|
Q_ASSERT(!m_levels.isEmpty() && m_levels.first() == 0);
|
|
|
|
// ECS-411 — reprise du palier depuis l'état RÉEL des relais. Partir de 0 alors que des
|
|
// contacts sont fermés, c'est le moteur qui croit 0 W pendant que le ballon tire 3 kW :
|
|
// même famille de défaut qu'ECS-410 (annoncer une puissance non appliquée).
|
|
m_currentStage = deduceStageFromThings();
|
|
}
|
|
|
|
LoadDescriptor RelayRouter::descriptor() const
|
|
{
|
|
LoadDescriptor d;
|
|
d.id = m_id;
|
|
d.label = m_label;
|
|
d.adapter = QStringLiteral("relay-router");
|
|
d.priority = m_priority;
|
|
|
|
// Paliers DÉRIVÉS exposés à l'optimiseur (watts) — JAMAIS les relais.
|
|
d.declared.powerLevels = m_levels;
|
|
d.declared.maxPowerW = m_levels.isEmpty() ? 0 : m_levels.last();
|
|
d.limits.minOnS = m_minOnS;
|
|
d.limits.minOffS = m_minOffS;
|
|
d.needs = m_needs;
|
|
|
|
d.supportedKinds = { LoadAction::Setpoint };
|
|
return d;
|
|
}
|
|
|
|
LoadTelemetry RelayRouter::telemetry() const
|
|
{
|
|
LoadTelemetry t;
|
|
t.available = !m_faulted; // ECS-410 : en défaut, la charge sort de l'ARBITRAGE…
|
|
t.lastActionAt = m_lastActionAt;
|
|
|
|
// currentPowerW :
|
|
// - MESURÉ dès qu'au moins un relais du palier expose un state "currentPower" (somme).
|
|
// Un thermostat coupé (relais ON mais 0 W) → 0, jamais de puissance fantôme.
|
|
// - NOMINAL commandé (repli) si aucun relais actif ne mesure (GPIO bool nu). Dans ce cas
|
|
// currentPowerW reflète le COMMANDÉ, pas le mesuré (contrat rév. 3 §3.a).
|
|
double power = 0;
|
|
bool metered = false;
|
|
const QList<QString> activeRelays = m_currentStage < m_relayMapping.size()
|
|
? m_relayMapping.at(m_currentStage)
|
|
: QList<QString>();
|
|
for (const QString &thingId : activeRelays) {
|
|
Thing *relay = m_thingManager->findConfiguredThing(ThingId(thingId));
|
|
if (!relay)
|
|
continue;
|
|
if (!relay->thingClass().stateTypes().findByName("currentPower").id().isNull()) {
|
|
metered = true;
|
|
power += relay->stateValue("currentPower").toDouble();
|
|
}
|
|
}
|
|
if (!metered && m_currentStage > 0 && m_currentStage < m_levels.size())
|
|
power = m_levels.at(m_currentStage); // nominal commandé
|
|
|
|
// ECS-410 — INDÉTERMINATION. Tant que des écritures sont en vol, on ne sait pas ce qui est
|
|
// réellement fermé : on annonce la PLUS HAUTE des deux puissances possibles. Même direction
|
|
// qu'ECS-411 (relais injoignable supposé fermé) : ne JAMAIS annoncer moins que ce qui peut
|
|
// être appliqué. Sous-estimer fait sur-allouer les charges suivantes et soutirer au réseau ;
|
|
// surestimer ne fait que retarder une montée.
|
|
if (!metered && m_pending > 0) {
|
|
const int hi = qMax(m_stagePrev, m_stageTarget);
|
|
if (hi >= 0 && hi < m_levels.size())
|
|
power = qMax(power, static_cast<double>(m_levels.at(hi)));
|
|
}
|
|
|
|
// …mais PAS de la COMPTABILITÉ : une puissance qu'on ne sait plus couper reste consommée.
|
|
// La retirer du bilan offrirait aux charges suivantes un budget qui n'existe pas.
|
|
t.currentPowerW = power;
|
|
return t;
|
|
}
|
|
|
|
LoadContext RelayRouter::toLoadContext(const QDateTime &now) const
|
|
{
|
|
LoadContext ctx;
|
|
ctx.id = m_id;
|
|
ctx.adapter = QStringLiteral("relay-router");
|
|
ctx.label = m_label;
|
|
ctx.priority = m_priority;
|
|
ctx.declared = descriptor().declared; // powerLevels/maxPowerW dérivés (watts)
|
|
ctx.needs = m_needs;
|
|
|
|
ctx.telemetry.currentPowerW = telemetry().currentPowerW;
|
|
|
|
// ECS-306 — fenêtre de verrou exposée au scheduler, EN WATTS. Aucun index de palier ni
|
|
// identifiant de relais ne franchit la frontière (rév. 3) : on convertit les bornes
|
|
// internes en puissances via la table de paliers. Sans ce canal, le scheduler décrémente
|
|
// le budget d'une valeur que l'adaptateur va écrêter, et les charges suivantes reçoivent
|
|
// un résidu surestimé pendant toute la fenêtre minOn (AGENTS règle 4).
|
|
ctx.telemetry.available = !m_faulted;
|
|
|
|
if (m_faulted) {
|
|
// ECS-410 — charge FIGÉE, ni montée ni descente : plancher ET plafond à la puissance
|
|
// crue engagée. Un plafond nul avec un plancher non nul serait contradictoire et
|
|
// ferait dérailler le qBound du scheduler.
|
|
ctx.telemetry.lockMinPowerW = ctx.telemetry.currentPowerW;
|
|
ctx.telemetry.lockMaxPowerW = ctx.telemetry.currentPowerW;
|
|
return ctx;
|
|
}
|
|
|
|
int minStage, maxStage;
|
|
lockWindow(now, minStage, maxStage);
|
|
ctx.telemetry.lockMinPowerW = m_levels.at(minStage);
|
|
ctx.telemetry.lockMaxPowerW = m_levels.at(maxStage);
|
|
return ctx;
|
|
}
|
|
|
|
LoadAction RelayRouter::applyAction(const LoadAction &action, const QDateTime &now)
|
|
{
|
|
if (action.kind != LoadAction::Setpoint)
|
|
return action;
|
|
|
|
if (action.reason.isEmpty()) {
|
|
qCWarning(dcNymeaEnergy()) << "[RelayRouter]" << m_label
|
|
<< "— LoadAction sans reason rejetée.";
|
|
return action;
|
|
}
|
|
|
|
// ECS-410 — en défaut, on CESSE TOUTE COMMANDE, y compris les replis forcés : le matériel
|
|
// n'a pas répondu à trois tentatives, en réémettre n'apporterait rien et masquerait l'état.
|
|
if (m_faulted) {
|
|
LoadAction refused = action;
|
|
refused.powerW = refused.estimatedPowerW = currentSetpointW();
|
|
return refused;
|
|
}
|
|
|
|
// ECS-412 — ARMEMENT PARESSEUX du verrou (démarrage à froid). Au premier \c now reçu,
|
|
// on estampille la « dernière commutation » : le verrou est donc armé pour sa durée
|
|
// configurée, puis EXPIRE naturellement.
|
|
//
|
|
// Sans cela, un \c m_lastSwitch nul faisait office de sentinelle « elapsed = 0 » à chaque
|
|
// cycle, donc un verrou PERMANENT : une charge démarrant au palier 0 avec minOffS > 0 ne
|
|
// pouvait jamais s'enclencher, donc jamais commuter, donc jamais valider m_lastSwitch —
|
|
// blocage circulaire. Constaté au banc le 2026-08-09 (charge sonde figée à 0 W sous
|
|
// 4 kW de surplus disponible).
|
|
//
|
|
// L'armement vit ICI, dans le chemin NON-const qui reçoit le temps de cycle, et non dans
|
|
// lockWindow() qui est const et ne fait que calculer. L'invariant « temps = paramètre,
|
|
// jamais l'horloge » (iloadadapter.h) est préservé : aucune horloge n'est lue.
|
|
if (!m_lastSwitch.isValid())
|
|
m_lastSwitch = now;
|
|
|
|
int newStage = stageForPower(action.powerW);
|
|
|
|
// Verrou anti-rebond INTERNE (clamp), au temps de cycle. Bypass si force==true (repli L2).
|
|
if (!action.force) {
|
|
int minStage, maxStage;
|
|
lockWindow(now, minStage, maxStage);
|
|
newStage = qBound(minStage, newStage, maxStage);
|
|
}
|
|
|
|
LoadAction applied = action;
|
|
applied.powerW = m_levels.at(newStage);
|
|
applied.estimatedPowerW = m_levels.at(newStage);
|
|
|
|
// Règle 7-c — journaliser L'ISSUE, pas seulement l'issue remarquable. Ce retour était MUET :
|
|
// « consigne reçue, palier déjà bon » ne se distinguait pas de « l'action n'est jamais
|
|
// parvenue au routeur », et c'est précisément le cas ordinaire qu'on vient vérifier quand
|
|
// une charge ne bouge pas. Même niveau que la branche qui commute (7-d, symétrie) — et le
|
|
// volume reste borné : une ligne par charge et par cycle, comme l'adaptateur à consigne
|
|
// continue en produit déjà une inconditionnellement.
|
|
if (newStage == m_currentStage) {
|
|
qCInfo(dcNymeaEnergy()) << "[RelayRouter]" << m_label
|
|
<< "→ consigne" << qRound(action.powerW) << "W → palier"
|
|
<< m_levels.at(newStage) << "W INCHANGÉ (aucune commutation)"
|
|
<< (action.force ? "(force)" : "")
|
|
<< "|" << renderFr(action.reason, m_label);
|
|
return applied; // idempotent — pas de re-commutation
|
|
}
|
|
|
|
qCInfo(dcNymeaEnergy()) << "[RelayRouter]" << m_label
|
|
<< "→ consigne" << qRound(action.powerW) << "W → palier"
|
|
<< m_levels.at(newStage) << "W"
|
|
<< (action.force ? "(force)" : "")
|
|
<< "|" << renderFr(action.reason, m_label);
|
|
|
|
applyRelayStage(newStage);
|
|
|
|
m_currentStage = newStage;
|
|
m_lastSwitch = now;
|
|
m_lastActionAt = now;
|
|
return applied;
|
|
}
|
|
|
|
// ---- privé ---------------------------------------------------------------
|
|
|
|
int RelayRouter::stageForPower(double powerW) const
|
|
{
|
|
int stage = 0; // m_levels[0] == 0 garanti
|
|
for (int i = 0; i < m_levels.size(); ++i) {
|
|
if (m_levels.at(i) <= powerW)
|
|
stage = i;
|
|
else
|
|
break; // m_levels triés croissants
|
|
}
|
|
return stage;
|
|
}
|
|
|
|
void RelayRouter::updateSoftConfig(int priority, const LoadNeeds &needs)
|
|
{
|
|
// ECS-412 — mise à jour EN PLACE : ni m_currentStage ni m_lastSwitch ne bougent,
|
|
// aucun relais n'est réécrit.
|
|
m_priority = priority;
|
|
m_needs = needs;
|
|
}
|
|
|
|
void RelayRouter::lockWindow(const QDateTime &now, int &minStage, int &maxStage) const
|
|
{
|
|
const int topStage = m_levels.size() - 1;
|
|
|
|
// ECS-412 — DÉMARRAGE À FROID. m_lastSwitch nul signifie « on ne sait pas quand la
|
|
// dernière commutation a eu lieu » (redémarrage de nymead, reconstruction). Le défaut
|
|
// SÛR est d'ARMER le verrou — traiter la commutation comme venant d'avoir lieu, donc
|
|
// elapsed = 0 — et non de le purger. L'écriture naturelle (`valid && elapsed < minOnS`)
|
|
// fait l'inverse et laisserait une boucle de redémarrage court-circuiter la protection
|
|
// compresseur exactement quand elle est la plus nécessaire.
|
|
//
|
|
// Cet armement est TRANSITOIRE : applyAction() estampille m_lastSwitch au premier \c now
|
|
// reçu, si bien que le verrou expire après sa durée configurée. Le repli ci-dessous ne
|
|
// couvre donc que les cycles précédant la première action — typiquement le premier
|
|
// toLoadContext(). Il ne doit JAMAIS devenir un état permanent : c'était le défaut
|
|
// corrigé le 2026-08-09 (cf. testEcsColdStartLockExpires).
|
|
//
|
|
// Armer ne signifie PAS verrouiller inconditionnellement : avec une durée nulle,
|
|
// `0 < 0` est faux et le verrou reste inactif.
|
|
const qint64 elapsed = m_lastSwitch.isValid() ? m_lastSwitch.secsTo(now) : 0;
|
|
const bool onHeld = elapsed < m_minOnS;
|
|
const bool offHeld = elapsed < m_minOffS;
|
|
|
|
// Plancher : si ON et minOn non écoulé → interdit de descendre (puissance engagée non-coupable).
|
|
minStage = (m_currentStage > 0 && onHeld) ? m_currentStage : 0;
|
|
// Plafond : si à l'arrêt et minOff non écoulé → interdit de redémarrer.
|
|
maxStage = (m_currentStage == 0 && offHeld) ? 0 : topStage;
|
|
}
|
|
|
|
int RelayRouter::deduceStageFromThings() const
|
|
{
|
|
// Ensemble des relais réellement FERMÉS, lu une seule fois.
|
|
QSet<QString> allRelays;
|
|
for (const auto &list : m_relayMapping)
|
|
for (const QString &id : list)
|
|
allRelays.insert(id);
|
|
|
|
// Puissance nominale par relais, pour sommer l'état lu. Prise à la DÉCLARATION
|
|
// (m_relayNominalW) et non redéduite des encodages solo : avec 500/1000/1500, le
|
|
// palier 1500 est encodé {500,1000} et le relais 1500 n'apparaît jamais seul. Sa
|
|
// nominale valait alors 0 — donc un démarrage sur ce seul relais donnait sumW = 0,
|
|
// aucune correspondance exacte, et un palier 0 annoncé pendant que 1500 W circulaient.
|
|
// C'est précisément l'annonce en sous-estimation qu'ECS-411 interdit. Trouvé par le
|
|
// relevé d'ECS-411-b, qui affichait « (0W) » pour ce relais.
|
|
const QHash<QString, int> &nominal = m_relayNominalW;
|
|
|
|
// ECS-411-b — journaliser ce qui est LU, pas seulement ce qui en est déduit.
|
|
// « palier repris : 0 » ne dit pas si les relais étaient ouverts ou si l'un d'eux
|
|
// était injoignable et supposé fermé : deux situations qui appellent des gestes
|
|
// opposés. On garde donc la lecture relais par relais, et on la publie avec l'issue.
|
|
QSet<QString> on;
|
|
int sumW = 0;
|
|
bool unreadable = false;
|
|
QStringList lecture;
|
|
for (const QString &thingId : allRelays) {
|
|
const int w = nominal.value(thingId, 0);
|
|
const QString court = thingId.left(8); // assez pour identifier, assez court pour tenir
|
|
Thing *relay = m_thingManager ? m_thingManager->findConfiguredThing(ThingId(thingId)) : nullptr;
|
|
if (!relay) {
|
|
// Thing absent : son état est INCONNU. On le suppose FERMÉ — sous-estimer la
|
|
// puissance appliquée est le défaut qu'ECS-411 corrige, le surestimer ne fait
|
|
// que retarder une montée en puissance.
|
|
unreadable = true;
|
|
on.insert(thingId);
|
|
sumW += w;
|
|
lecture << QStringLiteral("%1(%2W)=INJOIGNABLE→supposé fermé").arg(court).arg(w);
|
|
continue;
|
|
}
|
|
const bool ferme = relay->stateValue("power").toBool();
|
|
if (ferme) {
|
|
on.insert(thingId);
|
|
sumW += w;
|
|
}
|
|
lecture << QStringLiteral("%1(%2W)=%3").arg(court).arg(w)
|
|
.arg(ferme ? QStringLiteral("fermé") : QStringLiteral("ouvert"));
|
|
}
|
|
lecture.sort(); // ordre stable : QSet n'en garantit aucun, et un relevé se compare
|
|
|
|
// Sortie UNIQUE. Les trois issues étaient auparavant journalisées séparément, et la
|
|
// première se taisait au palier 0 — un silence indistinguable d'une reprise non
|
|
// exécutée. Toute issue est désormais annoncée, la nominale comme les remarquables.
|
|
int idx = -1;
|
|
QString issue;
|
|
|
|
// 1) Correspondance EXACTE de l'ensemble de relais avec un palier : cas nominal.
|
|
for (int i = 0; i < m_relayMapping.size() && idx < 0; ++i) {
|
|
const QList<QString> &l = m_relayMapping.at(i);
|
|
if (QSet<QString>(l.begin(), l.end()) == on) {
|
|
idx = i;
|
|
issue = QStringLiteral("correspondance exacte");
|
|
}
|
|
}
|
|
|
|
// 2) Ensemble non retenu dans la table — cas des encodages DÉDUPLIQUÉS (deux
|
|
// combinaisons de même puissance, une seule conservée). On se rabat sur la
|
|
// PUISSANCE : toute somme de sous-ensemble figure nécessairement dans m_levels,
|
|
// puisque m_levels est construit de toutes ces sommes. On annonce donc la bonne
|
|
// puissance, et le premier applyAction() normalisera l'encodage des relais.
|
|
if (idx < 0) {
|
|
const int parPuissance = m_levels.indexOf(sumW);
|
|
if (parPuissance >= 0) {
|
|
idx = parPuissance;
|
|
issue = QStringLiteral("encodage hors table, repris par la puissance "
|
|
"(normalisé au premier cycle)");
|
|
}
|
|
}
|
|
|
|
// 3) Inatteignable sauf troncature à MaxRelays : la somme lue dépasse la table. On prend
|
|
// le palier le plus haut plutôt que 0 — ne JAMAIS annoncer moins que ce qui est appliqué.
|
|
if (idx < 0) {
|
|
idx = m_levels.size() - 1;
|
|
issue = QStringLiteral("puissance hors table (relais tronqués ?) : palier maximal adopté");
|
|
}
|
|
|
|
const QString releve = QStringLiteral("[RelayRouter] %1 — reprise au démarrage : "
|
|
"palier %2 W [%3] ; relais lus : %4 ; somme lue %5 W")
|
|
.arg(m_label).arg(m_levels.at(idx)).arg(issue)
|
|
.arg(lecture.join(QStringLiteral(", "))).arg(sumW);
|
|
|
|
// Un relais injoignable rend la reprise incertaine : elle mérite un avertissement,
|
|
// pas une information. Le relevé est le même, seul le niveau change.
|
|
if (unreadable)
|
|
qCWarning(dcNymeaEnergy()).noquote() << releve << "— état INCERTAIN (relais injoignable).";
|
|
else
|
|
qCInfo(dcNymeaEnergy()).noquote() << releve;
|
|
|
|
return idx;
|
|
}
|
|
|
|
void RelayRouter::writeRelay(const QString &thingId, bool on)
|
|
{
|
|
Thing *relay = m_thingManager ? m_thingManager->findConfiguredThing(ThingId(thingId)) : nullptr;
|
|
if (!relay) {
|
|
qCWarning(dcNymeaEnergy()) << "[RelayRouter]" << m_label << "— relais non trouvé:" << thingId;
|
|
m_writeFailed = true; // compte comme un échec : ECS-410 §1
|
|
return;
|
|
}
|
|
StateType powerStateType = relay->thingClass().stateTypes().findByName("power");
|
|
if (powerStateType.id().isNull()) {
|
|
relay->setStateValue("power", on); // repli mock : synchrone, toujours réussi
|
|
return;
|
|
}
|
|
|
|
Action powerAction(powerStateType.id(), relay->id(), Action::TriggeredByRule);
|
|
powerAction.setParams(ParamList() << Param(powerStateType.id(), on));
|
|
ThingActionInfo *info = m_thingManager->executeAction(powerAction);
|
|
if (!info) {
|
|
m_writeFailed = true;
|
|
return;
|
|
}
|
|
|
|
// ECS-410 — on n'attend PAS : le cycle rend la main immédiatement (AGENTS règle 5).
|
|
// Le verdict arrive par signal, et la conséquence est traitée au cycle suivant.
|
|
// `this` en contexte de connexion : si l'adaptateur meurt, le callback est coupé
|
|
// proprement — motif repris d'EvCharger::setChargingEnabled (evcharger.cpp:293-299).
|
|
++m_pending;
|
|
connect(info, &ThingActionInfo::finished, this, [this, info]() {
|
|
if (info->status() != Thing::ThingErrorNoError) {
|
|
m_writeFailed = true;
|
|
qCWarning(dcNymeaEnergy()) << "[RelayRouter]" << m_label
|
|
<< "— écriture relais en échec, status" << info->status();
|
|
}
|
|
if (--m_pending == 0)
|
|
settleTransition();
|
|
});
|
|
}
|
|
|
|
void RelayRouter::settleTransition()
|
|
{
|
|
// Appelé quand TOUTES les écritures d'une étape sont acquittées. Fait avancer l'échelle
|
|
// d'ECS-410 d'un cran, une tentative par cran, sans jamais boucler.
|
|
if (!m_writeFailed) {
|
|
m_stagePrev = m_currentStage; // transition confirmée : l'indétermination cesse
|
|
m_stageTarget = m_currentStage;
|
|
return;
|
|
}
|
|
m_writeFailed = false;
|
|
|
|
switch (m_phase) {
|
|
case PhaseNominale:
|
|
// Barreau 2 — retour à l'état précédent.
|
|
qCWarning(dcNymeaEnergy()) << "[RelayRouter]" << m_label
|
|
<< "— échec d'écriture : retour au palier" << m_levels.at(m_stagePrev) << "W.";
|
|
m_phase = PhaseRepli;
|
|
m_currentStage = m_stagePrev;
|
|
emitRelayWrites(m_stagePrev);
|
|
break;
|
|
|
|
case PhaseRepli:
|
|
// Barreau 3 — arrêt total. Dernière tentative.
|
|
qCWarning(dcNymeaEnergy()) << "[RelayRouter]" << m_label
|
|
<< "— retour arrière en échec : arrêt total.";
|
|
m_phase = PhaseArretTotal;
|
|
m_currentStage = 0;
|
|
m_stageTarget = 0;
|
|
emitRelayWrites(0);
|
|
break;
|
|
|
|
case PhaseArretTotal:
|
|
case PhaseDefaut:
|
|
// Fin de l'échelle : verdict. Verrou COLLANT — aucune commande jusqu'à
|
|
// ClearLoadFault (ECS-410 §4). Il ne se lève pas tout seul : pas de clignotement.
|
|
qCCritical(dcNymeaEnergy()) << "[RelayRouter]" << m_label
|
|
<< "— arrêt total en échec : charge EN DÉFAUT, plus aucune"
|
|
<< "commande émise. Levée par NymeaEnergy.ClearLoadFault.";
|
|
m_phase = PhaseDefaut;
|
|
m_faulted = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
LoadRuntimeView RelayRouter::runtimeView(const QDateTime &now) const
|
|
{
|
|
LoadRuntimeView v;
|
|
v.mechanismKind = QStringLiteral("relay");
|
|
// Charge utile du mécanisme : des WATTS, jamais un index de palier ni un ThingId de relais.
|
|
// La frontière rév. 3 tient aussi dans le sens sortant.
|
|
v.mechanism.insert(QStringLiteral("stageW"), currentSetpointW());
|
|
v.mechanism.insert(QStringLiteral("maxStageW"),
|
|
m_levels.isEmpty() ? 0.0 : static_cast<double>(m_levels.last()));
|
|
|
|
// ECS-410 — le défaut sort en CODE. Il n'a aujourd'hui qu'une cause : l'échelle d'écriture
|
|
// épuisée. Une cause nouvelle vaudra un code nouveau, jamais une phrase.
|
|
if (m_faulted)
|
|
v.faultCode = QStringLiteral("WRITE_FAILED");
|
|
|
|
// Verrou en SECONDES RESTANTES. Le calcul dérive du MÊME m_lastSwitch et du même \c now que
|
|
// lockWindow() : ce que le client lit est ce qui a borné la décision, pas une seconde vue.
|
|
// m_lastSwitch nul = « commutation venant d'avoir lieu » (ECS-412, armement à froid) —
|
|
// même convention que lockWindow(), sans quoi l'affichage contredirait le comportement.
|
|
const qint64 elapsed = m_lastSwitch.isValid() ? m_lastSwitch.secsTo(now) : 0;
|
|
if (m_currentStage > 0 && elapsed < m_minOnS) {
|
|
v.lockKind = QStringLiteral("minOn");
|
|
v.lockRemainingS = static_cast<int>(m_minOnS - elapsed);
|
|
} else if (m_currentStage == 0 && elapsed < m_minOffS) {
|
|
v.lockKind = QStringLiteral("minOff");
|
|
v.lockRemainingS = static_cast<int>(m_minOffS - elapsed);
|
|
}
|
|
return v;
|
|
}
|
|
|
|
void RelayRouter::applySafeState(const QDateTime &now)
|
|
{
|
|
// ECS-413 — état sûr du routeur : tous relais ouverts. Passe par le chemin d'action
|
|
// NORMAL avec force = true, exactement comme le repli L2 : c'est le mécanisme qui existe
|
|
// déjà pour contourner les verrous quand l'arrêt prime. Rien à inventer.
|
|
LoadAction sur;
|
|
sur.loadId = m_id;
|
|
sur.kind = LoadAction::Setpoint;
|
|
sur.powerW = 0;
|
|
sur.force = true;
|
|
sur.reason = { DecisionCode::SafeStateRelay };
|
|
applyAction(sur, now);
|
|
}
|
|
|
|
bool RelayRouter::clearFault()
|
|
{
|
|
if (!m_faulted)
|
|
return false; // rien à lever — l'arbitre le dira (règle 7-c)
|
|
qCInfo(dcNymeaEnergy()) << "[RelayRouter]" << m_label
|
|
<< "— défaut levé par l'opérateur (ClearLoadFault).";
|
|
m_faulted = false;
|
|
m_phase = PhaseNominale;
|
|
m_writeFailed = false;
|
|
// L'état matériel réel est inconnu après un défaut : on le relit plutôt que de le supposer
|
|
// (même principe qu'ECS-411, et jamais moins que ce qui peut être appliqué).
|
|
m_currentStage = deduceStageFromThings();
|
|
m_stagePrev = m_stageTarget = m_currentStage;
|
|
return true;
|
|
}
|
|
|
|
void RelayRouter::emitRelayWrites(int stage)
|
|
{
|
|
// Set CIBLE du palier (delta complet : chaque relais connu amené à son état on/off cible).
|
|
// Gère les mappings NON-CASCADÉS (monter d'un palier peut éteindre des relais).
|
|
const QSet<QString> wantOn = [&]() {
|
|
QSet<QString> s;
|
|
if (stage >= 0 && stage < m_relayMapping.size())
|
|
for (const QString &id : m_relayMapping.at(stage))
|
|
s.insert(id);
|
|
return s;
|
|
}();
|
|
|
|
QSet<QString> allRelays;
|
|
for (const auto &list : m_relayMapping)
|
|
for (const QString &id : list)
|
|
allRelays.insert(id);
|
|
|
|
// off-before-on : couper d'abord les relais hors-cible, puis enclencher la cible → pas de
|
|
// sur-puissance transitoire (somme des deux paliers) sur une transition non-cascadée.
|
|
for (const QString &id : allRelays)
|
|
if (!wantOn.contains(id))
|
|
writeRelay(id, false);
|
|
for (const QString &id : wantOn)
|
|
writeRelay(id, true);
|
|
|
|
// Chemin entièrement synchrone (mock, ou aucun relais) : aucun acquittement n'arrivera,
|
|
// le verdict se prend donc tout de suite.
|
|
if (m_pending == 0)
|
|
settleTransition();
|
|
}
|
|
|
|
void RelayRouter::applyRelayStage(int stage)
|
|
{
|
|
m_stagePrev = m_currentStage;
|
|
m_stageTarget = stage;
|
|
m_writeFailed = false;
|
|
emitRelayWrites(stage);
|
|
}
|