diff --git a/CLAUDE.md b/CLAUDE.md index 84fd4c1..516f11d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 | diff --git a/lib/models/pv_forecast.dart b/lib/models/pv_forecast.dart new file mode 100644 index 0000000..7dbcead --- /dev/null +++ b/lib/models/pv_forecast.dart @@ -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 points; + final String? fault; + final Map 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? 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 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 m) { + final pts = []; + 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? chaines(Object? v) => + v is List ? v.whereType().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.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 [], + ), + ], + ); + } + + 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> get segments { + final p = pas; + final out = >[]; + 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 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 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().toList() : const [], + acLimitW: (m['acLimitW'] as num?)?.toInt(), + fault: m['fault'] as String?, + ); + } +} diff --git a/lib/screens/energy/tariff_screen.dart b/lib/screens/energy/tariff_screen.dart index 22a41a8..a09b043 100644 --- a/lib/screens/energy/tariff_screen.dart +++ b/lib/screens/energy/tariff_screen.dart @@ -51,8 +51,6 @@ class _TariffScreenState extends State { 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( - 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; diff --git a/lib/screens/energy_screen.dart b/lib/screens/energy_screen.dart index f009b76..2a8c073 100644 --- a/lib/screens/energy_screen.dart +++ b/lib/screens/energy_screen.dart @@ -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 { _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))), - ], - ), - ), - ], - ), - ), - ], - ), - ); - } -} diff --git a/lib/services/nymea_service.dart b/lib/services/nymea_service.dart index f62d889..20c931a 100644 --- a/lib/services/nymea_service.dart +++ b/lib/services/nymea_service.dart @@ -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.from(p)), + erreur: null, + ); + } catch (e) { + _log('fetchForecastSnapshot: $e'); + return (snapshot: null, erreur: e.toString()); + } + } + + /// `NymeaEnergy.GetForecastSite`. `null` = non lu. + Future 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.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 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 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 = { ...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); diff --git a/lib/widgets/pv_forecast_card.dart b/lib/widgets/pv_forecast_card.dart new file mode 100644 index 0000000..4dd05a4 --- /dev/null +++ b/lib/widgets/pv_forecast_card.dart @@ -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 createState() => _PvForecastCardState(); +} + +class _PvForecastCardState extends State { + Timer? _timer; + bool _chargement = true; + PvForecastSnapshot? _snapshot; + String? _erreur; + PvForecastSite? _site; + List _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 _lire() async { + if (!mounted) return; + final service = context.read(); + final r = await service.fetchForecastSnapshot(); + if (!mounted) return; + + PvForecastSite? site; + var mesure = const []; + 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 _designer(String siteId) async { + final service = context.read(); + final err = await service.setForecastSite(siteId); + if (!mounted) return; + setState(() => _refusDesignation = err); + await _lire(); + } + + @override + Widget build(BuildContext context) { + final service = context.watch(); + final installateur = context.watch().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 mesure; + final DateTime maintenant; + final bool installateur; + final String Function(String thingId) nomDuThing; + final String? refusDesignation; + final Future Function(String siteId)? onDesigner; + final Future 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 _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 _siteManquant(BuildContext context) { + final candidats = site?.candidats ?? const []; + 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 _confirmer(BuildContext context, String siteId) async { + final ok = await showDialog( + 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 _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 = []; + 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 = []; + 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))), + ]); +} diff --git a/test/fixtures/forecast_snapshot_hems_etm68.json b/test/fixtures/forecast_snapshot_hems_etm68.json new file mode 100644 index 0000000..7c4b7b3 --- /dev/null +++ b/test/fixtures/forecast_snapshot_hems_etm68.json @@ -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}" + } + ] +} diff --git a/test/pv_forecast_test.dart b/test/pv_forecast_test.dart new file mode 100644 index 0000000..b9f5d32 --- /dev/null +++ b/test/pv_forecast_test.dart @@ -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 _banc() => jsonDecode( + File('test/fixtures/forecast_snapshot_hems_etm68.json').readAsStringSync()) + as Map; + +Map _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 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(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(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(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'); + }); + }); +}