feat(ev): une borne à pleine puissance n'est pas arbitrée — les deux écrans le nomment

Geste de mise en service, signalé par le moteur : toute borne détectée reçoit d'office
ChargingModeNormal, c'est-à-dire pleine puissance HORS arbitrage. Une borne neuve n'est
donc jamais pilotée tant que personne n'a posé un mode éco, et rien ne le disait —
l'installateur pose une borne, elle charge à fond, et il cherche du côté du HEMS un défaut
qui n'existe pas.

La note apparaît aux deux endroits qui comptent, mot pour mot (kEvUnmanagedNote) :
  · la carte de configuration, celle qu'on ouvre à la mise en service ;
  · la tuile des favoris, où les trois boutons de mode sont juste à côté.

⚠️ Elle nomme l'ÉTAT, pas une négligence. La box publie le même chargingMode pour un boost
délibéré et pour une borne jamais réglée : l'app ne peut pas les distinguer, donc elle dit
« pleine puissance, hors arbitrage » et rappelle que c'est aussi le mode d'office. 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. Un mode NON LU ne déclenche rien non plus : on ne
conclut pas d'une absence.

Le piège qui aurait rendu la note invisible est couvert par un test : GetLoadConfig rend
l'id de charge AVEC accolades ({4d1adafd-…}), GetChargingInfos rend l'evChargerId SANS.
Comparer les deux tels quels ne rend jamais rien — en silence : l'écran aurait conclu
« mode non lu » sur une borne parfaitement lue. chargingModeOf() réconcilie les deux.

Six tests montés (trois par écran), dont les deux contrôles négatifs.

Et la réserve sessionEnergy est documentée à sa juste place : le champ est CONSTRUIT par
le plugin V2C 1.15.2+etm13 — il accumule les incréments de chargeEnergy, détecte les
remises à zéro, ne repart qu'à l'ouverture d'une session — donc l'égalité observée hier
s'explique d'elle-même. La réserve tient jusqu'à la trace que la campagne produira
(chargeEnergy à zéro, sessionEnergy qui continue) : aucun avancement affiché d'ici là.

Noté aussi, pour la suite : sessionEnergy n'est PAS historisé (suggestLogging écrasé par
things.conf sur un Thing déjà appairé ; la V2C n'y a que connected, charging, power). La
valeur courante est juste, il n'y aura pas de courbe — le choix des états enregistrés est
côté app, entrée au TODO.

164 tests, flutter analyze inchangé à 27 remarques.

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 13:08:01 +02:00
parent e45ca67151
commit e0264b5ed6
8 changed files with 265 additions and 10 deletions

19
TODO.md
View File

@ -35,8 +35,23 @@
prêtes, les motifs n'ont **jamais été vus sur machine**. Signaler la première occurrence
réelle. Ne rien bâtir autour d'ici là.
- [ ] ⏳ **`sessionEnergy` existe maintenant — mais rien ne prouve encore qu'il survive à
une interruption.** Relevé du 2026-08-28 après-midi sur `.75` (`Integrations.GetThings`) :
- [ ] ⏳ **`sessionEnergy` est CONSTRUIT par le plugin — il ne reste que la trace.**
Le plugin V2C `1.15.2+etm13` (déployé le 2026-08-28 à midi) accumule les incréments de
`chargeEnergy`, détecte les remises à zéro, et ne repart qu'à l'ouverture d'une session :
ce n'est **pas** un alias du registre `0x0BC4`. La campagne de validation en cours produit
le cas qui lève la réserve — `chargeEnergy` à zéro, `sessionEnergy` qui continue. **Aucun
avancement affiché avant cette trace** (LM-1009 §C1).
- [ ] **Historique des états d'une borne — `sessionEnergy` n'est pas enregistré.**
`suggestLogging` est écrasé par les préférences persistées de `things.conf` sur un Thing
déjà appairé, et la V2C n'y a que `connected`, `charging`, `power`. La valeur courante est
juste — « la nuit relue à 7 h » fonctionne — mais **il n'y aura pas de courbe**. Le
réglage est côté app : choisir les états enregistrés d'un Thing. À décider si un
historique de recharge est voulu ; sans lui, l'écran ne peut montrer qu'un instantané.
- [x] ~~⏳ `sessionEnergy` existe — mais rien ne prouve qu'il survive à une interruption~~ —
**répondu le 2026-08-28** par l'origine du champ (entrée ci-dessus). Le relevé qui a posé
la question est conservé : `Integrations.GetThings` sur `.75`, cet après-midi —
la V2C publie `sessionEnergy` **et** `chargeEnergy`, tous deux à **4,229 kWh** — égaux,
ce qui est exactement ce qu'on attend d'une session sans interruption. Le test qui
tranche est une **remise à zéro observée de `chargeEnergy` avec un `sessionEnergy` qui

