diff --git a/CLAUDE.md b/CLAUDE.md index fc28309..7078cfa 100644 --- a/CLAUDE.md +++ b/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. --- diff --git a/docs/captures/2026-09-07_fuseau-box-paris-ligne-absente.png b/docs/captures/2026-09-07_fuseau-box-paris-ligne-absente.png new file mode 100644 index 0000000..92aa74f Binary files /dev/null and b/docs/captures/2026-09-07_fuseau-box-paris-ligne-absente.png differ diff --git a/lib/models/box_instant.dart b/lib/models/box_instant.dart new file mode 100644 index 0000000..8d5ceb3 --- /dev/null +++ b/lib/models/box_instant.dart @@ -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(); +} diff --git a/lib/models/energy_ratios_snapshot.dart b/lib/models/energy_ratios_snapshot.dart index 5f55c23..993546c 100644 --- a/lib/models/energy_ratios_snapshot.dart +++ b/lib/models/energy_ratios_snapshot.dart @@ -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 m) => EnergyRatiosSnapshot( diff --git a/lib/models/load_telemetry.dart b/lib/models/load_telemetry.dart index c562c28..9529356 100644 --- a/lib/models/load_telemetry.dart +++ b/lib/models/load_telemetry.dart @@ -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.from(budget)) : null, diff --git a/lib/services/telemetry_text.dart b/lib/services/telemetry_text.dart index b095adc..250f670 100644 --- a/lib/services/telemetry_text.dart +++ b/lib/services/telemetry_text.dart @@ -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')}'; diff --git a/test/box_instant_test.dart b/test/box_instant_test.dart new file mode 100644 index 0000000..ce21cfc --- /dev/null +++ b/test/box_instant_test.dart @@ -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 {}), 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. + }); +} diff --git a/test/eco_levels_test.dart b/test/eco_levels_test.dart index 66be2a6..a7bed1f 100644 --- a/test/eco_levels_test.dart +++ b/test/eco_levels_test.dart @@ -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')}:'