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:
parent
935cec269f
commit
33d89a8c26
26
TODO.md
26
TODO.md
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
85
test/fixtures/loadtelemetry_hems75_thingmissing.json
vendored
Normal file
85
test/fixtures/loadtelemetry_hems75_thingmissing.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user