feat(énergie): six cartes journalières, la V2C qui commence en cours de route, et plus de simulation muette

CARTES. Elles lisaient `energyData.dayProductionWh`, qui porte en réalité le
compteur CUMULÉ depuis l'origine : « Production 62946.74… » au-dessus d'un
graphe de 24 h. Et « Gains 8789.84 € » n'avait aucune source — un prix du kWh
que personne n'a saisi. Elles lisent maintenant DailyBalance, comme le tableau
de bord, et chaque taux porte son dénominateur (« 22 % sur 1,1 kWh produits »).
Mise en page reprise de la maquette validée.

V2C. `powersync-plugin-v2c 1.15.2+etm14` lui a ajouté `smartmeterconsumer` :
vérifié à la sonde, elle rend 1 point contre 45 aux autres, son historique
commençant à 10:57. Le filtre d'alignement précédent l'aurait sortie du TRACÉ en
la laissant dans la LÉGENDE — le défaut qu'on venait de corriger sur l'autre
graphe. L'alignement passe donc par instant, et une série ne contribue que là
où elle a des points.

Avant son premier point, sa consommation est comptée dans le reste du foyer :
le total reste juste, seule la RÉPARTITION est incomplète, et c'est ce que dit
la note. Trois états, trois phrases — « pas d'historique », « mesurée depuis
HH:MM », « 0 W mesuré » — et aucune ne se dessine comme une autre.

Et le commentaire de fetchThingPowerLogs est corrigé deux fois : il était
périmé, et il nommait le mauvais portier. `smartmeter` est une interface VIDE
qui ne conditionne rien ; c'est `smartmeterconsumer` qui décide, via
EnergyManagerImpl::watchThing().

🔴 SIMULATION. L'écran faisait `if (!connected) startSimulation()` au montage.
Monté avant que la WebSocket soit établie — le cas normal — il basculait tout le
service en démo et n'en ressortait jamais. Vu à l'écran, box joignable : une
sinusoïde orange et un SOC à 75 % sur une installation SANS batterie. Rien ne
distinguait ces données inventées des vraies : ni couleur, ni mention, ni forme.
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. L'écran
attend maintenant la connexion et se rattrape dès qu'elle arrive.

Captures : docs/captures/2026-09-08_energie-cartes-journalieres.png
           docs/captures/2026-09-08_energie-empilement-v2c.png

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
This commit is contained in:
Patrick Schurig 2026-09-08 11:31:01 +02:00
parent 6d0ba26e07
commit a76798f2d8
4 changed files with 246 additions and 63 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

View File

@ -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<EnergyScreen> {
List<HistoryEntry> _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<EnergyScreen> {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_nymeaService = context.read<NymeaService>();
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<EnergyScreen> {
@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<EnergyScreen> {
: Future<List<HistoryEntry>>.value([]),
service.fetchThingPowerLogs(
appareils: appareils, from: from, to: to, sampleRate: tab.sampleRate),
service.fetchDailyBalance(),
]);
if (!mounted) return;
setState(() {
_data = results[0] as List<PowerBalanceEntry>;
_socData = results[1] as List<HistoryEntry>;
_conso = results[2] as ConsumerHistory;
_bilan = results[3] as DailyBalance?;
_loading = false;
_noData = _data.isEmpty;
});
@ -247,7 +277,7 @@ class _EnergyScreenState extends State<EnergyScreen> {
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<EnergyScreen> {
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 = <double>[];
for (int i = 0; i < n; i++) {
final somme = alignees.fold<double>(0, (a, s) => a + valeurAt(s, i));
final somme =
series.fold<double>(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<double> valeurs})>[
(
@ -613,7 +662,7 @@ class _EnergyScreenState extends State<EnergyScreen> {
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<EnergyScreen> {
/// 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<EnergyScreen> {
'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<EnergyScreen> {
// ─────────────────────────── 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))),
),
],
],
),
),

View File

@ -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