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>
This commit is contained in:
Patrick Schurig 2026-10-14 07:40:20 +02:00
parent bedeabf767
commit adb069f1d2
8 changed files with 1422 additions and 150 deletions

View File

@ -227,7 +227,7 @@ proprement** (`cause: "modeChanged"`, `restoredMode` absent — on ne restitue p
> `Energy.*` s'arrête là. **`SetChargingMode` n'existe pas** — ni ici, ni ailleurs.
### `NymeaEnergy.*` — powersync-energy-plugin-etm · **20 méthodes / 14 notifications**
### `NymeaEnergy.*` — powersync-energy-plugin-etm · **26 méthodes / 14 notifications** (compté sur le banc en `+etm68`, 2026-10-14)
> ⚠️ Le namespace est **`NymeaEnergy`**, pas `EnergyPlugin`. Ce dernier n'existe pas.
> Source d'autorité : `energyplugin/nymeaenergyjsonhandler.cpp` et `JSONRPC.Introspect`.
@ -258,7 +258,24 @@ proprement** (`cause: "modeChanged"`, `restoredMode` absent — on ne restitue p
| `GetAcquisitionTolerance` / `SetAcquisitionTolerance(Double)` | Seuil de surplus au démarrage — **unité non documentée par la box** | Community |
| `GetBatteryLevelConsideration` / `SetBatteryLevelConsideration(Double)` | Part du stockage prise en compte | Community |
**Tarifs dynamiques**
**Prévision PV** (`+etm68`, vérifié sur le banc le 2026-10-14) — carte `PvForecastCard` de l'écran Énergie
| Méthode | Usage | Tier |
|---|---|---|
| `GetForecastSnapshot` | Courbe **en vigueur** : `forecastPoints[{targetAt, forecastWh}]`, ou `fault` | Community |
| `GetForecastSite` / `SetForecastSite(forecastSiteThingId, o:acLimitW)` | Site désigné + candidats ; jamais d'adoption par l'app | Community |
| `GetPvForecast(from, to, o:knownAt)` | Table enregistrée (historique, révisions) — non câblé | Community |
> - `targetAt` = **début** du créneau, en UTC ; `forecastWh` = énergie du créneau. Le **pas n'est
> pas publié** : il se déduit (`PvForecastSnapshot.pas`), jamais codé en dur (1 h aujourd'hui).
> - **Créneau absent = trou, jamais zéro.** `fault` présent ⇒ pas de courbe.
> - **Aucune notification** : l'écran relit à l'ouverture puis toutes les 12 min.
> - C'est un **plafond optimiste** (ombrage, salissure non modélisés) : à dire à l'écran.
> - 🔴 `SetForecastSite.acLimitW` : **absent = inchangé**, `null` = effacer. L'app l'omet.
> - Bornes VE : identifiant en `{uuid}` minuscules **obligatoire jusqu'à `etm69`**, sinon la
> borne est vue absente — `idBorneCanonique()` dans `nymea_service.dart`.
**Tarifs dynamiques** — 🔴 **masqués** : depuis `+etm68` la liste des fournisseurs est vide et
`SetSpotMarketConfiguration{enabled:true}` rend `fault: "SPOT_MARKET_NOT_IN_BUILD"`.
| Méthode | Usage | Tier |
|---|---|---|
| `GetAvailableSpotMarketProviders` | Liste providers | Predict AI |

236
lib/models/pv_forecast.dart Normal file
View File

@ -0,0 +1,236 @@
/// **La prévision PV telle que la box la publie** — `NymeaEnergy.GetForecastSnapshot`.
///
/// Vérifié sur le banc `hems` (10.42.60.21) le 2026-10-14, moteur `1.15.2+etm68`,
/// `powersync-plugin-openmeteo 1.15.2+etm15` : 48 créneaux d'une heure, de
/// `2026-10-13T22:00:00Z` à `2026-10-15T21:00:00Z`.
///
/// ## Trois règles, chacune tenue ici plutôt qu'à l'écran
///
/// 1. **Un créneau absent n'est pas un zéro.** `forecastWh: 0` dit « on attend la nuit » ;
/// un créneau manquant dit « on ne sait pas ». Les points sont gardés tels quels, et
/// c'est la courbe qui laisse un trou là où il en manque un.
/// 2. **Le pas n'est pas publié** : il se DÉDUIT des écarts entre créneaux. Une heure
/// aujourd'hui ; le coder en dur ferait dessiner une barre d'une heure sur un créneau
/// de quinze minutes le jour où le producteur change de grille — sans erreur.
/// 3. **`fault` présent ⇒ pas de courbe**, même si des points traînaient dans la réponse.
///
/// L'app ne calcule aucune prévision : elle lit, convertit l'unité pour l'affichage, et
/// c'est tout.
library;
import 'box_instant.dart';
/// Un créneau prévu : `[debut, debut + pas)`, et l'énergie attendue dessus.
class PvForecastPoint {
/// Début du créneau, **en UTC** — converti en heure locale à l'affichage seulement.
final DateTime debutUtc;
/// Énergie prévue sur le créneau, en Wh. Jamais une puissance.
final double wh;
const PvForecastPoint(this.debutUtc, this.wh);
}
/// Pourquoi il n'y a pas de courbe — ou qu'il y en a une.
enum PvForecastEtat {
/// Une courbe est publiée.
courbe,
/// `MISSING_FORECAST_SITE` : des plans publient, mais aucun site n'est désigné. Le geste
/// est de configurer — proposer les candidats, jamais en adopter un d'office.
siteManquant,
/// `FORECAST_PAYLOAD_REFUSED` : le producteur météo publie quelque chose d'illisible.
/// Le geste est de regarder le producteur, `faultParams.cause` dit quoi.
chargeRefusee,
/// Un `fault` que cette version de l'app ne connaît pas : il est montré tel quel,
/// jamais tu — le taire ferait lire « rien à signaler ».
faultInconnu,
/// Ni courbe ni `fault` : pas de plugin météo, ou données pas encore arrivées. La box ne
/// distingue pas les deux, l'écran ne le prétend pas non plus.
rienPublie,
}
class PvForecastSnapshot {
final List<PvForecastPoint> points;
final String? fault;
final Map<String, dynamic> faultParams;
/// Quand la réponse a été construite.
final DateTime? capturedAt;
/// Quand le contenu actuel est apparu pour la première fois. C'est **lui** qui date la
/// courbe : sans lui, un producteur gelé se lit comme une prévision stable. Absent quand
/// la box ne l'a jamais vu — et alors on ne le devine pas.
final DateTime? contentChangedAt;
/// Émission du run météo. Peut manquer.
final DateTime? issuedAt;
final String? model;
final String? conversion;
/// Corrections physiques appliquées (`temperature`, `ac-limit`). Vide = GTI × kWc × PR
/// brut. `null` = la box ne publie pas le champ (antérieure à `+etm68`).
final List<String>? corrections;
/// Plafond AC du site en W, quand il est configuré : la courbe est alors écrêtée.
final int? acLimitW;
/// Un plan sommé par entrée : son Thing et ses corrections déclarées.
final List<({String id, List<String> corrections})> perField;
const PvForecastSnapshot({
required this.points,
this.fault,
this.faultParams = const {},
this.capturedAt,
this.contentChangedAt,
this.issuedAt,
this.model,
this.conversion,
this.corrections,
this.acLimitW,
this.perField = const [],
});
factory PvForecastSnapshot.fromJson(Map<String, dynamic> m) {
final pts = <PvForecastPoint>[];
final raw = m['forecastPoints'];
if (raw is List) {
for (final e in raw) {
if (e is! Map) continue;
final t = DateTime.tryParse(e['targetAt'] as String? ?? '');
final wh = e['forecastWh'];
// Un point illisible est un trou, pas un zéro : on le saute.
if (t == null || wh is! num) continue;
pts.add(PvForecastPoint(_enUtc(t), wh.toDouble()));
}
}
pts.sort((a, b) => a.debutUtc.compareTo(b.debutUtc));
List<String>? chaines(Object? v) =>
v is List ? v.whereType<String>().toList() : null;
final fp = m['faultParams'];
final champs = m['perField'];
final fault = m['fault'];
return PvForecastSnapshot(
points: pts,
fault: fault is String && fault.isNotEmpty ? fault : null,
faultParams: fp is Map ? Map<String, dynamic>.from(fp) : const {},
capturedAt: instantDeLaBox(m['capturedAt']),
contentChangedAt: instantDeLaBox(m['contentChangedAt']),
issuedAt: instantDeLaBox(m['issuedAt']),
model: m['model'] as String?,
conversion: m['conversion'] as String?,
corrections: chaines(m['corrections']),
acLimitW: (m['acLimitW'] as num?)?.toInt(),
perField: [
if (champs is List)
for (final c in champs)
if (c is Map)
(
id: c['id'] as String? ?? '',
corrections: chaines(c['corrections']) ?? const <String>[],
),
],
);
}
PvForecastEtat get etat {
switch (fault) {
case 'MISSING_FORECAST_SITE':
return PvForecastEtat.siteManquant;
case 'FORECAST_PAYLOAD_REFUSED':
return PvForecastEtat.chargeRefusee;
case null:
return points.isEmpty ? PvForecastEtat.rienPublie : PvForecastEtat.courbe;
default:
return PvForecastEtat.faultInconnu;
}
}
/// Le pas des créneaux, **déduit** : le plus petit écart positif entre deux débuts
/// consécutifs. Un trou élargit un écart, il ne le rétrécit jamais — le minimum reste
/// donc le pas même quand des créneaux manquent.
///
/// `null` sous deux créneaux : un point seul ne dit pas sa durée, et l'inventer serait
/// exactement le pas codé en dur que ce calcul remplace.
Duration? get pas {
Duration? min;
for (var i = 1; i < points.length; i++) {
final d = points[i].debutUtc.difference(points[i - 1].debutUtc);
if (d <= Duration.zero) continue;
if (min == null || d < min) min = d;
}
return min;
}
/// Les points regroupés en **segments contigus** : un nouveau segment commence à chaque
/// créneau manquant. C'est ce découpage qui fait le trou dans la courbe.
List<List<PvForecastPoint>> get segments {
final p = pas;
final out = <List<PvForecastPoint>>[];
for (final pt in points) {
if (out.isEmpty ||
p == null ||
pt.debutUtc.difference(out.last.last.debutUtc) != p) {
out.add([pt]);
} else {
out.last.add(pt);
}
}
return out;
}
/// Nombre de créneaux manquants entre le premier et le dernier publié.
int get creneauxManquants {
final p = pas;
if (p == null || points.length < 2) return 0;
final attendus =
points.last.debutUtc.difference(points.first.debutUtc).inSeconds ~/
p.inSeconds +
1;
return attendus - points.length;
}
static DateTime _enUtc(DateTime d) => d.isUtc
? d
// Même politique que `instantDeLaBox` : une chaîne nue venue de la box est de l'UTC.
: DateTime.utc(d.year, d.month, d.day, d.hour, d.minute, d.second);
}
/// `NymeaEnergy.GetForecastSite` — quel site est prévu, lesquels pourraient l'être.
class PvForecastSite {
/// Site désigné, forme canonique `{uuid}` minuscules. `null` = aucun — un état normal.
final String? siteThingId;
/// Chaque site dont au moins un plan publie une courbe : ce que l'écran propose.
final List<String> candidats;
/// Plafond AC du site en W. `null` = aucun plafond configuré.
final int? acLimitW;
final String? fault;
const PvForecastSite({
this.siteThingId,
this.candidats = const [],
this.acLimitW,
this.fault,
});
factory PvForecastSite.fromJson(Map<String, dynamic> m) {
final c = m['candidateSiteThingIds'];
final id = m['forecastSiteThingId'];
return PvForecastSite(
siteThingId: id is String && id.isNotEmpty ? id : null,
candidats: c is List ? c.whereType<String>().toList() : const [],
acLimitW: (m['acLimitW'] as num?)?.toInt(),
fault: m['fault'] as String?,
);
}
}

View File

@ -51,8 +51,6 @@ class _TariffScreenState extends State<TariffScreen> {
const SizedBox(height: 12),
_HcHpCard(tariff),
],
const SizedBox(height: 12),
_PricesCard(tariff),
],
],
),
@ -70,7 +68,10 @@ class _ProviderCard extends StatelessWidget {
return _SectionCard(
title: 'Provider actif',
child: Column(
children: TariffProviderType.values.map((p) {
// Les fournisseurs de prix dynamiques (Tibber, Octopus, ENTSO-E) sont masqués :
// le marché spot n'est pas dans le build du moteur (`+etm68` :
// `SPOT_MARKET_NOT_IN_BUILD`, liste des fournisseurs vide).
children: TariffProviderType.values.where((p) => !p.isPro).map((p) {
final selected = tariff.activeProvider == p;
return _ProviderTile(
provider: p,
@ -138,14 +139,18 @@ class _ModeCard extends StatelessWidget {
return _SectionCard(
title: 'Mode tarifaire',
child: DropdownButtonFormField<TariffMode>(
value: tariff.mode,
value: tariff.mode == TariffMode.spotMarket ? null : tariff.mode,
decoration: InputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12)),
contentPadding: const EdgeInsets.symmetric(
horizontal: 14, vertical: 12),
),
// « Spot Market » masqué : `SetSpotMarketConfiguration{enabled:true}` rend
// `SPOT_MARKET_NOT_IN_BUILD` depuis `+etm68`. Proposer un mode que la box refuse
// ferait croire à un réglage pris.
items: TariffMode.values
.where((m) => m != TariffMode.spotMarket)
.map((m) =>
DropdownMenuItem(value: m, child: Text(m.label)))
.toList(),
@ -430,84 +435,6 @@ class _ManualTariffCardState extends State<_ManualTariffCard> {
}
}
// ── Carte prix actuels ────────────────────────────────────────────────────────
class _PricesCard extends StatelessWidget {
final TariffProvider tariff;
const _PricesCard(this.tariff);
@override
Widget build(BuildContext context) {
final p = tariff.prices;
String _fmtTime(DateTime? dt) {
if (dt == null) return '';
return ' (${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')})';
}
return _SectionCard(
title: 'Prix actuels (live)',
child: Column(
children: [
_PriceItem(
label: 'Maintenant',
value: '${p.now.toStringAsFixed(3)} €/kWh',
),
_PriceItem(
label: 'Dans 1h',
value: '${p.inOneHour.toStringAsFixed(3)} €/kWh',
),
_PriceItem(
label: 'Minimum 24h',
value:
'${p.min24h.toStringAsFixed(3)} €/kWh${_fmtTime(p.minTime)}',
color: AppTheme.primaryGreen,
),
_PriceItem(
label: 'Maximum 24h',
value:
'${p.max24h.toStringAsFixed(3)} €/kWh${_fmtTime(p.maxTime)}',
color: AppTheme.boostRed,
),
],
),
);
}
}
class _PriceItem extends StatelessWidget {
final String label;
final String value;
final Color? color;
const _PriceItem({
required this.label,
required this.value,
this.color,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
Expanded(
child: Text(label,
style: const TextStyle(fontSize: 13))),
Text(
value,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: color ?? AppTheme.textDark,
),
),
],
),
);
}
}
// ── Card générique ────────────────────────────────────────────────────────────
class _SectionCard extends StatelessWidget {
final String title;

View File

@ -11,6 +11,7 @@ import '../providers/load_config_provider.dart';
import '../services/energy_ratios.dart';
import '../services/nymea_service.dart';
import '../theme/etm_tokens.dart';
import '../widgets/pv_forecast_card.dart';
import '../main.dart' show DrawerMenuButton;
// ─────────────────────────────────────────────────────────────────────────────
@ -662,8 +663,8 @@ class _EnergyScreenState extends State<EnergyScreen> {
_notesConsommateurs(),
const SizedBox(height: 14),
// Météo & prévision — placeholder
_ForecastPlaceholder(),
// Prévision PV — lue sur la box (`GetForecastSnapshot`, `+etm68`).
const PvForecastCard(),
]),
),
),
@ -1958,63 +1959,3 @@ class _LegendDot extends StatelessWidget {
}
}
// ─────────────────────────── Météo placeholder ─────────────────────────────────
class _ForecastPlaceholder extends StatelessWidget {
const _ForecastPlaceholder();
@override
Widget build(BuildContext context) {
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('Météo & prévision',
style: EtmTokens.sans(size: 16, weight: FontWeight.w600)),
Text('aujourd\'hui',
style: EtmTokens.sans(size: 12, color: EtmTokens.mutedOf(context))),
],
),
const SizedBox(height: 14),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: EtmTokens.bgOf(context),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(Icons.cloud_outlined, color: EtmTokens.faintOf(context), size: 30),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Prévisions non disponibles',
style: EtmTokens.sans(size: 13, weight: FontWeight.w500,
color: EtmTokens.mutedOf(context))),
const SizedBox(height: 4),
Text(
'Configurez un provider dans Tarifs & Héos '
'pour activer les prévisions PV et tarifaires.',
style: EtmTokens.sans(size: 11, color: EtmTokens.faintOf(context))),
],
),
),
],
),
),
],
),
);
}
}