View File

@ -21,14 +21,26 @@
///
/// **`sessionEnergy` existe** sur les deux (4,229 kWh sur la V2C), 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 : voir la note de `LoadMechanism`
/// (`models/load_telemetry.dart`), l'égalité `sessionEnergy == chargeEnergy` relevée ce
/// jour ne prouve pas encore que le premier survit à une interruption de charge, et c'est
/// exactement ce que LM-1009 §C1 demande de savoir avant d'afficher un avancement.
/// 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 — mais la trace qui le montre à l'œuvre (`chargeEnergy`
/// à zéro, `sessionEnergy` qui continue) sort de la campagne en cours. **Aucun avancement
/// n'est affiché avant elle** : LM-1009 §C1, cf. `LoadMechanism`.
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;
@ -68,6 +80,22 @@ class EvChargerLive {
_ => 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 =>

View File

@ -192,9 +192,24 @@ class LoadMechanism {
/// la carte Modbus était en retard sur la machine ;
/// - `sessionEnergy` **existe aussi** (4,229 kWh) — mais il valait **exactement**
/// `chargeEnergy` au moment du relevé. L'égalité ne prouve rien : c'est justement ce
/// qu'on attend d'une session sans interruption. **Tant qu'une remise à zéro de
/// `chargeEnergy` n'a pas été observée avec un `sessionEnergy` qui, lui, continue,
/// la condition C1 de LM-1009 reste entière** : ne pas afficher d'avancement.
/// qu'on attend d'une session sans interruption.
///
/// **Ce que le plugin en dit** (V2C `1.15.2+etm13`, déployé le 28 à midi) : ce
/// `sessionEnergy` est **construit**, pas un alias du registre `0x0BC4`. Il accumule les
/// incréments de `chargeEnergy`, détecte les remises à zéro, et ne repart qu'à
/// l'ouverture d'une session. L'égalité observée s'explique donc d'elle-même.
///
/// **La réserve tient quand même, jusqu'à la trace.** La campagne de validation en cours
/// produit exactement le cas qui la lève — `chargeEnergy` à zéro, `sessionEnergy` qui
/// continue. Tant qu'elle n'est pas au dossier, **aucun avancement de charge n'est
/// affiché** : la condition C1 de LM-1009 reste entière. Ce n'est pas de la défiance
/// envers le plugin, c'est la règle qui a évité d'afficher `chargeEnergy` lui-même.
///
/// > ⚠️ **`sessionEnergy` ne sera pas historisé.** `suggestLogging` est écrasé par les
/// > préférences persistées de `things.conf` sur un Thing déjà appairé, et la V2C n'y a
/// > que `connected`, `charging`, `power`. La **valeur courante** est juste — « la nuit
/// > relue à 7 h » fonctionne — mais il n'y aura **pas de courbe** tant que les états
/// > enregistrés n'auront pas été choisis côté app. Cf. `TODO.md`.
// ── evcharger (3g) — l'ÉTAT RÉEL de la borne ────────────────────────────────
//

View File

@ -1368,6 +1368,34 @@ class NymeaService extends ChangeNotifier {
}
}
/// Mode de recharge que la box porte pour cette borne, ou `null` si rien n'a été lu.
///
/// Accepte indifféremment un `thingId` et un identifiant de charge `GetLoadConfig` : le
/// premier arrive **sans** accolades (`4d1adafd-…`), le second **avec** (`{4d1adafd-…}`),
/// et c'est la même borne. Comparer les deux tels quels ne rend jamais rien — en
/// silence, ce qui est le pire des cas : l'écran conclurait « mode non lu » sur une borne
/// parfaitement lue.
String? chargingModeOf(String id) {
final nu = id.replaceAll('{', '').replaceAll('}', '').toLowerCase();
for (final e in _chargingInfos.entries) {
final k = e.key.replaceAll('{', '').replaceAll('}', '').toLowerCase();
if (k == nu) return e.value['chargingMode'] as String?;
}
return null;
}
/// Porte de test — même usage que `LoadConfigProvider.adoptForTest` : éprouver
/// l'affichage sur une charge utile réelle sans box.
@visibleForTesting
void adoptChargingInfosForTest(List<Map<String, dynamic>> infos) {
_chargingInfos.clear();
for (final m in infos) {
final id = m['evChargerId'] as String?;
if (id != null) _chargingInfos[id] = m;
}
notifyListeners();
}
/// Valeur d'un état du Thing **par son nom** (`pluggedIn`, `currentPower`, …).
///
/// Les états voyagent par `stateTypeId` (un UUID) ; le nom vit dans la `ThingClass`. Sans

View File

@ -3,10 +3,12 @@ import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../l10n/l10n_ext.dart';
import '../models/ev_charger_live.dart';
import '../models/load_config_entry.dart';
import '../providers/load_config_provider.dart';
import '../providers/load_telemetry_provider.dart';
import '../services/load_hardware.dart';
import '../services/nymea_service.dart';
import '../services/load_priority.dart';
import '../theme/etm_tokens.dart';
import 'load_cards.dart';
@ -260,6 +262,52 @@ class _NoteRangsExAequo extends StatelessWidget {
}
}
/// **Une borne en pleine puissance ne passe pas par l'arbitrage — et c'est son défaut.**
///
/// Le moteur crée d'office une entrée de configuration par borne détectée, en
/// `ChargingModeNormal` : pleine puissance, hors arbitrage. Tant que personne n'a posé un
/// mode éco, le HEMS ne pilote donc pas cette borne — et rien ne le disait. C'est le
/// pendant exact de la note de rangs ex æquo : un état par défaut qui a toutes les
/// apparences d'un réglage.
///
/// ⚠️ Elle ne dit pas « personne n'a choisi » : la box publie le même `chargingMode` pour
/// un boost délibéré et pour une borne jamais réglée. Elle nomme l'ÉTAT, et rappelle qu'il
/// est aussi le défaut.
class _NoteBornePleinePuissance extends StatelessWidget {
final LoadConfigEntry entry;
const _NoteBornePleinePuissance({required this.entry});
@override
Widget build(BuildContext context) {
// `GetChargingInfos` porte l'`evChargerId` sans accolades, `GetLoadConfig` l'id de
// charge avec : `chargingModeOf` réconcilie les deux.
final mode = context.watch<NymeaService>().chargingModeOf(entry.id);
if (mode != kChargingModeNormal) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(9),
decoration: BoxDecoration(
color: EtmTokens.solar.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl),
),
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
Icon(Icons.flash_on_rounded, size: 15, color: EtmTokens.solar),
const SizedBox(width: 7),
Expanded(
child: Text(kEvUnmanagedNote,
style: TextStyle(
fontSize: 11.5, height: 1.35, color: EtmTokens.inkOf(context))),
),
]),
),
);
}
}
/// Un domaine et ses charges. Les domaines se déplacent entre eux, les charges à
/// l'intérieur — les deux niveaux que l'ordre de service demande.
class _DomainBlock extends StatelessWidget {
@ -560,6 +608,14 @@ class LoadConfigCard extends StatelessWidget {
const SizedBox(height: 8),
Divider(height: 1, color: EtmTokens.lineOf(context)),
const SizedBox(height: 8),
// ── Une borne à pleine puissance n'est pas arbitrée ────────────────
//
// Elle se lit ici et nulle part ailleurs : la télémétrie n'en dit rien (une
// borne en `ChargingModeNormal` sans véhicule branché est simplement absente de
// `loads[]`), et l'écran de configuration est celui que l'installateur ouvre à
// la mise en service. Sans cette ligne, il pose une borne, elle charge à fond,
// et il cherche du côté du HEMS un défaut qui n'existe pas.
if (entry.isEvCharger) _NoteBornePleinePuissance(entry: entry),
if (live != null)
LoadLiveBlock(
live: live,

View File

@ -117,6 +117,22 @@ class _EvFavoriteTileState extends State<EvFavoriteTile> {
Text(_mode(live),
style: EtmTokens.sans(
size: 11, color: EtmTokens.mutedOf(context))),
// Pleine puissance = hors arbitrage, et c'est le mode d'office d'une borne
// détectée. Les trois boutons sont juste à côté : c'est l'endroit où le
// dire coûte le moins à celui qui veut le corriger.
if (live.isUnmanaged) ...[
const SizedBox(height: 3),
Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
Icon(Icons.info_outline_rounded,
size: 13, color: EtmTokens.amber),
const SizedBox(width: 4),
Expanded(
child: Text(kEvUnmanagedNote,
style: EtmTokens.sans(
size: 10.5, color: EtmTokens.mutedOf(context))),
),
]),
],
],
),
),

