fix(instants): un seul lecteur d'horodatage de box — et ma cause d'hier était FAUSSE
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
This commit is contained in:
parent
92495c0e87
commit
a430fb00c8
40
CLAUDE.md
40
CLAUDE.md
@ -456,11 +456,41 @@ telle quelle et on **nomme l'horloge** qui la lit. Un **instant** (`measuredSinc
|
||||
> en `Europe/London`, « mesuré depuis 14:38 » quand il était 15:38 à la box et 16:38 au
|
||||
> téléphone.
|
||||
>
|
||||
> Ce défaut est **invisible à offset nul**. Il a survécu à toute la campagne parce que box et
|
||||
> téléphone partageaient l'heure ; il a fallu deux fuseaux distincts pour qu'un instant mal
|
||||
> rendu se voie. Corollaire pour les tests : un attendu d'heure **se dérive** de `.toLocal()`,
|
||||
> il ne se recopie jamais depuis la charge utile — un « 03:42 » écrit en dur gèle l'heure UTC
|
||||
> et valide le défaut. Et sous `TZ=UTC`, ce contrôle-là ne mord pas : le dire dans le test.
|
||||
> 🔴 **Correction du 2026-09-07 : la cause que j'avais donnée était fausse.** J'avais écrit
|
||||
> que le défaut avait survécu « parce que box et téléphone partageaient l'heure ». Non.
|
||||
> L'écart est entre **UTC et l'appareil**, pas entre la box et l'appareil : il est visible
|
||||
> sur n'importe quel téléphone hors UTC+0, **quel que soit le fuseau de la box**. Mesuré le
|
||||
> 2026-09-07 avec les deux en `Europe/Paris` — la box publiait `04:33:28Z`, l'écran affichait
|
||||
> toujours « 04:33 » là où il fallait lire 06:33.
|
||||
>
|
||||
> Ce que la bascule en `Europe/London` a apporté, ce n'est pas la visibilité : c'est le
|
||||
> **motif de regarder**. Personne n'avait comparé cette heure à une vérité connue. La
|
||||
> distinction n'est pas de la nuance — la version fausse fait conclure « les fuseaux
|
||||
> coïncident, donc nous sommes tranquilles », et c'est exactement ce qui laisse le défaut en
|
||||
> place.
|
||||
>
|
||||
> Reste vrai : il est **invisible à offset nul**, celui du **lecteur**. Corollaire pour les
|
||||
> tests : un attendu d'heure **se dérive** de `.toLocal()`, il ne se recopie jamais depuis la
|
||||
> charge utile — un « 03:42 » écrit en dur gèle l'heure UTC et valide le défaut. Et sous
|
||||
> `TZ=UTC`, ce contrôle-là ne mord pas : le dire dans le test.
|
||||
|
||||
**Trois formes d'horodatage, et la nue est celle qui coûte.** Le contrat porte désormais la
|
||||
règle — *tout horodatage publié est en UTC avec son marqueur, ou en epoch* — parce que trois
|
||||
champs coexistaient sous deux formats sans que rien ne l'exige :
|
||||
|
||||
```
|
||||
DateTime.parse('2026-09-06T20:19:04') -> isUtc = false, toLocal() SANS EFFET
|
||||
DateTime.parse('2026-09-06T20:19:04Z') -> isUtc = true, toLocal() convertit
|
||||
```
|
||||
|
||||
Une chaîne **sans marqueur** est tenue pour locale par Dart : `toLocal()` n'y fait rien, et
|
||||
une valeur qui était de l'UTC se retrouve décalée sans erreur ni trace. C'est le défaut frère
|
||||
trouvé côté moteur le même jour sur `command.since`, corrigé en `ISODateWithMs`. Côté app,
|
||||
tout instant venu de la box passe par `instantDeLaBox` (`lib/models/box_instant.dart`), qui
|
||||
requalifie la forme nue en UTC — politique valable pour ce que **la box** publie, et pas pour
|
||||
les dates que l'app écrit elle-même (`Installation.lastSeen`, sérialisée en local sans
|
||||
marqueur et relue pareil). Deux héritages opposés sous une même forme : il n'y a donc **pas**
|
||||
de « parser une date » unique dans ce dépôt.
|
||||
|
||||
---
|
||||
|
||||
|
||||
BIN
docs/captures/2026-09-07_fuseau-box-paris-ligne-absente.png
Normal file
BIN
docs/captures/2026-09-07_fuseau-box-paris-ligne-absente.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 255 KiB |
65
lib/models/box_instant.dart
Normal file
65
lib/models/box_instant.dart
Normal file
@ -0,0 +1,65 @@
|
||||
/// **Lire un INSTANT publié par la box** — et refuser de deviner son fuseau.
|
||||
///
|
||||
/// Le contrat porte désormais la règle : *tout horodatage publié est en UTC avec son
|
||||
/// marqueur, ou en epoch*. Ce fichier existe parce qu'elle n'a pas toujours été portée, et
|
||||
/// que l'écart ne se voyait pas.
|
||||
///
|
||||
/// ## Ce que Dart fait, et pourquoi c'est le piège
|
||||
///
|
||||
/// ```
|
||||
/// DateTime.parse('2026-09-06T20:19:04') -> isUtc = false, .hour = 20, toLocal().hour = 20
|
||||
/// DateTime.parse('2026-09-06T20:19:04Z') -> isUtc = true, .hour = 20, toLocal().hour = 22
|
||||
/// ```
|
||||
///
|
||||
/// Une chaîne **sans marqueur** rend un `DateTime` que Dart tient pour local. `toLocal()`
|
||||
/// n'a alors **aucun effet** — il ne convertit que ce qui est marqué UTC. Une valeur qui
|
||||
/// était pourtant de l'UTC se retrouve donc décalée du fuseau de l'appareil, sans erreur,
|
||||
/// sans trace, et dans les deux sens selon l'endroit du monde où on la lit.
|
||||
///
|
||||
/// Deux défauts frères, trouvés le même jour par la même bascule : côté app, `measuredSince`
|
||||
/// rendu en heure UTC ; côté moteur, `command.since` publié sans marqueur.
|
||||
///
|
||||
/// ⚠️ Ce que la bascule a donné, c'est le **motif de regarder**, pas la visibilité. L'écart
|
||||
/// se joue entre UTC et l'**appareil** : il est visible sur tout téléphone hors UTC+0, quel
|
||||
/// que soit le fuseau de la box. Vérifié le 2026-09-07 avec les deux en `Europe/Paris` —
|
||||
/// `04:33:28Z` publié, « 04:33 » à l'écran, 06:33 attendu. Croire que le fuseau de la box y
|
||||
/// est pour quelque chose mène à « les fuseaux coïncident, donc c'est réglé », et laisse le
|
||||
/// défaut en place.
|
||||
///
|
||||
/// ## La politique retenue pour une chaîne sans marqueur
|
||||
///
|
||||
/// On la lit comme de l'**UTC**, et ce n'est pas une supposition : c'est la forme
|
||||
/// qu'une box antérieure au contrat publiait, **mesurée** avant sa correction — de l'UTC
|
||||
/// écrit sans le dire. Comme le contrat interdit désormais la forme nue, l'ensemble des
|
||||
/// chaînes non marquées est clos : ce sont les anciennes box, et elles sont en UTC.
|
||||
///
|
||||
/// ⚠️ Cette politique vaut pour ce que **la box publie**, et pour rien d'autre. Les dates
|
||||
/// que l'app écrit elle-même (`Installation.lastSeen`) sont sérialisées en local sans
|
||||
/// marqueur et relues pareil : les lire comme de l'UTC les décalerait. Deux héritages
|
||||
/// opposés sous une même forme — c'est précisément pourquoi il n'y a **pas** de « parser une
|
||||
/// date » unique dans ce dépôt, et pourquoi celui-ci nomme sa source dans son nom.
|
||||
library;
|
||||
|
||||
/// L'instant publié par la box, **rendu dans le fuseau du lecteur**.
|
||||
///
|
||||
/// Accepte l'epoch en secondes (`num`) et la chaîne ISO, marquée ou non. Rend `null` sur
|
||||
/// tout le reste — un champ absent est un état, pas un zéro.
|
||||
///
|
||||
/// Un INSTANT se rend toujours chez le lecteur ; c'est un **réglage** comme
|
||||
/// `needs.dailyDeadline` qui se rend dans l'horloge de la box, et qui doit alors la nommer.
|
||||
DateTime? instantDeLaBox(Object? v) {
|
||||
if (v is num) {
|
||||
return DateTime.fromMillisecondsSinceEpoch(v.toInt() * 1000, isUtc: true)
|
||||
.toLocal();
|
||||
}
|
||||
if (v is! String || v.isEmpty) return null;
|
||||
final d = DateTime.tryParse(v);
|
||||
if (d == null) return null;
|
||||
// `isUtc == false` ⇒ la chaîne ne portait pas de marqueur. On la requalifie en UTC au
|
||||
// lieu de laisser Dart la tenir pour locale : voir la politique en tête de fichier.
|
||||
final utc = d.isUtc
|
||||
? d
|
||||
: DateTime.utc(d.year, d.month, d.day, d.hour, d.minute, d.second,
|
||||
d.millisecond, d.microsecond);
|
||||
return utc.toLocal();
|
||||
}
|
||||
@ -28,6 +28,8 @@
|
||||
/// 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;
|
||||
@ -53,10 +55,9 @@ class EnergyRatiosSnapshot {
|
||||
this.timeZone,
|
||||
});
|
||||
|
||||
static DateTime? _epoch(Object? v) => v is num
|
||||
? DateTime.fromMillisecondsSinceEpoch(v.toInt() * 1000, isUtc: true)
|
||||
.toLocal()
|
||||
: null;
|
||||
/// 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(
|
||||
|
||||
@ -32,6 +32,8 @@
|
||||
/// (`services/telemetry_text.dart`).
|
||||
library;
|
||||
|
||||
import 'box_instant.dart';
|
||||
|
||||
/// Seuil de fraîcheur — au-delà, l'instantané est présenté comme **ancien**.
|
||||
///
|
||||
/// Calibré sur le battement de cœur du plugin : `LoadTelemetryChanged` est émise à
|
||||
@ -419,8 +421,9 @@ class EcoProgress {
|
||||
targetWh: _d(m['targetWh']),
|
||||
deliveredWh: _dn(m['deliveredWh']),
|
||||
purchasedWh: _d(m['purchasedWh']),
|
||||
measuredSince:
|
||||
(since is String && since.isNotEmpty) ? DateTime.tryParse(since) : null,
|
||||
// `instantDeLaBox` rend déjà l'heure du LECTEUR et requalifie une chaîne sans
|
||||
// marqueur en UTC — voir `box_instant.dart`.
|
||||
measuredSince: instantDeLaBox(since),
|
||||
measuredGaps: gaps is List && gaps.isNotEmpty ? gaps : null,
|
||||
);
|
||||
}
|
||||
@ -894,7 +897,7 @@ class LoadTelemetry {
|
||||
return LoadTelemetry(
|
||||
raw: m,
|
||||
degradedMode: m['degradedMode'] as bool? ?? false,
|
||||
timestamp: (ts is String && ts.isNotEmpty) ? DateTime.tryParse(ts) : null,
|
||||
timestamp: instantDeLaBox(ts),
|
||||
budget: budget is Map
|
||||
? TelemetryBudget.fromJson(Map<String, dynamic>.from(budget))
|
||||
: null,
|
||||
|
||||
@ -365,17 +365,15 @@ String? ecoProgressText(L10n t, LoadLevel niveau) {
|
||||
final base = dejaDit
|
||||
? null
|
||||
: t.ecoAvancementMesure(energieCourte(livre), energieCourte(p.targetWh));
|
||||
// ⚠️ `measuredSince` arrive en **UTC** (`2026-09-06T14:38:00Z` sur `.75`), et
|
||||
// `DateTime.tryParse` garde `isUtc: true` : lire `.hour` sans `toLocal()` affiche l'heure
|
||||
// UTC. Le 2026-09-06, la box était en `Europe/London` et le téléphone en CEST — l'écran
|
||||
// disait « mesuré depuis 14:38 » quand il était 15:38 à la box et 16:38 au téléphone.
|
||||
// Une heure qui n'est celle de PERSONNE, et que les deux fuseaux à égalité d'offset
|
||||
// rendaient invisible jusqu'ici.
|
||||
// `measuredSince` arrive en UTC et le MODÈLE le rend déjà dans le fuseau du lecteur —
|
||||
// `instantDeLaBox`, dans `box_instant.dart`, qui porte l'histoire du défaut et la
|
||||
// politique sur les chaînes sans marqueur. Un seul propriétaire de la conversion : la
|
||||
// rejouer ici serait sans effet, mais laisserait croire qu'elle peut manquer ailleurs.
|
||||
//
|
||||
// Un INSTANT se rend dans le fuseau du lecteur — contrairement à l'échéance
|
||||
// d'obligation, qui est un réglage que la box interprète et porte donc sa mention de
|
||||
// fuseau (cf. `obligationEcheanceHorloge`).
|
||||
final depuis = p.measuredSince?.toLocal();
|
||||
final depuis = p.measuredSince;
|
||||
if (depuis == null) return base;
|
||||
final h = '${depuis.hour.toString().padLeft(2, '0')}:'
|
||||
'${depuis.minute.toString().padLeft(2, '0')}';
|
||||
|
||||
78
test/box_instant_test.dart
Normal file
78
test/box_instant_test.dart
Normal file
@ -0,0 +1,78 @@
|
||||
/// **Un instant publié par la box se rend chez le LECTEUR** — quelle que soit sa forme.
|
||||
///
|
||||
/// Trois formes coexistaient à la frontière RPC, et rien ne l'exigeait : epoch, ISO marqué
|
||||
/// `Z`, ISO nu. La troisième est celle qui coûtait, parce qu'elle ne se distingue de la
|
||||
/// deuxième par rien de visible et que Dart la tient pour **locale** — `toLocal()` n'a
|
||||
/// alors aucun effet.
|
||||
///
|
||||
/// Les deux défauts frères du 2026-09-06, trouvés par la même bascule de `.75` en
|
||||
/// `Europe/London` :
|
||||
///
|
||||
/// - côté app, `measuredSince` marqué `Z` mais rendu sans `toLocal()` → heure UTC à l'écran ;
|
||||
/// - côté moteur, `command.since` en UTC publié **sans marqueur** → une chaîne qu'un client
|
||||
/// lit comme locale.
|
||||
///
|
||||
/// Aucun des deux ne se voit quand le **lecteur** est à UTC+0 — et le fuseau de la BOX n'y
|
||||
/// est pour rien : mesuré le 2026-09-07 avec box et téléphone en `Europe/Paris`, l'écran
|
||||
/// affichait encore l'heure UTC. C'est pour ça que ce fichier construit ses attendus par
|
||||
/// calcul plutôt que de les recopier : un littéral d'heure gèle un fuseau.
|
||||
library;
|
||||
|
||||
import 'package:etm_powersync_app/models/box_instant.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
/// L'instant de référence, choisi hors heure d'été nulle part : 2026-09-06 20:19:04 UTC.
|
||||
final reference = DateTime.utc(2026, 9, 6, 20, 19, 4);
|
||||
|
||||
test('les trois formes désignent le MÊME instant', () {
|
||||
final epoch = instantDeLaBox(reference.millisecondsSinceEpoch ~/ 1000);
|
||||
final marquee = instantDeLaBox('2026-09-06T20:19:04Z');
|
||||
final nue = instantDeLaBox('2026-09-06T20:19:04');
|
||||
|
||||
expect(epoch, isNotNull);
|
||||
expect(marquee, isNotNull);
|
||||
expect(nue, isNotNull);
|
||||
// On compare des instants absolus : `isAtSameMomentAs` ignore la représentation.
|
||||
expect(marquee!.isAtSameMomentAs(reference), isTrue);
|
||||
expect(epoch!.isAtSameMomentAs(reference), isTrue);
|
||||
expect(nue!.isAtSameMomentAs(reference), isTrue,
|
||||
reason: 'une chaîne sans marqueur est de l\'UTC d\'une box antérieure au contrat, '
|
||||
'pas une heure locale');
|
||||
});
|
||||
|
||||
test('le résultat est rendu dans le fuseau du LECTEUR, jamais en UTC', () {
|
||||
final d = instantDeLaBox('2026-09-06T20:19:04Z')!;
|
||||
expect(d.isUtc, isFalse, reason: 'un instant destiné à l\'écran est déjà local');
|
||||
expect(d.hour, reference.toLocal().hour);
|
||||
|
||||
// Le contrôle qui MORD — et il ne mord que hors UTC. Sous `TZ=UTC`, local et UTC
|
||||
// coïncident et cette assertion passerait avec le défaut comme sans lui : un vert
|
||||
// obtenu là ne prouve rien, et c'est écrit pour que personne ne le lise comme tel.
|
||||
if (reference.toLocal().hour != reference.hour) {
|
||||
expect(d.hour, isNot(reference.hour));
|
||||
}
|
||||
});
|
||||
|
||||
test('la forme NUE ne se lit pas comme locale — le défaut du moteur', () {
|
||||
// Le défaut consistait exactement en ceci : `DateTime.parse` d'une chaîne nue rend
|
||||
// `isUtc: false`, donc `.hour` vaut 20 quel que soit l'endroit du monde. Ici, 20 h ne
|
||||
// doit sortir que là où l'offset est nul.
|
||||
final nue = instantDeLaBox('2026-09-06T20:19:04')!;
|
||||
final naif = DateTime.parse('2026-09-06T20:19:04').toLocal();
|
||||
if (reference.toLocal().hour != reference.hour) {
|
||||
expect(nue.hour, isNot(naif.hour),
|
||||
reason: 'la lecture naïve est celle qui décalait silencieusement');
|
||||
}
|
||||
expect(nue.isAtSameMomentAs(reference), isTrue);
|
||||
});
|
||||
|
||||
test('ce qui n\'est pas un instant rend `null`, pas une date par défaut', () {
|
||||
expect(instantDeLaBox(null), isNull);
|
||||
expect(instantDeLaBox(''), isNull);
|
||||
expect(instantDeLaBox('pas une date'), isNull);
|
||||
expect(instantDeLaBox(const <String, Object>{}), isNull);
|
||||
// Un champ absent est un état. Retomber sur l'epoch 0 ou sur `DateTime.now()` en
|
||||
// ferait une mesure — le zéro forgé, appliqué au temps.
|
||||
});
|
||||
}
|
||||
@ -239,8 +239,9 @@ void main() {
|
||||
// box, 16:38 au téléphone). `DateTime.tryParse` d'une chaîne en `Z` rend un
|
||||
// `DateTime` **UTC**, et `.hour` sur un UTC donne l'heure UTC.
|
||||
//
|
||||
// Le défaut était invisible tant que box et téléphone partageaient l'offset : il
|
||||
// fallait DEUX fuseaux distincts pour qu'un instant mal rendu se voie.
|
||||
// ⚠️ La bascule a donné le MOTIF de regarder, pas la visibilité : 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 (vérifié le 2026-09-07, les deux en Europe/Paris).
|
||||
final instant = DateTime.utc(2026, 9, 6, 14, 38);
|
||||
final local = instant.toLocal();
|
||||
final attendu = '${local.hour.toString().padLeft(2, '0')}:'
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user