fix(ev): la réserve THING_MISSING est levée par la mesure, et la carte véhicule part

Deux gestes que le relevé du banc a tranchés, dans les deux sens.

1. THING_MISSING — MESURÉ, la réserve tombe

GetLoadTelemetry sur .75, 2026-08-28 10:29 UTC : la Terra AC supprimée est publiée
available=false · faultCode=THING_MISSING · decision=LOAD_UNAVAILABLE{frozenW:0} ·
mechanism.kind=evcharger. Le code SORT. Le test de rendu ne repose plus sur une trame
composée mais sur cette capture (test/fixtures/loadtelemetry_hems75_thingmissing.json).

Le masquage relevé mardi (etmvariableloadadapter.cpp:148-150, m_faulted testé avant que
THING_MISSING ne puisse s'exprimer) concerne l'adaptateur des charges VARIABLES — relais
et SG-Ready. L'adaptateur evcharger n'a pas ce chemin, d'où la différence. Il n'est donc
pas corrigé, seulement contourné par le hasard du chemin de code : remonté au brief
moteur avec la trame, parce que les deux codes envoient le client faire deux gestes
différents — « écriture échouée » fait démonter un coffret, « appareil absent » fait
rouvrir nymea.

2. EVChargingCard — elle part, et la monter aurait publié de la fiction

Trois des grandeurs qu'elle affiche ne sont JAMAIS lues sur la box : chargingMode,
chargingPower (3,6 kW) et solarSourcePercent (82 %) sont des valeurs par défaut de
EnergyData — _updateEnergyData ne les touche pas, et le mode n'est écrit qu'en optimiste
après une commande, jamais relu par GetChargingInfos. Sa feuille « Paramètres de charge »
proposait en plus un courant minimum, qui n'existe dans aucun contrat, et son bouton
« Enregistrer » ne faisait que Navigator.pop : rien n'était écrit. Ce n'était pas du code
mort à ranimer, c'était un écran à réécrire depuis le modèle.

⚠️ Et la surface vivante est pire que la morte : _EVWidget (favorites_screen.dart:419)
affiche les MÊMES valeurs par défaut comme si elles étaient mesurées — « 3600 W »,
« 82 % solaire », « Mode PV » — et écrit le mode sur des pastilles de 7 à 10 px sans
restituer un refus. Entrée 🔴 au TODO, avec ce qu'une carte de recharge peut dire
aujourd'hui : le mode lu par GetChargingInfos, la puissance mesurée du Thing, l'échéance
comme intention saisie. Ni SOC (n'existe pas), ni chargeEnergy (remis à zéro à chaque
interruption), ni courant minimum, ni phaseCount.

146 tests passent. flutter analyze : 27 remarques, deux de moins qu'avant — les deux
étaient dans le fichier supprimé.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
This commit is contained in:
Patrick Schurig 2026-08-28 12:36:00 +02:00
parent 935cec269f
commit 33d89a8c26
6 changed files with 170 additions and 715 deletions

26
TODO.md
View File

@ -46,9 +46,29 @@
(unité Ampere) est en cours côté plugin. Ne pas les afficher avant son déploiement, et
**n'écrire aucune conversion côté app** — elle survivrait au correctif.
- [ ] **`EVChargingCard` n'est montée par AUCUN écran** (constaté le 2026-08-27 — le
tableau de bord utilise `features/dashboard/widgets/`). Soit elle rejoint un écran, soit
elle part ; en l'état c'est du code mort qui a l'air vivant.
- [x] ~~**`EVChargingCard` n'est montée par AUCUN écran**~~ — **tranché le 2026-08-28 :
elle part.** La monter aurait publié de la fiction. Trois des grandeurs qu'elle affiche
ne sont **jamais lues sur la box** : `chargingMode`, `chargingPower` (3,6 kW) et
`solarSourcePercent` (82 %) sont des valeurs par défaut de `EnergyData`, et le mode n'est
écrit qu'en optimiste après une commande — jamais relu par `GetChargingInfos`. Sa feuille
« Paramètres de charge » proposait en plus un **courant minimum**, qui n'existe dans aucun
contrat, et son bouton « Enregistrer » ne faisait que `Navigator.pop` : rien n'était écrit.
- [ ] 🔴 **La borne EV n'a plus AUCUNE surface honnête dans l'app — et il en reste une
malhonnête.** `_EVWidget` (`screens/favorites_screen.dart:419`) est **vivante**, affiche
les mêmes valeurs par défaut comme si elles étaient mesurées (« 3600 W », « 82 % solaire »,
« Mode PV ») et **écrit** le mode de recharge sur des pastilles de 7 à 10 px, sans
restituer un refus. Deux gestes, dans cet ordre : rendre ce widget muet sur ce qu'il ne
lit pas, puis bâtir la vraie carte.
Ce qu'une carte de recharge peut dire aujourd'hui, et rien de plus :
- le **mode courant**, lu par `NymeaEnergy.GetChargingInfos` et tenu à jour par
`ChargingInfoChanged` — l'écriture existe déjà et est vérifiée
(`nymea_service.setChargingInfo`, lecture → patch → réécriture complète) ;
- la **puissance mesurée** de la borne, prise sur le Thing (`currentPower`) ;
- l'**échéance** (`targetPercentage` + `endDateTime`) — mais uniquement comme **intention
saisie**, jamais comme un avancement : LM-1009 §C1. Pas de SOC véhicule (il n'existe
pas), pas de `chargeEnergy` (remis à zéro à chaque interruption), pas de courant
minimum (aucun champ), pas de `phaseCount` (la V2C ne le publie pas).
- [ ] **Le passage sur appareil doit devenir une habitude, pas un rattrapage.** Il a glissé
deux fois, et au premier passage il a trouvé deux cibles tactiles hors norme (26 px, 18 px)

