Le contrat porte désormais la règle : tout horodatage publié est en UTC avec son
marqueur, ou en epoch. Trois champs, deux formats, rien ne l'exigeait.
`instantDeLaBox` (lib/models/box_instant.dart) devient l'unique lecteur pour ce
que la box publie — epoch ou ISO, marqué ou nu — et requalifie la forme nue en
UTC avant de rendre l'heure du lecteur. Politique valable pour la BOX seule :
`Installation.lastSeen` est écrite en local sans marqueur et relue pareil, donc
la lire en UTC la décalerait. Deux héritages opposés sous une même forme, d'où
l'absence délibérée d'un « parser une date » unique.
Le `.toLocal()` posé hier dans telemetry_text est retiré : le modèle est seul
propriétaire de la conversion. Le rejouer serait sans effet mais laisserait
croire qu'elle peut manquer ailleurs.
CORRECTION. J'avais écrit que le défaut avait survécu « parce que box et
téléphone partageaient l'heure ». C'est faux, et la fausseté est utile : l'écart
est entre UTC et l'APPAREIL, donc visible sur tout téléphone hors UTC+0 quel que
soit le fuseau de la box. Mesuré ce matin avec les deux en Europe/Paris — la box
publie 04:33:28Z, l'APK d'hier affiche « 04:33 », il fallait lire 06:33. La
bascule en Europe/London n'a pas donné la visibilité, elle a donné le MOTIF de
regarder. La version fausse fait conclure « les fuseaux coïncident, donc c'est
réglé » et laisse le défaut en place.
`GetVariableLoadState` n'existe pas sur .75 (absente d'Introspect) et l'app ne
lit aucun `since` : rien à corriger de ce côté. Le champ existe en revanche dans
GetLoadTelemetry — `loads[].command {divergent, expectedW, observedW, since}` —
et il porte bien le Z, vérifié à la sonde. Aucun écran ne le consomme encore.
Contrôle négatif de la ligne d'horloge : même binaire qu'hier, box repassée en
Europe/Paris, la ligne a disparu sous le champ d'échéance.
docs/captures/2026-09-07_fuseau-box-paris-ligne-absente.png
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
124 lines
6.1 KiB
Dart
124 lines
6.1 KiB
Dart
/// **Les ratios publiés par la box, et depuis QUAND ils parlent.**
|
|
///
|
|
/// ## Deux bornes, et c'est leur écart qui porte la phrase
|
|
///
|
|
/// `NymeaEnergy.GetEnergyRatios` publie deux instants depuis `+etm56`, et ils ne disent pas
|
|
/// la même chose :
|
|
///
|
|
/// - [periodStartedAt] — le **début de la journée de reporting**, à minuit de
|
|
/// l'installation. C'est la frontière que l'app cherchait : elle ne se dérive pas d'un nom
|
|
/// de fuseau IANA, et elle reste juste les jours à 23 ou 25 heures ;
|
|
/// - [measuredSince] — depuis quand la **baseline** des ratios tient. Elle se repose à chaque
|
|
/// redémarrage de `nymead` et à chaque recul de compteur, donc **en plein jour**.
|
|
///
|
|
/// Relevé sur `.75` le 2026-09-05 : `periodStartedAt` à 00:00 Londres (01:00 Paris),
|
|
/// `measuredSince` à 07:04 Londres — **sept heures d'écart**. Un ratio étiqueté
|
|
/// « aujourd'hui » aurait donc parlé d'une fenêtre commencée à 08:04, et rien ne l'aurait
|
|
/// signalé.
|
|
///
|
|
/// C'est la même paire, pour la même raison, que `progress.measuredSince` / `targetWh` :
|
|
/// une borne de période et une borne de mesure ne sont pas la même chose, et quand elles
|
|
/// coïncident — le cas normal — il n'y a rien à dire de plus.
|
|
///
|
|
/// ## Ce que ces ratios NE sont pas
|
|
///
|
|
/// Ce ne sont **pas** des ratios journaliers. Ce sont des ratios *depuis la baseline*. Les
|
|
/// intituler « du jour » serait faux, et les afficher sans dire depuis quand est exactement
|
|
/// le silence que [measuredSince] existe pour fermer. Le journalier se calcule ailleurs, par
|
|
/// différence de compteurs sur la fenêtre [periodStartedAt] → maintenant (`DailyBalance`).
|
|
library;
|
|
|
|
import 'box_instant.dart';
|
|
|
|
class EnergyRatiosSnapshot {
|
|
/// Part de la consommation couverte sans le réseau, en %. `null` = non publié.
|
|
final double? autonomyRate;
|
|
|
|
/// Part de la production consommée sur place, en %. `null` = non publié.
|
|
final double? selfConsumptionRate;
|
|
|
|
/// Début de la journée de reporting — **minuit de l'installation**, publié et non dérivé.
|
|
final DateTime? periodStartedAt;
|
|
|
|
/// Depuis quand la baseline tient. Voir la note de tête.
|
|
final DateTime? measuredSince;
|
|
|
|
/// Fuseau de l'installation (`Europe/London` sur le banc). Le **nom**, pas un décalage :
|
|
/// c'est la box qui datera, l'app se contente de le dire.
|
|
final String? timeZone;
|
|
|
|
const EnergyRatiosSnapshot({
|
|
this.autonomyRate,
|
|
this.selfConsumptionRate,
|
|
this.periodStartedAt,
|
|
this.measuredSince,
|
|
this.timeZone,
|
|
});
|
|
|
|
/// Voir `box_instant.dart` : un instant publié par la box se rend chez le lecteur, et
|
|
/// c'est la même fonction pour l'epoch et pour l'ISO.
|
|
static DateTime? _epoch(Object? v) => v is num ? instantDeLaBox(v) : null;
|
|
|
|
factory EnergyRatiosSnapshot.fromJson(Map<String, dynamic> m) =>
|
|
EnergyRatiosSnapshot(
|
|
autonomyRate: (m['autonomyRate'] as num?)?.toDouble(),
|
|
selfConsumptionRate: (m['selfConsumptionRate'] as num?)?.toDouble(),
|
|
periodStartedAt: _epoch(m['periodStartedAt']),
|
|
measuredSince: _epoch(m['measuredSince']),
|
|
timeZone: (m['timeZone'] is String && (m['timeZone'] as String).isNotEmpty)
|
|
? m['timeZone'] as String
|
|
: null,
|
|
);
|
|
|
|
/// **La baseline a été reposée APRÈS le début de la journée** — un redémarrage, un recul
|
|
/// de compteur. Les ratios ne parlent alors que depuis [measuredSince], et l'écran doit le
|
|
/// dire.
|
|
///
|
|
/// Tolérance d'une minute : les deux instants viennent de la même box, mais rien ne garantit
|
|
/// qu'ils tombent sur la même seconde au premier cycle après minuit.
|
|
bool get baselineRepriseEnJournee {
|
|
final debut = periodStartedAt;
|
|
final depuis = measuredSince;
|
|
if (debut == null || depuis == null) return false;
|
|
return depuis.difference(debut) > const Duration(minutes: 1);
|
|
}
|
|
|
|
/// **Les deux horloges divergent-elles ?** — sans base de données de fuseaux.
|
|
///
|
|
/// [periodStartedAt] est **minuit de l'installation**, publié en epoch. Rendu dans le
|
|
/// fuseau de l'appareil, il tombe donc sur 00:00 **si et seulement si** les deux partagent
|
|
/// le même décalage. Sur `.75` en `Europe/London` vu d'un téléphone français, il tombait
|
|
/// sur 01:00 ; les deux étant passés en `Europe/Paris`, il tombe sur 00:00.
|
|
///
|
|
/// C'est exact pour l'instant présent, y compris les jours de changement d'heure, et ça
|
|
/// n'exige **rien d'autre que ce que la box publie** — le nom IANA ne donnerait pas de
|
|
/// décalage, et une base embarquée pour le recalculer serait le mauvais geste.
|
|
///
|
|
/// `false` quand la date manque : on ne conclut pas d'une absence.
|
|
///
|
|
/// ## ⚠️ Ne PAS réutiliser tel quel pour une plage tarifaire
|
|
///
|
|
/// Ce test porte sur le **décalage**, pas sur le nom — et c'est juste ici : une échéance
|
|
/// qualifie une **horloge**, donc deux fuseaux au même décalage donnent la même heure et
|
|
/// il n'y a rien à signaler. `Europe/Berlin` et `Europe/Paris` ne divergeront jamais, été
|
|
/// comme hiver : afficher un avertissement parce que les *noms* diffèrent alors que les
|
|
/// *heures* coïncident serait exactement le bruit que cette condition existe pour éviter.
|
|
///
|
|
/// Une **plage tarifaire** pose l'autre question. Elle est définie par le fournisseur, donc
|
|
/// elle suit **son** fuseau et non celui de la box — et « même décalage, autre autorité »
|
|
/// reste une divergence réelle : le jour où l'un des deux change de règle d'heure d'été, la
|
|
/// plage bouge sans que la box ait bougé. Le lot tarifaire aura donc besoin d'une
|
|
/// comparaison de **provenance**, pas de décalage, et d'un fuseau de référence publié avec
|
|
/// la configuration tarifaire — jamais codé en dur côté app.
|
|
bool get fuseauDiffereDeLAppareil {
|
|
final d = periodStartedAt?.toLocal();
|
|
if (d == null) return false;
|
|
return d.hour != 0 || d.minute != 0;
|
|
}
|
|
|
|
/// Vrai quand la box publie de quoi dater la journée. Sans ça, l'app ne **peut pas**
|
|
/// calculer la bonne fenêtre — et elle le dit plutôt que de prendre minuit du téléphone
|
|
/// en silence.
|
|
bool get datePeriode => periodStartedAt != null;
|
|
}
|