etm-powersync-app/lib/widgets/pv_forecast_card.dart
Patrick Schurig adb069f1d2 feat(énergie): prévision PV lue sur la box, marché spot masqué (+etm68)
Consigne app etm66/etm68, vérifiée contre le banc hems (1.15.2+etm68) le 2026-10-14.

- PvForecastCard remplace le placeholder « Météo & prévision » : lit
  NymeaEnergy.GetForecastSnapshot à l'ouverture puis toutes les 12 min
  (aucune notification n'existe). Pas des créneaux DÉDUIT, créneau absent
  = trou (jamais zéro), fault ⇒ pas de courbe avec son motif, libellé
  « plafond optimiste », corrections/plafond AC en mode installateur.
- MISSING_FORECAST_SITE : les candidats de GetForecastSite sont proposés,
  la désignation (SetForecastSite) se confirme ; acLimitW omis = inchangé.
- Production mesurée superposée depuis GetPowerBalanceLogs, signe inversé
  (pas .abs()), aucun cumul total*.
- Marché spot masqué dans Tarifs (SPOT_MARKET_NOT_IN_BUILD) : mode Spot
  Market, fournisseurs Tibber/Octopus/ENTSO-E et la carte de prix codés en dur.
- Bornes VE envoyées en {uuid} minuscules jusqu'à etm69 (SetChargingInfo,
  SetLoadConfig, Set/ClearLoadDerogation) via idBorneCanonique().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 07:40:20 +02:00