View File

@ -2,6 +2,46 @@
---
## 2026-08-28 — `THING_MISSING` sort sur les bornes, et reste masqué sur les charges variables
**Mesuré**, `NymeaEnergy.GetLoadTelemetry` sur `.75` le 2026-08-28 à 10:29 UTC. La charge
`{88160e45-…}` (Terra AC Charger (TCP), dont le Thing a été supprimé de nymea) est publiée :
```json
{ "loadId": "{88160e45-7df2-48cc-9639-f0d15ae6e382}",
"available": false, "allocatedW": 0, "faultCode": "THING_MISSING",
"decision": { "code": "LOAD_UNAVAILABLE", "params": { "frozenW": 0 } },
"mechanism": { "kind": "evcharger" }, "funding": "surplus" }
```
**Ce que ça règle** : l'app affiche désormais, sur ce cas réel, « l'appareil n'existe plus
dans nymea — réinstallez-le, ou retirez cette charge », et **retire le bouton « Lever le
défaut »** : `ClearLoadFault` répondrait `EnergyErrorNoError` sans rien changer, et le défaut
reviendrait au cycle suivant. Rendu figé par test sur la trame ci-dessus.
**Ce qui reste chez vous** : le masquage relevé le 2026-08-25 dans
`etmvariableloadadapter.cpp:148-150` — `m_faulted` est testé **avant** que `THING_MISSING`
ne puisse s'exprimer, donc une charge variable dont le Thing a disparu sort en
`WRITE_FAILED` après épuisement de l'échelle d'écriture. L'adaptateur `evcharger` n'a pas ce
défaut, ce qui explique que le code sorte proprement ici et pas là. Il n'est donc **pas
corrigé, seulement contourné par le hasard du chemin de code**.
**Pourquoi ça vaut un correctif et pas un renvoi à la doc.** Les deux codes envoient le
client faire deux gestes différents : `WRITE_FAILED` fait chercher un défaut de liaison —
câble, bus, alimentation ; `THING_MISSING` fait rouvrir nymea et réinstaller l'appareil.
Publier le premier là où le second est vrai, c'est envoyer quelqu'un démonter un coffret pour
un Thing effacé. L'app ne peut pas rattraper ça : elle affiche le code, elle ne le devine pas.
**Deux points mineurs sur la même trame**, sans urgence :
- `funding: "surplus"` est publié sur une charge `available: false` à 0 W. Le champ ne veut
rien dire pour une charge qui ne reçoit rien ; l'omettre serait plus juste que d'annoncer
un financement.
- Les deux autres bornes (`Simulated wallbox`, `V2C Trydan`) sont **absentes de `loads[]`** —
aucun véhicule branché, conforme à `+etm24`. Rien à changer : c'est lisible, et l'app le
présente comme « hors arbitrage », jamais comme un défaut.
---
## 2026-08-27 (soir) — la batterie comme charge : sept questions
**Origine** : la maquette « Configurer · Batterie », septième colonne de

