RELAIS : mon avertissement de reconstruction sur un réordonnancement est devenu FAUX, et
il est corrigé. `+etm16` compare la TABLE que les relais produisent (deriveStages), plus la
liste par index. Le miroir Dart suit, y compris le détail qui compte : les ThingIds d'une
combinaison se comparent comme un ENSEMBLE — collectés dans l'ordre de déclaration, fermer
{K1,K2} ou {K2,K1} est le même geste électrique. Mais l'appariement palier par palier
reste positionnel, ce qui préserve le seul cas où l'ordre signifie encore quelque chose :
deux relais de MÊME puissance, où il décide quel contact sert ce palier.
Conséquence pour l'écran : réordonner des contacteurs de puissances distinctes n'avertit
plus de rien. Avertir sur un geste devenu gratuit serait aussi trompeur que de se taire sur
un geste coûteux.
PALIERS : l'app ne dérive plus la combinatoire pour l'affichage — la box les publie
(mechanism.stagesW). Vérifié sur appareil : [0, 500, 1000, 1500, 2000, 2500, 3000, 3500].
deriveStages reste côté app pour la seule annonce d'impact, qui se fait avant l'écriture.
Tant que le brouillon n'a pas touché aux contacteurs, c'est la table PUBLIÉE qui s'affiche ;
dès qu'il y touche elle devient périmée, et l'écran montre une prévision annoncée comme
telle plutôt qu'une table qui décrit l'état d'avant.
COMPTEUR ET SONDE : ils ne « vont pas arriver », ils SONT au schéma de .75
(o:meterThingId, o:sensorThingId). Le grisage dérivé les a donc activés seul, sans nouvelle
version de l'app — exactement ce qui était promis. Mais un bloc actif sur un placeholder
mort est pire qu'un bloc grisé : les deux sont désormais câblés sur un vrai sélecteur,
filtré par INTERFACE (smartmeter, temperaturesensor) et jamais par nom de plugin.
Ni l'un ni l'autre n'entre dans sameHardware() : les rattacher ne reconstruit rien. Ni dans
le contrôle d'unicité : deux charges peuvent partager une sonde, un compteur divisionnaire
en couvrir plusieurs — le sélecteur ne grise donc rien ici, contrairement aux contacteurs.
Et la mesure sert à VÉRIFIER, jamais à décider : elle n'entre pas dans le budget, la charge
étant déjà comptée dans le racine dont elle n'est qu'une décomposition.
COMPTEURS CUMULÉS : la base purgée, les valeurs sont saines (55 730 / 50 / 6 327 / 62 007
kWh) et la tuile « compteur box invalide » a disparu d'elle-même. Le plafond de
plausibilité reste : un zéro réel s'affiche « 0,0 kWh », jamais « — ».
hasNeverRun était déjà traité comme un état distinct — « l'absence de cycle est dite pour
elle-même, jamais rendue par un âge de zéro ».
Vérifié sur appareil contre .75 : compteur racine relu, « à configurer » vide, deux bornes
listées (Simulated wallbox + Terra AC Charger), stagesW de la box, les deux champs
writable, 5/5 sections de l'écran SG-Ready. Tests 98/98, analyze 0 erreur.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
320 lines
12 KiB
Dart
320 lines
12 KiB
Dart
/// Vue **en lecture** au-dessus de la charge utile de `NymeaEnergy.GetLoadTelemetry`.
|
||
///
|
||
/// ## Ce que ce type n'est pas
|
||
///
|
||
/// Ce n'est **pas** un état que l'app entretient. C'est l'instantané du dernier cycle
|
||
/// d'arbitrage **publié par la box**, transporté tel quel. Rien n'y est extrapolé entre
|
||
/// deux trames : ni les secondes restantes d'un verrou (elles décroissent sur la box, et
|
||
/// le plugin les exclut explicitement de sa détection de changement), ni une allocation
|
||
/// qui « devrait » avoir bougé. Une valeur affichée ici a été dite par la box.
|
||
///
|
||
/// ## Omis, jamais nul
|
||
///
|
||
/// Trois champs sont optionnels au schéma, et leur **absence porte un sens** que zéro ne
|
||
/// porterait pas :
|
||
///
|
||
/// - `timestamp` absent = **aucun cycle d'arbitrage depuis le démarrage du service**. En
|
||
/// forger un se lirait « cycle exécuté, rien à allouer ».
|
||
/// - `budget` absent = **mode dégradé L2** : la planification est suspendue, il n'existe
|
||
/// aucun budget pour ce cycle. Publier des zéros se lirait « arbitrage exécuté, rien à
|
||
/// allouer ».
|
||
/// - `mechanism` absent = la charge n'a pas publié de charge utile de mécanisme ce
|
||
/// cycle-là.
|
||
///
|
||
/// D'où l'usage systématique de types nullables plutôt que de valeurs par défaut.
|
||
///
|
||
/// ## Codes, jamais des phrases
|
||
///
|
||
/// `decision.code` et `faultCode` sont des **codes à catalogue fermé** accompagnés de
|
||
/// paramètres numériques. La box ne compose aucune phrase : la locale nymea est par
|
||
/// connexion, et `NymeaEnergyJsonHandler` est un `JsonHandler` sans `pluginId`, donc
|
||
/// structurellement hors du catalogue de traduction. Le rendu est à la charge de l'app
|
||
/// (`services/telemetry_text.dart`).
|
||
library;
|
||
|
||
/// Seuil de fraîcheur — au-delà, l'instantané est présenté comme **ancien**.
|
||
///
|
||
/// Calibré sur le battement de cœur du plugin : `LoadTelemetryChanged` est émise à
|
||
/// chaque changement significatif **et** périodiquement toutes les 60 s même sans
|
||
/// changement, et le `timestamp` avance d'un cycle par minute (vérifié sur `.75` :
|
||
/// 12:37 → 12:38 → 12:39). Un seuil ≤ 60 s crierait donc « données anciennes » sur une
|
||
/// installation parfaitement vivante, au premier battement un peu tardif.
|
||
///
|
||
/// Trois cycles laissent passer un battement manqué et un aller-retour réseau lent, sans
|
||
/// laisser un arbitre réellement figé passer pour une installation stable plus de trois
|
||
/// minutes.
|
||
const Duration kTelemetryStaleAfter = Duration(seconds: 180);
|
||
|
||
/// Décomposition du budget de surplus du cycle. Absent en mode dégradé.
|
||
class TelemetryBudget {
|
||
/// Budget entrant du waterfall non-EV : net signé − réservation EV.
|
||
final double surplusW;
|
||
|
||
/// Puissance EV commandée ce cycle, pas encore vue au compteur.
|
||
final double evReservedW;
|
||
|
||
/// Recrédits anti-clignotement rendus aux charges avant arrondi.
|
||
final double recreditedW;
|
||
|
||
/// Somme des allocations du cycle.
|
||
final double allocatedW;
|
||
|
||
/// Résidu de fin de cascade.
|
||
final double remainingW;
|
||
|
||
const TelemetryBudget({
|
||
required this.surplusW,
|
||
required this.evReservedW,
|
||
required this.recreditedW,
|
||
required this.allocatedW,
|
||
required this.remainingW,
|
||
});
|
||
|
||
factory TelemetryBudget.fromJson(Map<String, dynamic> m) => TelemetryBudget(
|
||
surplusW: _d(m['surplusW']),
|
||
evReservedW: _d(m['evReservedW']),
|
||
recreditedW: _d(m['recreditedW']),
|
||
allocatedW: _d(m['allocatedW']),
|
||
remainingW: _d(m['remainingW']),
|
||
);
|
||
|
||
/// Identité que le plugin déclare vérifiable :
|
||
/// `surplusW + recreditedW − allocatedW == remainingW`.
|
||
///
|
||
/// `recreditedW` n'est pas décoratif : le waterfall rend à chaque charge sa propre
|
||
/// consommation de début de cycle avant d'arrondir. Sans ce terme, la somme serait
|
||
/// fausse dès qu'une charge consomme déjà — c'est-à-dire dans le cas normal.
|
||
///
|
||
/// Tolérance 1 W : les valeurs voyagent en `Double`.
|
||
bool get identityHolds =>
|
||
(surplusW + recreditedW - allocatedW - remainingW).abs() < 1.0;
|
||
}
|
||
|
||
/// Verrou actif sur une charge. Absent quand aucun verrou ne mord.
|
||
class LoadLock {
|
||
/// `minOn` · `minOff` · `minStateHold`.
|
||
final String kind;
|
||
|
||
/// Secondes restantes **au moment du cycle publié** — jamais décomptées côté app.
|
||
final int remainingS;
|
||
|
||
const LoadLock({required this.kind, required this.remainingS});
|
||
|
||
factory LoadLock.fromJson(Map<String, dynamic> m) => LoadLock(
|
||
kind: m['kind'] as String? ?? '',
|
||
remainingS: (m['remainingS'] as num?)?.toInt() ?? 0,
|
||
);
|
||
}
|
||
|
||
/// Union discriminée par `kind` — chaque variante porte sa charge utile, et seulement la
|
||
/// sienne. Les clés sont toutes optionnelles au schéma (règle LM-302 du plugin) : une
|
||
/// variante ajoutée par une box plus récente ne doit pas faire échouer la lecture.
|
||
class LoadMechanism {
|
||
final Map<String, dynamic> raw;
|
||
|
||
/// `relay` · `variable` · `sgReady` · `evcharger` (ce dernier réservé à l'étape 3g,
|
||
/// non émis en beta).
|
||
final String kind;
|
||
|
||
const LoadMechanism(this.raw, this.kind);
|
||
|
||
factory LoadMechanism.fromJson(Map<String, dynamic> m) =>
|
||
LoadMechanism(m, m['kind'] as String? ?? '');
|
||
|
||
bool get isRelay => kind == 'relay';
|
||
bool get isVariable => kind == 'variable';
|
||
bool get isSgReady => kind == 'sgReady';
|
||
bool get isEvCharger => kind == 'evcharger';
|
||
|
||
// relay
|
||
double? get stageW => _dn(raw['stageW']);
|
||
double? get maxStageW => _dn(raw['maxStageW']);
|
||
|
||
/// Paliers atteignables, **publiés par la box** depuis `+etm16`.
|
||
///
|
||
/// Une seule fonction moteur (`RelayRouter::deriveStages`) sert la construction du
|
||
/// routeur, la comparaison matérielle et cette publication : il n'y a donc plus de
|
||
/// combinatoire à refaire côté app, ni de divergence possible.
|
||
///
|
||
/// **Elle est en télémétrie, pas dans `GetLoadConfig`** — précisément pour qu'elle ne
|
||
/// parte jamais dans une écriture : ce sont les relais qui se déclarent, les paliers
|
||
/// s'en déduisent.
|
||
///
|
||
/// `null` sur une box antérieure, ou sur un mécanisme qui n'en produit pas.
|
||
List<int>? get stagesW {
|
||
final v = raw['stagesW'];
|
||
if (v is! List) return null;
|
||
return [for (final x in v) if (x is num) x.toInt()];
|
||
}
|
||
|
||
// variable
|
||
double? get setpointW => _dn(raw['setpointW']);
|
||
double? get percent => _dn(raw['percent']);
|
||
double? get maxPowerW => _dn(raw['maxPowerW']);
|
||
|
||
// sgReady — `estimatedPowerW` est une ESTIMATION déclarée, jamais un engagement.
|
||
int? get state => (raw['state'] as num?)?.toInt();
|
||
double? get estimatedPowerW => _dn(raw['estimatedPowerW']);
|
||
|
||
// evcharger (3g)
|
||
bool? get chargingEnabled => raw['chargingEnabled'] as bool?;
|
||
bool? get pluggedIn => raw['pluggedIn'] as bool?;
|
||
double? get currentA => _dn(raw['currentA']);
|
||
int? get phaseCount => (raw['phaseCount'] as num?)?.toInt();
|
||
}
|
||
|
||
/// Motif de décision : un code du catalogue fermé, et ses paramètres numériques.
|
||
///
|
||
/// `params` ne contient que des nombres et des booléens ; le libellé de la charge n'y
|
||
/// figure jamais — le client le résout via `GetLoadConfig`.
|
||
class LoadDecision {
|
||
final String code;
|
||
final Map<String, dynamic> params;
|
||
|
||
const LoadDecision({required this.code, required this.params});
|
||
|
||
factory LoadDecision.fromJson(Map<String, dynamic>? m) {
|
||
if (m == null) return const LoadDecision(code: '', params: {});
|
||
final p = m['params'];
|
||
return LoadDecision(
|
||
code: m['code'] as String? ?? '',
|
||
params: p is Map ? Map<String, dynamic>.from(p) : const {},
|
||
);
|
||
}
|
||
|
||
double? numParam(String key) => _dn(params[key]);
|
||
bool? boolParam(String key) => params[key] as bool?;
|
||
}
|
||
|
||
/// État runtime d'**une** charge arbitrée par le waterfall.
|
||
class LoadTelemetryEntry {
|
||
final Map<String, dynamic> raw;
|
||
final String loadId;
|
||
|
||
/// Ce que le waterfall a **alloué** — pas une mesure de compteur.
|
||
final double allocatedW;
|
||
|
||
/// Faux = charge en défaut : aucune commande n'est émise, et le verrou ne se lève pas
|
||
/// tout seul (`ClearLoadFault` est une action délibérée d'exploitant).
|
||
final bool available;
|
||
|
||
/// Absent quand la charge est saine.
|
||
final String? faultCode;
|
||
|
||
final LoadLock? lock;
|
||
final LoadMechanism? mechanism;
|
||
final LoadDecision decision;
|
||
|
||
const LoadTelemetryEntry({
|
||
required this.raw,
|
||
required this.loadId,
|
||
required this.allocatedW,
|
||
required this.available,
|
||
required this.faultCode,
|
||
required this.lock,
|
||
required this.mechanism,
|
||
required this.decision,
|
||
});
|
||
|
||
factory LoadTelemetryEntry.fromJson(Map<String, dynamic> m) {
|
||
final lock = m['lock'];
|
||
final mech = m['mechanism'];
|
||
final fault = m['faultCode'];
|
||
return LoadTelemetryEntry(
|
||
raw: m,
|
||
loadId: m['loadId'] as String? ?? '',
|
||
allocatedW: _d(m['allocatedW']),
|
||
// Absent au schéma ne peut pas arriver (champ requis) ; en cas de box exotique,
|
||
// « disponible » est le repli qui n'invente pas un défaut.
|
||
available: m['available'] as bool? ?? true,
|
||
faultCode: (fault is String && fault.isNotEmpty) ? fault : null,
|
||
lock: lock is Map ? LoadLock.fromJson(Map<String, dynamic>.from(lock)) : null,
|
||
mechanism: mech is Map
|
||
? LoadMechanism.fromJson(Map<String, dynamic>.from(mech))
|
||
: null,
|
||
decision: LoadDecision.fromJson(
|
||
m['decision'] is Map ? Map<String, dynamic>.from(m['decision'] as Map) : null),
|
||
);
|
||
}
|
||
|
||
bool get inFault => !available;
|
||
}
|
||
|
||
/// Instantané complet de l'arbitrage.
|
||
class LoadTelemetry {
|
||
final Map<String, dynamic> raw;
|
||
|
||
/// `true` = watchdog L2 en repli (compteur muet > 90 s, planification suspendue).
|
||
final bool degradedMode;
|
||
|
||
/// Fin du **dernier cycle d'arbitrage**. `null` = aucun cycle depuis le démarrage du
|
||
/// service : c'est la preuve de fraîcheur, et son absence est une information.
|
||
final DateTime? timestamp;
|
||
|
||
/// `null` en mode dégradé — il n'existe alors aucun budget pour ce cycle.
|
||
final TelemetryBudget? budget;
|
||
|
||
/// Uniquement les charges **arbitrées par le waterfall** (`relay-router`,
|
||
/// `etmvariableload`, `sg-ready`). Le VE n'y figure pas en beta : il est décidé en
|
||
/// amont et sa puissance est réservée dans `budget.evReservedW`, pas allouée depuis le
|
||
/// budget publié.
|
||
///
|
||
/// Depuis le lot plugin B-bis, tout `loadId` publié ici a une entrée dans
|
||
/// `GetLoadConfig` — l'inverse n'est pas vrai : une charge configurée absente d'ici est
|
||
/// **désactivée**, jamais perdue.
|
||
final List<LoadTelemetryEntry> loads;
|
||
|
||
const LoadTelemetry({
|
||
required this.raw,
|
||
required this.degradedMode,
|
||
required this.timestamp,
|
||
required this.budget,
|
||
required this.loads,
|
||
});
|
||
|
||
factory LoadTelemetry.fromJson(Map<String, dynamic> m) {
|
||
final ts = m['timestamp'];
|
||
final budget = m['budget'];
|
||
final loads = m['loads'];
|
||
return LoadTelemetry(
|
||
raw: m,
|
||
degradedMode: m['degradedMode'] as bool? ?? false,
|
||
timestamp: (ts is String && ts.isNotEmpty) ? DateTime.tryParse(ts) : null,
|
||
budget: budget is Map
|
||
? TelemetryBudget.fromJson(Map<String, dynamic>.from(budget))
|
||
: null,
|
||
loads: loads is List
|
||
? [
|
||
for (final l in loads.whereType<Map>())
|
||
LoadTelemetryEntry.fromJson(Map<String, dynamic>.from(l)),
|
||
]
|
||
: const [],
|
||
);
|
||
}
|
||
|
||
/// `true` tant qu'aucun cycle d'arbitrage n'a eu lieu depuis le démarrage du service.
|
||
bool get hasNeverRun => timestamp == null;
|
||
|
||
LoadTelemetryEntry? entryFor(String loadId) {
|
||
for (final e in loads) {
|
||
if (e.loadId == loadId) return e;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/// Âge du dernier cycle publié, mesuré contre l'horloge locale.
|
||
///
|
||
/// `null` si aucun cycle n'a eu lieu. **Négatif ramené à zéro** : l'horloge de la box
|
||
/// et celle du téléphone ne sont pas synchronisées, et un âge négatif se lirait comme
|
||
/// une donnée du futur.
|
||
Duration? ageAt(DateTime now) {
|
||
final ts = timestamp;
|
||
if (ts == null) return null;
|
||
final d = now.toUtc().difference(ts.toUtc());
|
||
return d.isNegative ? Duration.zero : d;
|
||
}
|
||
}
|
||
|
||
double _d(Object? v) => (v as num?)?.toDouble() ?? 0.0;
|
||
double? _dn(Object? v) => (v as num?)?.toDouble();
|