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>
237 lines
8.3 KiB
Dart
237 lines
8.3 KiB
Dart
/// **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?,
|
||
);
|
||
}
|
||
}
|