diff --git a/docs/captures/2026-09-08_energie-cartes-journalieres.png b/docs/captures/2026-09-08_energie-cartes-journalieres.png new file mode 100644 index 0000000..35b4db1 Binary files /dev/null and b/docs/captures/2026-09-08_energie-cartes-journalieres.png differ diff --git a/docs/captures/2026-09-08_energie-empilement-v2c.png b/docs/captures/2026-09-08_energie-empilement-v2c.png new file mode 100644 index 0000000..7624e40 Binary files /dev/null and b/docs/captures/2026-09-08_energie-empilement-v2c.png differ diff --git a/lib/screens/energy_screen.dart b/lib/screens/energy_screen.dart index 43b4477..e5c2305 100644 --- a/lib/screens/energy_screen.dart +++ b/lib/screens/energy_screen.dart @@ -4,6 +4,7 @@ import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../models/consumer_series.dart'; +import '../models/daily_balance.dart'; import '../models/energy_data.dart'; import '../models/nymea_models.dart'; import '../providers/load_config_provider.dart'; @@ -68,6 +69,10 @@ class _EnergyScreenState extends State { List _socData = []; ConsumerHistory _conso = ConsumerHistory.vide; + /// Le bilan du JOUR — indépendant de l'onglet de période choisi juste en dessous. + /// C'est délibéré : les tuiles disent la journée, les graphes disent la fenêtre. + DailyBalance? _bilan; + /// Vrai quand la box a livré ses Things ET qu'aucun ne porte de batterie. /// /// Les deux moitiés comptent : tant que les Things ne sont pas chargés, « pas de @@ -91,8 +96,19 @@ class _EnergyScreenState extends State { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { _nymeaService = context.read(); - if (!_nymeaService!.connected) _nymeaService!.startSimulation(); - _nymeaService!.addListener(_onServiceChangedForSoc); + // 🔴 **Plus d'auto-simulation ici.** L'écran faisait + // `if (!connected) startSimulation()` : monté avant que la WebSocket soit établie — + // le cas NORMAL, puisqu'on y arrive en tapant l'installation — il basculait tout le + // service en démo, et n'en ressortait jamais. + // + // Vu à l'écran le 2026-09-08, box parfaitement joignable : une sinusoïde orange et + // une courbe de SOC à 75 % sur une installation **qui n'a pas de batterie**. Rien ne + // distinguait ces données inventées des vraies — ni couleur, ni mention, ni forme. + // C'est la fabrication sans même l'étiquette qui la blanchit. + // + // Le tableau de bord avait retiré la même chose pour la même raison : la démo est un + // CHOIX, pris depuis l'écran Installations, jamais un repli silencieux. + _nymeaService!.addListener(_onServiceChanged); _fetch(); }); _refreshTimer = Timer.periodic(const Duration(minutes: 5), (_) { @@ -103,13 +119,25 @@ class _EnergyScreenState extends State { @override void dispose() { _refreshTimer?.cancel(); - _nymeaService?.removeListener(_onServiceChangedForSoc); + _nymeaService?.removeListener(_onServiceChanged); super.dispose(); } - void _onServiceChangedForSoc() { - if (!mounted || _initialSocFetched || _loading) return; - if (_nymeaService?.batterySOCSource != null && _socData.isEmpty) { + /// Rattrape ce qui n'était pas encore disponible au montage. + /// + /// Deux cas, et le premier est la raison d'être de ce listener depuis qu'on ne simule + /// plus : la **connexion** peut arriver après l'écran. Sans ce rattrapage, l'écran + /// resterait vide jusqu'au tick de cinq minutes. + void _onServiceChanged() { + if (!mounted || _loading) return; + final svc = _nymeaService; + if (svc == null) return; + + if (svc.connected && _data.isEmpty) { + _fetch(); + return; + } + if (!_initialSocFetched && svc.batterySOCSource != null && _socData.isEmpty) { _initialSocFetched = true; _fetchSocOnly(); } @@ -168,12 +196,14 @@ class _EnergyScreenState extends State { : Future>.value([]), service.fetchThingPowerLogs( appareils: appareils, from: from, to: to, sampleRate: tab.sampleRate), + service.fetchDailyBalance(), ]); if (!mounted) return; setState(() { _data = results[0] as List; _socData = results[1] as List; _conso = results[2] as ConsumerHistory; + _bilan = results[3] as DailyBalance?; _loading = false; _noData = _data.isEmpty; }); @@ -247,7 +277,7 @@ class _EnergyScreenState extends State { sliver: SliverList( delegate: SliverChildListDelegate([ // KPI 2x2 - _KpiSection(service: service), + _KpiSection(bilan: _bilan), const SizedBox(height: 16), // Sélecteur période @@ -588,19 +618,38 @@ class _EnergyScreenState extends State { final series = _conso.series; final n = _data.length; - // Alignement par INDEX, et seulement si les longueurs coïncident : les compteurs - // n'échantillonnent pas tous au même instant, et interpoler inventerait des points. - double valeurAt(ConsumerSeries s, int i) => - s.points.length == n ? s.points[i].powerW : 0.0; - final alignees = series.where((s) => s.points.length == n).toList(); + // ⚠️ **Alignement par INSTANT, pas par index** — et une série peut légitimement + // commencer au milieu du graphe. + // + // La V2C Trydan a reçu son comptage le 2026-09-08 à 10:57 (`powersync-plugin-v2c + // +etm14`) : sur une fenêtre de 24 h elle rend **1 point contre 45** aux autres. Le + // filtre précédent (« même longueur, sinon on ignore ») l'aurait écartée du tracé + // **en la laissant dans la légende** — une légende qui promet une bande qu'on ne + // dessine pas, le défaut même qu'on venait de corriger sur l'autre graphe. + // + // Une série ne contribue donc que **là où elle a des points**. Avant son premier + // point, elle n'apporte rien à la pile — et l'énergie qu'elle a consommée est + // comptée dans le reste du foyer, ce qui est **arithmétiquement juste** : le total + // reste le compteur du foyer, seule la RÉPARTITION est incomplète. C'est ce que dit + // la note, et c'est une phrase différente de « elle a consommé zéro ». + final index = { + for (final s in series) + s.thingId: { + for (final p in s.points) p.t.millisecondsSinceEpoch: p.powerW + } + }; + double? valeurAt(ConsumerSeries s, int i) => + index[s.thingId]?[_data[i].timestamp.millisecondsSinceEpoch]; // Cumuls empilés, base = reste du foyer. final restes = []; for (int i = 0; i < n; i++) { - final somme = alignees.fold(0, (a, s) => a + valeurAt(s, i)); + final somme = + series.fold(0, (a, s) => a + (valeurAt(s, i) ?? 0)); restes.add(_data[i].consumptionW.abs() - somme); } final resteNegatif = restes.any((v) => v < -1); + final alignees = series; final bandes = <({String label, Color couleur, List valeurs})>[ ( @@ -613,7 +662,7 @@ class _EnergyScreenState extends State { label: s.label, couleur: EtmTokens.domaineOf(context, s.domaine) ?? EtmTokens.mutedOf(context), - valeurs: [for (int i = 0; i < n; i++) valeurAt(s, i)] + valeurs: [for (int i = 0; i < n; i++) valeurAt(s, i) ?? 0.0] ), ]; @@ -706,7 +755,25 @@ class _EnergyScreenState extends State { /// n'entre pas dans `GetThingPowerLogs` — alors que sa puissance instantanée est lisible. Widget _notesConsommateurs() { final manquants = _conso.sansHistorique; - if (manquants.isEmpty && !_conso.signeIndetermine) { + + // **Une série qui COMMENCE au milieu du graphe.** + // + // Cas rencontré le 2026-09-08 : la V2C a reçu son comptage à 10:57, donc sur 24 h + // elle rend un point sur quarante-cinq. Le total de la pile reste juste — le foyer + // est mesuré de bout en bout — mais la RÉPARTITION ne vaut qu'à partir de là. Le + // taire ferait lire « cette charge n'a rien tiré avant », qui est le mensonge d'hier + // sous une autre forme : ce n'est plus la présence de la série qui est en jeu, c'est + // son début. + final debut = _data.isEmpty ? null : _data.first.timestamp; + final tardives = <(String, DateTime)>[ + for (final s in _conso.series) + if (s.points.isNotEmpty && + debut != null && + s.points.first.t.isAfter(debut.add(const Duration(minutes: 2)))) + (s.label, s.points.first.t) + ]; + + if (manquants.isEmpty && tardives.isEmpty && !_conso.signeIndetermine) { return const SizedBox.shrink(); } return Padding( @@ -720,6 +787,15 @@ class _EnergyScreenState extends State { 'publie pas de comptage. Sa puissance instantanée reste lisible.' : '${manquants.join(', ')} : pas d\'historique de puissance.', ), + for (final (label, t) in tardives) + _NoteEcran( + icon: Icons.start_rounded, + texte: '$label n\'est mesurée que depuis ' + '${t.hour.toString().padLeft(2, '0')}:' + '${t.minute.toString().padLeft(2, '0')} : avant, sa consommation est ' + 'comptée dans le reste du foyer. Le total est juste, la répartition ne ' + 'l\'est qu\'à partir de cette heure.', + ), if (_conso.signeIndetermine) _NoteEcran( icon: Icons.help_outline_rounded, @@ -747,59 +823,132 @@ class _EnergyScreenState extends State { // ─────────────────────────── KPI section ─────────────────────────────────────── +/// **Les six tuiles du haut — la JOURNÉE, et rien d'autre.** +/// +/// Elles lisaient `service.energyData` : `dayProductionWh` y porte en réalité le compteur +/// **cumulé depuis l'origine** de l'installation. Vu à l'écran le 2026-09-08 : +/// « Production 62946.74… » au-dessus d'un graphe qui montre 24 heures. Et « Gains +/// 8789.84 € » n'avait aucune source du tout — un prix du kWh que personne n'a saisi. +/// +/// Elles lisent maintenant [DailyBalance], le même objet que le tableau de bord : une +/// différence de compteurs sur la fenêtre `periodStartedAt → maintenant`, publiée par la +/// box et non dérivée d'un fuseau. +/// +/// ## Chaque taux porte son dénominateur +/// +/// Décidé après le 2026-09-08 : « 70 % » se lisait comme une bonne matinée alors que +/// l'installation avait produit **79 Wh** et acheté 6,4 kWh. Le nombre était exact ; la +/// question à laquelle il répond n'intéressait personne. +/// +/// Un seuil aurait demandé une constante inventée — et celle que j'avais proposée +/// (0,05 kWh) aurait laissé passer ces 79 Wh. « 70 % sur 79 Wh produits » se lit juste +/// toute l'année ; « 70 % » ne se lit juste que les bons jours. **Un ratio sans son +/// dénominateur est une part sans son tout.** class _KpiSection extends StatelessWidget { - final NymeaService service; - const _KpiSection({required this.service}); + /// `null` = pas encore lu, ou journée non mesurable. Les deux se disent « — ». + final DailyBalance? bilan; + const _KpiSection({required this.bilan}); @override Widget build(BuildContext context) { - final d = service.energyData; - return Column( - children: [ - Row( - children: [ - _KpiTile( - icon: Icons.wb_sunny_rounded, - color: EtmTokens.amber, - bgColor: EtmTokens.amberSoft, - label: 'Production', - value: _kwh(d.dayProductionWh), - ), - const SizedBox(width: 14), - _KpiTile( - icon: Icons.home_rounded, - color: EtmTokens.blue, - bgColor: EtmTokens.blueSoft, - label: 'Consommation', - value: _kwh(d.daySelfConsumptionWh), - ), - ], + final b = bilan; + String kwh(double? v) => v == null ? '—' : '${v.toStringAsFixed(2)} kWh'; + String pct(double? v) => v == null ? '—' : '${v.round()} %'; + + // « sur X produits » n'a de sens que s'il y a un X. Sans dénominateur, la tuile dit + // pourquoi elle n'a pas de taux — pas un zéro, pas un tiret muet. + final surProduction = b == null + ? null + : b.productionKwh > 0 + ? 'sur ${_court(b.productionKwh)} produits' + : 'sans production'; + final surConsommation = b == null + ? null + : b.consumptionKwh > 0 + ? 'sur ${_court(b.consumptionKwh)} consommés' + : 'sans consommation'; + + return Column(children: [ + Row(children: [ + _KpiTile( + icon: Icons.wb_sunny_rounded, + color: EtmTokens.amber, + bgColor: EtmTokens.amberSoft, + label: 'Production', + value: kwh(b?.productionKwh), ), - const SizedBox(height: 14), - Row( - children: [ - _KpiTile( - icon: Icons.recycling_rounded, - color: EtmTokens.green, - bgColor: EtmTokens.greenSoft, - label: 'Autoconsommation', - value: '${d.selfConsumptionRate.toStringAsFixed(0)}%', - ), - const SizedBox(width: 14), - _KpiTile( - icon: Icons.euro_rounded, - color: EtmTokens.greenDark, - bgColor: EtmTokens.greenSoft, - label: 'Gains', - value: '${d.dayGains.toStringAsFixed(2)} €', - ), - ], + const SizedBox(width: 14), + _KpiTile( + icon: Icons.home_rounded, + color: EtmTokens.blue, + bgColor: EtmTokens.blueSoft, + label: 'Consommation', + value: kwh(b?.consumptionKwh), ), - ], - ); + ]), + const SizedBox(height: 14), + Row(children: [ + _KpiTile( + icon: Icons.north_west_rounded, + color: EtmTokens.green, + bgColor: EtmTokens.greenSoft, + label: 'Vers réseau', + value: kwh(b?.exportKwh), + ), + const SizedBox(width: 14), + _KpiTile( + icon: Icons.south_east_rounded, + color: EtmTokens.importColor, + bgColor: EtmTokens.amberSoft, + label: 'Depuis réseau', + value: kwh(b?.importKwh), + ), + ]), + const SizedBox(height: 14), + Row(children: [ + _KpiTile( + icon: Icons.recycling_rounded, + color: EtmTokens.selfUse, + bgColor: EtmTokens.amberSoft, + label: 'Autoconsommation', + value: pct(b?.selfConsumptionRate), + detail: surProduction, + ), + const SizedBox(width: 14), + _KpiTile( + icon: Icons.bolt_rounded, + color: EtmTokens.green, + bgColor: EtmTokens.greenSoft, + label: 'Autonomie', + value: pct(b?.autonomyRate), + detail: surConsommation, + ), + ]), + // L'identité des compteurs est le seul contrôle disponible sur cette fenêtre. + // Fausse, elle désigne un compteur cassé ou remis à zéro — et le bilan ne doit + // alors pas être présenté comme une mesure. + if (b != null && !b.identityHolds) + const _NoteEcran( + icon: Icons.rule_rounded, + texte: 'Les compteurs ne se recoupent pas sur la journée : production + soutiré ' + 'devrait égaler consommation + injecté. Un compteur a probablement été remis ' + 'à zéro.', + ), + if (b != null && b.startsLate) + _NoteEcran( + icon: Icons.schedule_rounded, + texte: 'Journée partielle : la box ne publie rien avant ' + '${b.from.hour.toString().padLeft(2, '0')}:' + '${b.from.minute.toString().padLeft(2, '0')}. Le bilan part de cette heure, ' + 'pas de minuit.', + ), + ]); } - static String _kwh(double wh) => '${(wh / 1000).toStringAsFixed(2)} kWh'; + /// Une énergie courte : les watt-heures sous le kilowattheure, parce que « 0,08 kWh » + /// se lit comme un arrondi et « 79 Wh » comme une mesure. + static String _court(double kwh) => + kwh < 1 ? '${(kwh * 1000).round()} Wh' : '${kwh.toStringAsFixed(1)} kWh'; } class _KpiTile extends StatelessWidget { @@ -809,10 +958,14 @@ class _KpiTile extends StatelessWidget { final String label; final String value; + /// Ce dont la valeur est la fraction — `null` quand elle n'est fraction de rien. + final String? detail; + const _KpiTile({ required this.icon, required this.color, required this.bgColor, + this.detail, required this.label, required this.value, }); @@ -839,12 +992,33 @@ class _KpiTile extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(label, style: EtmTokens.sans(size: 11, color: EtmTokens.mutedOf(context))), + // `scaleDown` dans une largeur BORNÉE (on est dans un `Expanded`) : + // il réduit seulement quand il le faut. Sans lui, « Autoconsommation » + // se coupait au milieu du mot — vu à l'écran le 2026-09-08. + FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Text(label, + maxLines: 1, + style: EtmTokens.sans( + size: 11, color: EtmTokens.mutedOf(context))), + ), const SizedBox(height: 2), Text(value, style: EtmTokens.mono(size: 15, weight: FontWeight.w700, color: color), maxLines: 1, overflow: TextOverflow.ellipsis), + if (detail != null) ...[ + const SizedBox(height: 2), + FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Text(detail!, + maxLines: 1, + style: EtmTokens.sans( + size: 10, color: EtmTokens.mutedOf(context))), + ), + ], ], ), ), diff --git a/lib/services/nymea_service.dart b/lib/services/nymea_service.dart index d703a56..89d3b26 100644 --- a/lib/services/nymea_service.dart +++ b/lib/services/nymea_service.dart @@ -991,8 +991,17 @@ class NymeaService extends ChangeNotifier { /// l'appareil (pas d'interface de comptage), pas une erreur de la requête, et le taire /// ferait lire une réponse partielle comme une réponse complète. /// - /// Vérifié sur `.75` le 2026-09-08 : six Things demandés, cinq répondent. La `V2C Trydan` - /// est absente — sa classe déclare `evcharger · power`, pas `smartmeter`. + /// ⚠️ **Le portier est `smartmeterconsumer`, pas `smartmeter`.** `smartmeter` est une + /// interface VIDE, sans state requis, et ne conditionne rien. Le moteur teste + /// `energymeter | smartmeterconsumer | smartmeterproducer | energystorage` + /// (`EnergyManagerImpl::watchThing`) — c'est là que se décide qui a un historique. + /// + /// Relevé le 2026-09-08 : six Things demandés, cinq répondaient, la `V2C Trydan` était + /// absente. **Ce n'est plus vrai** — `powersync-plugin-v2c 1.15.2+etm14`, posé le même + /// jour à 10:57, lui a ajouté `smartmeterconsumer` et elle répond depuis. + /// + /// Le cas particulier a disparu, **pas le besoin** : une borne sans comptage (`kebaSimple`, + /// `elvi`) ou une charge SG-Ready tombera toujours dans `sansHistorique`. /// /// La charge utile porte `currentPower`, `totalConsumption`, `totalProduction` et /// `thingId`. **Seul `currentPower` est lu ici**, brut : voir `consumer_series.dart` pour