View File

@ -106,6 +106,37 @@ void main() {
});
});
group('pleine puissance = hors arbitrage, et l\'écran le dit', () {
testWidgets('ChargingModeNormal porte la note — c\'est le mode d\'office',
(tester) async {
// Une borne détectée arrive en ChargingModeNormal : pleine puissance, hors
// arbitrage. Sans cette ligne, l'installateur pose une borne, elle charge à fond,
// et il cherche pourquoi le HEMS l'ignore.
await tester.pumpWidget(_harness(live: _v2c(apiMode: 'ChargingModeNormal')));
expect(find.textContaining('hors arbitrage'), findsOneWidget);
expect(find.textContaining('mode d\'office'), findsOneWidget);
// Elle n'accuse personne : la box publie le même code pour un boost délibéré.
final note = tester
.widget<Text>(find.textContaining('hors arbitrage'))
.data!;
expect(note, isNot(contains('personne')));
expect(note, isNot(contains('oubli')));
});
testWidgets('un mode éco la fait disparaître', (tester) async {
await tester.pumpWidget(_harness(live: _v2c(apiMode: 'ChargingModeEco')));
expect(find.textContaining('hors arbitrage'), findsNothing);
});
testWidgets('mode non lu : aucune note — on ne conclut pas d\'une absence',
(tester) async {
await tester.pumpWidget(_harness(live: _v2c(apiMode: null)));
expect(find.textContaining('hors arbitrage'), findsNothing);
expect(find.text('Mode non lu'), findsOneWidget);
});
});
group('l\'écriture du mode restitue le refus', () {
testWidgets('un refus de la box est affiché, avec son code', (tester) async {
await tester.pumpWidget(_harness(

View File

@ -41,6 +41,7 @@ List<Map<String, dynamic>> _benchConfig() {
Widget _harness({
required LoadConfigProvider config,
required LoadTelemetryProvider telemetry,
NymeaService? service,
}) =>
MultiProvider(
providers: [
@ -49,7 +50,7 @@ Widget _harness({
// La section « Recharge véhicule » lit les Things de la box pour savoir s'il y a
// une borne. Hors connexion, la liste est vide et la section s'efface — c'est
// exactement le comportement attendu sur une installation sans borne.
ChangeNotifierProvider.value(value: NymeaService()),
ChangeNotifierProvider.value(value: service ?? NymeaService()),
],
child: MaterialApp(
locale: const Locale('fr'),
@ -228,6 +229,71 @@ void main() {
});
});
// ───────────────────────────────────────────────────────────────────────────
group('une borne à pleine puissance n\'est pas arbitrée — et l\'écran le nomme', () {
List<Map<String, dynamic>> v2cConfig() =>
(_fixture('loadconfig_hems75_v2c.json')['loadConfigs'] as List)
.cast<Map<String, dynamic>>();
/// `GetChargingInfos` du banc, 2026-08-28 : les deux bornes appairées y sont en
/// `ChargingModeNormal` / `ChargingStateIdle`. **L'`evChargerId` n'a pas d'accolades**,
/// là où l'id de charge de `GetLoadConfig` en a — c'est le piège que
/// `chargingModeOf` réconcilie.
NymeaService service({String mode = 'ChargingModeNormal'}) =>
NymeaService()
..adoptChargingInfosForTest([
{
'evChargerId': '4d1adafd-cafc-4813-93b1-ca8933ce71ab',
'chargingMode': mode,
'chargingState': 'ChargingStateIdle',
},
]);
testWidgets('la note est à l\'écran sur la borne en ChargingModeNormal',
(tester) async {
final config = LoadConfigProvider()..adoptForTest(v2cConfig());
final telemetry = LoadTelemetryProvider()
..adoptForTest({'degradedMode': false, 'loads': const []});
await tester.pumpWidget(_harness(
config: config, telemetry: telemetry, service: service()));
await tester.pump();
// Une seule des cinq charges est concernée : la V2C. Les deux autres bornes n'ont
// pas d'entrée dans la charge utile lue, et une absence ne se conclut pas.
expect(find.textContaining('hors arbitrage'), findsOneWidget);
expect(find.textContaining('choisir PV ou Min+PV'), findsOneWidget);
});
testWidgets('un mode éco fait taire la note', (tester) async {
final config = LoadConfigProvider()..adoptForTest(v2cConfig());
final telemetry = LoadTelemetryProvider()
..adoptForTest({'degradedMode': false, 'loads': const []});
await tester.pumpWidget(_harness(
config: config,
telemetry: telemetry,
service: service(mode: 'ChargingModeEcoWithMinCurrent')));
await tester.pump();
expect(find.textContaining('hors arbitrage'), findsNothing);
});
testWidgets('⚠️ les accolades de l\'id de charge ne doivent PAS faire rater la borne',
(tester) async {
// `GetLoadConfig` rend `{4d1adafd-…}`, `GetChargingInfos` rend `4d1adafd-…`. Comparer
// les deux tels quels ne rend jamais rien — en silence : l'écran conclurait « mode
// non lu » sur une borne parfaitement lue, et la note ne sortirait jamais.
final svc = service();
expect(svc.chargingModeOf('{4d1adafd-cafc-4813-93b1-ca8933ce71ab}'),
'ChargingModeNormal');
expect(svc.chargingModeOf('4d1adafd-cafc-4813-93b1-ca8933ce71ab'),
'ChargingModeNormal');
expect(svc.chargingModeOf('{09e520fd-d472-47c4-8887-6e7759372889}'), isNull,
reason: 'une borne sans entrée lue n\'a pas de mode — pas un mode par défaut');
});
});
// ───────────────────────────────────────────────────────────────────────────
// Les deux notes de la configuration réelle du 2026-08-28 — MONTÉES, pas
// seulement calculées.