docs: ouvrir le lot fuseau, et graver les deux masques empilés

LOT FUSEAU (docs/DESIGN_FUSEAU.md) — design avant code, comme pour le délestage
et le §10. Deux questions étaient posées ; les deux sont fermées par mesure.

1. Le fuseau vient-il de la box ou de nymea ? Des deux, et c'est le MÊME.
   `EnergyArbitrator::update()` prend `QDateTime::currentDateTime()`
   (energyarbitrator.cpp:329), `resolveDeadline()` hérite via `now.timeZone()`,
   et nymea ne fait que relayer `QTimeZone::systemTimeZoneId()`. Aucun réglage
   nymea ne peut dire autre chose que timedatectl : box et moteur ne peuvent pas
   diverger entre eux.

2. Mesuré dans le conteneur, avec un programme Qt6 pendant qu'on bascule
   /etc/localtime sous lui : un processus VIVANT suit le changement, sans
   redémarrage. Ce n'était pas acquis — la mise en cache du fuseau par processus
   est le comportement classique de la libc, et si Qt l'avait fait, corriger un
   fuseau n'aurait rien corrigé jusqu'au redéploiement suivant.

La vraie divergence est ailleurs, et elle est double. Entre l'INSTALLATION et le
CLIENT d'abord : `dailyDeadline` est une heure murale SANS fuseau, saisie sur un
téléphone qui a le sien, lue dans celui de la box — silencieuse des deux côtés,
chacun ayant raison dans son référentiel. Un fuseau implicite est une absence
qui signifie « le défaut s'applique », ce qu'on s'interdit partout ailleurs.

Et entre l'installation et le CALENDRIER RÉGLEMENTÉ, qui est le point neuf : une
plage d'heures creuses française est définie en Europe/Paris par le fournisseur,
pas « à l'heure locale de la box ». Deux natures d'heure murale, deux sources de
fuseau — l'habitude du client suit l'installation, le tarif suit le texte qui le
définit. Les confondre ne décale pas l'affichage : ça achète au mauvais moment.

DÉCISION : ni imposer, ni refuser — déclarer. Imposer serait affirmer un fuseau
qu'on ne sait pas vérifier. Refuser de fonctionner déplacerait un problème
d'affichage vers la sécurité, alors que L0..L4 raisonnent en durées et ne
dépendent d'aucun fuseau. Déclarer est la doctrine maison appliquée telle quelle.

DEUX MASQUES EMPILÉS (AGENTS.md, quatrième occurrence de la même famille). Le
mock plus homogène que la réalité avait une suite : l'environnement de
vérification plus indulgent que la réalité, deux fois dans la même chaîne de
build. Un artefact périmé couvrant un lien cassé, et le build vert couvrant les
deux. Un répertoire de travail accumule de l'état qui répond à la place du
dépôt — « est-ce que ça compile ? » n'a pas de valeur sans dire DEPUIS QUOI.
Seule la reconstruction dans un répertoire vierge les révèle, et le md5 identique
au binaire en service est la façon de fermer : il ne dit pas « ça compile », il
dit que le build reproductible produit exactement ce qui tourne.
This commit is contained in:
Patrick Schurig 2026-09-04 07:52:28 +02:00
parent bb12992513
commit 18e9c3abe4
2 changed files with 163 additions and 0 deletions

View File

@ -1022,6 +1022,34 @@ aucune, la décision est une intention, et elle se périmera à la vitesse du co
> test — **elle a trouvé un défaut**, le mock déréférençant le contrôleur de chaque Thing en
> supposant que toute classe en possède un. L'homogénéité protégeait sa propre prémisse fausse.
> ### Deux masques empilés dans la même chaîne (2026-09-04, QUATRIÈME occurrence)
>
> Même famille, autre étage : **l'environnement de vérification était plus indulgent que la
> réalité**, et il l'a été deux fois de suite dans la même chaîne de build.
>
> 1. Le paquetage qui NOMME le paquet vivait dans l'arbre de travail, pas dans git. Le build
> passait parce qu'il partait de l'arbre.
> 2. Renommer les `.install.in` de `debian-qt5` a fait pendouiller les dix liens de
> `debian-qt6` qui pointaient dessus. Le build passait quand même : les `.install`
> d'un build PRÉCÉDENT traînaient encore, et make les trouvait à jour **sans jamais suivre
> le lien**.
>
> Un artefact périmé couvrait un lien cassé, et le build vert couvrait les deux. Aucune des
> deux erreurs n'était visible depuis le répertoire où l'on travaille — et c'est la propriété
> qui les rend chères : **un répertoire de travail accumule de l'état qui répond à la place du
> dépôt.** La question « est-ce que ça compile ? » n'a pas de valeur si l'on ne dit pas
> *depuis quoi*.
>
> **La seule vérification qui les révèle est la reconstruction dans un répertoire VIERGE, à
> partir de `git archive` — jamais depuis l'arbre de travail, même « propre » au sens de
> `git status`.** Un arbre sans modification en attente peut porter des fichiers générés que
> personne ne versionne, et ce sont eux qui mentent.
>
> **La bonne façon de fermer : le md5.** Le `.so` reconstruit depuis git seul s'est révélé
> identique bit à bit à celui posé sur la box le matin même. Ce n'est pas la même affirmation
> que « ça compile » : c'est la preuve que le build reproductible produit **exactement ce qui
> tourne**. Tant qu'on ne l'a pas comparé, on a deux artefacts dont on espère qu'ils coïncident.
> **Piège de build, 2026-09-01** : changer la DISPOSITION d'une structure dans un en-tête inclus
> transitivement (ici un `QDateTime` ajouté à `EcoProgress`, atteint par `LoadAction`) peut
> laisser des objets compilés contre l'ancienne. Le symptôme est un **plantage dans un