View File

@ -12,11 +12,29 @@ import '../models/energy_data.dart'; // EnergyData, HistoryPoint, PowerBalanc
import '../models/ev_charger_live.dart';
import '../models/modbus_models.dart'; // ModbusRtuMaster, SerialPort
import '../models/nymea_models.dart';
import '../models/pv_forecast.dart';
import '../models/consumer_series.dart';
import '../models/load_telemetry.dart' show kDerogationMinS, kDerogationMaxS;
import 'energy_ratios.dart'; // seam interim ratios (§2.1)
// ── Protocole de connexion ─────────────────────────────────────────────────────
/// **Identifiant de borne à la forme que le moteur exige : `{uuid}` en minuscules.**
///
/// Obligatoire jusqu'à la release des correctifs moteur (`etm69`, consigne du 2026-10-13) :
/// sous une autre forme, le rang de la borne est ignoré et elle est vue comme **absente** —
/// sans erreur. Les Things arrivent déjà sous cette forme de `GetThings` ; ce garde-fou vise
/// les autres sources (saisie, identifiant sans accolades, majuscules).
///
/// Un identifiant qui n'est pas un UUID (`chauffe-eau`) est rendu **tel quel** : c'est le
/// nom d'une charge déclarée, et le réécrire la ferait disparaître.
String idBorneCanonique(String id) {
final nu = id.trim().replaceAll(RegExp(r'^\{|\}$'), '');
final estUuid = RegExp(
r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$')
.hasMatch(nu);
return estUuid ? '{${nu.toLowerCase()}}' : id;
}
enum NymeaProtocol {
/// TCP brut port 2222 — nymea parle en premier (\n-delimited JSON)
tcpRaw,
@ -977,6 +995,76 @@ class NymeaService extends ChangeNotifier {
EnergyRatiosSnapshot? _ratios;
// ─── Prévision PV (`+etm68`) ───────────────────────────────────────────────
//
// Aucune notification `*Changed` n'existe pour la prévision, par décision du moteur :
// l'écran RELIT, à l'ouverture puis périodiquement. Ces appels ne cachent rien.
/// `NymeaEnergy.GetForecastSnapshot` — la prévision **en vigueur**, lue sur le canal.
///
/// `erreur` non nulle = **non lu** : ce n'est pas « rien de publié ». Une box antérieure
/// qui ne connaît pas la méthode rend `NoSuchMethod`, un échec de transport son message.
Future<({PvForecastSnapshot? snapshot, String? erreur})>
fetchForecastSnapshot() async {
if (_isSimulation) return (snapshot: null, erreur: 'Simulation');
if (!_connected) return (snapshot: null, erreur: 'Disconnected');
try {
final r = await _sendRequest('NymeaEnergy.GetForecastSnapshot', {});
final p = r['params'];
if (r['status'] != 'success' || p is! Map) {
final e = '${r['error'] ?? r['status']}';
return (
snapshot: null,
erreur: e.toLowerCase().contains('no such method') ? 'NoSuchMethod' : e,
);
}
return (
snapshot: PvForecastSnapshot.fromJson(Map<String, dynamic>.from(p)),
erreur: null,
);
} catch (e) {
_log('fetchForecastSnapshot: $e');
return (snapshot: null, erreur: e.toString());
}
}
/// `NymeaEnergy.GetForecastSite`. `null` = non lu.
Future<PvForecastSite?> fetchForecastSite() async {
if (_isSimulation || !_connected) return null;
try {
final r = await _sendRequest('NymeaEnergy.GetForecastSite', {});
final p = r['params'];
if (r['status'] != 'success' || p is! Map) return null;
return PvForecastSite.fromJson(Map<String, dynamic>.from(p));
} catch (e) {
_log('fetchForecastSite: $e');
return null;
}
}
/// `NymeaEnergy.SetForecastSite` — désigne le site dont la prévision est gardée.
///
/// Geste **explicite** de l'utilisateur, jamais une adoption automatique.
///
/// `acLimitW` est **omis** : c'est le seul champ que le moteur ne remplace pas, et
/// l'omettre le laisse **inchangé**. L'envoyer à `null` l'effacerait — ce que ce geste
/// ne demande pas.
///
/// Rend `null` si la box a accepté, sinon son code de refus.
Future<String?> setForecastSite(String siteThingId) async {
if (_isSimulation || !_connected) return 'Disconnected';
try {
final r = await _sendRequest('NymeaEnergy.SetForecastSite',
{'forecastSiteThingId': siteThingId});
if (r['status'] != 'success') return '${r['error'] ?? r['status']}';
final err = r['params']?['energyError'] as String?;
return (err == null || err == 'EnergyErrorNoError') ? null : err;
} catch (e) {
_log('setForecastSite: $e');
return e.toString();
}
}
/// Le dernier instantané lu, ou `null` tant que rien ne l'a demandé. Les écrans de
/// configuration s'en servent pour **qualifier une heure**, pas pour afficher un taux.
EnergyRatiosSnapshot? get energyRatios => _ratios;
@ -1037,7 +1125,7 @@ class NymeaService extends ChangeNotifier {
}
try {
final r = await _sendRequest('NymeaEnergy.SetLoadDerogation', {
'loadId': loadId,
'loadId': idBorneCanonique(loadId),
'durationS': durationS,
'targetWh': ?targetWh,
});
@ -1057,7 +1145,7 @@ class NymeaService extends ChangeNotifier {
Future<String?> clearLoadDerogation(String loadId) async {
try {
final r = await _sendRequest(
'NymeaEnergy.ClearLoadDerogation', {'loadId': loadId});
'NymeaEnergy.ClearLoadDerogation', {'loadId': idBorneCanonique(loadId)});
final err = (r['params']?['energyError'] ?? r['energyError']) as String?;
return (err == null || err == 'EnergyErrorNoError') ? null : err;
} catch (e) {
@ -1524,7 +1612,7 @@ class NymeaService extends ChangeNotifier {
final info = <String, dynamic>{
...courant,
'evChargerId': vise,
'evChargerId': idBorneCanonique(vise),
'chargingMode': apiMode,
};
// `chargingState` est en lecture seule (`r:`) au schéma : le renvoyer ferait
@ -1863,8 +1951,17 @@ class NymeaService extends ChangeNotifier {
return 'Disconnected';
}
try {
// Les bornes partent sous `{uuid}` minuscules (cf. [idBorneCanonique]). Les autres
// charges restent verbatim : leur `id` est un nom, pas un Thing.
final envoi = [
for (final p in payloads)
if (p['adapter'] == 'evcharger' && p['id'] is String)
{...p, 'id': idBorneCanonique(p['id'] as String)}
else
p,
];
final r = await _sendRequest(
'NymeaEnergy.SetLoadConfig', {'loadConfigs': payloads});
'NymeaEnergy.SetLoadConfig', {'loadConfigs': envoi});
final err = r['params']?['energyError'] as String?;
_log('[LoadConfig] SetLoadConfig(${payloads.length} charge(s)) ← '
'${err ?? r['status']}', force: true);

View File

@ -0,0 +1,623 @@
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))),
]);
}

View File

@ -0,0 +1,212 @@
{
"capturedAt": "2026-10-14T05:24:48Z",
"contentChangedAt": "2026-10-14T05:15:00Z",
"conversion": "physics",
"corrections": [
"temperature"
],
"forecastPoints": [
{
"forecastWh": 0,
"targetAt": "2026-10-13T22:00:00Z"
},
{
"forecastWh": 0,
"targetAt": "2026-10-13T23:00:00Z"
},
{
"forecastWh": 0,
"targetAt": "2026-10-14T00:00:00Z"
},
{
"forecastWh": 0,
"targetAt": "2026-10-14T01:00:00Z"
},
{
"forecastWh": 0,
"targetAt": "2026-10-14T02:00:00Z"
},
{
"forecastWh": 0,
"targetAt": "2026-10-14T03:00:00Z"
},
{
"forecastWh": 0,
"targetAt": "2026-10-14T04:00:00Z"
},
{
"forecastWh": 0,
"targetAt": "2026-10-14T05:00:00Z"
},
{
"forecastWh": 153,
"targetAt": "2026-10-14T06:00:00Z"
},
{
"forecastWh": 525,
"targetAt": "2026-10-14T07:00:00Z"
},
{
"forecastWh": 896,
"targetAt": "2026-10-14T08:00:00Z"
},
{
"forecastWh": 1118,
"targetAt": "2026-10-14T09:00:00Z"
},
{
"forecastWh": 1123,
"targetAt": "2026-10-14T10:00:00Z"
},
{
"forecastWh": 1230,
"targetAt": "2026-10-14T11:00:00Z"
},
{
"forecastWh": 1191,
"targetAt": "2026-10-14T12:00:00Z"
},
{
"forecastWh": 997,
"targetAt": "2026-10-14T13:00:00Z"
},
{
"forecastWh": 733,
"targetAt": "2026-10-14T14:00:00Z"
},
{
"forecastWh": 375,
"targetAt": "2026-10-14T15:00:00Z"
},
{
"forecastWh": 33,
"targetAt": "2026-10-14T16:00:00Z"
},
{
"forecastWh": 0,
"targetAt": "2026-10-14T17:00:00Z"
},
{
"forecastWh": 0,
"targetAt": "2026-10-14T18:00:00Z"
},
{
"forecastWh": 0,
"targetAt": "2026-10-14T19:00:00Z"
},
{
"forecastWh": 0,
"targetAt": "2026-10-14T20:00:00Z"
},
{
"forecastWh": 0,
"targetAt": "2026-10-14T21:00:00Z"
},
{
"forecastWh": 0,
"targetAt": "2026-10-14T22:00:00Z"
},
{
"forecastWh": 0,
"targetAt": "2026-10-14T23:00:00Z"
},
{
"forecastWh": 0,
"targetAt": "2026-10-15T00:00:00Z"
},
{
"forecastWh": 0,
"targetAt": "2026-10-15T01:00:00Z"
},
{
"forecastWh": 0,
"targetAt": "2026-10-15T02:00:00Z"
},
{
"forecastWh": 0,
"targetAt": "2026-10-15T03:00:00Z"
},
{
"forecastWh": 0,
"targetAt": "2026-10-15T04:00:00Z"
},
{
"forecastWh": 0,
"targetAt": "2026-10-15T05:00:00Z"
},
{
"forecastWh": 140,
"targetAt": "2026-10-15T06:00:00Z"
},
{
"forecastWh": 495,
"targetAt": "2026-10-15T07:00:00Z"
},
{
"forecastWh": 761,
"targetAt": "2026-10-15T08:00:00Z"
},
{
"forecastWh": 1094,
"targetAt": "2026-10-15T09:00:00Z"
},
{
"forecastWh": 1278,
"targetAt": "2026-10-15T10:00:00Z"
},
{
"forecastWh": 1305,
"targetAt": "2026-10-15T11:00:00Z"
},
{
"forecastWh": 1226,
"targetAt": "2026-10-15T12:00:00Z"
},
{
"forecastWh": 1029,
"targetAt": "2026-10-15T13:00:00Z"
},
{
"forecastWh": 711,
"targetAt": "2026-10-15T14:00:00Z"
},
{
"forecastWh": 304,
"targetAt": "2026-10-15T15:00:00Z"
},
{
"forecastWh": 21,
"targetAt": "2026-10-15T16:00:00Z"
},
{
"forecastWh": 0,
"targetAt": "2026-10-15T17:00:00Z"
},
{
"forecastWh": 0,
"targetAt": "2026-10-15T18:00:00Z"
},
{
"forecastWh": 0,
"targetAt": "2026-10-15T19:00:00Z"
},
{
"forecastWh": 0,
"targetAt": "2026-10-15T20:00:00Z"
},
{
"forecastWh": 0,
"targetAt": "2026-10-15T21:00:00Z"
}
],
"issuedAt": "2026-10-14T00:00:00Z",
"model": "meteofrance_seamless",
"perField": [
{
"corrections": [
"temperature"
],
"id": "{3cee9703-5406-426e-9a85-913746175253}"
}
]
}

219
test/pv_forecast_test.dart Normal file
View File

@ -0,0 +1,219 @@
/// La prévision PV de `+etm68` — `NymeaEnergy.GetForecastSnapshot`.
///
/// La fixture est la réponse RÉELLE du banc `hems` (10.42.60.21), capturée le 2026-10-14.
/// Les cas de trou, de pas et de défaut sont dérivés d'elle ou écrits à la main, et chaque
/// test dit ce qu'il aurait attrapé s'il avait échoué.
library;
import 'dart:convert';
import 'dart:io';
import 'package:etm_powersync_app/models/pv_forecast.dart';
import 'package:etm_powersync_app/services/nymea_service.dart';
import 'package:etm_powersync_app/widgets/pv_forecast_card.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
Map<String, dynamic> _banc() => jsonDecode(
File('test/fixtures/forecast_snapshot_hems_etm68.json').readAsStringSync())
as Map<String, dynamic>;
Map<String, dynamic> _pt(String t, num wh) => {'targetAt': t, 'forecastWh': wh};
Widget _monte(PvForecastView v) => MaterialApp(
// Contrainte BORNÉE en largeur, hauteur infinie : c'est le montage réel (un
// ListView dans l'écran Énergie).
home: Scaffold(body: ListView(children: [v])),
);
PvForecastView _vue(PvForecastSnapshot? s,
{String? erreur,
PvForecastSite? site,
bool installateur = false,
List<ProductionMesuree> mesure = const []}) =>
PvForecastView(
chargement: false,
snapshot: s,
erreur: erreur,
site: site,
mesure: mesure,
maintenant: DateTime.utc(2026, 10, 14, 9),
installateur: installateur,
nomDuThing: (id) => id == '{8e0b7b38-3180-46be-ba41-08b08d630173}'
? 'Kutzenhausen'
: id,
);
void main() {
group('lecture de la réponse du banc', () {
final s = PvForecastSnapshot.fromJson(_banc());
test('48 créneaux, en UTC, triés', () {
expect(s.etat, PvForecastEtat.courbe);
expect(s.points, hasLength(48));
expect(s.points.first.debutUtc.isUtc, isTrue);
expect(s.points.first.debutUtc, DateTime.utc(2026, 10, 13, 22));
});
test('le pas est DÉDUIT : une heure sur le banc', () {
expect(s.pas, const Duration(hours: 1));
expect(s.creneauxManquants, 0);
expect(s.segments, hasLength(1));
});
test('métadonnées +etm68 lues', () {
expect(s.corrections, ['temperature']);
expect(s.conversion, 'physics');
expect(s.model, 'meteofrance_seamless');
expect(s.perField.single.corrections, ['temperature']);
expect(s.acLimitW, isNull); // pas de plafond configuré sur le banc
// Un INSTANT se rend chez le lecteur : l'attendu se dérive de toLocal(), il ne se
// recopie pas de la charge utile (sous TZ=UTC ce contrôle ne mord pas).
expect(s.contentChangedAt,
DateTime.utc(2026, 10, 14, 5, 15).toLocal());
expect(s.contentChangedAt!.isUtc, isFalse);
});
});
group('un créneau absent n\'est pas un zéro', () {
final s = PvForecastSnapshot.fromJson({
'capturedAt': '2026-10-14T05:00:00Z',
'forecastPoints': [
_pt('2026-10-14T08:00:00Z', 500),
_pt('2026-10-14T09:00:00Z', 900),
// 10:00 et 11:00 manquent
_pt('2026-10-14T12:00:00Z', 1100),
],
});
test('le pas reste une heure malgré le trou', () {
// Aurait attrapé un pas pris sur le premier écart venu, ou sur la moyenne.
expect(s.pas, const Duration(hours: 1));
expect(s.creneauxManquants, 2);
expect(s.segments.map((g) => g.length), [2, 1]);
});
testWidgets('la courbe est COUPÉE au trou, sans point à zéro', (t) async {
await t.pumpWidget(_monte(_vue(s)));
final chart = t.widget<LineChart>(find.byType(LineChart));
final spots = chart.data.lineBarsData.first.spots;
expect(spots.where((p) => p == FlSpot.nullSpot), hasLength(1));
// Aucun zéro n'a été inventé pour combler 10:00–12:00.
expect(spots.where((p) => p != FlSpot.nullSpot && p.y == 0), isEmpty);
expect(find.textContaining('2 créneaux sans prévision'), findsOneWidget);
});
});
test('un pas de 15 min se déduit aussi — rien n\'est codé en dur', () {
final s = PvForecastSnapshot.fromJson({
'forecastPoints': [
_pt('2026-10-14T08:00:00Z', 100),
_pt('2026-10-14T08:15:00Z', 120),
_pt('2026-10-14T08:30:00Z', 140),
],
});
expect(s.pas, const Duration(minutes: 15));
});
testWidgets('Wh par créneau → kW moyen : 250 Wh sur 15 min = 1 kW', (t) async {
final s = PvForecastSnapshot.fromJson({
'forecastPoints': [
_pt('2026-10-14T08:00:00Z', 250),
_pt('2026-10-14T08:15:00Z', 250),
],
});
await t.pumpWidget(_monte(_vue(s)));
final spots =
t.widget<LineChart>(find.byType(LineChart)).data.lineBarsData.first.spots;
// Aurait attrapé une heure codée en dur (0,25 kW) au lieu du pas déduit.
expect(spots.first.y, closeTo(1.0, 1e-9));
});
group('fault présent ⇒ pas de courbe', () {
testWidgets('MISSING_FORECAST_SITE propose les candidats, n\'adopte rien',
(t) async {
final s = PvForecastSnapshot.fromJson({
'capturedAt': '2026-10-14T05:00:00Z',
'fault': 'MISSING_FORECAST_SITE',
// Des points qui traîneraient ne doivent PAS être tracés.
'forecastPoints': [_pt('2026-10-14T08:00:00Z', 500)],
});
expect(s.etat, PvForecastEtat.siteManquant);
await t.pumpWidget(_monte(_vue(s,
site: const PvForecastSite(
candidats: ['{8e0b7b38-3180-46be-ba41-08b08d630173}']))));
expect(find.byType(LineChart), findsNothing);
expect(find.text('Kutzenhausen'), findsOneWidget);
expect(find.text('Désigner'), findsOneWidget);
});
testWidgets('FORECAST_PAYLOAD_REFUSED montre sa cause', (t) async {
final s = PvForecastSnapshot.fromJson({
'fault': 'FORECAST_PAYLOAD_REFUSED',
'faultParams': {'cause': 'unknown version 3'},
});
await t.pumpWidget(_monte(_vue(s)));
expect(find.byType(LineChart), findsNothing);
expect(find.textContaining('unknown version 3'), findsOneWidget);
});
testWidgets('un défaut inconnu est montré, jamais tu', (t) async {
final s = PvForecastSnapshot.fromJson({'fault': 'SOMETHING_NEW'});
expect(s.etat, PvForecastEtat.faultInconnu);
await t.pumpWidget(_monte(_vue(s)));
expect(find.text('SOMETHING_NEW'), findsOneWidget);
});
});
testWidgets('ni courbe ni fault : « aucune prévision publiée »', (t) async {
final s = PvForecastSnapshot.fromJson({'capturedAt': '2026-10-14T05:00:00Z'});
expect(s.etat, PvForecastEtat.rienPublie);
await t.pumpWidget(_monte(_vue(s)));
expect(find.text('Aucune prévision publiée'), findsOneWidget);
expect(find.byType(LineChart), findsNothing);
});
testWidgets('non lu ≠ rien de publié', (t) async {
await t.pumpWidget(_monte(_vue(null, erreur: 'TimeoutException')));
expect(find.text('Prévision non lue'), findsOneWidget);
expect(find.text('Aucune prévision publiée'), findsNothing);
});
testWidgets('libellé honnête, corrections en mode installateur seulement',
(t) async {
final s = PvForecastSnapshot.fromJson(_banc());
await t.pumpWidget(_monte(_vue(s)));
expect(find.textContaining('plafond optimiste'), findsWidgets);
expect(find.textContaining('température de module'), findsNothing);
await t.pumpWidget(_monte(_vue(s, installateur: true)));
expect(find.textContaining('température de module'), findsWidgets);
});
testWidgets('la production mesurée se superpose, signe déjà retourné',
(t) async {
final s = PvForecastSnapshot.fromJson(_banc());
await t.pumpWidget(_monte(_vue(s, mesure: [
(t: DateTime.utc(2026, 10, 14, 8), w: 600),
(t: DateTime.utc(2026, 10, 14, 8, 15), w: 700),
])));
final bars = t.widget<LineChart>(find.byType(LineChart)).data.lineBarsData;
expect(bars, hasLength(2));
expect(bars[1].spots.map((p) => p.y), [0.6, 0.7]);
});
group('bornes VE : `{uuid}` minuscules (jusqu\'à etm69)', () {
test('sans accolades, en majuscules → canonique', () {
expect(idBorneCanonique('4D1ADAFD-CAFC-4813-93B1-CA8933CE71AB'),
'{4d1adafd-cafc-4813-93b1-ca8933ce71ab}');
});
test('déjà canonique → inchangé', () {
const id = '{4d1adafd-cafc-4813-93b1-ca8933ce71ab}';
expect(idBorneCanonique(id), id);
});
test('un nom de charge n\'est pas réécrit', () {
expect(idBorneCanonique('chauffe-eau'), 'chauffe-eau');
});
});
}