View File

@ -14,9 +14,11 @@
///
/// ## Ce qu'il n'exerce PAS, et pourquoi
///
/// La carte véhicule n'y figure pas : `EVChargingCard` **n'est montée par aucun écran**
/// (constaté le 2026-08-27 — le tableau de bord utilise `features/dashboard/widgets/`).
/// Il n'y a donc rien à regarder, et prétendre l'avoir vue serait faux.
/// La carte véhicule n'y figure pas : `EVChargingCard` **a été supprimée** le 2026-08-28.
/// Montée par aucun écran depuis sa création, elle affichait trois grandeurs que l'app ne
/// lit pas (mode, puissance, part solaire — des valeurs par défaut du modèle) et proposait
/// un réglage de courant minimum qui n'existe dans aucun contrat. Ce qu'une vraie carte de
/// recharge doit lire est écrit dans `TODO.md` ; il n'y a rien à exercer d'ici là.
///
/// Le glissement est exercé **puis abandonné** (`discardDraft`) : le geste est ce qu'on
/// veut voir, pas l'écriture. Réordonner pour de bon changerait l'ordre d'arbitrage d'une

View File

@ -1,692 +0,0 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/energy_data.dart';
import '../providers/schema_provider.dart';
import '../services/nymea_service.dart';
import '../services/rpc_schema.dart';
import '../theme/etm_tokens.dart';
import 'schema_gated.dart';
class EVChargingCard extends StatefulWidget {
final EnergyData data;
final NymeaService service;
const EVChargingCard({
super.key,
required this.data,
required this.service,
});
@override
State<EVChargingCard> createState() => _EVChargingCardState();
}
class _EVChargingCardState extends State<EVChargingCard> {
// Deadline option state — local UI, not persisted
bool _deadlineEnabled = false;
int _targetSoc = 80;
DateTime _endTime = DateTime.now().add(const Duration(hours: 4));
void _selectMode(ChargingMode mode) {
// Selecting Boost auto-disables deadline
if (mode == ChargingMode.boost && _deadlineEnabled) {
setState(() => _deadlineEnabled = false);
}
_send(
mode: mode,
deadline: mode != ChargingMode.boost && _deadlineEnabled,
);
}
/// Envoie, et **annonce le refus** au lieu de le perdre. La cause exacte reste au
/// journal de la box : on ne la devine pas.
Future<void> _send({required ChargingMode mode, required bool deadline}) async {
final err = await widget.service.setChargingInfo(
mode: mode,
deadline: deadline,
targetSoc: _targetSoc,
endTime: _endTime,
);
if (err == null || !mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('La box a refusé le mode de recharge ($err). '
'Détail dans son journal.'),
));
}
void _toggleDeadline(bool enabled) {
setState(() => _deadlineEnabled = enabled);
_send(mode: widget.data.chargingMode, deadline: enabled);
}
void _applyDeadline() {
_send(mode: widget.data.chargingMode, deadline: _deadlineEnabled);
}
/// Support des champs d'échéance, lu dans le schéma de la box.
///
/// Les deux vont ensemble : une échéance sans cible de charge n'a pas de sens, et
/// l'inverse non plus. On grise donc le bloc entier dès que l'un manque.
FieldSupport _socSupport(BuildContext context) {
final schema = context.watch<SchemaProvider>().schema;
final soc = schema.typeSupport('ChargingInfo', ['targetPercentage']);
final end = schema.typeSupport('ChargingInfo', ['endDateTime']);
if (soc == FieldSupport.unknown || end == FieldSupport.unknown) {
return FieldSupport.unknown;
}
if (soc == FieldSupport.absent || end == FieldSupport.absent) {
return FieldSupport.absent;
}
return FieldSupport.writable;
}
/// Vrai si le contrat de recharge ne porte aucun champ de courant minimal.
bool _minCurrentAbsent(BuildContext context) {
final schema = context.watch<SchemaProvider>().schema;
if (!schema.loaded) return false;
return schema.typeSupport('ChargingInfo', ['minCurrent']) ==
FieldSupport.absent;
}
String _statusLabel(ChargingMode mode) {
switch (mode) {
case ChargingMode.pv:
return _deadlineEnabled
? 'Surplus PV → Boost auto avant deadline'
: 'Surplus PV disponible';
case ChargingMode.minPv:
return _deadlineEnabled
? 'Min garanti + PV → Boost si deadline'
: 'Minimum + surplus PV';
case ChargingMode.boost:
return 'Charge rapide (réseau)';
}
}
Future<void> _pickEndTime() async {
final initial = TimeOfDay.fromDateTime(_endTime);
final picked = await showTimePicker(context: context, initialTime: initial);
if (picked != null) {
setState(() {
final now = DateTime.now();
_endTime = DateTime(now.year, now.month, now.day, picked.hour, picked.minute);
// If picked time is in the past, roll to tomorrow
if (_endTime.isBefore(now)) {
_endTime = _endTime.add(const Duration(days: 1));
}
});
_applyDeadline();
}
}
@override
Widget build(BuildContext context) {
final mode = widget.data.chargingMode;
final data = widget.data;
final showDeadlineOption = mode != ChargingMode.boost;
return Container(
decoration: BoxDecoration(
color: EtmTokens.surfaceOf(context),
borderRadius: BorderRadius.circular(EtmTokens.radiusLg),
boxShadow: EtmTokens.cardShadow,
),
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
children: [
Container(
width: 34, height: 34,
decoration: BoxDecoration(
color: EtmTokens.navy.withValues(alpha: 0.07),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(Icons.ev_station_rounded, color: EtmTokens.navy, size: 20),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Borne de recharge (EVSE)',
style: EtmTokens.sans(size: 15, weight: FontWeight.w600)),
Text(_statusLabel(mode),
style: EtmTokens.sans(size: 12, color: EtmTokens.mutedOf(context))),
],
),
),
GestureDetector(
onTap: () => _showSettings(context),
child: Icon(Icons.tune, color: EtmTokens.faintOf(context), size: 20),
),
],
),
const SizedBox(height: 14),
// Status badge + power
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: EtmTokens.greenSoft,
borderRadius: BorderRadius.circular(99),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(width: 6, height: 6,
decoration: const BoxDecoration(color: EtmTokens.green, shape: BoxShape.circle)),
const SizedBox(width: 5),
Text('En charge',
style: EtmTokens.sans(size: 12, weight: FontWeight.w600, color: EtmTokens.greenDark)),
],
),
),
const SizedBox(height: 8),
Text(
'${(data.chargingPower * 1000).toStringAsFixed(0)}',
style: EtmTokens.mono(size: 38, weight: FontWeight.w700),
),
Text('W Puissance actuelle',
style: EtmTokens.sans(size: 12, color: EtmTokens.mutedOf(context))),
],
),
const Spacer(),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('${data.solarSourcePercent.toStringAsFixed(0)}%',
style: EtmTokens.mono(size: 22, color: EtmTokens.green)),
Text('solaire', style: EtmTokens.sans(size: 11, color: EtmTokens.mutedOf(context))),
],
),
],
),
const SizedBox(height: 16),
// Mode buttons — 3 boutons : PV / Min+PV / Boost
Row(
children: [
_ModeButton(
label: 'PV',
icon: Icons.wb_sunny_rounded,
color: EtmTokens.amber,
isSelected: mode == ChargingMode.pv,
onTap: () => _selectMode(ChargingMode.pv),
),
const SizedBox(width: 8),
_ModeButton(
label: 'Min+PV',
icon: Icons.bolt,
color: EtmTokens.blue,
isSelected: mode == ChargingMode.minPv,
onTap: () => _selectMode(ChargingMode.minPv),
),
const SizedBox(width: 8),
_ModeButton(
label: 'Boost',
icon: Icons.rocket_launch_rounded,
color: EtmTokens.green,
isSelected: mode == ChargingMode.boost,
onTap: () => _selectMode(ChargingMode.boost),
),
],
),
// Échéance — visible pour PV et Min+PV, et **active seulement si la box
// porte les champs**. Le grisage vient du schéma, jamais d'une table de
// versions : le jour où le plugin expose `endDateTime`, le réglage s'active
// seul sur les box à jour et reste gris sur les autres.
if (showDeadlineOption) ...[
const SizedBox(height: 8),
SchemaGated(
support: _socSupport(context),
fieldName: 'targetPercentage / endDateTime',
label: 'Échéance de recharge',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_DeadlineToggleRow(
enabled: _deadlineEnabled,
onChanged: _toggleDeadline,
),
if (_deadlineEnabled) ...[
const SizedBox(height: 10),
_DeadlineParamsRow(
targetSoc: _targetSoc,
endTime: _endTime,
onSocChanged: (v) {
setState(() => _targetSoc = v);
_applyDeadline();
},
onPickTime: _pickEndTime,
),
],
],
),
),
// Le mode « minimum garanti » existe côté box, mais la VALEUR du courant
// minimal n'est dans aucun champ de `ChargingInfo` : elle ne se règle pas
// ici. Le dire, plutôt que d'offrir un réglage sans effet.
if (_minCurrentAbsent(context)) ...[
const SizedBox(height: 8),
_AbsentFieldNote(
'Le courant minimal du mode « Min + PV » n\'est pas réglable depuis '
'l\'app : la box choisit le mode, mais n\'expose aucun champ '
'« minCurrent » dans son contrat de recharge.',
),
],
],
// Cible de charge — affichée SEULEMENT quand l'écran l'a lui-même écrite.
//
// `targetPercentage` n'est pas un réglage que l'app peut lire et présenter
// comme un choix de l'utilisateur : `SetChargingInfo` reconstruit l'objet
// entier, et le défaut C++ du champ vaut **80**. Une écriture qui ne parlait
// que du mode de charge laisse donc 80 derrière elle, sans que rien ne le
// signale — reproduit sur `.75` le 2026-08-27. Montrer « cible 80 % » hors
// échéance afficherait cette empreinte comme une intention.
//
// Hors échéance, l'app n'a pas de cible : elle n'en montre pas.
if (_deadlineEnabled) ...[
const SizedBox(height: 14),
_SocProgress(targetSoc: _targetSoc),
],
],
),
);
}
void _showSettings(BuildContext context) {
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (_) => const _ChargingSettingsSheet(),
);
}
}
class _DeadlineToggleRow extends StatelessWidget {
final bool enabled;
final ValueChanged<bool> onChanged;
const _DeadlineToggleRow({required this.enabled, required this.onChanged});
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(Icons.flag_outlined, size: 16,
color: enabled ? EtmTokens.blue : EtmTokens.faintOf(context)),
const SizedBox(width: 6),
Text('Cible deadline',
style: EtmTokens.sans(size: 13,
color: enabled ? EtmTokens.blue : EtmTokens.mutedOf(context),
weight: enabled ? FontWeight.w600 : FontWeight.w400)),
],
),
Switch(
value: enabled,
onChanged: onChanged,
activeThumbColor: EtmTokens.blue,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
],
);
}
}
class _DeadlineParamsRow extends StatelessWidget {
final int targetSoc;
final DateTime endTime;
final ValueChanged<int> onSocChanged;
final VoidCallback onPickTime;
const _DeadlineParamsRow({
required this.targetSoc,
required this.endTime,
required this.onSocChanged,
required this.onPickTime,
});
@override
Widget build(BuildContext context) {
final timeLabel =
'${endTime.hour.toString().padLeft(2, '0')}:${endTime.minute.toString().padLeft(2, '0')}';
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: EtmTokens.blueSoft,
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
Row(
children: [
const Icon(Icons.battery_charging_full, size: 16, color: EtmTokens.blue),
const SizedBox(width: 6),
Text('SOC cible : $targetSoc %',
style: EtmTokens.sans(size: 12, color: EtmTokens.blue)),
Expanded(
child: Slider(
value: targetSoc.toDouble(),
min: 0,
max: 100,
divisions: 20,
label: '$targetSoc %',
activeColor: EtmTokens.blue,
onChanged: (v) => onSocChanged(v.round()),
),
),
],
),
GestureDetector(
onTap: onPickTime,
child: Row(
children: [
const Icon(Icons.access_time, size: 16, color: EtmTokens.blue),
const SizedBox(width: 6),
Text('Heure d\'arrivée :', style: EtmTokens.sans(size: 12, color: EtmTokens.blue)),
const SizedBox(width: 8),
Text(timeLabel, style: EtmTokens.mono(size: 13, color: EtmTokens.blue)),
const SizedBox(width: 4),
const Icon(Icons.edit, size: 12, color: EtmTokens.blue),
],
),
),
],
),
);
}
}
class _ModeButton extends StatelessWidget {
final String label;
final IconData icon;
final Color color;
final bool isSelected;
final VoidCallback onTap;
const _ModeButton({
required this.label,
required this.icon,
required this.color,
required this.isSelected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Expanded(
child: GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(vertical: 11),
decoration: BoxDecoration(
color: isSelected ? color : color.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: isSelected ? color : color.withValues(alpha: 0.3)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, color: isSelected ? Colors.white : color, size: 15),
const SizedBox(width: 4),
Text(
label,
style: EtmTokens.sans(
size: 13,
weight: FontWeight.w600,
color: isSelected ? Colors.white : color,
),
),
],
),
),
),
);
}
}
/// Barre de progression SOC de la voiture avec target et valeur courante.
/// Cible de charge du véhicule — **la cible seule, jamais un état de charge**.
///
/// Cette carte affichait « 62 % » et « Aujourd'hui à 07:30 » en dur. Les deux sont
/// retirés, et il faut dire pourquoi le second correctif possible n'en est pas un :
///
/// **aucun SOC de véhicule n'existe dans ce contrat.** Vérifié sur `.75` le 2026-08-27,
/// sur les 58 classes de la box : aucune classe portant l'interface `evcharger` ne
/// déclare d'état de charge, aucune classe ne porte l'interface `car`, et les bornes du
/// banc publient `pluggedIn`, `charging`, `maxChargingCurrent` — jamais un pourcentage.
///
/// **Et l'énergie livrée n'est pas là non plus, sur la borne du banc.** La V2C Trydan
/// arrivée le 2026-08-28 ne publie ni `sessionEnergy` ni `phaseCount` : elle publie
/// `chargeEnergy`, exact pendant la charge et **remis à zéro à chaque interruption** —
/// donc à chaque nuage sur du pilotage par surplus. Le détail et la mesure sont dans
/// `LoadMechanism` (`models/load_telemetry.dart`). Tant que le plugin ne publie pas le
/// `sessionEnergy` construit qu'il prépare, l'avancement de LM-1009 se dit **« pas
/// mesurable »** — et surtout pas zéro, qui se lirait « rien livré ».
///
/// Le seul `batteryLevel` de l'installation est celui de la **batterie de la maison**
/// (Fronius Storage, 50 %), rendu visible par le correctif `energystorage` du plugin
/// SunSpec. C'est une autre grandeur : la brancher ici afficherait la charge du bâtiment
/// en face d'une cible de voiture. Un chiffre faux et plausible est pire qu'un chiffre
/// absent — c'était déjà le défaut du 62 en dur, le remplacer par 50 le garderait en le
/// rendant crédible.
///
/// Reste donc ce que l'app SAIT : la cible qu'elle vient elle-même d'écrire. La barre de
/// progression disparaît avec la mesure — une jauge sans grandeur mesurée est un dessin.
class _SocProgress extends StatelessWidget {
final int targetSoc;
const _SocProgress({required this.targetSoc});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: EtmTokens.blueSoft,
borderRadius: BorderRadius.circular(13),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Charge demandée jusqu\'à',
style: EtmTokens.sans(size: 13, color: EtmTokens.navy)),
Text('$targetSoc%',
style: EtmTokens.mono(size: 13, color: EtmTokens.blue)),
],
),
const SizedBox(height: 2),
Text(
'La box ne publie pas l\'état de charge du véhicule : l\'avancement ne peut '
'pas être affiché.',
style: EtmTokens.sans(size: 11, color: EtmTokens.mutedOf(context)),
),
],
),
);
}
}
class _ChargingSettingsSheet extends StatefulWidget {
const _ChargingSettingsSheet();
@override
State<_ChargingSettingsSheet> createState() => _ChargingSettingsSheetState();
}
class _ChargingSettingsSheetState extends State<_ChargingSettingsSheet> {
double _minPower = 6;
double _maxPower = 16;
bool _scheduleEnabled = false;
TimeOfDay _startTime = const TimeOfDay(hour: 22, minute: 0);
TimeOfDay _endTime = const TimeOfDay(hour: 6, minute: 0);
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Paramètres de charge',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 20),
Text('Courant minimum (A)', style: EtmTokens.sans(size: 14, weight: FontWeight.w500)),
Slider(
value: _minPower,
min: 6,
max: 16,
divisions: 10,
label: '${_minPower.toStringAsFixed(0)} A',
onChanged: (v) => setState(() => _minPower = v),
activeColor: EtmTokens.green,
),
Text('Courant maximum (A)', style: EtmTokens.sans(size: 14, weight: FontWeight.w500)),
Slider(
value: _maxPower,
min: 6,
max: 32,
divisions: 26,
label: '${_maxPower.toStringAsFixed(0)} A',
onChanged: (v) => setState(() => _maxPower = v),
activeColor: EtmTokens.green,
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Programmation horaire', style: EtmTokens.sans(size: 14, weight: FontWeight.w500)),
Switch(
value: _scheduleEnabled,
onChanged: (v) => setState(() => _scheduleEnabled = v),
activeColor: EtmTokens.green,
),
],
),
if (_scheduleEnabled) ...[
Row(
children: [
Expanded(
child: _TimePicker(
label: 'Début',
time: _startTime,
onChanged: (t) => setState(() => _startTime = t),
),
),
const SizedBox(width: 16),
Expanded(
child: _TimePicker(
label: 'Fin',
time: _endTime,
onChanged: (t) => setState(() => _endTime = t),
),
),
],
),
],
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () => Navigator.pop(context),
style: ElevatedButton.styleFrom(
backgroundColor: EtmTokens.green,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
child: const Text('Enregistrer'),
),
),
],
),
);
}
}
class _TimePicker extends StatelessWidget {
final String label;
final TimeOfDay time;
final ValueChanged<TimeOfDay> onChanged;
const _TimePicker({
required this.label,
required this.time,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () async {
final picked = await showTimePicker(context: context, initialTime: time);
if (picked != null) onChanged(picked);
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(Icons.access_time, size: 16, color: EtmTokens.faintOf(context)),
const SizedBox(width: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: EtmTokens.sans(size: 11, color: EtmTokens.mutedOf(context))),
Text(
'${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}',
style: EtmTokens.mono(size: 13),
),
],
),
],
),
),
);
}
}
/// Note d'un champ que la box n'expose pas — **avec sa raison**, jamais un simple gris.
class _AbsentFieldNote extends StatelessWidget {
final String text;
const _AbsentFieldNote(this.text);
@override
Widget build(BuildContext context) {
final muted = EtmTokens.mutedOf(context);
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.cloud_off_outlined, size: 13, color: muted),
const SizedBox(width: 5),
Expanded(
child: Text(text,
style: TextStyle(fontSize: 11.5, color: muted, height: 1.3)),
),
],
);
}
}

