From dd798ae85e360a9921130416f3c06ab901c9e9cc Mon Sep 17 00:00:00 2001 From: Patrick Schurig ETM-Schurig Date: Fri, 29 May 2026 22:33:18 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20graphe=20=C3=A9nergie=20=E2=80=94=20doub?= =?UTF-8?q?le=20axe=20kW/SOC%=20visible=20+=20simulation=20SOC=20r=C3=A9al?= =?UTF-8?q?iste?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NymeaService - batterySOCSource : retourne {'thingId': 'sim-battery', 'stateName': 'soc'} en simulation au lieu de null — le SOC est désormais fetchable - fetchHistory en simulation : données SOC réalistes en % (0-100) avec courbe charge solaire 8h-15h (20→85%) puis décharge soir (85→20%) au lieu de valeurs sinus 100-900 W incorrectes EnergyScreen - initState : appelle startSimulation() si non connecté (comme les autres écrans) - leftTitles : interval explicite (yMax/4) + SideTitleWidget pour ancrage correct - gridData : horizontalInterval aligné sur yInterval - rightTitles (SOC %) : SideTitleWidget + interval aligné - bottomTitles : SideTitleWidget sur les deux graphes - barChart leftTitles : interval + SideTitleWidget Co-Authored-By: Claude Sonnet 4.6 --- lib/screens/energy_screen.dart | 77 +++++++++++++++++++++------------ lib/services/nymea_service.dart | 22 +++++++--- 2 files changed, 67 insertions(+), 32 deletions(-) diff --git a/lib/screens/energy_screen.dart b/lib/screens/energy_screen.dart index 31e0fdf..6cb0216 100644 --- a/lib/screens/energy_screen.dart +++ b/lib/screens/energy_screen.dart @@ -54,6 +54,7 @@ class _EnergyScreenState extends State { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { _nymeaService = context.read(); + if (!_nymeaService!.connected) _nymeaService!.startSimulation(); _nymeaService!.addListener(_onServiceChangedForSoc); _fetch(); }); @@ -298,7 +299,8 @@ class _EnergyScreenState extends State { } final xInterval = _xInterval(); - final labelStyle = EtmTokens.sans(size: 9, color: EtmTokens.muted); + final labelStyle = EtmTokens.sans(size: 9, color: EtmTokens.muted); + final yInterval = yMax / 4; // 5 graduations : 0, 25%, 50%, 75%, 100% de yMax return LineChart( LineChartData( @@ -308,52 +310,67 @@ class _EnergyScreenState extends State { gridData: FlGridData( show: true, drawVerticalLine: false, + horizontalInterval: yInterval, getDrawingHorizontalLine: (_) => FlLine(color: EtmTokens.line, strokeWidth: 1), ), borderData: FlBorderData(show: false), titlesData: FlTitlesData( topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + + // Axe gauche : kW leftTitles: AxisTitles( - axisNameWidget: Text('kW', style: EtmTokens.sans(size: 9, color: EtmTokens.muted)), - axisNameSize: 14, sideTitles: SideTitles( showTitles: true, - reservedSize: 42, - getTitlesWidget: (v, _) => Text( - v.toStringAsFixed(1), - style: labelStyle, - textAlign: TextAlign.right, - ), + reservedSize: 46, + interval: yInterval, + getTitlesWidget: (v, meta) { + if (v == meta.max) return const SizedBox.shrink(); // évite label dupliqué au sommet + return SideTitleWidget( + meta: meta, + child: Text( + '${v.toStringAsFixed(1)}kW', + style: labelStyle, + ), + ); + }, ), ), + + // Axe droit : SOC % rightTitles: hasSoc ? AxisTitles( - axisNameWidget: Text('%', style: EtmTokens.sans(size: 9, color: EtmTokens.green)), - axisNameSize: 14, sideTitles: SideTitles( showTitles: true, - reservedSize: 34, - interval: yMax / 4, - getTitlesWidget: (v, _) { + reservedSize: 40, + interval: yInterval, + getTitlesWidget: (v, meta) { + if (v == meta.max) return const SizedBox.shrink(); final pct = (v / yMax * 100).round(); if (pct < 0 || pct > 100) return const SizedBox.shrink(); - return Text('$pct%', - style: EtmTokens.sans(size: 9, color: EtmTokens.green)); + return SideTitleWidget( + meta: meta, + child: Text( + '$pct%', + style: EtmTokens.sans(size: 9, color: EtmTokens.green), + ), + ); }, ), ) : const AxisTitles(sideTitles: SideTitles(showTitles: false)), + + // Axe bas : temps bottomTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, - reservedSize: 20, + reservedSize: 22, interval: xInterval, - getTitlesWidget: (v, _) { + getTitlesWidget: (v, meta) { final idx = v.round(); if (idx < 0 || idx >= _data.length) return const SizedBox.shrink(); - return Padding( - padding: const EdgeInsets.only(top: 4), + return SideTitleWidget( + meta: meta, child: Text(_fmtTime(_data[idx].timestamp), style: labelStyle), ); }, @@ -457,22 +474,28 @@ class _EnergyScreenState extends State { leftTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, - reservedSize: 46, - getTitlesWidget: (v, _) => Text(_fmtWh(v), - style: labelStyle, textAlign: TextAlign.right), + reservedSize: 52, + interval: maxE * 1.15 / 4, + getTitlesWidget: (v, meta) { + if (v == meta.max) return const SizedBox.shrink(); + return SideTitleWidget( + meta: meta, + child: Text(_fmtWh(v), style: labelStyle), + ); + }, ), ), bottomTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, - reservedSize: 20, - getTitlesWidget: (v, _) { + reservedSize: 22, + getTitlesWidget: (v, meta) { final idx = v.toInt(); if (idx < 0 || idx >= _data.length) return const SizedBox.shrink(); final step = _xInterval().toInt().clamp(1, _data.length); if (idx % step != 0) return const SizedBox.shrink(); - return Padding( - padding: const EdgeInsets.only(top: 4), + return SideTitleWidget( + meta: meta, child: Text(_fmtTime(_data[idx].timestamp), style: labelStyle), ); }, diff --git a/lib/services/nymea_service.dart b/lib/services/nymea_service.dart index f073330..5eb4150 100644 --- a/lib/services/nymea_service.dart +++ b/lib/services/nymea_service.dart @@ -125,9 +125,11 @@ class NymeaService extends ChangeNotifier { /// Retourne {thingId, stateName} de la première batterie trouvée, /// ou null si aucune batterie configurée / pas encore chargée. - /// Non disponible en simulation (fetchHistory retourne des W, pas des %). Map? get batterySOCSource { - if (_isSimulation) return null; + // En simulation : retourne directement la batterie simulée. + if (_isSimulation) { + return {'thingId': 'sim-battery', 'stateName': 'soc'}; + } if (_things.isEmpty) { _log('🔋 batterySOCSource: things pas encore chargés', force: true); return null; @@ -1137,14 +1139,24 @@ class NymeaService extends ChangeNotifier { String sampleRate = 'SampleRate15Mins', }) async { if (_isSimulation) { - // Données sinusoïdales simulées + // SOC simulé : charge dans la journée (solaire) puis décharge le soir. + // Valeurs en % (0-100) pour être normalisables côté graphe. final entries = []; const steps = 48; final step = to.difference(from) ~/ steps; for (int i = 0; i <= steps; i++) { final t = from.add(step * i); - final v = 500 + 400 * sin(i * pi / 12); - entries.add(HistoryEntry(timestamp: t, value: v)); + final hour = t.hour + t.minute / 60.0; + // Montée solaire 8h-15h → 20% → 85%, descente soir → 20% + final double soc; + if (hour < 8) { + soc = 20 + 5 * (hour / 8); + } else if (hour < 15) { + soc = 25 + 60 * ((hour - 8) / 7); + } else { + soc = 85 - 65 * ((hour - 15) / 9); + } + entries.add(HistoryEntry(timestamp: t, value: soc.clamp(5.0, 95.0))); } return entries; }