191 Commits

Author SHA1 Message Date
Patrick Schurig
4da904fc7a feat(énergie): la vue Mois est le mois civil
Patrick, 2026-10-16 : le titre disait « oct. 2026 » au-dessus d'une vue
des 30 derniers jours (17/9 → 16/10). L'onglet Mois prend désormais le
mois civil du jour choisi, du 1er au dernier jour — barres, tuiles du
bilan et titre disent la même chose. Les flèches avancent d'un MOIS (et
non de 30 jours : le 31 janvier menait au 2 mars) ; revenir sur le mois
en cours reprend le suivi d'aujourd'hui. Vu sur le téléphone (oct. 2026,
sept. 2026).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 00:33:30 +02:00
Patrick Schurig
e562fb5bf5 fix(énergie): plus de notes sous les graphes, axes Jour / Mois lisibles
Vu sur le téléphone le 2026-10-16 (demandes de Patrick).
- Notes retirées sous les graphes : absence de batterie, geste de
  glissement, séries par consommateur, et les deux notes sur la prévision
  (vue Heures seulement, plage non couverte). Les notes sous les tuiles du
  bilan restent.
- Graphes en barres : fl_chart appelle l'étiquette de CHAQUE barre en
  ignorant `interval` — 24 heures ou 30 dates imprimées les unes sur les
  autres. Une étiquette tous les _xIntervalPeriodes.
- La paire qui commence avant la période (point d'appui demandé un pas
  plus tôt) n'est plus dessinée comme une barre : « 23:00 » en tête de la
  vue Jour, « 9/10 » en tête d'une semaine du 10 au 16.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:18:58 +02:00
Patrick Schurig
2e66511332 feat(énergie): les tuiles du haut suivent la période des graphes
Demande de Patrick du 2026-10-16 : les six tuiles (production,
consommation, réseau, taux) disaient toujours la journée en cours, quel
que soit l'onglet.
- Heures / Jour : le jour choisi (aujourd'hui : la journée de la BOX,
  periodStartedAt, comme avant). Semaine / Mois / Année : les 7, 30 ou
  365 jours qui finissent le jour choisi — la plage des barres.
- NymeaService.fetchBilan : le calcul du bilan journalier généralisé à une
  période (différence des compteurs, mêmes refus). Borne de départ = le
  dernier échantillon AU PLUS TARD au début : sur un pas d'un jour, le
  premier échantillon postérieur serait la fin du premier jour.
- « Bilan · 10 oct. → 16 oct. 2026 » au-dessus des tuiles.
- Précision selon la grandeur (« 286.22 k… » était tronqué) : MWh au-delà
  de 1 000 kWh. Note « période partielle » datée au lieu de « journée
  partielle avant 01:00 » sous un bilan d'un an.

Vu sur le téléphone (Heures, Semaine, Année). Sur l'année, le banc montre
63 MWh produits : saut des compteurs cumulés le 2026-08-03 (0 → 62 008
kWh), pas un défaut du calcul.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 22:58:52 +02:00
Patrick Schurig
727f309d75 fix(accueil): « En ce moment » n'affiche que ce qui consomme ou travaille
Patrick, 2026-10-16 : « un chauffe-eau qui ne consomme pas, pas la peine
de l'afficher — uniquement des choses actives ».
- Une charge agit si elle consomme (mesure > 50 W) ou si son mécanisme
  est réellement commandé (palier de relais > 0, consigne > 0, borne
  autorisée avec du courant, SG-Ready en état 3 ou 4) — plus sur la
  puissance seulement DÉCIDÉE. Sur le banc, le chauffe-eau avait 83 W
  décidés en éco mais son relais restait au palier 0 : il ne chauffait
  pas. Alertes (défaut, dérogation, objectif manqué) toujours montrées.
- Ventilation « 83 W éco + 0 W confort » retirée : éco / confort se
  lisent comme des réglages de température. Question posée au moteur
  (consignes/QUESTION-moteur-vocabulaire-niveaux.md).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 22:26:52 +02:00
