Patrick Schurig 71d88418fd feat(tarif): l'écran Tarif lit et écrit le tarif du moteur (+etm69)
Consigne app §F, vérifiée sur le banc le 2026-10-15.
- Lecture GetTariff à l'ouverture : aucun tarif / fixe / HC-HP (deux
  zones, la moins chère sur UNE plage) / lecture seule pour tout le reste
  (HC en deux fenêtres, > 2 zones, prix égaux) — jamais réécrit.
- Écriture SetTariff en c€/kWh (€ × 100, 4 décimales) ; HC/HP : la plage
  HP est le complément exact de la plage HC ; revente facultative. Relue
  après succès (aucune notification).
- Refus TARIFF_REFUSED rendu en français depuis faultParams (cause, zone
  nommée par le libellé envoyé, minute en cause).
- Heures affichées comme celles de la box (timeZone).
- Retirés : prix par défaut codés en dur, stockage local, fournisseurs
  fictifs, prix « live » inventés. Menu « Tarifs & providers » → « Tarif ».
- Test : ce que l'app enverrait est identique, champ pour champ, au tarif
  réel du banc (aucune écriture faite sur la box).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 09:12:21 +02:00

185 lines
6.8 KiB
Dart

/// **Le tarif enregistré sur la box** — `NymeaEnergy.GetTariff` / `SetTariff` (moteur
/// `1.15.2+etm69`, vérifié sur le banc le 2026-10-15).
///
/// Le moteur est la **source de vérité** du tarif : l'app le lit, le montre, et l'écrit — elle
/// ne garde plus rien en local ni de prix par défaut. Le moteur **expose** le prix ; il ne
/// décide pas avec.
///
/// ## Trois pièges, chacun tenu ici
///
/// 1. **L'unité.** Le moteur parle en **c€/kWh TTC**, l'écran en €/kWh : 0,2142 € s'envoie
/// `21.42`. Un prix envoyé en euros serait cent fois trop bas, et accepté.
/// 2. **L'horloge.** Les plages sont en heure **locale de la box** (`timeZone`), pas celle du
/// téléphone. L'écran le dit.
/// 3. **Ne jamais écraser ce qu'on ne sait pas éditer.** Un tarif à plusieurs plages HC ou à
/// plus de deux zones s'affiche en lecture seule : le réécrire en HC/HP à une plage
/// perdrait la seconde fenêtre sans que personne l'ait demandé.
library;
typedef Plage = ({String from, String to});
class ZoneTarif {
final String label;
final double priceCtkWh;
final List<Plage> ranges;
const ZoneTarif(this.label, this.priceCtkWh, this.ranges);
/// Prix en €/kWh, arrondi à 4 décimales : le moteur publie un bruit flottant
/// (`15.890000000000017`) qu'il ne faut ni afficher ni renvoyer.
double get prixEuro => arrondi4(priceCtkWh / 100);
Map<String, dynamic> toJson() => {
'label': label,
'priceCtkWh': priceCtkWh,
'ranges': [for (final r in ranges) {'from': r.from, 'to': r.to}],
};
}
enum NatureTarif {
/// `zones` absent : aucun tarif — un ÉTAT, pas un tarif à 0.
nonConfigure,
/// Une zone 00:00 → 24:00.
fixe,
/// Deux zones, la moins chère (HC) sur UNE seule plage, l'autre son complément.
hcHp,
/// Tout le reste (HC en deux fenêtres, plus de deux zones…) : montré, jamais réécrit.
lectureSeule,
}
class TarifBox {
final String currency;
final List<String> includes;
/// Fuseau IANA de la box — celui dans lequel les plages se lisent.
final String timeZone;
final String? source;
final List<ZoneTarif>? zones;
final double? exportCtkWh;
const TarifBox({
this.currency = 'EUR',
this.includes = const [],
required this.timeZone,
this.source,
this.zones,
this.exportCtkWh,
});
factory TarifBox.fromJson(Map<String, dynamic> m) {
final z = m['zones'];
return TarifBox(
currency: m['currency'] as String? ?? 'EUR',
includes: (m['includes'] as List?)?.whereType<String>().toList() ?? const [],
timeZone: m['timeZone'] as String? ?? '',
source: m['source'] as String?,
exportCtkWh: (m['exportCtkWh'] as num?)?.toDouble(),
zones: z is! List
? null
: [
for (final e in z)
if (e is Map)
ZoneTarif(
e['label'] as String? ?? '',
(e['priceCtkWh'] as num?)?.toDouble() ?? 0,
[
for (final r in (e['ranges'] as List? ?? const []))
if (r is Map)
(from: r['from'] as String? ?? '', to: r['to'] as String? ?? ''),
],
),
],
);
}
NatureTarif get nature {
final z = zones;
if (z == null || z.isEmpty) return NatureTarif.nonConfigure;
if (source == 'fixed' && z.length == 1) return NatureTarif.fixe;
if (source == 'tou' && z.length == 2) {
final hc = heuresCreuses;
if (hc != null && hc.ranges.length == 1) return NatureTarif.hcHp;
}
return NatureTarif.lectureSeule;
}
/// La zone la moins chère d'un tarif à deux zones — `null` à égalité : on ne devine pas
/// laquelle est « creuse ».
ZoneTarif? get heuresCreuses {
final z = zones;
if (z == null || z.length != 2 || z[0].priceCtkWh == z[1].priceCtkWh) return null;
return z[0].priceCtkWh < z[1].priceCtkWh ? z[0] : z[1];
}
ZoneTarif? get heuresPleines {
final hc = heuresCreuses;
if (hc == null) return null;
return zones!.firstWhere((z) => !identical(z, hc));
}
double? get exportEuro => exportCtkWh == null ? null : arrondi4(exportCtkWh! / 100);
}
double arrondi4(double v) => double.parse(v.toStringAsFixed(4));
/// €/kWh → c€/kWh, 4 décimales au plus (consigne F).
double euroVersCt(double euro) => arrondi4(euro * 100);
/// **Tarif fixe** : une zone 00:00 → 24:00.
Map<String, dynamic> payloadFixe(double prixEuro, {double? exportEuro}) => {
'zones': [
ZoneTarif('Base', euroVersCt(prixEuro), const [(from: '00:00', to: '24:00')])
.toJson(),
],
if (exportEuro != null) 'exportCtkWh': euroVersCt(exportEuro),
};
/// **HC/HP** : deux zones, la plage HP est le **complément exact** de la plage HC — sinon le
/// moteur refuse (trou ou chevauchement). `null` si la plage HC est vide (début = fin) : le
/// moteur la refuserait, autant ne pas l'envoyer.
Map<String, dynamic>? payloadHcHp({
required double hcEuro,
required double hpEuro,
required String hcDe,
required String hcA,
double? exportEuro,
}) {
String norm(String h) => h == '24:00' ? '00:00' : h;
if (norm(hcDe) == norm(hcA)) return null;
return {
'zones': [
ZoneTarif('HC', euroVersCt(hcEuro), [(from: hcDe, to: hcA)]).toJson(),
// `to: "00:00"` vaut 24:00 pour le moteur ; `from: "24:00"` serait refusé, d'où norm().
ZoneTarif('HP', euroVersCt(hpEuro), [(from: norm(hcA), to: hcDe)]).toJson(),
],
if (exportEuro != null) 'exportCtkWh': euroVersCt(exportEuro),
};
}
/// **Le refus du moteur, en français.** `faultParams` porte des codes et des index, jamais une
/// phrase ; la zone se nomme avec le libellé que l'app vient elle-même d'envoyer.
String messageRefus(Map<String, dynamic> p, List<String> libellesEnvoyes) {
final zone = p['zone'];
final nom = zone is num && zone.toInt() < libellesEnvoyes.length
? ' (zone « ${libellesEnvoyes[zone.toInt()]} »)'
: '';
final a = p['at'] is String ? ' à ${p['at']}' : '';
return switch (p['cause']) {
'MALFORMED' => 'La box n\'a pas pu lire la saisie.',
'TOO_MANY_ZONES' => 'Trop de zones : ${p['max'] ?? 12} au plus.',
'LABEL' => 'Libellé de zone vide, trop long ou en double$nom.',
'PRICE' => 'Prix invalide$nom : un nombre positif est attendu.',
'NO_RANGE' => 'Une zone n\'a aucune plage horaire$nom.',
'TOO_MANY_RANGES' => 'Trop de plages$nom : ${p['max'] ?? 12} au plus.',
'RANGE_FORMAT' => 'Heure illisible$nom : format HH:MM attendu.',
'RANGE_EMPTY' => 'Plage vide$nom : le début et la fin sont identiques.',
'OVERLAP' => 'Deux plages se chevauchent$a.',
'GAP' => 'Les plages ne couvrent pas les 24 h : trou$a.',
'EXPORT_PRICE' => 'Prix de revente invalide : un nombre positif est attendu.',
final c => 'Tarif refusé par la box (${c ?? 'cause inconnue'}).',
};
}