View File

@ -0,0 +1,85 @@
{
"_note": "Capturé pour éprouver le RENDU de THING_MISSING sur une charge réelle. La Terra AC Charger (TCP) a été supprimée de nymea le 2026-08-26 ; son entrée LoadConfig survit au rang 4, et la télémétrie la publie available=false / faultCode=THING_MISSING / decision=LOAD_UNAVAILABLE{frozenW:0}, mechanism.kind=evcharger. ⚠️ Le masquage relevé mardi (etmvariableloadadapter.cpp:148-150, m_faulted testé avant THING_MISSING) concerne l'adaptateur des charges VARIABLES : sur l'adaptateur evcharger, le code sort tel quel. Les deux autres bornes (Simulated wallbox, V2C Trydan) sont absentes de loads[] — aucun véhicule branché, comportement +etm24.",
"_source": "dump réel HEMS 192.168.1.75, NymeaEnergy.GetLoadTelemetry, 2026-08-28 10:29 UTC — plugin 1.15.2+etm24.",
"telemetry": {
"budget": {
"allocatedW": 5000,
"evReservedW": 0,
"recreditedW": 5000,
"remainingW": 5741.7001953125,
"surplusW": 5741.7001953125
},
"degradedMode": false,
"loads": [
{
"allocatedW": 3500,
"available": true,
"decision": {
"code": "SURPLUS_SETPOINT",
"params": {
"budgetW": 9242,
"setpointW": 3500,
"stepped": true
}
},
"funding": "surplus",
"loadId": "chauffe-eau",
"measuredW": 1500,
"mechanism": {
"kind": "relay",
"maxStageW": 3500,
"stageW": 3500,
"stagesW": [
0,
500,
1000,
1500,
2000,
2500,
3000,
3500
]
}
},
{
"allocatedW": 1500,
"available": true,
"decision": {
"code": "LOCK_MIN_STATE_HOLD",
"params": {
"state": 3
}
},
"funding": "surplus",
"loadId": "pac-terrain",
"lock": {
"kind": "minStateHold",
"remainingS": 60
},
"measuredW": 800,
"mechanism": {
"estimatedPowerW": 1500,
"kind": "sgReady",
"state": 3
}
},
{
"allocatedW": 0,
"available": false,
"decision": {
"code": "LOAD_UNAVAILABLE",
"params": {
"frozenW": 0
}
},
"faultCode": "THING_MISSING",
"funding": "surplus",
"loadId": "{88160e45-7df2-48cc-9639-f0d15ae6e382}",
"mechanism": {
"kind": "evcharger"
}
}
],
"timestamp": "2026-08-28T10:29:00Z"
}
}