Patrick Schurig
250c0fcb6b fix(accueil, énergie): charges en attente regroupées, titres lisibles, axes propres
Vu sur le téléphone le 2026-10-16 (première vérification à l'écran).
- « En ce moment » : une ligne complète seulement pour une charge qui AGIT
  (puissance portée sous un motif qui n'est pas une attente, puissance
  mesurée, dérogation, défaut, manque retenu) ; les autres sur une ligne
  discrète « En attente : … » (Patrick). La PAC SG-Ready qui porte 1 200 W
  en état normal maintenu sous BELOW_MIN_POWER attend.
- Motif affiché = celui du niveau qui porte la plus grosse part (règle R7
  d'etm70) : en etm69 la racine disait « rien n'est commandé » pendant que
  l'éco achetait 83 W. LoadDecision lit désormais `level`.
- Écran Énergie : titre de l'écran et titres des cartes de graphe
  invisibles en sombre (sans() = bleu marine par défaut) → inkOf ;
  étiquettes des bords du graphe « Qui consomme » qui se chevauchaient
  (« 09:4510:00 ») retirées ; axe de la prévision qui affichait
  « -0.0kW » pour quelques watts nocturnes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 22:12:00 +02:00
Patrick Schurig
ae454b703e feat(prévision): consommation de base prévue, bande apprise en Wh (p10Wh/p90Wh)
Consigne app §H.4 et §G (2026-10-16), contre INTERFACE.md local de la
branche moteur — etm70 pas encore sur le banc.

- baseload : lu sur la grille start + i·resolutionS, W moyens → Wh par
  créneau. Sous model « none » (forme réelle du banc etm69 : p50W tout
  null) rien n'est tracé ni légendé — une absence, jamais 0 ; une case
  null reste absente. Carte « Journée » : heures à venir, colonne
  consommation en contour bleu « Consommation de base prévue ». Premier
  profil attendu vers le 25 octobre.
- Bande apprise : forme fixée par la consigne G, p10Wh / p90Wh en Wh par
  point de forecastPoints (l'ancienne lecture p10W/p90W est retirée et
  testée comme telle). Libellé client « Fourchette apprise (8 cas sur
  10) ». Toujours inactive : aucune box ne la publie.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 21:31:11 +02:00
Patrick Schurig
fb5b0e7165 feat(accueil): carte « En ce moment » sur la télémétrie, plus de marque ni de démo
Consigne app §G (décision Patrick du 2026-10-16) et motifs etm70 (§H).

G — la carte « Héos pilote en ce moment » affichait des décisions
inventées (HeosDecision.demo : voiture 3,6 kW, batterie en charge sur un
banc sans batterie). Remplacée par EnCeMomentCard : une ligne par charge
de GetLoadTelemetry — libellé de la configuration, decision.code + params
rendus par decisionText, puissance MESURÉE seulement (jamais l'allocation
à sa place). Titre en clé l10n. Rien sur « optimiseur actif » tant que
l'objet de capacités n'existe pas.
Marque retirée des identifiants (HeosDecisionCard, HeosDecision,
HeosAction, _HeosChip, _HeosBadgeSmall, heosActive → pilotageActif…), des
fichiers (heos_decision_card.dart, dashboard_models.dart supprimés), des
textes affichés (hub du flux, écran A/C) et des commentaires :
grep -ri héos/heos vide sur lib/, test/, assets/ et les .arb.

H — contre INTERFACE.md local de la branche moteur, etm70 pas encore sur
le banc :
- motifs éco publiés à la racine (R7, part majoritaire) rendus en clair ;
  ventilation des niveaux affichée (« 83 W éco + 0 W confort », vu sur
  la trame réelle etm69) ;
- ECO_FLOOR_MISSED retenu par charge (publié un seul cycle), y compris
  pour une charge sortie de loads[] ; « manque non chiffrable » quand
  missingWh est absent, jamais 0 Wh ;
- PRICE_SLOT et OPTIMIZER_DECISION en FR / DE / EN (app_en.arb et
  app_de.arb créés pour ces seules clés ; le reste retombe sur le
  français, listé dans l10n_untranslated.json). Code inconnu : repli
  générique avec le code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 21:26:16 +02:00
Patrick Schurig
044fcbd0f8 style(accueil): choix du jour en menu + calendrier, textes lisibles en sombre
Patrick, 2026-10-16 : « fonctionnel mais visuellement trop », et « le
texte est trop sombre ».
- Choix du jour réduit à un menu Hier / Aujourd'hui / Demain et une icône
  calendrier, à droite du titre. Flèches, puces et lien retirés ; un jour
  pris au calendrier s'ajoute au menu sous sa date.
- EtmTokens.sans() prend « ink » (bleu marine) par défaut, invisible sur
  une carte sombre : titre, menu et météo de la carte « Journée », titres
  de la carte « Prévision PV » passent à inkOf(context). Test en thème
  sombre ajouté.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 06:44:36 +02:00
Patrick Schurig
b65b5474fb feat(accueil): choisir le jour de la carte « Journée »
Demande de Patrick du 2026-10-16 (inspiré du sélecteur Victron, réduit) :
‹ jour › avec calendrier, raccourcis Hier / Aujourd'hui / Demain, et un
lien « Plusieurs jours » vers l'écran Énergie — pas de plage de plusieurs
jours dans une carte à colonnes horaires.

- Jour passé : mesures du jour et prévision ENREGISTRÉE telle qu'elle
  était connue à son début (GetPvForecast + knownAt) — sinon la table
  rendrait la dernière révision, faite après coup.
- Demain : prévision seule ; au-delà, flèche désactivée (horizon).
- Météo de maintenant sur aujourd'hui seulement ; rangée horaire si la
  série couvre le jour (aujourd'hui, demain).
- Une réponse tardive d'un jour quitté est jetée.
- Correctif trouvé par le test de navigation : la carte ne relisait ses
  mesures qu'après une prévision lue avec succès ; sur une box sans
  prévision, elle restait sur le sablier. Elle suit désormais le compteur
  de lectures du provider.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 06:12:01 +02:00
Patrick Schurig
71d88418fd feat(tarif): l'écran Tarif lit et écrit le tarif du moteur (+etm69)
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>
2026-10-15 09:12:21 +02:00
Patrick Schurig
335e6a1290 feat(accueil): météo heure par heure sur la carte « Journée » (openmeteo etm16)
Consigne app §E, vérifiée sur le banc le 2026-10-15 :
- lecture de forecastHourlyWeather (v 1) sur le Thing SITE, parent des
  plans de perField (parentId désormais lu sur les Things) ; v inconnu =
  rien d'affiché, jamais forecastCurveTransport ;
- case i = valeur au DÉBUT du créneau t0 + i·step : posée sur l'heure qui
  commence là, comme la prévision PV ;
- icône (code WMO → icône, variante de nuit par isDay) et température une
  heure sur trois, alignées sur les colonnes du graphe ;
- une case null n'a ni icône ni température : WMO 0 = ciel dégagé, et un
  null lu comme 0 afficherait un soleil. Test à la lecture ajouté après
  qu'une mutation null → 0 n'eut fait échouer aucun test (la fixture du
  banc n'a pas de null).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 08:32:28 +02:00
Patrick Schurig
c7f857ecb3 feat(prévision): fourchette météo datée et invalidée (openmeteo etm16)
Consigne app §D, mise à jour du 2026-10-15, vérifiée sur le banc (etm16) :
un plan qui publie forecastWeatherSpreadValid ne compte que si ce témoin
vaut true et que le pourcentage du jour est un nombre (null sinon) — pas
de bande, jamais une bande à 0. Un 0 % valide est une vraie mesure. La
fourchette datée (forecastWeatherSpreadTime) n'est plus « non datée ».
Les plans antérieurs gardent l'ancienne règle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 08:22:14 +02:00
Patrick Schurig
e38bd89cd5 style(prévision): plein = réel, contour = prévu
Patrick, 2026-10-15 : la production mesurée et la prévision, deux aplats
de jaune voisins, ne se distinguaient pas. La distinction passe par la
forme : le mesuré est une surface dorée pleine, la prévision un trait
pointillé jaune sans remplissage tracé par-dessus, la fourchette un fond
très pâle. Légende assortie (pavé plein / trait pointillé).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 08:11:39 +02:00
Patrick Schurig
6e6523b2a4 style(prévision): plus de texte sous les graphes, production en jaune
Demande de Patrick du 2026-10-15.
- Carte « Journée » (accueil) : totaux et notes retirés sous le graphe.
- Carte « Prévision PV » (écran Énergie) : notes retirées (plafond
  optimiste, fourchette, créneaux manquants, date du dernier changement) ;
  ce qui doit rester dit tient dans la légende (« plafond optimiste »,
  « non datée »). Production mesurée en jaune doré au lieu du vert, la
  prévision en jaune translucide derrière.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 08:04:17 +02:00
Patrick Schurig
28c4196b9b feat(accueil): carte « Journée » — consommation par consommateur et solaire
Variante E de la maquette, retenue par Patrick le 2026-10-15 (« deux
colonnes à la Victron ») ; remplace la carte « Production du jour ».

- Par heure, deux colonnes : à gauche la consommation, Maison (bleu) puis
  chaque consommateur déclaré avec compteur (couleur de son domaine, même
  règle que l'écran Énergie) ; à droite le solaire, autoconsommé (doré)
  puis injecté (pâle), la prévision en fond pour comparer. Heures à venir :
  la prévision seule, en contour — aucun plan n'est publié par la box.
- Échantillon horaire horodaté T = heure qui commence à T − 1 h, pour le
  bilan comme pour les Things.
- Météo de maintenant lue sur le Thing site openmeteo, et seulement si
  updateTime a été écrit : les défauts du plugin (clear-day, 0 °C)
  afficheraient sinon un soleil à 0 °C. Les icônes horaires attendent
  openmeteo etm16 (consignes/CONSIGNE-openmeteo-meteo-horaire.md).
- Charges sans compteur déclaré nommées sous la carte (« comptée dans
  Maison »), jamais devinées.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 07:53:24 +02:00
Patrick Schurig
c1a4d5f46c style(énergie): solaire en jaune, SOC en vert, maison en bleu
Demande de Patrick du 2026-10-15, alignée sur la lecture Victron :
- autoconsommé : jaune doré (était orange) — tout le solaire est jaune,
  l'exporté en jaune pâle par-dessus (nouveau jeton exportSolaire) ;
- SOC de la batterie : vert pointillé (était l'indigo des bornes) ;
- consommation / soutiré : bleu, inchangé.
Le jeton garde son nom (selfUse) : c'est son rôle, pas sa teinte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 07:09:13 +02:00
Patrick Schurig
467384cd4d fix(tarifs): le prix manuel n'est transmis nulle part — le dire
« Prix unique appliqué à toutes les décisions du scheduler » était faux :
TariffProvider.load() ne lit ni n'écrit rien sur la box, et le moteur ne
décide sur aucun prix. Remplacé par « Saisie locale, pas encore transmise
à la box. » Rien d'autre n'est branché, en attendant le RPC tarif (T4).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:15:59 +02:00
Patrick Schurig
2cd7662f2d feat(énergie): fourchette météo autour de la prévision (section D)
Consigne app etm66, section D (décision Patrick du 2026-10-14).

- Bande prévu × (1 ± s_J), s_J = dispersion du jour J lue sur les Things
  plan openmeteo (forecastWeatherSpread{Today,Tomorrow}Percent, plus forte
  des plans). J est le jour civil de PARIS : openmeteo découpe ses jours en
  Europe/Paris, écrit en dur dans sa requête.
- Bas arrêté à 0, haut au plafond AC sinon à la crête cumulée des plans
  (kwp). Créneau absent = trou, bande comprise. 0 % sans modèle d'ensemble
  = état jamais écrit = pas de bande.
- Tracée atténuée, « non datée » : openmeteo etm15 ne date pas la valeur
  et la garde quand l'API d'ensemble échoue.
- p10W / p90W publiés par la box remplacent la fourchette (code prêt,
  inactif). La bande n'est jamais renvoyée à la box.
- Accueil : fourchette du total du jour quand chaque créneau en a une.
- Détails installateur retirés sous le graphe (demande de Patrick).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:03:39 +02:00
Patrick Schurig
96794cd831 feat(énergie): prévision sur l'axe commun, production du jour sur l'accueil
Demandes de Patrick du 2026-10-14.

- Écran Énergie, vue Heures : la prévision partage la fenêtre de 12 h et
  le glissement des graphes du dessus. Les instants sont convertis en
  index fractionnaire (AxeEchantillons : interpolé entre deux échantillons,
  extrapolé au pas au-delà) ; seule la borne droite de la fenêtre peut
  dépasser la dernière mesure, jusqu'à la fin de la prévision. Prévision
  semi-transparente, mesuré en plein par-dessus.
- Accueil : carte « Production du jour » (0 h → 24 h, barres horaires),
  sous les décisions. Prévision derrière, mesuré devant. Un échantillon
  SampleRate1Hour horodaté T est la moyenne de (T − 1 h, T]
  (energylogger.cpp) : il va dans l'heure qui commence à T − 1 h. Totaux
  affichés seulement quand ils sont complets.
- PvForecastProvider : une lecture et un minuteur (12 min) partagés par
  les deux écrans, remis à zéro au changement d'installation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 08:47:53 +02:00
Patrick Schurig
adb069f1d2 feat(énergie): prévision PV lue sur la box, marché spot masqué (+etm68)
Consigne app etm66/etm68, vérifiée contre le banc hems (1.15.2+etm68) le 2026-10-14.

- PvForecastCard remplace le placeholder « Météo & prévision » : lit
  NymeaEnergy.GetForecastSnapshot à l'ouverture puis toutes les 12 min
  (aucune notification n'existe). Pas des créneaux DÉDUIT, créneau absent
  = trou (jamais zéro), fault ⇒ pas de courbe avec son motif, libellé
  « plafond optimiste », corrections/plafond AC en mode installateur.
- MISSING_FORECAST_SITE : les candidats de GetForecastSite sont proposés,
  la désignation (SetForecastSite) se confirme ; acLimitW omis = inchangé.
- Production mesurée superposée depuis GetPowerBalanceLogs, signe inversé
  (pas .abs()), aucun cumul total*.
- Marché spot masqué dans Tarifs (SPOT_MARKET_NOT_IN_BUILD) : mode Spot
  Market, fournisseurs Tibber/Octopus/ENTSO-E et la carte de prix codés en dur.
- Bornes VE envoyées en {uuid} minuscules jusqu'à etm69 (SetChargingInfo,
  SetLoadConfig, Set/ClearLoadDerogation) via idBorneCanonique().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 07:40:20 +02:00
Patrick Schurig
bedeabf767 fix(banc): l'adresse du banc passe à 10.42.60.21 — sondes, tests on-device, consignes
Le banc `hems` est renuméroté (192.168.1.75 → 10.42.60.21). Portés : les 11
sondes `tools/rpc/*` et leur README, les 3 tests on-device, le `hint` de
l'écran Installations, les procédures de CLAUDE.md et des deux documents de
cadrage.

Ce qui n'est PAS touché, et c'est délibéré : les relevés datés (RELEVE_LOTC,
RECAP, Prompt 3g2, maquettes) et les fixtures `hems75_*.json`. Un relevé daté
dit ce qui a été observé ce jour-là ; le réécrire échange une trace contre une
cohérence de surface. CLAUDE.md porte la note qui fait le lien : `.75` et
`hems75` désignent la même machine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-13 06:37:33 +02:00
Patrick Schurig
d6240984ee feat(énergie): naviguer de période en période, et un sous-titre en moins
DEUX DEMANDES DE PATRICK.

1. Une barre de navigation sous les onglets, à la place de la puce de date :

     ‹        3 sept. → 9 sept.        ›   Aujourd'hui

   Le libellé suit la granularité de l'onglet — « 9 sept. 2026 », « 3 → 9 sept. »,
   « sept. 2026 », « 2026 » — parce que dire « jour » en montrant une semaine est
   exactement l'erreur que les onglets faisaient avant d'être corrigés.

   Le sélecteur existait déjà dans la barre du haut, mais il permettait de sauter
   à un jour, pas de reculer d'une semaine puis d'une autre. Or lire une
   consommation, c'est presque toujours la comparer à la période précédente : ce
   geste-là doit coûter un appui, pas l'ouverture d'un calendrier et le calcul
   mental de la date d'avant.

   La flèche « suivant » est GRISÉE, pas masquée : une commande qui disparaît
   laisse chercher où elle est passée, une commande grisée dit « il n'y a rien
   après ». Et elle est bornée au présent — sans ça on part dans un futur où la
   box n'a rien, avec un graphe vide qui ne dit pas qu'il l'est PARCE QU'on est
   allé trop loin.

2. Le sous-titre « le bloc orange est partagé : ce qui a été produit ET gardé »
   est supprimé.

Et une variable morte retirée avec sa raison : le soutiré n'est pas relu de
`totalAcquisition` mais dérivé de `consommation − autoconsommé`. Le relire
ferait une pile dont les segments ne rejoignent pas leur sommet dès qu'un
compteur dérive — un trou dans la barre que personne ne saurait lire.
L'identité des compteurs se vérifie ailleurs, sur le bilan du jour, où elle a
une phrase pour se dire.

358 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-09 10:44:44 +02:00
Patrick Schurig
9a034aecbc fix(énergie): la date sous la légende, et l'échelle qui suit la FENÊTRE
LA DATE PASSE ENTRE LA LÉGENDE ET LE TRACÉ (demande de Patrick). Sa place n'est
pas cosmétique : elle QUALIFIE ce qu'on regarde, donc elle doit se lire avant le
graphe, pas se trouver après lui. Sous les deux cartes, elle arrivait une fois
la lecture faite — et sur trois jours, une courbe lue sans sa date est une
courbe lue de travers.

Posée sur les DEUX cartes : elles partagent le cadrage, mais si on défile
jusqu'à ne voir que la seconde, elle doit se suffire. Une carte qui dépend d'une
autre pour être comprise ne tient pas dès qu'on la regarde seule.

🔴 ET L'ÉCHELLE SUIT DÉSORMAIS LA FENÊTRE, PAS TOUT LE JEU CHARGÉ.

Vu à l'écran : axe à 11,9 kW pour une fenêtre qui ne dépasse pas 2,5 — les
courbes rampaient au ras de l'abscisse, et la production était purement
invisible. Une pointe d'avant-hier écrasait le détail d'aujourd'hui.

C'est le passage à 72 h qui l'a créé, et c'est lui qui l'a rendu visible : sur
24 h, fenêtre et jeu de données avaient presque la même amplitude, donc le
défaut ne pouvait pas se voir. Élargir le glissement sans rééchelonner faisait
une loupe qui déplace le cadre sans grossir — exactement ce qu'il devait éviter.

Corrigé sur les deux graphes : cadrer l'un sur la fenêtre et l'autre sur tout
les rendrait incomparables.

Captures : docs/captures/2026-09-09_energie-plage-sous-legende.png
           docs/captures/2026-09-09_energie-echelle-fenetre.png

358 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-09 09:49:06 +02:00
Patrick Schurig
2a1e7f2dbb feat(énergie): glisser sur trois jours — fenêtre fixe de 12 h, et la date avec
Demande de Patrick : élargir le glissement. 72 h chargées au quart d'heure, la
fenêtre en montre 12 — on remonte donc sur trois jours sans changer d'onglet.

TROIS CHOSES QUE CE CHANGEMENT RENDAIT NÉCESSAIRES.

La taille de fenêtre était « la moitié du jeu de données ». Avec 24 h ça donnait
12 h ; avec 72 h ça en aurait donné 36, et la fenêtre aurait grandi avec les
données au lieu de rester une fenêtre. Elle est fixée en DURÉE : la densité de
lecture ne dépend plus de la profondeur chargée.

La fenêtre s'ouvre sur le plus RÉCENT. Calée au début, elle aurait montré
avant-hier soir à l'ouverture — la seule vue dont personne n'a besoin en
premier.

Et la plage visible porte sa DATE : « 8/9 21:00 → 9/9 09:00 ». Sur 24 h,
« 09:00 » désignait sans ambiguïté ce matin ; sur trois jours, c'est l'un de
trois matins, et l'axe n'affiche que des heures. Sans cette ligne, l'écran
répondait à côté de la question « c'était quand ? ».

Et les étiquettes des deux BORDS de l'axe sont retirées : fl_chart en pose une à
`min` et à `max` en plus de celles de l'intervalle, et elles se superposaient à
leur voisine — « 20:4500 » à gauche, « 08:1509:00 » à droite. J'avais corrigé le
bord gauche sans penser que le droit avait le même défaut : la recherche
auto-confirmante, appliquée à un axe. On ne perd rien, la ligne de plage donne
les deux bornes exactes avec leur jour.

Vérifié à l'écran : fenêtre 21:15 → 09:00 à l'ouverture, ligne de plage et
indice de geste présents.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-09 09:20:21 +02:00
Patrick Schurig
f1547f674a fix(énergie): grouper par période CIVILE, soutiré en bleu, second graphe d'énergie
TROIS DEMANDES DE PATRICK.

1. Le soutiré passe du rouge au BLEU. Avec l'orange partagé sur les deux barres,
   le rouge rendait l'image trop chaude — et le bleu est déjà la couleur de la
   maison partout ailleurs. Il ne reste d'orange que le bloc partagé, celui qui
   doit se voir.

2. Second graphe en vue barres : « Où est passée la consommation » — une barre
   empilée par période, une couleur par charge déclarée, plus le reste du foyer
   qui ferme la barre à la consommation totale.

   L'énergie vient du compteur cumulé de chaque appareil (`totalConsumption`),
   jamais d'une intégration de la puissance — intégrer supposerait la puissance
   constante entre deux échantillons. Vérifié à la sonde avant d'écrire : ECS
   80,8 kWh et PAC 43,1 kWh sur la fenêtre, avec des deltas journaliers réels.

   Une charge dont le compteur n'a pas bougé donne 0 kWh, qui est une MESURE.
   Une charge dont le cumul manque à une borne n'a PAS de part. Les deux se
   ressemblent à l'écran et ne disent pas la même chose. Et un compteur qui
   recule est une remise à zéro, pas une consommation négative : la part est
   refusée plutôt que rendue absurde.

🔴 3. LE « 6/9 » EN DOUBLE N'ÉTAIT PAS UN DÉFAUT DE L'APP. C'est la trace de
   notre propre bascule de fuseau du 2026-09-06 : la frontière de journée de la
   box a glissé d'une heure, et l'historique porte deux échantillons pour le
   06/09 — 00:00 et 01:00. Le graphe affichait fidèlement une « journée » d'une
   heure à côté de journées entières.

   La couture est PERMANENTE : elle est dans les données passées, pas dans une
   requête. Tout écran qui compare des périodes doit grouper par période civile,
   jamais par indices consécutifs. Aucune énergie n'est perdue — la tranche
   d'une heure est absorbée dans sa journée.

Et une étiquette d'axe illisible : fl_chart en pose une à `min` en plus de
celles de l'intervalle, et les deux se superposaient — « 20:4500 ». Deux heures
imprimées l'une sur l'autre ne se lisent ni l'une ni l'autre.

358 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-09 08:52:54 +02:00
Patrick Schurig
2292c7df8a feat(énergie): cinq onglets qui disent vrai, et les vues d'énergie en barres empilées
Les onglets portaient chacun le nom d'un cran EN DESSOUS de leur plage : « Jour »
montrait 7 jours, « Semaine » 28, « Mois » 365. Quatre étiquettes, quatre
mensonges — et la journée que le premier promettait n'était nulle part.
L'onglet « Année » manquait, puisque c'est « Mois » qui la montrait.

  Heures   24 h au ¼ h        courbes, axe glissable
  Jour     00:00 -> 24:00     barres, au pas horaire
  Semaine  7 jours            barres, au jour
  Mois     30 jours           barres, au jour
  Année    365 jours          barres, au mois

LES BARRES. Deux barres par période, chacune empilée, et le segment
« autoconsommé » est LE MÊME des deux côtés, à la même couleur :

  Production   = autoconsommé + exporté
  Consommation = autoconsommé + soutiré

Deux barres simples diraient laquelle est la plus haute ; celles-ci disent où
est passée la différence — la question qu'on se pose devant un bilan.

Pas de diagramme divergent, et la raison est propre à ce dépôt : la box publie
déjà `production` avec un signe négatif, défaut qui nous a coûté deux écrans et
trois jours. Dessiner la production sous l'axe rendrait une capture correcte
indiscernable de ce bug.

L'énergie vient des CUMULS, jamais des puissances : chaque barre est une
différence de compteurs. Une période dont une borne ne publie pas ses cumuls n'a
pas de barre — pas une barre à zéro. Et la fenêtre demandée déborde d'un
échantillon avant le début, sans quoi la première barre n'a pas de prédécesseur.

Quatre défauts trouvés à l'écran, tous invisibles au test :
- `barGroups` était OUBLIÉ. Les groupes étaient calculés, `maxY` en découlait,
  donc l'axe montait à la bonne hauteur — et aucune barre n'était dessinée. Tout
  ce qui entoure les données avait l'air correct ;
- « 80.0kWh » ne tenait pas dans 46 px et se coupait en « 80.0kW / h ». Une
  unité coupée en deux lignes se lit comme une autre unité ;
- une plage de 7 jours partant de 08:12 coupe deux journées en deux : deux
  barres portaient « 6/9 » et la semaine en comptait huit. Toutes les vues en
  barres se calent maintenant sur minuit ;
- les notes par consommateur s'affichaient sous les barres, où aucun
  consommateur n'est tracé.

358 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-09 08:17:43 +02:00
Patrick Schurig
9491077497 feat(énergie): l'axe X de la vue Heures se glisse — par fenêtre, pas par défilement
Demande de Patrick. Fait par FENÊTRE glissante (minX/maxX) plutôt qu'en faisant
défiler le graphe entier : l'échelle en kW reste visible pendant le geste, alors
qu'un défilement horizontal l'emporterait hors du champ au moment où on en a
besoin.

⚠️ On borne minX/maxX, on ne DÉCOUPE pas les données : découper renumérotrait
les index, et l'axe du bas — qui lit `_data[x.round()]` — désignerait alors les
mauvaises heures. Les séries restent entières, la fenêtre ne fait que cadrer.

96 points au quart d'heure sur 24 h : tout montrer écrase le détail, et il n'y a
alors rien à faire glisser. La fenêtre en montre la moitié.

Trois détails qui vont avec :
- le geste convertit des pixels en points d'échantillon, donc glisser d'une
  largeur d'écran déplace d'une fenêtre entière quelle que soit la densité ;
- la fenêtre est bornée aux données — glisser dans le vide ferait croire qu'il
  n'y a rien à cet endroit, alors qu'il n'y a pas d'endroit ;
- l'espacement des étiquettes se calcule sur ce qui est VISIBLE : le calculer
  sur le jeu entier en placerait trois, mal réparties.

Le détecteur de geste n'est posé que sur les vues glissables : envelopper un
graphe qui ne glisse pas volerait le défilement vertical de la page pour rien.
Et l'indice ne sort que là où le geste existe.

Les deux graphes partagent le même cadrage — comparer deux fenêtres différentes
n'aurait aucun sens.

358 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-09 07:39:24 +02:00
Patrick Schurig
b0afba11c7 fix(ev): pas de bouton de boost — trois modes et rien d'autre
Décision de Patrick, réaffirmée : « tu gardes tout, seulement le bouton boost on
supprime ».

Je l'avais écrit et posé quelques heures plus tôt, en lisant « le bouton de
boost : ouvre-le quand tu veux » dans un message du moteur comme un feu vert —
alors que la décision produit était déjà prise en sens inverse. Un avis
technique sur la faisabilité ne révise pas un arbitrage de produit ; il fallait
demander, pas inférer.

Retiré : l'usage de `EvBoostButton` sur l'écran de la borne, son import, et le
helper `_entreeTelemetrie` devenu orphelin.

Gardé, sur instruction explicite : le widget, `setLoadDerogation` /
`clearLoadDerogation`, le modèle `Derogation` / `DerogationEnded` et leurs 19
tests. Ce n'est pas de la donnée fabriquée comme `DayPlan.demo()` — c'est la
lecture d'un contrat réel, testée, qui ne s'affiche nulle part et ne peut donc
rien afficher de faux. Le jour où le boost revient, il se rebranche en trois
lignes.

Vérifié à l'écran : « Mode de recharge · lu : Min+PV », trois boutons Manuel /
PV / Min+PV, Min+PV allumé parce que c'est ce que la box porte. Rien d'autre.

Capture : docs/captures/2026-09-08_ev-trois-modes.png

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-08 15:08:58 +02:00
Patrick Schurig
0d1732dc61 fix(ev): l'écran ne renvoie plus ailleurs pour un réglage qu'il porte lui-même
Vu à la capture du bouton de boost : la note « le mode de recharge se règle
depuis la tuile Borne EV des favoris » est visible trois centimètres au-dessus
des boutons de mode, dans le même défilement. Les boutons ont été déplacés ici
le 2026-08-28 et la phrase ne l'avait pas suivi — elle envoyait chercher ce qui
est sous les yeux.

Capture des trois modes et du bouton d'action :
docs/captures/2026-09-08_ev-modes-et-boost.png

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-08 14:58:47 +02:00
Patrick Schurig
b0b6666bc8 feat(ev): le boost est une DÉROGATION — bouton d'action, durée obligatoire, six fins
Le lot complet côté app, écrit sur le contrat vérifié dans Introspect sur .75 en
1.15.2+etm61 : SetLoadDerogation { loadId, durationS, o:targetWh } /
ClearLoadDerogation { loadId }, et derogationEnded[] { cause, loadId,
o:restoredMode } au niveau de la trame.

CE QUI EST UN BOUTON D'ACTION, PAS UN MODE. Une dérogation se superpose au mode
courant et s'efface ; elle ne le remplace pas. La mettre dans le sélecteur
ferait croire qu'on en sort en choisissant autre chose.

UNE DURÉE, TOUJOURS. Bornée [60, 21600] côté moteur. L'écran n'offre AUCUNE
option « jusqu'à annulation » — pas parce que l'appel échouerait, mais parce que
la promesse serait mauvaise : une dérogation qui ne sait pas dire quand elle
finit ne devrait pas exister (LM-1500). Le filet reste armé même avec une cible,
parce qu'une cible repose sur un instrument et qu'un instrument peut se taire.

L'ÉTAT NE SE LIT PAS DANS LE MODE. Si la borne était en manuel, le moteur force
Eco le temps de la dérogation : GetChargingInfos montrerait alors un mode que
personne n'a choisi. La vérité est sur la charge —
LoadTelemetryEntry.enDerogation.

Trois refus d'inventer, chacun figé par un test :
- entrée de télémétrie absente -> le bouton se TAIT. « Pas d'entrée » n'est pas
  « pas de boost », c'est « on ne sait pas » ;
- échéance dépassée alors que la box annonce encore la dérogation -> on la NOMME
  au lieu de rabattre à « 00:00 » : c'est une contradiction entre deux champs de
  la même trame, et le zéro l'effacerait ;
- cause de fin inconnue -> aucun libellé. Une box plus récente peut en publier
  une septième ; lui coller la phrase d'une autre serait pire.

Et `EnergyErrorInvalidParameter` sur l'arrêt ne crie pas au refus : la
dérogation a pu finir seule pendant l'appui, et une course que l'utilisateur ne
peut pas éviter n'est pas une erreur à lui montrer.

⚠️ boundBy a DEUX catalogues : drawCap|loadCeiling sur la dérogation, mais
« minimum » sur ECO_FLOOR_GRID — relevé sur la V2C aujourd'hui. Même nom, sens
différent : aucune traduction partagée.

⚠️ Écrit d'après le contrat, PAS observé : DEROGATION_ACTIVE, endsAt et boundBy
ne sont pas dans Introspect (les params d'une décision sont une carte libre), et
aucune dérogation n'a tourné pendant une sonde. Tous les champs restent
nullables. Noté aussi qu'Introspect SOUS-DÉCLARE — il annonce draw{committedW}
seul alors que la box publie authorisedW/binding/bindingPhase/perPhaseBound —
donc son silence ne prouve rien.

La feuille de choix défile : 47 px de débordement trouvés par le test sur une
surface courte, donc réels sur un téléphone à police agrandie.

358 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-08 13:59:32 +02:00
Patrick Schurig
5944d3a24a fix(dashboard): « Plan de la journée » retirée — cinquième donnée inventée, et la dernière
DayPlan.demo() fabriquait vingt-quatre heures de flux : une gaussienne solaire
et des coefficients écrits en dur — solToEcs = excédent × 0,35, solToVe × 0,3,
solToBatt × 0,15, battToHouse = déficit × 0,55 — sous le titre « Plan de la
journée · aujourd'hui », sans la moindre mention. Y compris une batterie
alimentant la maison à partir de 17 h, sur une installation qui n'a pas de
batterie.

Cinquième après les deux MPPT, les 38 % de PAC, les 82 % de part solaire et le
SOC à 62 %. Même réponse à chaque fois : il n'y a rien derrière.

Un plan suppose de savoir ce qui VA arriver — il demande une prévision PV et une
source tarifaire, dont aucune n'existe. La carte reviendra avec elles.

Le code de fabrication part AVEC la carte : DayPlan, HourlyFlow, WeatherPoint,
WeatherKind et le painter sont supprimés, pas mis de côté. Un générateur de
données plausibles laissé dans le dépôt se rebranche — c'est exactement ce qui
s'est passé quatre fois déjà.

339 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-08 13:26:30 +02:00
Patrick Schurig
5a51582729 fix(ev): « Boost » devient « Manuel » — le nom promettait le contraire du comportement
Décision de Patrick : trois modes suffisent — Manuel, PV, Min+PV. Le boost n'est
pas un quatrième mode, c'est la dérogation (+etm61).

LE FAIT. Le bouton écrivait `ChargingModeNormal`, qui sort la borne de
l'arbitrage et lui restitue ses valeurs manuelles stockées —
manualChargingEnabled, manualMaxChargingCurrent, manualDesiredPhaseCount. Sur
.75 elles valaient false / 6 A / 3 phases : mesuré le 2026-09-08 à 11:09:51,
l'appui a ARRÊTÉ la charge et armé un verrou de 300 s. Pas une régression —
c'est ce que Normal a toujours fait. C'est le nom qui mentait.

Renommé jusque dans le code (`ChargingMode.boost` -> `ChargingMode.manuel`), pas
seulement sur l'étiquette : un identifiant faux reste un piège pour le prochain
lecteur, et c'est précisément ce qui a duré des mois ici. L'ordre met Manuel en
tête, parce que ChargingModeNormal est aussi le mode d'office d'une borne
détectée.

ET LE RENOMMAGE NE SUFFISAIT PAS. « Manuel » dit QUI commande, pas ce que la
borne va faire : tant que l'app n'offre pas de régler ces valeurs stockées, le
mode veut dire « ce qui traînait la dernière fois ». Passer en manuel demande
donc confirmation, et la confirmation dit la conséquence — la charge peut
s'arrêter — AVANT, pas après.

⚠️ La confirmation ne se déclenche pas sur un ré-appui du mode déjà en place :
une confirmation qu'on apprend à cliquer sans lire ne protège plus de rien. Un
test le fige.

Vérifié sur .75 (1.15.2+etm61) : SetLoadDerogation et ClearLoadDerogation sont
dans Introspect, avec derogationEnded / durationS / restoredMode. Le contrat de
la dérogation est écrit dans CLAUDE.md — durée obligatoire bornée [60, 21600],
état lu dans decision.code == "DEROGATION_ACTIVE" et jamais dans le mode, fin au
niveau de la trame avec six causes. Aucun bouton de boost n'est écrit : c'est le
lot suivant.

Et une sonde pour le moteur, faite au passage : la V2C est bien restée en
ChargingModeNormal et elle est ABSENTE de loads[] — hors arbitrage. Leur
hypothèse est confirmée sans redémarrage.

339 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-08 12:19:39 +02:00
Patrick Schurig
4d3f35d6d0 feat(énergie): les charges d'un même domaine se nuancent — et une troisième le fait seule
La V2C et la borne simulée, toutes deux en `ev`, recevaient exactement le même
violet : indiscernables dans l'empilement, et la légende répétait la même
pastille. Vu à l'écran dès qu'une seconde borne est apparue.

`EtmTokens.nuance` décline sur le RANG dans le domaine, donc une troisième borne
prend sa teinte sans qu'on déclare quoi que ce soit. Le décalage porte sur la
LUMINOSITÉ, pas sur la teinte : glisser la teinte finirait par heurter le
domaine voisin, l'éclaircir garde la famille lisible.

⚠️ La série est bornée à quatre, et c'est explicite plutôt que subi : au-delà,
l'écart ne se voit plus, et prétendre distinguer huit nuances d'un même violet
serait une promesse que l'œil ne tient pas. Le rang 3 et le rang 9 rendent la
même couleur — le signe qu'il faut alors autre chose que la couleur pour
identifier, et c'est un choix d'écran, pas un réglage de palette. Un test le
fige.

La couleur est calculée à UN endroit, partagé par le tracé et la légende : deux
sources de couleur, c'est une légende qui finit par désigner la mauvaise bande.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-08 11:41:24 +02:00
Patrick Schurig
a76798f2d8 feat(énergie): six cartes journalières, la V2C qui commence en cours de route, et plus de simulation muette
CARTES. Elles lisaient `energyData.dayProductionWh`, qui porte en réalité le
compteur CUMULÉ depuis l'origine : « Production 62946.74… » au-dessus d'un
graphe de 24 h. Et « Gains 8789.84 € » n'avait aucune source — un prix du kWh
que personne n'a saisi. Elles lisent maintenant DailyBalance, comme le tableau
de bord, et chaque taux porte son dénominateur (« 22 % sur 1,1 kWh produits »).
Mise en page reprise de la maquette validée.

V2C. `powersync-plugin-v2c 1.15.2+etm14` lui a ajouté `smartmeterconsumer` :
vérifié à la sonde, elle rend 1 point contre 45 aux autres, son historique
commençant à 10:57. Le filtre d'alignement précédent l'aurait sortie du TRACÉ en
la laissant dans la LÉGENDE — le défaut qu'on venait de corriger sur l'autre
graphe. L'alignement passe donc par instant, et une série ne contribue que là
où elle a des points.

Avant son premier point, sa consommation est comptée dans le reste du foyer :
le total reste juste, seule la RÉPARTITION est incomplète, et c'est ce que dit
la note. Trois états, trois phrases — « pas d'historique », « mesurée depuis
HH:MM », « 0 W mesuré » — et aucune ne se dessine comme une autre.

Et le commentaire de fetchThingPowerLogs est corrigé deux fois : il était
périmé, et il nommait le mauvais portier. `smartmeter` est une interface VIDE
qui ne conditionne rien ; c'est `smartmeterconsumer` qui décide, via
EnergyManagerImpl::watchThing().

🔴 SIMULATION. L'écran faisait `if (!connected) startSimulation()` au montage.
Monté avant que la WebSocket soit établie — le cas normal — il basculait tout le
service en démo et n'en ressortait jamais. Vu à l'écran, box joignable : une
sinusoïde orange et un SOC à 75 % sur une installation SANS batterie. Rien ne
distinguait ces données inventées des vraies : ni couleur, ni mention, ni forme.
La fabrication sans même l'étiquette qui la blanchit.

Le tableau de bord avait retiré la même chose pour la même raison : la démo est
un CHOIX, pris depuis l'écran Installations, jamais un repli silencieux. L'écran
attend maintenant la connexion et se rattrape dès qu'elle arrive.

Captures : docs/captures/2026-09-08_energie-cartes-journalieres.png
           docs/captures/2026-09-08_energie-empilement-v2c.png

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-08 11:31:01 +02:00
Patrick Schurig
6d0ba26e07 feat(énergie): le graphe de flux EMPILE au lieu de superposer
Validé par Patrick. Sa proposition — production en jaune, autoconso en orange,
et des traits pleins — partait d'un bon constat, mais la couleur ne pouvait pas
régler le problème : superposées, autoconsommation et production sont égales au
watt près dès que rien n'est exporté, et deux traits pleins superposés n'en
laissent voir qu'un, quelles que soient leurs teintes. Le défaut est
géométrique, pas chromatique — et le pointillé posé avant n'était qu'un
contournement.

Empilées, le recouvrement devient impossible :

    jaune   PV -> réseau (exporté)      ┐
    orange  PV -> maison (autoconsommé) ┘  sommet = production totale

La production cesse d'être une courbe : elle EST le haut de la pile. Quand rien
n'est exporté la bande jaune a une hauteur nulle — vrai, et lisible d'un coup
d'œil. L'export devient une SURFACE au lieu d'un écart à mesurer à l'œil entre
deux traits.

Vérifié à l'écran : l'orange porte le fond de la journée, le jaune ne sort que
sur l'épisode d'export de fin d'après-midi.

Deux détails qui vont avec :
- `_aire` ne courbe pas (`isCurved: false`) — une spline peut passer sous zéro
  entre deux points, et une bande d'énergie sous l'axe affiche une grandeur qui
  n'existe pas ;
- les lignes n'ont plus de remplissage par défaut : un voile à 7 % recouvrait
  les bandes et faussait leurs teintes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-08 11:00:38 +02:00
Patrick Schurig
67bc0ca7ea fix(énergie): la légende ne promet pas une courbe qui n'existe pas
« SOC % » figurait en dur dans la légende du premier graphe. Sur une
installation sans batterie — celle du banc — l'écran annonçait donc une série
qu'aucune donnée ne pouvait remplir. Même défaut que partout ailleurs (montrer
ce qu'on n'a pas), pris par la légende au lieu du tracé.

Le titre, le sous-titre et l'entrée de légende suivent maintenant la présence
d'une batterie, et la note d'absence passe SOUS ce graphe-ci : c'est là que le
SOC aurait été tracé, donc c'est là qu'il manque.

`_sansBatterie` exige que les Things soient chargés : tant qu'ils ne le sont
pas, « pas de batterie » et « on ne sait pas encore » sont indiscernables, et
annoncer l'absence trop tôt serait une absence côté APP présentée comme une
absence côté BOX.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-08 10:43:12 +02:00
Patrick Schurig
9c19791e9a feat(énergie): les deux graphes — flux corrigé, et l'empilement par consommateur
Maquette validée par Patrick (couleurs comprises). Deux graphes, et trois
défauts d'affichage trouvés en chemin — dont deux à l'écran seulement.

GRAPHE 1 — les trois « on ne voit pas » avaient trois causes.

  production : publiée NÉGATIVE (−103 W). La magnitude est prise AU RENDU, pas
  au transport : le champ reste brut (§7.1), et `.abs()` est licite sur une
  production, dont le signe ne porte aucune information — contrairement au
  réseau, où le signe dit le sens (d'où gridImportW/gridExportW là-bas).

  autoconsommation : `production − export` clampé à 0, donc TOUJOURS zéro avec
  une production négative. Même racine, corrigée par la même magnitude.

  SOC : correctement absent — aucune batterie sur l'installation. Ce qui
  manquait, c'était de le DIRE ; c'est maintenant une note sous le graphe.

  ⚠️ Et un deuxième empêchement, vu seulement à l'écran : le signe réparé, la
  production restait invisible SOUS l'autoconsommation. Tant que rien n'est
  exporté les deux sont égales au pixel près, et la verte était tracée par
  dessus. L'autoconso passe en pointillé — des tirets sur la bande ambre disent
  « tout est consommé sur place », leur décrochage dit l'export. Deux courbes
  qui coïncident doivent pouvoir se VOIR coïncider.

  ⚠️ Ce correctif en créait un autre : le SOC était aussi vert pointillé. Sur
  une installation AVEC batterie les deux auraient été indiscernables — collision
  qu'aucun banc sans batterie ne pouvait montrer. Le SOC passe en indigo.

GRAPHE 2 — qui consomme, empilé.

  `Energy.GetThingPowerLogs`, et la liste des appareils vient de GetLoadConfig :
  chaque charge déclarée porte son `meterThingId`, son label et son domaine —
  vérifié renseigné sur les quatre charges de .75. Rien n'est deviné d'un nom
  d'appareil.

  Un appareil demandé sans réponse sort dans `sansHistorique` et est NOMMÉ sous
  le graphe, jamais tracé à zéro : la V2C ne déclare pas `smartmeter`, donc elle
  n'a pas de passé — ce qui n'est pas « elle n'a rien tiré ».

  Le RESTE du foyer est licite ici, contrairement au Sankey qui l'avait fait
  interdire : chaque part est un compteur dédié et le total est le compteur du
  foyer, donc la soustraction ne porte que du mesuré. Et un reste NÉGATIF n'est
  pas raboté — c'est la preuve qu'un sous-compteur n'est pas dans le périmètre
  du total, et l'écraser effacerait la seule chose qui le dise.

  🔴 Le signe des sous-compteurs n'est PAS vérifié : les trois lisent 0 W à tous
  les pas, donc aucun échantillon non nul n'existe. Le compteur PV publie en
  négatif — rien n'autorise à supposer. La valeur est transportée brute et
  `signeIndetermine` le dit à l'écran.

Couleurs par consommateur ajoutées au thème (pac ambre, ecs sarcelle, ev
indigo) : distinctes des couleurs de FLUX, qui disent d'où vient l'énergie, là
où celles-ci disent qui la prend.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-08 10:25:16 +02:00
Patrick Schurig
3ff42624f0 docs(maquette): écran Énergie — le relevé d'abord, la maquette ensuite
Quatre demandes de Patrick. Sondé sur .75 avant de dessiner quoi que ce soit :
Energy.GetThingPowerLogs répond, six Things demandés, cinq répondent.

TRAÇABLE : Maison (dérivée Grid+PV), PAC-Meter, ECS-Meter, Simulated wallbox —
tous quatre en smartmeterconsumer, tous à 0 W aujourd'hui sauf Grid et PV.

PAS D'HISTORIQUE : V2C Trydan. Sa classe `trydan` ne déclare pas l'interface
`smartmeter`, donc elle est ABSENTE de GetThingPowerLogs — la borne simulée, qui
la déclare, répond. Sa puissance instantanée reste lisible, sa courbe n'existe
pas. Une ligne à zéro dirait « elle n'a rien tiré », ce qui est autre chose.

AUCUNE SOURCE : la batterie. Aucun Thing ne porte battery / energystorage /
batterymonitor. Donc ni SOC ni charge/décharge — et le « Batterie 0 % » du
tableau de bord est un zéro forgé de plus (EnergyData.batterySOC vaut 0 par
défaut et personne ne l'écrase). À corriger dans le même lot.

HORS NYMEA : la météo. Aucun Thing météo ; Open-Meteo serait un appel externe.

Et les trois « on ne voit pas » du premier graphe ont trois causes distinctes,
dont deux partagent une racine : `production` est publiée NÉGATIVE, donc la
courbe est sous l'axe ET `selfConsumptionPower` (production − export, clampé à
0) rend zéro à tous les pas. Le SOC, lui, est correctement absent.

Le signe se corrigera au RENDU, jamais au transport : la magnitude est vérifiée
(identité sur 97 échantillons sur 97), mais un .abs() dans `fetchPowerBalanceLogs`
effacerait la trace du défaut et masquerait un vrai zéro le jour d'un changement
de convention.

Maquette publiée : https://claude.ai/code/artifact/f90d71d0-b876-4477-9231-4089d521b8ea
Aucune ligne de code écrite — quatre décisions attendent Patrick.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-08 09:28:43 +02:00
Patrick Schurig
5a3f9a4fc6 docs: le contexte de montage trop lâche, et chercher à voir plutôt qu'à casser
Troisième face du même mode : la prémisse fausse n'est pas dans le code testé,
elle est dans ce qui l'entoure. Jeu d'essai trop homogène, deux horloges au même
décalage, contexte de montage trop lâche — même constante.

Et la leçon de découverte, à côté de « qu'est-ce que ce contrôle aurait attrapé
s'il avait échoué ? » : le chemin des flèches a été trouvé en cherchant comment
OBSERVER un correctif, pas en cherchant le défaut. Se demander « par quel chemin
puis-je voir ça ? » fait apparaître des chemins que « comment je casse ça ? » ne
fait pas apparaître.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-08 09:07:29 +02:00
Patrick Schurig
1dece8eb33 fix(brouillon): la barre suit — et le déplacement a révélé un défaut de contrainte
La barre d'action passe en pied d'écran fixe (`bottomNavigationBar`), parce
qu'un correctif juste mais hors du champ reste inaccessible : le geste se fait
en haut de la liste, la barre était un écran entier plus bas. C'est le défaut
d'hier sous une autre forme — cause de disposition au lieu de cause de logique.

`bottomNavigationBar` plutôt qu'une superposition : il RÉDUIT la hauteur du
corps au lieu de le recouvrir, donc rien ne se cache derrière la barre.

⚠️ ET LE DÉPLACEMENT A CASSÉ L'ÉCRAN. Le `Column` d'`ImpactSaveBar` était en
`MainAxisSize.max` — le défaut. Dans un `ListView` la hauteur disponible est
infinie et le Column se contente de ses enfants : le défaut ne pouvait pas se
voir. En pied d'écran la contrainte est lâche mais BORNÉE, le Column a pris
toute la hauteur, le corps a été écrasé à zéro. Écran vide sous une barre
géante, vu en release sur appareil — les 319 tests étaient verts.

Un widget qui ne se contraint pas lui-même hérite du contexte : le déplacer
change ses contraintes, donc sa taille, sans qu'une ligne de son code ait bougé.

Le test ajouté monte la barre dans un `bottomNavigationBar` et mesure sa
hauteur. Éprouvé par son contraire : sans `mainAxisSize: min` il rend 600 sur
600 et échoue. Aucun test précédent ne pouvait mordre — tous montaient la barre
dans une hauteur infinie, où `max` se comporte comme `min`.

Vérifié sur appareil, chemin des flèches : brouillon réel -> barre en bas avec
Annuler/Enregistrer, corps intact ; ordre rétabli -> « L'ordre est revenu à
celui de la box » avec le seul « Terminer » ; Terminer -> barre absente. Banc
inchangé, aucune écriture.

Et la note gardée sur `draftIsNoOp` : « rien à enregistrer » veut dire « rien
par rapport à ce que tu as vu ». Comparer à l'état vivant conclurait à la place
de l'utilisateur sur des faits qu'il n'a pas vus. Le référentiel de la
CONCLUSION et celui de l'ÉCRITURE sont deux choses, et il faut les deux.

Capture : docs/captures/2026-09-08_barre-en-pied-terminer.png

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-08 08:30:11 +02:00
Patrick Schurig
346f162273 docs(captures): « Terminer » vu à l'écran — et par un chemin que la sonde peut prendre
Le brouillon sans effet se produit aussi par les FLÈCHES d'une carte, pas
seulement par le glissement : descendre une charge puis la remonter. Les flèches
se tapent, donc l'état est atteignable à l'entrée synthétique — là où le
glisser-déposer ne l'est pas.

Vérifié sur appareil : après les deux taps, la barre affiche « L'ordre est
revenu à celui de la box — il n'y a rien à enregistrer. » avec le seul bouton
« Terminer », et « Modifications en attente » a disparu. Le tap sur Terminer
referme l'état.

Banc rendu intact : `reorderWithinGroup` et `discardDraft` ne font que poser et
retirer le brouillon — aucune écriture, ce que la barre annonçait elle-même
(« rien n'est parti vers la box »). Vérifié dans le code avant de conclure.

⚠️ Noté au passage, sans rien y changer : l'ordre des rangs sur .75 a bougé
entre ma sonde de ce matin (chauffe-eau au rang 2) et maintenant (Simulated
wallbox au rang 2). Ce n'est pas le fait de l'app. Je ne sais pas de qui, et je
ne le devine pas.

Et une propriété à connaître : `draftIsNoOp` compare le brouillon à la dernière
lecture de l'app, pas à l'état vivant de la box. C'est le bon référentiel pour
« j'ai décidé de ne rien changer » — l'utilisateur conclut sur ce qu'il a vu —
et l'écriture, elle, relit toujours frais.

Capture : docs/captures/2026-09-08_brouillon-sans-effet-terminer.png

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-08 07:56:03 +02:00
Patrick Schurig
c015bff63b docs(ordre): la géométrie mesurée — le seuil est en pixels, pas en nombre de charges
Relevé à uiautomator sur le banc (Redmi Note 9S, 3 domaines, 4 charges, blocs
dépliés) :

  viewport de la liste             1 912 px
  Véhicule électrique (2 charges)  1 355 px
  Eau chaude sanitaire (1 charge)  1 171 px
  Chauffage (1 charge)             1 029 px

Le doigt parcourt la hauteur de chaque bloc dépassé. D'où :

  échange de deux blocs voisins    au pire 1 355 px  <  1 912  -> passe
  remonter de deux rangs d'un coup       2 526 px    >  1 912  -> impossible

Le défaut est donc DÉJÀ atteignable à quatre charges, contrairement à ce qu'on
m'a attribué : ce n'est pas cinq charges qui l'ouvrent, et ce n'est pas un seuil
en nombre de charges du tout. Il se compte en pixels, et une charge de plus dans
un domaine déjà servi ne coûte pas la même chose qu'un domaine de plus.

Ce qui le rend peu grave n'est pas son inaccessibilité mais son contournement :
deux échanges voisins successifs font le même déplacement, et replier les blocs
aussi.

Et la citation de favorites_screen.dart est corrigée. L'écran utilise bien
CustomScrollView + SliverReorderableList, mais ce n'est pas un précédent
transposable : là-bas la liste est plate et posée directement dans les slivers.
Ici elle est deux composants plus bas — il faudrait que ControllableLoadsSection
cesse de rendre une Column pour émettre des slivers, donc changer le contrat du
widget et convertir tout le corps de roles_devices_screen. Un montage à écrire,
pas à copier.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-08 07:42:45 +02:00
Patrick Schurig
e76d01160d fix(brouillon): « j'ai décidé de ne rien changer » est un état, et il se conclut
Le geste sans repli de cet écran — déplacer une plage — puis le geste inverse
laissaient un brouillon qui rend exactement la configuration de la box. L'écran
le traitait comme n'importe quel brouillon, et n'offrait plus pour en sortir que
des portes mal nommées. Quatre messages, tous faux au même instant :

  la barre                 « Modifications en attente »
  la sortie d'écran        « vos changements n'ont pas été envoyés »
  adopter un rang          « enregistrez ou annulez vos modifications »
  retirer une charge       idem

Il n'y avait plus rien à enregistrer, ni rien à perdre.

`draftIsNoOp` nomme l'état : un brouillon dont les charges utiles brutes, clés
triées, sont identiques à celles de la box — ce qui couvre l'ordre ET un patch
de champ. `hasSubstantiveChanges` garde désormais les trois portes ; la barre,
elle, continue de s'afficher, parce que c'est là qu'est la sortie.

Elle dit maintenant « L'ordre est revenu à celui de la box — il n'y a rien à
enregistrer » et n'offre qu'un geste, « Terminer ». Pas « Enregistrer » :
réécrire tout l'ensemble pour n'y rien changer reconstruirait les charges et
réarmerait les verrous, au prix de zéro.

⚠️ Le remède n'est PAS d'effacer le brouillon dès qu'il devient sans effet : la
barre disparaîtrait sous le doigt à l'instant où l'utilisateur repose la plage,
et « j'ai décidé » redeviendrait « il ne s'est rien passé ». Un état se conclut,
il ne s'évapore pas. Un test le fige.

Et la note sur le glissement est REPRISE. J'y affirmais un défaut ; le test au
doigt montre que le réordonnancement fonctionne, liste dépliée comprise. Le
mécanisme du shrinkWrap reste écrit — il est probablement juste — mais comme une
LIMITE CONNUE non reproduite, avec le protocole en trois gestes (A témoin,
B discriminant, C cause), le contournement (replier les blocs) et le remède
nommé. On ne réécrit pas un écran qui marche sur la foi d'une lecture de code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-08 07:30:12 +02:00
Patrick Schurig
dfa327cbcd docs(ordre): pourquoi le glissement ne peut pas défiler — établi par construction
Le moteur soupçonnait un ReorderableListView en conflit avec un défilement
parent. C'est exact, et le mécanisme se lit dans le code sans avoir à toucher
l'écran :

  roles_devices_screen.dart:113   body: ListView(...)        <- le parent défile
  controllable_loads_section:104  ReorderableListView.builder(
                                    shrinkWrap: true,
                                    physics: NeverScrollableScrollPhysics())

En `shrinkWrap`, le viewport de la liste vaut la hauteur de son contenu : son
`maxScrollExtent` est NUL. Or l'auto-défilement du glisser-déposer agit sur le
Scrollable de la liste réordonnable, jamais sur un parent. Il n'a donc nulle
part où aller, et le ListView extérieur n'est pas prévenu.

Conséquence : on ne réordonne que ce qui tient à l'écran en même temps. Un bloc
de domaine déplié fait plus d'un écran, donc le geste qui compte — remonter un
domaine par-dessus un autre — est justement celui qui ne peut pas aboutir.
Replier les blocs le rend possible : contournement immédiat, et contrôle qui
prouve le diagnostic.

Remède structurel : un seul Scrollable, CustomScrollView + SliverReorderableList
— le montage déjà en place dans favorites_screen.dart. PAS fait tant que le
geste n'est pas éprouvé au doigt : l'entrée synthétique ne reproduit pas un
appui-glissé, donc aucun test de ce dépôt ne peut montrer ce défaut ni prouver
sa correction.

Et une piste abandonnée plutôt que remontée : j'ai soupçonné les écritures de
l'app d'effacer `rankOrigin`. Faux — l'aller-retour est verbatim sur les charges
utiles brutes (LIRE -> PATCHER -> RÉÉCRIRE), rien n'est reconstruit depuis le
modèle typé.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-08 07:05:56 +02:00
Patrick Schurig
0df80b5b9b fix(écran): un instant d'un autre jour porte son jour, et le motif ne rapetisse plus la grandeur
Deux défauts vus sur appareil après installation, tous deux d'affichage.

1. « mesuré depuis 16:37 » à 06:15, pour un `measuredSince` de la VEILLE. Le
   lecteur comprend 16:37 aujourd'hui — une heure qui n'est pas encore arrivée —
   et « 2,5 kWh achetés » juste à côté devient incompréhensible avec elle.
   Même défaut que l'heure UTC, d'un cran plus haut : là on perdait le fuseau,
   ici le jour. `heureLisible` rend « HH:MM » dans la journée du lecteur,
   « hier HH:MM » la veille, « le JJ/MM à HH:MM » au-delà — et la comparaison
   porte sur le jour civil, pas sur 24 h : à 00:05, un instant de 23:50 est hier
   même s'il a quinze minutes.

2. « Autoconsommation · sans production » rendu à la moitié de la taille
   d'« Autonomie » à côté. Cause : un FittedBox reçoit des contraintes de
   largeur infinies, donc son enfant tient sur une ligne aussi large qu'il veut
   et c'est tout le bloc qui rétrécit — `maxLines: 2` n'y sert à rien.
   Rallonger le texte d'un suffixe rapetissait donc le NOM de la grandeur, qui
   est ce qu'on lit en premier. Le motif passe sur sa propre ligne.

Les tests qui figeaient « HH:MM » dérivent maintenant de `heureLisible`, la
fonction même de l'écran — un attendu recomposé à la main aurait gelé la forme
d'avant, exactement comme les « 03:42 » en dur de vendredi.

Captures : docs/captures/2026-09-08_taux-sans-production.png
           docs/captures/2026-09-08_instant-hier.png

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-08 06:31:52 +02:00
Patrick Schurig
0c5f0ee30d docs(ev): Min+PV sert bien le plancher — prouvé par deux comptages, et boundBy ne prouve rien
+etm58 posé (au-delà de +etm57 annoncé). Trois vérifications.

1. ANCRAGE DES INSTANTS : livré et observable. Les trois champs ISO de la trame
   portent le marqueur Z, `command.since` compris —
   `{divergent, expectedW, observedW, since: "2026-09-07T14:37:00Z"}`.

2. MIN+PV : livré. La voiture a chargé toute la journée sans jamais quitter
   ChargingModeEcoWithMinCurrent, et deux comptages sans source commune
   concordent à 4 % :
     surplus MAX du jour        536 W — 0 quart d'heure sur 71 au-dessus de 4140
     fenêtre 08:45 -> 15:15     16,2 kWh dont 14,2 au-dessus de la base (539 W)
     sessionEnergy de la V2C    1,06 -> 14,72 kWh, soit +13,7 kWh
   Le surplus n'ayant jamais dépassé 536 W, tout a été acheté au réseau : c'est
   le plancher demandé.

3. `boundBy` : ABSENT, et cette absence ne prouve rien — le seul ECO_FLOOR_GRID
   de la trame est celui du chauffe-eau, dont le plancher vient d'une obligation
   quotidienne et non d'un minimum matériel ; et la borne est sortie de loads[]
   (pluggedIn: false). Un `grep -c boundBy` valant 0 aurait fait conclure
   « non livré » à l'instant où la mesure prouve le contraire. À revoir voiture
   branchée.

Et une affirmation de CLAUDE.md devenue FAUSSE, corrigée : le plafond de
soutirage du §10 existe désormais —
  "draw": { "authorisedW": 6000.4, "committedW": 103.2, "remainingW": 5897.2,
            "binding": "connection", "bindingPhase": "A", "perPhaseBound": true }
`binding` nomme ce qui contraint et `bindingPhase` la phase qui mord : le
plafond se dit donc avec sa source, la condition qu'on avait posée pour
l'afficher. Aucun écran ne le lit encore, et rien n'est câblé — l'écran du §10
n'est pas validé.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-07 16:49:34 +02:00
Patrick Schurig
a44c84a037 docs(ev): boundBy est le marqueur de présence du plancher — avec sa mesure « avant »
Le moteur donne le bon signal : `boundBy`, dans `ECO_FLOOR_GRID.params`,
n'apparaît que sur un moteur qui sert effectivement le minimum (+etm57). C'est
donc lui qui répond à « le plancher est-il servi ici ? », et pas le mode ni une
version annoncée.

Absent ne veut PAS dire « pas de plancher » : il veut dire « moteur antérieur à
+etm57 ». Même règle que levels[] vide ou counts absent — l'absence date la box,
elle ne décrit pas le cycle.

Mesure « avant » prise aujourd'hui, sans quoi un boundBy toujours absent après
livraison serait indiscernable d'un champ jamais ajouté :
  powersync-energy-plugin-nymea 1.15.2+etm56 posé
  boundBy : 0 occurrence dans toute la trame
  ECO_FLOOR_GRID.params : atMaximum, gridW, regime, remainingS, remainingWh,
                          targetWh — les six clés connues, pas une de plus

Le bouton Min+PV reste tel quel (Patrick). Aucun code conditionnel écrit : le
champ n'est pas observable, et « sonder avant de câbler » vaut aussi pour un
marqueur de présence.

+etm57 portera aussi le correctif d'ancrage des instants — à sa pose, resonder
les champs ISO pour vérifier le marqueur Z partout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-07 11:25:42 +02:00
Patrick Schurig
f2bd07d538 docs(ev): le « Min » de Min+PV ne sert aucun plancher — mesuré, et le plancher est dérivable
Sémantique voulue (Patrick, 2026-09-07) : Min+PV charge toujours au minimum de
la borne, acheté au réseau s'il le faut, et ajoute le surplus par-dessus. 400 W
d'injection sur un plancher de 4 140 W donnent 4 540 W.

Mesuré sur .75, voiture branchée et borne joignable : la borne ne reçoit qu'un
niveau `comfort`, aucun niveau `eco`, décision `SURPLUS_INSUFFICIENT`,
`chargingEnabled: false`. ChargingModeEcoWithMinCurrent se comporte exactement
comme ChargingModeEco.

Le mécanisme existe déjà — `ECO_FLOOR_GRID`, ce que le chauffe-eau fait dans la
même trame : plancher servi, acheté au réseau, compté dans draw.committedW,
surplus distribué au-dessus par comfort. Il n'est pas câblé sur la borne.

Et le blocage noté depuis longtemps (« minCurrent n'existe nulle part ») n'en
est pas un : la valeur est sur la box, dans la classe du Thing —
maxChargingCurrent.minValue = 6 A, phaseCount = 3, soit les 4 140 W attendus.
Aucun champ de contrat à ajouter, et rien à saisir côté app : une limite
matérielle se lit sur le matériel.

Pourquoi ça n'a pas été vu : sur ce banc le surplus n'atteint jamais le
plancher — 0 quart d'heure sur 97 le 2026-09-06, meilleur 2 214 W — donc les
deux modes rendent le même rien et ne se distinguent par aucune observation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-07 10:24:12 +02:00
Patrick Schurig
3710401ad6 docs(captures): l'heure locale à l'écran — « mesuré depuis 06:33 »
APK reconstruit et posé. La box publie `measuredSince: 2026-09-07T04:33:28Z` ;
l'écran affiche 06:33, l'attendu dérivé par `.toLocal()`. L'APK précédent, sur
la même box et le même téléphone tous deux en Europe/Paris, affichait 04:33 —
la preuve que l'écart ne devait rien au fuseau de la box.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-07 08:31:16 +02:00
Patrick Schurig
a430fb00c8 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
2026-09-07 07:34:24 +02:00