135
docs/DESIGN_FUSEAU.md Normal file
View File

@ -0,0 +1,135 @@
# Fuseau horaire — le silence d'une heure
> Ouvert le 2026-09-04, après qu'une obligation éco déclarée à `"12:00"` se soit résolue
> à 13:00 sur l'horloge de l'atelier. Le moteur avait raison ; c'est la question qui
> n'avait jamais été posée.
## 1. Le constat
`.75` tourne en `Europe/London`. Une obligation `dailyDeadline: "12:00"` s'y résout donc à
12:00 heure de Londres, soit **13:00 en France**. Rien, nulle part, ne le dit.
L'écart est d'une heure et ressemble trait pour trait à un défaut d'heure d'été dans la
résolution d'échéance — j'ai failli l'imputer au moteur avant de lire l'horloge de la box.
C'est le premier coût du silence : il déplace le soupçon.
Le second coût est celui qui compte. Chez un client français, une box mal fuseau-horée décale
**toutes** ses fenêtres temporelles. Une obligation « avant 7 h » se déclenche à 8 h. Et
quand les heures creuses, Tempo et le marché de gros arriveront, elles se décaleront aussi —
sauf que là, personne ne le verra avant de compter sa facture.
## 2. Fait établi — il n'y a qu'UN fuseau, et il vient du système
Vérifié, pas supposé :
- `EnergyArbitrator::update()` prend `QDateTime::currentDateTime()`
(`energyplugin/etm/energyarbitrator.cpp:329`), donc le fuseau du PROCESSUS `nymead`.
- `resolveDeadline()` construit l'échéance avec `now.timeZone()`
(`energyplugin/etm/types/ecotarget.cpp`). Elle n'introduit aucun fuseau à elle : elle
hérite.
- nymea n'a pas de fuseau propre. `System.GetTime` renvoie `QTimeZone::systemTimeZoneId()`
(`libnymea-core/jsonrpc/systemhandler.cpp`), et `System.SetTimeZone` écrit le fuseau
**système** via le `SystemController` de la plateforme. `Configuration.SetTimeZone` est
déprécié au profit du premier.
**Conséquence : la box et le moteur ne peuvent pas diverger entre eux.** Il n'existe pas de
réglage nymea qui dirait autre chose que `timedatectl`. C'était la première question — elle
est fermée, et dans le sens rassurant.
## 3. Fait mesuré — un processus vivant SUIT le changement
Mesuré le 2026-09-04 dans le conteneur `build-cross-arm64`, avec un programme Qt6 qui lit
`QTimeZone::systemTimeZoneId()` et `QDateTime::currentDateTime()` en boucle pendant qu'on
bascule `/etc/localtime` sous lui :
```
systemTimeZoneId=Europe/London currentDateTime=06:48:50 BST zone(now)=Europe/London
--- /etc/localtime bascule vers Europe/Paris ---
systemTimeZoneId=Europe/Paris currentDateTime=07:48:53 CEST zone(now)=Europe/Paris
```
**Changer le fuseau ne demande donc PAS de redémarrer `nymead`.** Ce n'est pas une évidence :
la mise en cache du fuseau par processus est un comportement classique de la libc, et si Qt
l'avait fait, une correction de fuseau serait restée sans effet jusqu'au prochain
redéploiement — une correction qui ne corrige rien étant pire que pas de correction.
Corollaire à ne pas perdre : une échéance en cours de résolution peut donc **changer d'heure
sous le moteur**, sans redémarrage et sans notification. Cf. §6, étape 3.
## 4. La vraie divergence n'est pas là où on la cherchait
Box et moteur s'accordent par construction. Ce qui peut diverger, c'est :
**(a) L'installation et le client.** `dailyDeadline` est une chaîne `"HH:mm"` **sans fuseau**.
Le client saisit « 7:00 » dans l'app, sur un téléphone qui est dans SON fuseau, en pensant
7:00 chez lui. Le moteur la lit dans le fuseau de la box. Si les deux diffèrent, l'écart est
silencieux **des deux côtés** — l'app affiche ce qu'elle a saisi, le moteur exécute autre
chose, et les deux ont raison dans leur référentiel.
Un fuseau implicite est une absence qui signifie « le défaut s'applique ». C'est exactement
ce qu'on s'interdit ailleurs (`measurement.source`, `rankOrigin`, `progress.regime`).
**(b) L'installation et le CALENDRIER RÉGLEMENTÉ.** Et c'est le point neuf.
## 5. La distinction qui décide : deux natures d'heure murale
Elles ne prennent pas leur fuseau au même endroit, et les confondre est le piège.
| | nature | fuseau qui fait foi |
|---|---|---|
| `dailyDeadline`, « avant 7 h » | **habitude du client** | celui de l'installation |
| heures creuses, Tempo, EPEX | **calendrier réglementé** | celui où le tarif est DÉFINI |
Une plage d'heures creuses française est définie en `Europe/Paris` par le fournisseur, pas
« à l'heure locale de la box ». Une box française mal réglée ne décale pas seulement
l'affichage : elle **achète au mauvais moment**, et le tarif, lui, ne s'est pas décalé.
Aujourd'hui les deux natures prendraient silencieusement le fuseau de la box. Pour la
première c'est correct **si** la box est bien réglée ; pour la seconde c'est faux dès qu'elle
ne l'est pas. Une grandeur réglementée ne doit donc pas hériter du fuseau système : elle doit
porter le sien.
> Le marché de gros amont travaille en instants ABSOLUS (les tests construisent leurs
> horodatages via `EnergyTestBase::utcDateTime`), donc il est déjà à l'abri. Le risque naît
> le jour où l'on écrira une plage tarifaire en heures murales — c'est-à-dire au prochain lot
> tarifaire, pas plus tard.
## 6. La décision : ni imposer, ni refuser — DÉCLARER
**Ne pas imposer.** Le moteur ne connaît pas le fuseau « vrai » de l'installation ; il ne
connaît que celui de la box. Le forcer à `Europe/Paris` serait poser une affirmation qu'on ne
sait pas vérifier, et serait faux par construction pour toute box vendue hors de France.
**Ne pas refuser de fonctionner.** Un HEMS qui s'arrête parce qu'un téléphone est en
itinérance laisse un chauffe-eau sans pilotage et une protection de disjoncteur sans arbitre.
**Les couches L0 à L4 ne doivent dépendre d'aucun fuseau** : elles raisonnent en durées, pas
en heures murales. Le refus est disproportionné, et il déplacerait un problème d'affichage
vers la sécurité.
**Déclarer**, donc — la seule réponse conforme à la doctrine maison : *publier le régime, ne
jamais le laisser déduire.* Le moteur dit dans quel fuseau il résout les heures murales ;
c'est au client de rapprocher cela de l'heure de son utilisateur, et de l'avertir.
### Les étapes, à comportement constant d'abord
1. **Documenter le contrat** (aucun changement de code). `dailyDeadline` est une heure murale
du fuseau de la box, tel que publié par `System.GetTime.timeZone`. Écrit dans
`INTERFACE.md`, à côté du champ.
2. **Publier le fuseau avec la configuration**, en lecture seule, plutôt que d'exiger du
client qu'il croise deux espaces de noms pour interpréter un de NOS champs. Un client qui
doit appeler `System.GetTime` pour donner un sens à `dailyDeadline` finira par ne pas le
faire.
3. **Dire le changement.** Le fuseau peut bouger sous le moteur sans redémarrage (§3). Une
échéance résolue à 12:00 hier peut valoir 13:00 aujourd'hui sans qu'aucun champ n'ait
changé. À traiter comme tout élément runtime : un `Get*` ET un `*Changed`.
4. **Séparer les deux natures** au moment d'écrire la première plage tarifaire : une plage
réglementée porte le fuseau où elle est définie, elle n'hérite pas du système.
5. **Côté app** (lot séparé, à elle) : comparer le fuseau de la box à celui de l'appareil au
moment de la SAISIE d'une échéance, et avertir. C'est l'écran de saisie qui doit parler,
pas l'écran de résultat — au moment du résultat, l'heure est déjà passée.
### Ce qui reste ouvert, et qu'on ne tranche pas ici
Le fuseau de `.75` lui-même. `Europe/London` sur un banc français est une erreur de
configuration, mais la corriger déplace d'une heure la lecture de tout l'historique
d'énergie déjà enregistré. C'est une décision d'exploitation, pas de conception.