View File

@ -291,24 +291,19 @@ void main() {
testWidgets('THING_MISSING se lit « appareil absent », et n\'offre RIEN à lever',
(tester) async {
// ⚠️ Télémétrie COMPOSÉE ici, pas capturée : le banc publiait `WRITE_FAILED` sur
// cette charge au dernier relevé (l'adaptateur épuise son échelle d'écriture avant
// que `THING_MISSING` ne sorte). Ce test éprouve donc le RENDU du code, sur le
// `loadId` réel de la Terra AC supprimée — pas la présence du code sur la box.
const terra = '{88160e45-7df2-48cc-9639-f0d15ae6e382}';
// Télémétrie CAPTURÉE sur `.75` le 2026-08-28 à 10:29 UTC — la réserve de la veille
// est levée par la mesure. La box publie bien `THING_MISSING` sur cette charge :
// `available: false`, `decision: LOAD_UNAVAILABLE {frozenW: 0}`,
// `mechanism.kind: evcharger`.
//
// Le masquage relevé mardi côté moteur (`etmvariableloadadapter.cpp:148-150`,
// `m_faulted` testé avant que `THING_MISSING` ne puisse s'exprimer) concerne
// l'adaptateur des charges **variables** — relais et SG-Ready. L'adaptateur
// `evcharger` n'en souffre pas, et c'est pour ça que le code sort ici.
final telemetry = LoadTelemetryProvider()
..adoptForTest(_fixture('loadtelemetry_hems75_thingmissing.json')['telemetry']
as Map<String, dynamic>);
final config = LoadConfigProvider()..adoptForTest(v2cConfig());
final telemetry = LoadTelemetryProvider()..adoptForTest({
'degradedMode': false,
'loads': [
{
'loadId': terra,
'allocatedW': 0,
'available': false,
'faultCode': 'THING_MISSING',
'decision': {'code': 'NO_BUDGET', 'params': const {}},
},
],
});
await tester.pumpWidget(_harness(config: config, telemetry: telemetry));
await tester.pump();
@ -320,6 +315,11 @@ void main() {
expect(find.textContaining('Réinstallez-le dans nymea'), findsOneWidget);
// Et le libellé ne doit pas se lire « votre configuration est cassée ».
expect(find.textContaining('n\'existe plus dans nymea'), findsOneWidget);
// Les deux autres bornes du banc sont ABSENTES de la télémétrie captée — aucun
// véhicule branché (`+etm24`). Elles restent affichées comme charges configurées,
// et aucune ne porte de défaut : « hors arbitrage » n'est pas « en panne ».
expect(find.text('V2C Trydan'), findsWidgets);
expect(find.textContaining('En défaut'), findsOneWidget);
});
});
}