624 lines
22 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:async';
import 'dart:math';
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/pv_forecast.dart';
import '../providers/installer_mode_provider.dart';
import '../services/nymea_service.dart';
import '../theme/etm_tokens.dart';
/// Une production mesurée, prête à superposer : instant absolu et puissance en W,
/// **déjà retournée** (nymea publie la production négative).
typedef ProductionMesuree = ({DateTime t, double w});
/// Intervalle de relecture. La prévision ne notifie RIEN (décision moteur) : sans
/// relecture, l'écran laissé ouvert montrerait la courbe du matin le soir.
const Duration kRelecturePrevision = Duration(minutes: 12);
/// **Carte « Prévision PV »** — lit la box, relit périodiquement, et délègue le rendu à
/// [PvForecastView].
///
/// Remplace le placeholder « Météo & prévision » de l'écran Énergie : la courbe existe
/// désormais sur la box (`NymeaEnergy.GetForecastSnapshot`, `+etm68`).
class PvForecastCard extends StatefulWidget {
const PvForecastCard({super.key});
@override
State<PvForecastCard> createState() => _PvForecastCardState();
}
class _PvForecastCardState extends State<PvForecastCard> {
Timer? _timer;
bool _chargement = true;
PvForecastSnapshot? _snapshot;
String? _erreur;
PvForecastSite? _site;
List<ProductionMesuree> _mesure = const [];
String? _refusDesignation;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _lire());
_timer = Timer.periodic(kRelecturePrevision, (_) => _lire());
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
Future<void> _lire() async {
if (!mounted) return;
final service = context.read<NymeaService>();
final r = await service.fetchForecastSnapshot();
if (!mounted) return;
PvForecastSite? site;
var mesure = const <ProductionMesuree>[];
final snap = r.snapshot;
if (snap?.etat == PvForecastEtat.siteManquant) {
site = await service.fetchForecastSite();
} else if (snap?.etat == PvForecastEtat.courbe) {
final debut = snap!.points.first.debutUtc;
final maintenant = DateTime.now().toUtc();
if (debut.isBefore(maintenant)) {
final logs = await service.fetchPowerBalanceLogs(
from: debut.toLocal(), to: maintenant.toLocal());
mesure = [
// `production` est NÉGATIVE dans nymea : on INVERSE le signe, on ne prend pas
// la valeur absolue. Un onduleur qui consomme la nuit publie une production
// positive ; `.abs()` la ferait lire comme une production.
for (final e in logs) (t: e.timestamp, w: -e.productionW),
];
}
}
if (!mounted) return;
setState(() {
_chargement = false;
_snapshot = snap;
_erreur = r.erreur;
_site = site;
_mesure = mesure;
});
}
Future<void> _designer(String siteId) async {
final service = context.read<NymeaService>();
final err = await service.setForecastSite(siteId);
if (!mounted) return;
setState(() => _refusDesignation = err);
await _lire();
}
@override
Widget build(BuildContext context) {
final service = context.watch<NymeaService>();
final installateur = context.watch<InstallerModeProvider>().isUnlocked;
return PvForecastView(
chargement: _chargement,
snapshot: _snapshot,
erreur: _erreur,
site: _site,
mesure: _mesure,
maintenant: DateTime.now(),
installateur: installateur,
nomDuThing: (id) => _nom(service, id),
refusDesignation: _refusDesignation,
onDesigner: _designer,
onRelire: _lire,
);
}
static String _nom(NymeaService s, String id) {
final nu = id.replaceAll(RegExp(r'[{}]'), '').toLowerCase();
for (final t in s.things) {
if (t.id.replaceAll(RegExp(r'[{}]'), '').toLowerCase() == nu) return t.name;
}
return id;
}
}
/// Rendu pur de la prévision — tout ce qu'il affiche lui est donné.
class PvForecastView extends StatelessWidget {
final bool chargement;
final PvForecastSnapshot? snapshot;
/// Non nul = la prévision n'a pas été LUE. Ce n'est pas « rien de publié ».
final String? erreur;
final PvForecastSite? site;
final List<ProductionMesuree> mesure;
final DateTime maintenant;
final bool installateur;
final String Function(String thingId) nomDuThing;
final String? refusDesignation;
final Future<void> Function(String siteId)? onDesigner;
final Future<void> Function()? onRelire;
const PvForecastView({
super.key,
required this.chargement,
required this.snapshot,
this.erreur,
this.site,
this.mesure = const [],
required this.maintenant,
this.installateur = false,
required this.nomDuThing,
this.refusDesignation,
this.onDesigner,
this.onRelire,
});
@override
Widget build(BuildContext context) {
final muted = EtmTokens.mutedOf(context);
final snap = snapshot;
final pas = snap?.pas;
return Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: EtmTokens.surfaceOf(context),
borderRadius: BorderRadius.circular(EtmTokens.radiusLg),
boxShadow: EtmTokens.cardShadow,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Prévision PV',
style: EtmTokens.sans(size: 16, weight: FontWeight.w600)),
if (snap?.etat == PvForecastEtat.courbe && pas != null)
Text('par créneau de ${_duree(pas)}',
style: EtmTokens.sans(size: 12, color: muted)),
],
),
const SizedBox(height: 12),
..._corps(context),
],
),
);
}
List<Widget> _corps(BuildContext context) {
if (chargement) {
return const [
SizedBox(
height: 60,
child: Center(
child: SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(strokeWidth: 2)))),
];
}
final snap = snapshot;
if (snap == null) {
if (erreur == 'NoSuchMethod') {
return [
_Encadre(
icone: Icons.cloud_off_outlined,
titre: 'Cette box ne publie pas de prévision PV',
texte: 'Le moteur installé ne connaît pas encore '
'« NymeaEnergy.GetForecastSnapshot ». La carte s\'activera après '
'mise à jour.'),
];
}
if (erreur == 'Simulation') {
return [
_Encadre(
icone: Icons.science_outlined,
titre: 'Pas de prévision en simulation',
texte: 'La prévision vient de la box ; aucune n\'est inventée ici.'),
];
}
return [
_Encadre(
icone: Icons.sync_problem_rounded,
titre: 'Prévision non lue',
texte: 'La box n\'a pas répondu (${erreur ?? 'raison inconnue'}). '
'Ce n\'est pas une absence de prévision : elle n\'a pas pu être lue.',
action: onRelire == null
? null
: TextButton(onPressed: onRelire, child: const Text('Relire'))),
];
}
switch (snap.etat) {
case PvForecastEtat.siteManquant:
return _siteManquant(context);
case PvForecastEtat.chargeRefusee:
final cause = snap.faultParams['cause'];
return [
_Encadre(
icone: Icons.report_gmailerrorred_rounded,
titre: 'Prévision refusée par la box',
texte: 'Le producteur météo publie une prévision que le moteur ne sait '
'pas lire — c\'est lui qu\'il faut regarder, pas ce réglage.'
'${cause != null ? '\nCause : $cause' : ''}',
code: 'FORECAST_PAYLOAD_REFUSED'),
];
case PvForecastEtat.faultInconnu:
return [
_Encadre(
icone: Icons.help_outline,
titre: 'Pas de courbe : défaut publié par la box',
texte: 'Ce défaut est inconnu de cette version de l\'app.'
'${snap.faultParams.isNotEmpty ? '\n${snap.faultParams}' : ''}',
code: snap.fault),
];
case PvForecastEtat.rienPublie:
return [
_Encadre(
icone: Icons.cloud_queue_rounded,
titre: 'Aucune prévision publiée',
texte: 'Pas de plugin météo sur cette box, ou ses données ne sont pas '
'encore arrivées. La box ne distingue pas les deux cas.'),
];
case PvForecastEtat.courbe:
return _courbe(context, snap);
}
}
List<Widget> _siteManquant(BuildContext context) {
final candidats = site?.candidats ?? const <String>[];
return [
_Encadre(
icone: Icons.place_outlined,
titre: 'Aucun site de prévision désigné',
texte: candidats.isEmpty
? 'Des champs PV publient une prévision, mais aucun site n\'est '
'désigné et la liste des sites possibles n\'a pas pu être lue.'
: 'Des champs PV publient une prévision. Choisissez le site dont la '
'production doit être prévue — tous ses champs seront additionnés.',
code: 'MISSING_FORECAST_SITE'),
for (final c in candidats)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Row(children: [
const Icon(Icons.solar_power_outlined, size: 18),
const SizedBox(width: 8),
Expanded(
child: Text(nomDuThing(c),
style: EtmTokens.sans(size: 13, weight: FontWeight.w500))),
TextButton(
onPressed: onDesigner == null ? null : () => _confirmer(context, c),
child: const Text('Désigner'),
),
]),
),
if (refusDesignation != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text('La box a refusé la désignation : $refusDesignation',
style: EtmTokens.sans(size: 12, color: EtmTokens.danger)),
),
];
}
Future<void> _confirmer(BuildContext context, String siteId) async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Désigner ce site ?'),
content: Text('La prévision de « ${nomDuThing(siteId)} » sera gardée et '
'utilisée par le moteur. Le choix est enregistré sur la box.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Annuler')),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Désigner')),
],
),
);
if (ok == true) await onDesigner?.call(siteId);
}
List<Widget> _courbe(BuildContext context, PvForecastSnapshot snap) {
final muted = EtmTokens.mutedOf(context);
final manquants = snap.creneauxManquants;
final change = snap.contentChangedAt;
return [
Wrap(spacing: 14, runSpacing: 4, children: [
_Legende(EtmTokens.solarOf(context), 'Prévision (plafond optimiste)'),
if (mesure.isNotEmpty) _Legende(EtmTokens.ecoOf(context), 'Production mesurée'),
]),
const SizedBox(height: 10),
SizedBox(height: 200, child: _graphe(context, snap)),
const SizedBox(height: 10),
_Note(
icone: Icons.info_outline_rounded,
texte: 'Une prévision, pas une mesure — et un plafond optimiste : ombrage et '
'salissure ne sont pas modélisés, la production réelle sera en général '
'en dessous.'),
if (manquants > 0)
_Note(
icone: Icons.more_horiz_rounded,
texte: '$manquants créneau${manquants > 1 ? 'x' : ''} sans prévision — '
'laissé${manquants > 1 ? 's' : ''} vide${manquants > 1 ? 's' : ''}, '
'pas à zéro.'),
_Note(
icone: Icons.update_rounded,
texte: change != null
? 'Courbe inchangée depuis ${_jourHeure(change)}.'
: 'Date du dernier changement de la courbe non publiée par la box.'),
if (installateur) ...[
const SizedBox(height: 6),
Text('Détails installateur',
style: EtmTokens.sans(size: 12, weight: FontWeight.w600, color: muted)),
const SizedBox(height: 4),
_ligne(context, 'Corrections',
snap.corrections == null
? 'non publié'
: snap.corrections!.isEmpty
? 'aucune (GTI × kWc × PR brut)'
: snap.corrections!.map(_correction).join(', ')),
if (snap.acLimitW != null)
_ligne(context, 'Plafond AC du site', '${snap.acLimitW} W (courbe écrêtée)'),
if (snap.perField.isNotEmpty)
_ligne(context, 'Champs additionnés', '${snap.perField.length}'),
for (final f in snap.perField)
_ligne(context, ' ${nomDuThing(f.id)}',
f.corrections.isEmpty ? 'aucune correction' : f.corrections.map(_correction).join(', ')),
if (snap.model != null) _ligne(context, 'Modèle', snap.model!),
if (snap.conversion != null) _ligne(context, 'Conversion', snap.conversion!),
_ligne(context, 'Run émis',
snap.issuedAt != null ? _jourHeure(snap.issuedAt!) : 'non publié'),
if (snap.capturedAt != null)
_ligne(context, 'Lue à', _jourHeure(snap.capturedAt!)),
],
];
}
Widget _graphe(BuildContext context, PvForecastSnapshot snap) {
final t0 = snap.points.first.debutUtc;
final pas = snap.pas;
double x(DateTime t) => t.toUtc().difference(t0).inSeconds / 3600.0;
// L'énergie d'un créneau devient une puissance MOYENNE sur ce créneau : seule unité
// commune avec la production mesurée. Simple changement d'unité, pas un calcul de
// prévision. Sans pas connu (un point seul), on ne sait pas sur quoi diviser.
final prev = <FlSpot>[];
if (pas != null) {
final h = pas.inSeconds / 3600.0;
for (final seg in snap.segments) {
if (prev.isNotEmpty) prev.add(FlSpot.nullSpot); // un trou, jamais un zéro
for (final p in seg) {
final kw = p.wh / h / 1000;
prev
..add(FlSpot(x(p.debutUtc), kw))
..add(FlSpot(x(p.debutUtc) + h, kw));
}
}
}
final xMax = pas == null
? max(1.0, x(snap.points.last.debutUtc))
: x(snap.points.last.debutUtc) + pas.inSeconds / 3600.0;
final mes = <FlSpot>[];
DateTime? avant;
for (final m in mesure) {
final xm = x(m.t);
if (xm < 0 || xm > xMax) continue;
// Un trou de mesure reste un trou : plus de deux fois l'écart attendu (15 min).
if (avant != null && m.t.difference(avant) > const Duration(minutes: 31)) {
mes.add(FlSpot.nullSpot);
}
mes.add(FlSpot(xm, m.w / 1000));
avant = m.t;
}
final valeurs = [
for (final s in [...prev, ...mes])
if (s != FlSpot.nullSpot) s.y
];
final yMax = max(0.5, (valeurs.isEmpty ? 0.0 : valeurs.reduce(max)) * 1.15);
final yMin = min(0.0, valeurs.isEmpty ? 0.0 : valeurs.reduce(min));
final xNow = x(maintenant);
final label = EtmTokens.sans(size: 9, color: EtmTokens.mutedOf(context));
final solaire = EtmTokens.solarOf(context);
final eco = EtmTokens.ecoOf(context);
return LineChart(
LineChartData(
clipData: const FlClipData.all(),
minX: 0,
maxX: xMax,
minY: yMin,
maxY: yMax,
borderData: FlBorderData(show: false),
gridData: FlGridData(
show: true,
drawVerticalLine: false,
horizontalInterval: yMax / 4,
getDrawingHorizontalLine: (_) =>
FlLine(color: EtmTokens.lineOf(context), strokeWidth: 1),
),
extraLinesData: ExtraLinesData(verticalLines: [
if (xNow > 0 && xNow < xMax)
VerticalLine(
x: xNow,
color: EtmTokens.mutedOf(context),
strokeWidth: 1,
dashArray: const [3, 3]),
]),
titlesData: FlTitlesData(
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 40,
interval: yMax / 4,
getTitlesWidget: (v, meta) => v == meta.max
? const SizedBox.shrink()
: SideTitleWidget(
meta: meta,
child: Text('${v.toStringAsFixed(1)}kW', style: label)),
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 22,
interval: 1,
getTitlesWidget: (v, meta) {
if (v <= meta.min || v >= meta.max) return const SizedBox.shrink();
// Heure LOCALE à l'affichage seulement ; la grille reste en UTC.
final t = t0.add(Duration(seconds: (v * 3600).round())).toLocal();
if (t.minute != 0 || t.hour % 6 != 0) return const SizedBox.shrink();
final txt = t.hour == 0
? '${t.day}/${t.month}'
: '${t.hour.toString().padLeft(2, '0')}h';
return SideTitleWidget(meta: meta, child: Text(txt, style: label));
},
),
),
),
lineTouchData: const LineTouchData(enabled: false),
lineBarsData: [
LineChartBarData(
spots: prev,
isCurved: false,
color: solaire,
barWidth: 1.5,
dashArray: const [5, 3],
dotData: FlDotData(show: pas == null),
belowBarData:
BarAreaData(show: true, color: solaire.withValues(alpha: 0.18)),
),
if (mes.isNotEmpty)
LineChartBarData(
spots: mes,
isCurved: false,
color: eco,
barWidth: 1.8,
dotData: const FlDotData(show: false),
),
],
),
);
}
Widget _ligne(BuildContext context, String k, String v) => Padding(
padding: const EdgeInsets.only(top: 2),
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
SizedBox(
width: 130,
child: Text(k,
style: EtmTokens.sans(size: 11.5, color: EtmTokens.mutedOf(context)))),
Expanded(child: Text(v, style: EtmTokens.mono(size: 11.5))),
]),
);
static String _correction(String c) => switch (c) {
'temperature' => 'température de module',
'ac-limit' => 'plafond onduleur',
_ => c,
};
static String _duree(Duration d) => d.inMinutes % 60 == 0
? '${d.inHours} h'
: '${d.inMinutes} min';
static String _jourHeure(DateTime t) {
final l = t.toLocal();
String d2(int v) => v.toString().padLeft(2, '0');
return '${l.day}/${l.month} ${d2(l.hour)}:${d2(l.minute)}';
}
}
class _Encadre extends StatelessWidget {
final IconData icone;
final String titre;
final String texte;
final String? code;
final Widget? action;
const _Encadre({
required this.icone,
required this.titre,
required this.texte,
this.code,
this.action,
});
@override
Widget build(BuildContext context) {
final muted = EtmTokens.mutedOf(context);
return Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: EtmTokens.bgOf(context),
borderRadius: BorderRadius.circular(12),
),
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
Icon(icone, color: EtmTokens.faintOf(context), size: 26),
const SizedBox(width: 12),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(titre,
style: EtmTokens.sans(size: 13, weight: FontWeight.w600, color: muted)),
const SizedBox(height: 4),
Text(texte,
style: EtmTokens.sans(size: 11.5, color: muted).copyWith(height: 1.35)),
// Le code brut rend le rapport de terrain actionnable.
if (code != null) ...[
const SizedBox(height: 4),
Text(code!, style: EtmTokens.mono(size: 10.5, color: EtmTokens.faintOf(context))),
],
?action,
]),
),
]),
);
}
}
class _Note extends StatelessWidget {
final IconData icone;
final String texte;
const _Note({required this.icone, required this.texte});
@override
Widget build(BuildContext context) {
final muted = EtmTokens.mutedOf(context);
return Padding(
padding: const EdgeInsets.only(top: 4),
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
Icon(icone, size: 13, color: muted),
const SizedBox(width: 6),
Expanded(
child: Text(texte,
style: EtmTokens.sans(size: 11, color: muted).copyWith(height: 1.35))),
]),
);
}
}
class _Legende extends StatelessWidget {
final Color couleur;
final String texte;
const _Legende(this.couleur, this.texte);
@override
Widget build(BuildContext context) => Row(mainAxisSize: MainAxisSize.min, children: [
Container(
width: 14,
height: 3,
decoration: BoxDecoration(
color: couleur, borderRadius: BorderRadius.circular(2))),
const SizedBox(width: 5),
Text(texte, style: EtmTokens.sans(size: 11, color: EtmTokens.mutedOf(context))),
]);
}