etm-powersync-app/lib/models/ev_charger_live.dart
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

166 lines
8.4 KiB
Dart

/// Ce qu'une borne de recharge **publie réellement**, et rien d'autre.
///
/// ## Pourquoi ce type existe
///
/// L'app portait trois grandeurs de recharge qui n'étaient jamais lues sur la box :
/// `EnergyData.chargingMode` (défaut `pv`), `chargingPower` (défaut 3,6 kW) et
/// `solarSourcePercent` (défaut 82 %). Elles étaient affichées comme des mesures — « 3600
/// W », « 82 % solaire » — sur un écran de favoris que le client peut ouvrir. C'est la même
/// famille de défaut que le SOC à 62 % en dur et les quatre pièces de climatisation
/// fictives : une valeur par défaut présentée comme une lecture.
///
/// Ce type ne porte donc **que des champs nullables**, et `null` veut dire *la box ne le
/// publie pas* — jamais zéro. L'écran doit le dire, pas le combler.
///
/// ## Ce qui est lisible aujourd'hui, mesuré sur `.75` le 2026-08-28
///
/// Les deux bornes du banc (Simulated wallbox, V2C Trydan) publient `pluggedIn`,
/// `charging`, `currentPower` et `maxChargingCurrent`. Le mode, lui, ne vient pas du Thing
/// mais de `NymeaEnergy.GetChargingInfos` — les deux bornes y étaient en
/// `ChargingModeNormal`, `ChargingStateIdle`.
///
/// **`sessionEnergy` existe** sur les deux, et le renommage `currentL1/L2/L3` **est
/// déployé** — plus de `powerL1/L2/L3`. Ces deux champs ne sont pas portés ici pour autant.
///
/// Le plugin V2C `1.15.2+etm13` **construit** ce `sessionEnergy` : il accumule les
/// incréments de `chargeEnergy`, détecte les remises à zéro, et ne repart qu'à l'ouverture
/// d'une session. **Relevé sur charge réelle le 2026-08-30** (Skoda branchée, lien rétabli),
/// deux échantillons à deux heures d'écart :
///
/// | | 12:15 | 14:12 |
/// |---|---|---|
/// | `sessionEnergy` | 7,5147 kWh | 15,8015 → 15,8405 kWh, en mouvement |
/// | `chargeEnergy` | 0,3103 kWh | 8,5972 → 8,6361 kWh |
/// | écart | **7,2044** | **7,2044** |
///
/// L'écart est **constant au dix-millième sur deux heures** : c'est l'accumulateur du
/// plugin, et il porte les incréments d'avant la dernière remise à zéro. La remise à zéro
/// elle-même n'a pas été surprise en direct, mais l'accumulation, elle, est prouvée par cet
/// écart — elle ne se déduisait pas du code.
///
/// **Aucun avancement n'est affiché pour autant** : c'est une décision (LM-1009 §D1 —
/// l'avancement se dit en énergie livrée, et il n'y a pas encore d'écran pour le porter),
/// plus une mesure manquante. LM-1009 §C1 reste vraie pour les bornes qui ne publient pas
/// `sessionEnergy` ; **la V2C n'en fait plus partie**.
library;
import 'energy_data.dart' show ChargingMode;
/// Pleine puissance — et **mode d'office** de toute borne détectée par le moteur.
const String kChargingModeNormal = 'ChargingModeNormal';
/// Ce que l'écran doit dire d'une borne en [EvChargerLive.isUnmanaged], mot pour mot aux
/// deux endroits qui l'affichent : la tuile des favoris, où les modes se changent, et la
/// carte de configuration, que l'installateur ouvre à la mise en service.
const String kEvUnmanagedNote =
'Pleine puissance, hors arbitrage : le HEMS ne pilote pas cette borne. '
'C\'est aussi le mode d\'office d\'une borne qui vient d\'être détectée — '
'choisir PV ou Min+PV la fait entrer dans l\'arbitrage du surplus.';
class EvChargerLive {
/// `thingId` de la borne — l'identifiant que `SetChargingInfo` attend.
final String thingId;
final String name;
/// États du Thing. `null` = non publié par cette borne, **ou borne injoignable**.
///
/// ## Pourquoi l'injoignable produit `null` ici, et pas la dernière valeur connue
///
/// Mesuré sur `.75` le 2026-08-30, V2C Trydan : quand le lien Modbus tombe, la box ne se
/// contente pas de figer les états — elle **publie des zéros**.
///
/// | État | Avant la coupure | Après |
/// |---|---|---|
/// | `connected` | true | **false** |
/// | `pluggedIn` | true | **false** |
/// | `charging` | true | **false** |
/// | `currentPower` | 4 396 W | **0** |
/// | `statusMessage` | "" | « Hors ligne depuis 30/08 12:39 — timeout Modbus … » |
///
/// Une voiture branchée et en charge devient donc, à l'écran, « aucun véhicule branché,
/// 0 W » — **un fait forgé, pas une absence**. Et avant ces zéros, la dernière valeur
/// connue reste affichée un moment comme si elle était actuelle (vérifié : aucun état ne
/// bouge pendant 45 s alors que la borne est censée tirer 4 356 W).
///
/// L'app refuse donc de relayer les deux : sous [horsLigne], ces trois champs sont mis à
/// `null` **à la construction**, pour qu'aucun écran ne puisse afficher ni le zéro forgé
/// ni la valeur périmée. Seuls [joignable] et [messageEtat] disent quelque chose de vrai.
final bool? pluggedIn;
final bool? charging;
final double? currentPowerW;
/// L'état `connected` du Thing — **le seul champ qui reste vrai quand le lien tombe**.
///
/// `null` = la borne ne publie pas cet état ; on ne conclut alors rien.
final bool? joignable;
/// `statusMessage` du Thing : une phrase déjà écrite par le plugin, en français, avec
/// l'heure de la coupure et la piste à suivre. On l'affiche telle quelle — la
/// reformuler perdrait l'horodatage et le diagnostic.
final String? messageEtat;
/// Le mode reste lisible **même hors ligne**, et c'est voulu : il est stocké dans la
/// box (`GetChargingInfos`), pas lu sur la borne. Un lien coupé n'invalide donc pas le
/// mode, seulement les états du matériel — et les trois boutons continuent d'écrire,
/// puisqu'ils écrivent eux aussi dans la box.
///
/// Mode **tel que la box le porte** (`ChargingModeEco`, `ChargingModeNormal`, …), lu par
/// `GetChargingInfos`. `null` = pas encore lu, ou aucune entrée pour cette borne — et
/// dans ce cas l'écran n'a **aucun** mode à présenter comme courant.
final String? apiMode;
const EvChargerLive({
required this.thingId,
required this.name,
this.pluggedIn,
this.charging,
this.currentPowerW,
this.apiMode,
this.joignable,
this.messageEtat,
});
/// **La box ne joint plus la borne.** Rien de ce qu'elle publierait d'autre n'est un fait.
///
/// `joignable == null` n'est pas hors ligne : une borne qui ne publie pas `connected` ne
/// dit rien de son lien, et supposer la panne serait le symétrique du zéro forgé.
bool get horsLigne => joignable == false;
/// Traduction du mode de la box vers les trois boutons de l'IU.
///
/// Les cinq valeurs de l'énumération se replient sur trois : les variantes « avec
/// échéance » gardent le bouton de base, l'échéance étant une option à côté. `null` pour
/// une valeur inconnue — une borne peut porter un mode qu'aucune version d'app ne
/// connaît, et deviner serait pire que dire « mode non reconnu ».
ChargingMode? get uiMode => switch (apiMode) {
'ChargingModeNormal' => ChargingMode.manuel,
'ChargingModeEco' || 'ChargingModeEcoWithTargetTime' => ChargingMode.pv,
'ChargingModeEcoWithMinCurrent' ||
'ChargingModeEcoMinWithTargetTime' =>
ChargingMode.minPv,
_ => null,
};
/// **La borne charge à pleine puissance, hors arbitrage.**
///
/// `ChargingModeNormal` n'est pas seulement le « boost » que quelqu'un peut choisir :
/// c'est le **mode d'office d'une borne détectée**. Une borne neuve n'est donc jamais
/// pilotée par le HEMS tant que personne n'a posé un mode éco — et rien, ni dans la
/// télémétrie ni sur l'écran, ne le disait. L'installateur pose une borne, elle charge à
/// fond, et il cherche pourquoi le HEMS l'ignore.
///
/// ⚠️ **L'app ne peut PAS distinguer « personne n'a choisi » de « quelqu'un a choisi
/// Boost »** : la box publie le même `chargingMode` dans les deux cas. La note dit donc
/// l'état — pleine puissance, hors arbitrage — et signale qu'il est aussi le défaut ;
/// elle n'accuse personne de ne pas avoir réglé. Même règle que la note de rangs ex
/// æquo : reproductible n'est pas choisi, et on ne fait pas dire à un écran ce que la
/// donnée ne porte pas.
bool get isUnmanaged => apiMode == kChargingModeNormal;
/// Vrai quand la borne porte une échéance dans son mode — l'information est dans le nom
/// de l'énumération, pas dans un champ à part.
bool get hasDeadlineMode =>
apiMode == 'ChargingModeEcoWithTargetTime' ||
apiMode == 'ChargingModeEcoMinWithTargetTime';
}