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>
185 lines
6.8 KiB
Dart
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'}).',
|
|
};
|
|
}
|