Décision de Patrick : trois modes suffisent — Manuel, PV, Min+PV. Le boost n'est pas un quatrième mode, c'est la dérogation (+etm61). LE FAIT. Le bouton écrivait `ChargingModeNormal`, qui sort la borne de l'arbitrage et lui restitue ses valeurs manuelles stockées — manualChargingEnabled, manualMaxChargingCurrent, manualDesiredPhaseCount. Sur .75 elles valaient false / 6 A / 3 phases : mesuré le 2026-09-08 à 11:09:51, l'appui a ARRÊTÉ la charge et armé un verrou de 300 s. Pas une régression — c'est ce que Normal a toujours fait. C'est le nom qui mentait. Renommé jusque dans le code (`ChargingMode.boost` -> `ChargingMode.manuel`), pas seulement sur l'étiquette : un identifiant faux reste un piège pour le prochain lecteur, et c'est précisément ce qui a duré des mois ici. L'ordre met Manuel en tête, parce que ChargingModeNormal est aussi le mode d'office d'une borne détectée. ET LE RENOMMAGE NE SUFFISAIT PAS. « Manuel » dit QUI commande, pas ce que la borne va faire : tant que l'app n'offre pas de régler ces valeurs stockées, le mode veut dire « ce qui traînait la dernière fois ». Passer en manuel demande donc confirmation, et la confirmation dit la conséquence — la charge peut s'arrêter — AVANT, pas après. ⚠️ La confirmation ne se déclenche pas sur un ré-appui du mode déjà en place : une confirmation qu'on apprend à cliquer sans lire ne protège plus de rien. Un test le fige. Vérifié sur .75 (1.15.2+etm61) : SetLoadDerogation et ClearLoadDerogation sont dans Introspect, avec derogationEnded / durationS / restoredMode. Le contrat de la dérogation est écrit dans CLAUDE.md — durée obligatoire bornée [60, 21600], état lu dans decision.code == "DEROGATION_ACTIVE" et jamais dans le mode, fin au niveau de la trame avec six causes. Aucun bouton de boost n'est écrit : c'est le lot suivant. Et une sonde pour le moteur, faite au passage : la V2C est bien restée en ChargingModeNormal et elle est ABSENTE de loads[] — hors arbitrage. Leur hypothèse est confirmée sans redémarrage. 339 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
232 lines
10 KiB
Dart
232 lines
10 KiB
Dart
class EnergyData {
|
||
final double pvPower; // Watts - production solaire
|
||
final double homePower; // Watts - consommation maison
|
||
final double batteryPower; // Watts - puissance batterie (+ charge, - décharge)
|
||
/// **Réseau, signé : `+` SOUTIRÉ, `−` INJECTÉ.**
|
||
///
|
||
/// ⚠️ Le commentaire précédent disait l'inverse — « + injection, − soutirage » — et la
|
||
/// carte de flux l'a cru : le 2026-09-06, une installation qui **exportait 2,5 kW**
|
||
/// affichait « consommation réseau », flèche à l'envers. Le sens de l'animation était
|
||
/// inversé, et rien dans les tests ne pouvait le voir.
|
||
///
|
||
/// La convention vient de la box et se vérifie à la sonde : `currentPowerAcquisition`
|
||
/// vaut **−2 511 W** pendant que la production (5,76 kW) dépasse la consommation
|
||
/// (3,25 kW) de 2,5 kW. Elle est transportée telle quelle (§7.1, signes bruts).
|
||
///
|
||
/// Ne pas relire ce signe à la main : passer par [gridImportW] / [gridExportW]. Le
|
||
/// dépôt portait DEUX conventions opposées dans deux widgets voisins, et celle qui était
|
||
/// à l'écran était la fausse.
|
||
final double gridPower;
|
||
final double batterySOC; // % - état de charge batterie
|
||
final double temperature; // °C
|
||
final String weatherIcon;
|
||
|
||
// Journée
|
||
final double dayProductionWh;
|
||
final double daySelfConsumptionWh;
|
||
final double dayGridInjectionWh;
|
||
/// Compteurs CUMULÉS de la box (kWh), tels qu'elle les publie — pas des totaux du jour.
|
||
///
|
||
/// `Energy.GetPowerBalance` rend `totalReturn` (exporté) et `totalAcquisition`
|
||
/// (importé). Ils sont **cumulés depuis toujours**, pas remis à zéro chaque nuit : les
|
||
/// afficher sous un libellé « aujourd'hui » serait faux.
|
||
///
|
||
/// `NaN` signifie **valeur non exploitable**, et c'est un état à part entière : le banc
|
||
/// `.75` publie des compteurs manifestement corrompus (7,5e+33 kWh importés) alors que
|
||
/// `totalProduction` reste sain. L'app ne les corrige pas — elle refuse de les
|
||
/// présenter comme une mesure (cf. [gridExportKwhOrNull]).
|
||
final double gridExportKwh;
|
||
final double gridImportKwh;
|
||
|
||
final double selfConsumptionRate; // %
|
||
final double autonomyRate; // %
|
||
final double dayGains; // €
|
||
|
||
// ── Borne de recharge : PLUS RIEN ICI ──────────────────────────────────────
|
||
//
|
||
// `chargingMode`, `chargingPower` (3,6 kW) et `solarSourcePercent` (82 %) ont vécu ici
|
||
// avec des valeurs par défaut que rien ne remplaçait : `_updateEnergyData` ne les
|
||
// touchait pas, et le mode n'avançait qu'en optimiste après une commande envoyée depuis
|
||
// l'app. Trois écrans les affichaient comme des mesures — la tuile des favoris, le
|
||
// Sankey du tableau de bord, et la carte véhicule (supprimée le 2026-08-28).
|
||
//
|
||
// Ce que la box publie vraiment sur une borne vit désormais dans `EvChargerLive`
|
||
// (`models/ev_charger_live.dart`), champ par champ, nullable : `null` veut dire « non
|
||
// publié », jamais zéro.
|
||
final bool gridLimitOk;
|
||
|
||
const EnergyData({
|
||
this.pvPower = 0,
|
||
this.homePower = 0,
|
||
this.batteryPower = 0,
|
||
this.gridPower = 0,
|
||
this.batterySOC = 0,
|
||
this.temperature = 0,
|
||
this.weatherIcon = 'cloud_snow',
|
||
this.dayProductionWh = 0,
|
||
this.daySelfConsumptionWh = 0,
|
||
this.dayGridInjectionWh = 0,
|
||
this.gridExportKwh = 0,
|
||
this.gridImportKwh = 0,
|
||
this.selfConsumptionRate = 0,
|
||
this.autonomyRate = 0,
|
||
this.dayGains = 0,
|
||
this.gridLimitOk = true,
|
||
});
|
||
|
||
/// Puissance **soutirée** au réseau, ou 0. Voir [gridPower] pour la convention.
|
||
double get gridImportW => gridPower > 0 ? gridPower : 0;
|
||
|
||
/// Puissance **injectée** vers le réseau, ou 0.
|
||
double get gridExportW => gridPower < 0 ? -gridPower : 0;
|
||
|
||
EnergyData copyWith({
|
||
double? pvPower,
|
||
double? homePower,
|
||
double? batteryPower,
|
||
double? gridPower,
|
||
double? batterySOC,
|
||
double? temperature,
|
||
String? weatherIcon,
|
||
double? dayProductionWh,
|
||
double? daySelfConsumptionWh,
|
||
double? dayGridInjectionWh,
|
||
double? gridExportKwh,
|
||
double? gridImportKwh,
|
||
double? selfConsumptionRate,
|
||
double? autonomyRate,
|
||
double? dayGains,
|
||
bool? gridLimitOk,
|
||
}) {
|
||
return EnergyData(
|
||
pvPower: pvPower ?? this.pvPower,
|
||
homePower: homePower ?? this.homePower,
|
||
batteryPower: batteryPower ?? this.batteryPower,
|
||
gridPower: gridPower ?? this.gridPower,
|
||
batterySOC: batterySOC ?? this.batterySOC,
|
||
temperature: temperature ?? this.temperature,
|
||
weatherIcon: weatherIcon ?? this.weatherIcon,
|
||
dayProductionWh: dayProductionWh ?? this.dayProductionWh,
|
||
daySelfConsumptionWh: daySelfConsumptionWh ?? this.daySelfConsumptionWh,
|
||
dayGridInjectionWh: dayGridInjectionWh ?? this.dayGridInjectionWh,
|
||
gridExportKwh: gridExportKwh ?? this.gridExportKwh,
|
||
gridImportKwh: gridImportKwh ?? this.gridImportKwh,
|
||
selfConsumptionRate: selfConsumptionRate ?? this.selfConsumptionRate,
|
||
autonomyRate: autonomyRate ?? this.autonomyRate,
|
||
dayGains: dayGains ?? this.dayGains,
|
||
gridLimitOk: gridLimitOk ?? this.gridLimitOk,
|
||
);
|
||
}
|
||
|
||
/// Plafond de plausibilité pour un compteur d'énergie domestique cumulé, en kWh.
|
||
///
|
||
/// 10 GWh, soit environ **trois mille ans** de consommation d'une maison. Aucune
|
||
/// installation résidentielle ne l'atteindra ; tout ce qui le dépasse est un compteur
|
||
/// cassé, pas un record. Le seuil est volontairement absurde-haut : il ne s'agit pas de
|
||
/// juger une valeur, seulement d'attraper les corrompues.
|
||
static const double kPlausibleMaxKwh = 1e7;
|
||
|
||
/// Vrai si [v] peut être présenté comme une mesure.
|
||
static bool isPlausibleKwh(double v) =>
|
||
v.isFinite && v >= 0 && v <= kPlausibleMaxKwh;
|
||
|
||
/// Export cumulé, ou `null` si la box publie une valeur inexploitable.
|
||
double? get gridExportKwhOrNull =>
|
||
isPlausibleKwh(gridExportKwh) ? gridExportKwh : null;
|
||
|
||
/// Import cumulé, ou `null` si la box publie une valeur inexploitable.
|
||
double? get gridImportKwhOrNull =>
|
||
isPlausibleKwh(gridImportKwh) ? gridImportKwh : null;
|
||
}
|
||
|
||
/// Les trois modes de recharge offerts par l'écran.
|
||
///
|
||
/// 🔴 **`manuel` s'appelait `boost`, et le nom était FAUX.** Il pose
|
||
/// `ChargingModeNormal`, qui **sort la borne de l'arbitrage** et restitue ses valeurs
|
||
/// manuelles — `manualChargingEnabled`, `manualMaxChargingCurrent`,
|
||
/// `manualDesiredPhaseCount`. Sur `.75` ces valeurs valaient `false / 6 A / 3 phases` :
|
||
/// mesuré le 2026-09-08 à 11:09:51, le bouton « Boost » a donc **arrêté la charge** et armé
|
||
/// un verrou de 300 s.
|
||
///
|
||
/// Ce n'était pas une régression : c'est ce que `Normal` a toujours fait. Le nom promettait
|
||
/// le contraire du comportement, et c'est le nom qui a menti pendant des mois — d'où le
|
||
/// renommage jusque dans le code, et pas seulement sur l'étiquette.
|
||
///
|
||
/// Le vrai boost existe depuis `+etm61`, et **ce n'est pas un mode** : c'est une
|
||
/// dérogation (`NymeaEnergy.SetLoadDerogation`) qui se superpose au mode courant, sert la
|
||
/// charge à son maximum en tête de passe éco, et s'efface d'elle-même. Elle aura son propre
|
||
/// bouton d'action, avec une durée obligatoire — pas une entrée de ce sélecteur.
|
||
///
|
||
/// L'ordre compte : `manuel` en tête, parce que `ChargingModeNormal` est aussi le mode
|
||
/// **d'office** d'une borne détectée (cf. `EvChargerLive.isUnmanaged`).
|
||
enum ChargingMode { manuel, pv, minPv }
|
||
|
||
class HistoryPoint {
|
||
final DateTime time;
|
||
final double pvWh;
|
||
final double homeWh;
|
||
final double gridWh;
|
||
final double batteryWh;
|
||
|
||
const HistoryPoint({
|
||
required this.time,
|
||
this.pvWh = 0,
|
||
this.homeWh = 0,
|
||
this.gridWh = 0,
|
||
this.batteryWh = 0,
|
||
});
|
||
|
||
}
|
||
|
||
// ── Entrée Energy.GetPowerBalanceLogs ─────────────────────────────────────────
|
||
class PowerBalanceEntry {
|
||
final DateTime timestamp;
|
||
final double productionW; // puissance PV (valeur absolue, W)
|
||
final double consumptionW; // consommation maison (W)
|
||
final double acquisitionW; // import réseau (W, positif)
|
||
final double storageW; // batterie : +charge / -décharge (W)
|
||
/// Compteurs cumulés depuis l'origine, en **kWh** — et non en Wh comme le nom de ces
|
||
/// champs l'a longtemps dit.
|
||
///
|
||
/// Vérifié sur `.75` le 2026-08-27 par recoupement, parce qu'aucune documentation ne le
|
||
/// tranchait : sur une fenêtre de 21,75 h, l'intégration trapézoïdale des puissances
|
||
/// instantanées (qui sont bien en **W**) rend **77 697 Wh**, quand la différence de
|
||
/// `totalProduction` sur la même fenêtre rend **78,31**. Le rapport est mille.
|
||
///
|
||
/// Le mauvais nom n'était pas cosmétique : l'écran Énergie affichait « 78 Wh » pour une
|
||
/// journée de 78 kWh, et personne ne pouvait le voir sans connaître l'ordre de grandeur
|
||
/// attendu.
|
||
final double totalProductionKwh;
|
||
final double totalConsumptionKwh;
|
||
|
||
/// Injecté vers le réseau, cumulé (kWh).
|
||
final double totalReturnKwh;
|
||
|
||
/// Soutiré au réseau, cumulé (kWh).
|
||
final double totalAcquisitionKwh;
|
||
|
||
/// **Les quatre cumuls étaient-ils PRÉSENTS dans la charge utile ?**
|
||
///
|
||
/// ⚠️ Sans ce drapeau, un champ absent devient `0.0` au décodage — et un bilan bâti sur
|
||
/// deux bornes à zéro s'affiche « 0,0 kWh · Vers réseau » comme s'il était mesuré. C'est
|
||
/// le zéro forgé, du côté de l'écran cette fois : « rien échangé » et « la box ne le
|
||
/// publie pas » sont deux états, et le second n'a pas le droit de porter le premier.
|
||
///
|
||
/// Le cas est réel : un compteur racine bien en place et des cumuls qui n'arrivent pas —
|
||
/// une intégration incomplète côté box. La distinction doit tenir **quelle que soit la
|
||
/// raison** de l'absence, donc sans attendre que cette raison soit corrigée.
|
||
final bool totalsPresent;
|
||
|
||
const PowerBalanceEntry({
|
||
required this.timestamp,
|
||
this.totalsPresent = false,
|
||
this.productionW = 0,
|
||
this.consumptionW = 0,
|
||
this.acquisitionW = 0,
|
||
this.storageW = 0,
|
||
this.totalProductionKwh = 0,
|
||
this.totalConsumptionKwh = 0,
|
||
this.totalReturnKwh = 0,
|
||
this.totalAcquisitionKwh = 0,
|
||
});
|
||
} |