From 763df68a2a40f0492e6e2fde44493a25d178a69e Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sat, 5 Sep 2026 09:31:44 +0200 Subject: [PATCH] =?UTF-8?q?feat(bilan):=20deux=20grandeurs,=20deux=20phras?= =?UTF-8?q?es=20=E2=80=94=20les=20ratios=20de=20la=20box=20=C3=A0=20c?= =?UTF-8?q?=C3=B4t=C3=A9=20de=20la=20journ=C3=A9e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les tuiles disent la JOURNÉE, par différence de compteurs sur la fenêtre publiée. La nouvelle ligne dit ce que la BOX publie, qui part de la baseline — et elle ne s'affiche QUE lorsque cette origine diffère du début de journée. Le cas est arrivé de lui-même : `nymead` redémarré à 09:23, sonde à 09:26, et la box publiait 100 % d'autoconsommation. Vrai sur trois minutes ; absurde intitulé « aujourd'hui ». La ligne le rend lisible — « Instantané depuis 09:23 » — au lieu de le laisser passer pour la journée. Vérifié avec le MODÈLE de l'app et non à l'œil sur le JSON : `ratios_check.dart` importe `EnergyRatiosSnapshot` (idiome de `measurement_check.dart`) et rend `baselineRepriseEnJournee: true`, ratios couvrant 3 minutes. Le banc est passé en Europe/Paris entre-temps, et `periodStartedAt` suit sans que le code bouge — c'est l'intérêt d'une frontière publiée. Quand les deux coïncident, rien n'est ajouté : une phrase permanente cesse d'être lue, comme un avertissement permanent. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw --- lib/features/dashboard/widgets/kpi_row.dart | 50 ++++++++++- lib/screens/dashboard_screen.dart | 19 ++++- test/kpi_ratios_test.dart | 91 +++++++++++++++++++++ tools/rpc/ratios_check.dart | 49 +++++++++++ 4 files changed, 205 insertions(+), 4 deletions(-) create mode 100644 test/kpi_ratios_test.dart create mode 100644 tools/rpc/ratios_check.dart diff --git a/lib/features/dashboard/widgets/kpi_row.dart b/lib/features/dashboard/widgets/kpi_row.dart index 1be9f6c..d40290f 100644 --- a/lib/features/dashboard/widgets/kpi_row.dart +++ b/lib/features/dashboard/widgets/kpi_row.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import '../../../models/daily_balance.dart'; +import '../../../models/energy_ratios_snapshot.dart'; import '../../../models/energy_data.dart'; import '../../../theme/etm_tokens.dart'; import '../painters/ring_painter.dart'; @@ -24,13 +25,42 @@ import '../painters/ring_painter.dart'; /// corrompus, ou compteur en recul. Les tuiles affichent alors « — » et le disent. Elles /// n'affichent **jamais zéro** : « rien produit » et « on ne sait pas » sont deux états /// différents, et à 3 h du matin le premier est vrai sans que le second le soit. +/// La phrase des ratios de la box — **avec leur origine, puisqu'elle diffère**. +/// +/// Appelée seulement quand la baseline a été reposée après le début de journée : sans ça, +/// il n'y a rien à dire de plus que ce que les tuiles disent déjà. +String _instantane(EnergyRatiosSnapshot r) { + String pc(double? v) => v == null ? '—' : '${v.round()} %'; + final d = r.measuredSince!; + final h = '${d.hour.toString().padLeft(2, '0')}:' + '${d.minute.toString().padLeft(2, '0')}'; + return 'Instantané depuis $h — autoconsommation ${pc(r.selfConsumptionRate)}, ' + 'autonomie ${pc(r.autonomyRate)}'; +} + class KpiRow extends StatelessWidget { final EnergyData data; /// Bilan du jour, ou `null` tant qu'il n'est pas lu — ou s'il n'est pas mesurable. final DailyBalance? balance; - const KpiRow({super.key, required this.data, this.balance}); + /// **Les ratios de la BOX, qui ne sont pas ceux des tuiles.** + /// + /// Deux grandeurs voisines et deux phrases différentes, comme `purchasedWh` et + /// `deliveredWh` : les tuiles disent la **journée**, calculée par différence de compteurs + /// sur la fenêtre `periodStartedAt → maintenant` ; ceux-ci partent de la **baseline**, qui + /// se repose à chaque redémarrage de `nymead`. + /// + /// Mesuré sur `.75` le 2026-09-05 à 09:26, après un redémarrage à 09:23 : la box publiait + /// **100 % d'autoconsommation** — vrai sur trois minutes, absurde intitulé « aujourd'hui ». + /// D'où la règle : on les garde comme instantanés, et on **dit leur origine** quand elle + /// diffère du début de journée. Quand les deux coïncident — le cas normal — on n'ajoute + /// rien : une phrase permanente cesse d'être lue, exactement comme un avertissement + /// permanent. + final EnergyRatiosSnapshot? ratios; + + const KpiRow( + {super.key, required this.data, this.balance, this.ratios}); @override Widget build(BuildContext context) { @@ -86,6 +116,24 @@ class KpiRow extends StatelessWidget { ), ], ), + if (ratios?.baselineRepriseEnJournee ?? false) ...[ + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.only(left: 2), + child: Row(children: [ + Icon(Icons.bolt_rounded, + size: 13, color: EtmTokens.mutedOf(context)), + const SizedBox(width: 5), + Expanded( + child: Text( + _instantane(ratios!), + style: EtmTokens.sans( + size: 11, color: EtmTokens.mutedOf(context)), + ), + ), + ]), + ), + ], const SizedBox(height: 10), Row( children: [ diff --git a/lib/screens/dashboard_screen.dart b/lib/screens/dashboard_screen.dart index c543d9a..448c17d 100644 --- a/lib/screens/dashboard_screen.dart +++ b/lib/screens/dashboard_screen.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../models/daily_balance.dart'; +import '../models/energy_ratios_snapshot.dart'; import '../main.dart' show DrawerMenuButton; import '../providers/app_settings_provider.dart'; import '../services/nymea_service.dart'; @@ -33,6 +34,9 @@ class _DashboardScreenState extends State { /// puissance. DailyBalance? _bilan; + /// Les ratios de la box — lus à côté du bilan, et affichés à côté de lui. Voir [KpiRow]. + EnergyRatiosSnapshot? _ratios; + /// Le bilan avance d'un point par quart d'heure : le relire plus souvent coûterait un /// aller-retour de 88 échantillons pour rien. Cinq minutes laissent la journée fraîche /// sans marteler la box. @@ -54,8 +58,17 @@ class _DashboardScreenState extends State { Future _lireBilan() async { if (!mounted) return; - final b = await context.read().fetchDailyBalance(); - if (mounted) setState(() => _bilan = b); + final svc = context.read(); + // Les ratios de la box sont lus à côté du bilan, pas à sa place : ils ne couvrent pas + // la même fenêtre, et c'est justement ce que l'écran doit pouvoir dire. + final r = await svc.fetchEnergyRatios(); + final b = await svc.fetchDailyBalance(); + if (mounted) { + setState(() { + _bilan = b; + _ratios = r; + }); + } } @override @@ -86,7 +99,7 @@ class _DashboardScreenState extends State { evChargerPowerW: service.evCharger?.currentPowerW), const SizedBox(height: 14), - KpiRow(data: data, balance: _bilan), + KpiRow(data: data, balance: _bilan, ratios: _ratios), const SizedBox(height: 14), const HeosDecisionCard(), const SizedBox(height: 14), diff --git a/test/kpi_ratios_test.dart b/test/kpi_ratios_test.dart new file mode 100644 index 0000000..3e0d8ce --- /dev/null +++ b/test/kpi_ratios_test.dart @@ -0,0 +1,91 @@ +/// **Deux grandeurs, deux phrases, aucune ne ment.** +/// +/// Les tuiles disent la JOURNÉE — différence de compteurs sur `periodStartedAt → maintenant`. +/// La ligne des ratios dit ce que la BOX publie, qui part de la **baseline** et non du début +/// de journée. +/// +/// Le cas est arrivé de lui-même le 2026-09-05 : `nymead` redémarré à 09:23, sonde à 09:26, +/// et la box publiait **100 % d'autoconsommation**. Vrai sur trois minutes ; absurde intitulé +/// « aujourd'hui ». C'est ce que le drapeau rend lisible — et c'est pour ça que la ligne ne +/// s'affiche QUE lorsque l'origine diffère : quand les deux coïncident, une phrase de plus +/// serait du bruit, et un texte permanent cesse d'être lu. +library; + +import 'package:etm_powersync_app/features/dashboard/widgets/kpi_row.dart'; +import 'package:etm_powersync_app/models/daily_balance.dart'; +import 'package:etm_powersync_app/models/energy_data.dart'; +import 'package:etm_powersync_app/models/energy_ratios_snapshot.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Relevé `.75` du 2026-09-05 09:26, après le redémarrage de 09:23. +EnergyRatiosSnapshot _releve({int measuredSince = 1788592995}) => + EnergyRatiosSnapshot.fromJson({ + 'autonomyRate': 28.570199887530475, + 'selfConsumptionRate': 100, + 'periodStartedAt': 1788559200, + 'measuredSince': measuredSince, + 'timeZone': 'Europe/Paris', + }); + +DailyBalance _bilan() => DailyBalance( + from: DateTime(2026, 9, 5, 0, 15), + to: DateTime(2026, 9, 5, 9, 26), + periodStart: DateTime(2026, 9, 5), + sampleCount: 37, + productionKwh: 8.0, + consumptionKwh: 12.0, + importKwh: 5.0, + exportKwh: 1.0, + ); + +Widget _harness({EnergyRatiosSnapshot? ratios}) => MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: KpiRow( + data: const EnergyData(), balance: _bilan(), ratios: ratios), + ), + ), + ); + +void main() { + testWidgets('baseline reposée en journée : l\'origine est DITE', (tester) async { + await tester.binding.setSurfaceSize(const Size(1000, 2000)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget(_harness(ratios: _releve())); + + expect(find.textContaining('Instantané depuis 09:23'), findsOneWidget); + // Le 100 % est affiché — il est vrai — mais jamais sous le mot « aujourd'hui ». + expect(find.textContaining('autoconsommation 100 %'), findsOneWidget); + }); + + testWidgets('les tuiles restent la JOURNÉE, calculée sur la fenêtre de la box', + (tester) async { + await tester.binding.setSurfaceSize(const Size(1000, 2000)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget(_harness(ratios: _releve())); + // Les anneaux animent leur valeur : sans laisser l'animation finir, ils affichent 0 — + // une absence qui se lirait comme un succès si on cherchait justement une absence. + await tester.pumpAndSettle(); + + expect(find.text('Aujourd\'hui'), findsOneWidget); + // 8 kWh produits, 1 exporté -> 87,5 % ; et surtout PAS le 100 % de la box. + expect(find.textContaining('88'), findsOneWidget); + }); + + testWidgets('origine confondue avec le début de journée : rien de plus', + (tester) async { + await tester.binding.setSurfaceSize(const Size(1000, 2000)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + // Le cas normal : la baseline date du début de journée. + await tester.pumpWidget(_harness(ratios: _releve(measuredSince: 1788559200))); + expect(find.textContaining('Instantané depuis'), findsNothing); + }); + + testWidgets('box antérieure : aucune ligne, aucune supposition', (tester) async { + await tester.binding.setSurfaceSize(const Size(1000, 2000)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget(_harness()); + expect(find.textContaining('Instantané'), findsNothing); + }); +} diff --git a/tools/rpc/ratios_check.dart b/tools/rpc/ratios_check.dart new file mode 100644 index 0000000..568c84e --- /dev/null +++ b/tools/rpc/ratios_check.dart @@ -0,0 +1,49 @@ +// Ce que l'APP conclut des deux bornes de `GetEnergyRatios` — pas ce que le JSON montre. +// +// ⚠️ `dart run` : cette sonde **importe le modèle de l'app** (`EnergyRatiosSnapshot`) plutôt +// que de relire la charge utile à sa façon. Une lecture parallèle ne prouverait rien sur ce +// que l'écran dira — c'est la leçon de `measurement_check.dart`. +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:etm_powersync_app/models/energy_ratios_snapshot.dart'; + +Future main(List args) async { + final host = args.isNotEmpty ? args[0] : '192.168.1.75'; + final ws = await WebSocket.connect('ws://$host:4444') + .timeout(const Duration(seconds: 8)); + var id = 0; + final pending = >>{}; + ws.listen((raw) { + final m = jsonDecode(raw as String) as Map; + final rid = m['id']; + if (rid is int && pending.containsKey(rid)) pending.remove(rid)!.complete(m); + }); + Future> call(String m) { + final myId = ++id; + final c = Completer>(); + pending[myId] = c; + ws.add(jsonEncode({'id': myId, 'method': m, 'params': const {}})); + return c.future.timeout(const Duration(seconds: 15)); + } + + await call('JSONRPC.Hello'); + final r = await call('NymeaEnergy.GetEnergyRatios'); + final p = Map.from(r['params'] as Map); + print('brut : ${jsonEncode(p)}'); + + final s = EnergyRatiosSnapshot.fromJson(p); + String q(DateTime? d) => d == null ? '—' : d.toLocal().toString(); + print('périodeDébut : ${q(s.periodStartedAt)} (frontière de journée → BILAN)'); + print('mesuréDepuis : ${q(s.measuredSince)} (baseline → RATIOS)'); + print('fuseau : ${s.timeZone}'); + print('datePeriode : ${s.datePeriode}'); + print('baselineRepriseEnJournee : ${s.baselineRepriseEnJournee}'); + if (s.baselineRepriseEnJournee) { + final couvert = DateTime.now().difference(s.measuredSince!); + print(' -> les ratios ne couvrent que ${couvert.inMinutes} min, ' + 'pas la journée. C\'est CE que le drapeau rend lisible.'); + } + await ws.close(); +}