feat(telemetry): le régime de mesure est LU, plus déduit — contrat measurement de +etm26

Écrit AVANT le déploiement du moteur, pour qu'il n'y ait pas de trou.

`measuredW` portait trois régimes sous un seul nom et jetait la distinction : une borne
rendait une mesure, un routeur de relais une mesure OU son palier nominal selon que ses
relais mesurent, un SG-Ready une puissance toujours déclarée. C'est la condition C2 de
LM-1009. Le régime se publie désormais :

  measurement: { source: "meter" | "device" | "none", powerW }

Les trois pièges d'affichage du §7, figés par test :

· `none` est un état POSITIF — « cette charge n'est pas mesurable ». Il autorise à MASQUER
  le bloc au lieu d'afficher un vide, et sépare enfin « pas mesurable » de « compteur en
  panne ». Aucune puissance nominale n'est allée la chercher dans mechanism.* pour combler :
  elle produirait un écart identiquement nul à tous les cycles, soit un « tout concorde »
  permanent qui ne signale plus rien.

· {source: "device", powerW: 0} est une MESURE. Une borne branchée qui ne charge pas mesure
  zéro watt. Si la source basculait à l'extinction, le bloc apparaîtrait et disparaîtrait au
  rythme du thermostat.

· L'écart n'a pas la même valeur selon la source, et l'écran le DIT : sous `device`, il
  montre la désobéissance mais une concordance ne prouve rien — plusieurs wallbox renvoient
  leur consigne ; sous `meter`, la mesure est indépendante de l'organe commandé et la
  concordance devient une preuve.

Le compteur dédié se décide sur `measurement.source`, JAMAIS sur `adapter` :

  source == device → réglage avancé, replié (l'appareil publie déjà)
  sinon            → proposé, visible

La règle couvre SG-Ready sans avoir à le connaître — `none` par construction, LM-1105-b : un
contact de signal ne voit pas le courant de la machine, et pour une PAC le compteur dédié est
la SEULE voie vers une mesure. Elle couvre aussi la topologie de relais mixte, `none` elle
aussi, qu'un « masqué dès que la charge mesure, sauf SG-Ready » aurait manquée. Redéduire le
régime du mécanisme remettrait ici la déduction que le moteur vient d'en retirer.

⚠️ Une distinction que le contrat n'a pas à porter mais que l'écran doit faire : une charge
ABSENTE de la télémétrie n'est pas `none`. `none` est publié ; l'absence ne dit rien. Les
confondre annoncerait « seule voie vers une mesure » sur une borne qui publiera très bien la
sienne au prochain cycle.

⏳ Alias temporaire `measuredW` en repli, avec sa date de retrait : dès que +etm26 est sur
.75 (entrée TODO, sonde nommée). `measurement` gagne quand les deux sont là.

204 tests (+16), flutter analyze inchangé à 27 remarques. Prêt pour le déploiement.

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 15:12:37 +02:00
parent 32d50f5d80
commit 1a357d25c0
7 changed files with 527 additions and 28 deletions

10
TODO.md
View File

@ -2,6 +2,16 @@
## Ouvert au 2026-08-27 ## Ouvert au 2026-08-27
- [ ] ⏳ **Retirer l'alias `measuredW` dès que `1.15.2+etm26` est sur `.75`.**
L'app lit `measurement: {source, powerW}` et retombe sur `measuredW` en son absence —
uniquement pour couvrir l'intervalle entre ce livrable et le déploiement du moteur, sans
quoi la mesure disparaîtrait des écrans quelques jours. **Le moteur, lui, ne double pas
la clé** : deux noms pour une chose divergent. La branche à supprimer est nommée dans
`LoadMeasurement.fromJson` (`models/load_telemetry.dart`), avec la sonde qui tranche :
`dart tools/rpc/probe.dart 192.168.1.75 NymeaEnergy.GetLoadTelemetry` — dès que
`measurement` y figure, l'alias part, et `MeasurementSource.legacy` avec lui.
- [x] ~~🔴 **Bilan JOURNALIER**~~ — **fait le 2026-08-28.** Les quatre tuiles montrent la - [x] ~~🔴 **Bilan JOURNALIER**~~ — **fait le 2026-08-28.** Les quatre tuiles montrent la
journée, sous un en-tête « Aujourd'hui » qui le dit une fois. Méthode : différence des journée, sous un en-tête « Aujourd'hui » qui le dit une fois. Méthode : différence des
compteurs entre minuit local et maintenant, jamais l'intégration des puissances — compteurs entre minuit local et maintenant, jamais l'intégration des puissances —

View File

@ -45,6 +45,119 @@ library;
/// minutes. /// minutes.
const Duration kTelemetryStaleAfter = Duration(seconds: 180); const Duration kTelemetryStaleAfter = Duration(seconds: 180);
/// **D'où vient la mesure d'une charge** — publié par le moteur, jamais déduit ici.
///
/// ## Pourquoi un régime publié plutôt qu'une valeur
///
/// `measuredW` portait trois régimes sous un seul nom, et jetait la distinction : une borne
/// rendait une mesure ; un routeur de relais rendait une mesure **ou son palier nominal**
/// selon que ses relais mesurent ; un SG-Ready rendait **toujours** une puissance déclarée,
/// jamais mesurée. Rien ne permettait de savoir laquelle on regardait — c'est la condition
/// C2 de LM-1009, mot pour mot.
///
/// Depuis `+etm26`, le régime se publie :
/// `measurement: {source: "meter" | "device" | "none", powerW: 3980.0}`.
///
/// ## Les trois sources, et ce qu'elles autorisent à conclure d'un écart
///
/// | source | un écart commandé ↔ mesuré dit |
/// |---|---|
/// | `device` | **la charge n'obéit pas** — véhicule qui refuse, thermostat ouvert, plafond interne |
/// | `meter` | **la charge, le compteur ou le câblage** — trois hypothèses, l'écart n'en tranche aucune |
/// | `none` | **rien**, et surtout pas « 0 W d'écart » |
///
/// ⚠️ **L'asymétrie qui compte pour l'écran** : sous `device`, une **concordance** ne prouve
/// rien — plusieurs wallbox renvoient leur consigne en guise de puissance. Sous `meter`,
/// elle est une **preuve**. `device` détecte la désobéissance ; `meter` seul peut détecter
/// un mensonge.
///
/// ## `none` est un état POSITIF
///
/// « Cette charge n'est pas mesurable », et non « la mesure manque ». C'est lui qui autorise
/// à **masquer** le bloc plutôt qu'à afficher un vide, et qui sépare enfin « pas mesurable »
/// de « compteur en panne ».
///
/// Deux cas de terrain qu'il couvre, et qu'aucune déduction côté app n'aurait trouvés :
/// un **SG-Ready** est `none` par construction — son contact commande un signal, la PAC est
/// alimentée ailleurs, et un contact qui mesurerait quelque chose mesurerait sa propre
/// bobine (LM-1105-b) ; une **topologie de relais mixte** est `none` aussi, parce qu'une
/// somme partielle annoncerait 500 W là où 1 500 W coulent.
///
/// ## `{source: "device", powerW: 0}` est une MESURE
///
/// Une borne branchée qui ne charge pas mesure zéro watt, et c'est une information. La
/// source ne bascule pas vers `none` quand la charge s'éteint — sinon le bloc de mesure
/// apparaîtrait et disparaîtrait au rythme du thermostat.
enum MeasurementSource {
/// Un compteur a été désigné exprès (`meterThingId`).
meter,
/// L'appareil publie lui-même sa puissance.
device,
/// Pas mesurable — état positif, pas une absence.
none,
/// **Box antérieure à `+etm26`** : `measuredW` était là, sans régime. On affiche la
/// valeur, on ne prétend rien sur ce qu'un écart prouverait.
legacy,
}
class LoadMeasurement {
final MeasurementSource source;
/// Absent sous [MeasurementSource.none] — et `0` n'en est pas l'équivalent.
final double? powerW;
const LoadMeasurement(this.source, this.powerW);
static const LoadMeasurement aucune =
LoadMeasurement(MeasurementSource.none, null);
/// Vrai quand il y a une puissance à afficher.
bool get mesuree => source != MeasurementSource.none && powerW != null;
/// Vrai quand une **concordance** entre commande et mesure prouve quelque chose.
///
/// Faux sous `device` : plusieurs wallbox renvoient leur consigne, donc une concordance
/// peut n'être qu'un écho. L'écart, lui, reste informatif dans les deux cas.
bool get concordanceProbante => source == MeasurementSource.meter;
/// Lecture tolérante — `measurement` s'il est là, `measuredW` sinon.
///
/// > ⏳ **ALIAS TEMPORAIRE, à retirer quand `1.15.2+etm26` sera déployé sur `.75`.**
/// > Il n'existe que pour couvrir l'intervalle entre cette version de l'app et le
/// > déploiement du moteur : sans lui, la mesure disparaîtrait des écrans pendant
/// > quelques jours. Le moteur, lui, **ne double pas la clé** — deux noms pour une chose
/// > divergent — et l'app ne doit pas les doubler plus longtemps que nécessaire.
/// > Vérification : `dart tools/rpc/probe.dart 192.168.1.75 NymeaEnergy.GetLoadTelemetry`
/// > ; dès que `measurement` y figure, supprimer la branche `measuredW` et ce bloc.
factory LoadMeasurement.fromJson(Map<String, dynamic> m) {
final brut = m['measurement'];
if (brut is Map) {
final src = brut['source'] as String?;
final source = switch (src) {
'meter' => MeasurementSource.meter,
'device' => MeasurementSource.device,
'none' => MeasurementSource.none,
// Une source qu'aucune version d'app ne connaît : on affiche la valeur sans
// prétendre savoir ce qu'un écart prouverait. Deviner serait pire.
_ => MeasurementSource.legacy,
};
final w = _dn(brut['powerW']);
// `none` avec une puissance ne devrait pas arriver ; si ça arrive, `none` gagne —
// c'est le régime qui est publié, pas la valeur.
return LoadMeasurement(
source, source == MeasurementSource.none ? null : w);
}
final ancien = _dn(m['measuredW']);
return ancien == null
? LoadMeasurement.aucune
: LoadMeasurement(MeasurementSource.legacy, ancien);
}
}
/// Décomposition du budget de surplus du cycle. Absent en mode dégradé. /// Décomposition du budget de surplus du cycle. Absent en mode dégradé.
class TelemetryBudget { class TelemetryBudget {
/// Budget entrant du waterfall non-EV : net signé − réservation EV. /// Budget entrant du waterfall non-EV : net signé − réservation EV.
@ -298,6 +411,9 @@ class LoadTelemetryEntry {
/// à la place ferait passer une absence de mesure pour une absence de consommation. /// à la place ferait passer une absence de mesure pour une absence de consommation.
final double? measuredW; final double? measuredW;
/// D'où vient la mesure, et ce qu'elle autorise à conclure. Voir [LoadMeasurement].
final LoadMeasurement measurement;
final LoadLock? lock; final LoadLock? lock;
final LoadMechanism? mechanism; final LoadMechanism? mechanism;
final LoadDecision decision; final LoadDecision decision;
@ -309,6 +425,7 @@ class LoadTelemetryEntry {
required this.available, required this.available,
required this.faultCode, required this.faultCode,
this.measuredW, this.measuredW,
this.measurement = LoadMeasurement.aucune,
this.funding, this.funding,
required this.lock, required this.lock,
required this.mechanism, required this.mechanism,
@ -328,6 +445,7 @@ class LoadTelemetryEntry {
available: m['available'] as bool? ?? true, available: m['available'] as bool? ?? true,
faultCode: (fault is String && fault.isNotEmpty) ? fault : null, faultCode: (fault is String && fault.isNotEmpty) ? fault : null,
measuredW: _dn(m['measuredW']), measuredW: _dn(m['measuredW']),
measurement: LoadMeasurement.fromJson(m),
funding: (m['funding'] is String && (m['funding'] as String).isNotEmpty) funding: (m['funding'] is String && (m['funding'] as String).isNotEmpty)
? m['funding'] as String ? m['funding'] as String
: null, : null,

View File

@ -5,6 +5,7 @@ import '../../l10n/l10n_ext.dart';
import '../../models/energy_data.dart' show ChargingMode; import '../../models/energy_data.dart' show ChargingMode;
import '../../models/ev_charger_live.dart'; import '../../models/ev_charger_live.dart';
import '../../models/load_config_entry.dart'; import '../../models/load_config_entry.dart';
import '../../models/load_telemetry.dart';
import '../../providers/load_config_provider.dart'; import '../../providers/load_config_provider.dart';
import '../../providers/load_telemetry_provider.dart'; import '../../providers/load_telemetry_provider.dart';
import '../../providers/schema_provider.dart'; import '../../providers/schema_provider.dart';
@ -156,7 +157,8 @@ class _LoadMechanismScreenState extends State<LoadMechanismScreen> {
entry: e, entry: e,
provider: provider, provider: provider,
schema: schema.schema, schema: schema.schema,
service: service), service: service,
telemetry: telemetry),
const SizedBox(height: 14), const SizedBox(height: 14),
_Arbitrage(entry: e, provider: provider), _Arbitrage(entry: e, provider: provider),
const SizedBox(height: 18), const SizedBox(height: 18),
@ -1536,12 +1538,14 @@ class _CompteurEtSonde extends StatelessWidget {
final LoadConfigProvider provider; final LoadConfigProvider provider;
final RpcSchema schema; final RpcSchema schema;
final NymeaService service; final NymeaService service;
final LoadTelemetryProvider telemetry;
const _CompteurEtSonde({ const _CompteurEtSonde({
required this.entry, required this.entry,
required this.provider, required this.provider,
required this.schema, required this.schema,
required this.service, required this.service,
required this.telemetry,
}); });
FieldSupport _support(String champ) => schema.paramSupport( FieldSupport _support(String champ) => schema.paramSupport(
@ -1552,15 +1556,43 @@ class _CompteurEtSonde extends StatelessWidget {
final supportCompteur = _support('meterThingId'); final supportCompteur = _support('meterThingId');
final supportSonde = _support('sensorThingId'); final supportSonde = _support('sensorThingId');
// ⚠️ LA RÈGLE PORTE SUR `measurement.source`, JAMAIS SUR `adapter`.
//
// « Masqué dès que la charge mesure, sauf SG-Ready » paraît équivalent et ne l'est
// pas : un routeur de relais à topologie MIXTE publie `none` lui aussi — une mesure
// partielle n'est pas une mesure — et on masquerait le champ sur un ECS qui ne peut
// être mesuré QUE par un compteur dédié. Redéduire le régime du mécanisme
// remettrait ici la déduction que le moteur vient d'en retirer, et il faudrait la
// maintenir à chaque mécanisme ajouté.
//
// source == device → réglage avancé, replié (l'appareil publie déjà)
// sinon → proposé, visible
//
// Le cas SG-Ready est couvert sans avoir à le connaître : son contact commande un
// signal, la PAC est alimentée ailleurs, la source est `none` par construction
// (LM-1105-b) — donc le compteur reste visible, et c'est sa SEULE voie vers une
// mesure.
// `null` = la charge n'est PAS dans la télémétrie du dernier cycle (hors arbitrage,
// borne sans véhicule branché, charge désactivée). Ce n'est pas `none` : `none` est un
// état publié — « pas mesurable » — alors qu'ici le régime n'est simplement pas dit.
// Les confondre ferait annoncer « seule voie vers une mesure » sur une borne qui, une
// fois arbitrée, publiera très bien la sienne.
final source = telemetry.entryFor(entry.id)?.measurement.source;
final replie = source == MeasurementSource.device;
return Column( return Column(
children: [ children: [
_Carte( _Carte(
titre: 'Compteur dédié', titre: 'Compteur dédié',
badge: supportCompteur.isWritable badge: supportCompteur.isWritable
? 'enregistré · meterThingId' ? (replie ? 'avancé · meterThingId' : 'enregistré · meterThingId')
: 'pas encore supporté', : 'pas encore supporté',
sousTitre: 'Mesure de CETTE charge — pour vérifier ce qui est commandé, ' sousTitre: replie
'jamais pour décider.', ? 'Cet appareil publie déjà sa puissance : un compteur séparé est un '
'réglage avancé, pas un manque.'
: 'Mesure de CETTE charge — pour vérifier ce qui est commandé, '
'jamais pour décider.',
replie: replie,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -1591,15 +1623,8 @@ class _CompteurEtSonde extends StatelessWidget {
'elle n\'est qu\'une décomposition. L\'y ajouter compterait deux fois la ' 'elle n\'est qu\'une décomposition. L\'y ajouter compterait deux fois la '
'même énergie.', 'même énergie.',
), ),
if (entry.isEvCharger) ...[ const SizedBox(height: 6),
const SizedBox(height: 6), _Aide(_aideSelonSource(source)),
_Aide(
'Sur une borne, aucun compteur séparé n\'est nécessaire : elle publie '
'elle-même sa puissance. La désigner ICI comme son propre compteur est '
'ce qui fait apparaître « mesuré » à côté de « alloué » sur sa carte — '
'sans quoi le moteur n\'a rien à comparer à ce qu\'il commande.',
),
],
], ],
), ),
), ),
@ -1643,6 +1668,33 @@ class _CompteurEtSonde extends StatelessWidget {
} }
} }
/// Ce qu'un compteur dédié apporte **à cette charge-ci**, selon ce que la box publie.
String _aideSelonSource(MeasurementSource? source) => switch (source) {
null =>
'Cette charge n\'est pas dans la télémétrie du dernier cycle : la box ne dit donc '
'pas d\'où viendrait sa mesure. Le compteur reste réglable — il servira dès '
'qu\'elle sera arbitrée.',
MeasurementSource.device =>
'Cet appareil publie déjà sa puissance, et elle s\'affiche sur sa carte. Un '
'compteur dédié reste permis, et il apporte une chose que l\'appareil ne peut '
'pas donner : une mesure INDÉPENDANTE de l\'organe commandé. Sans elle, une '
'concordance entre commande et mesure ne prouve rien — plusieurs bornes '
'renvoient leur consigne en guise de puissance.',
MeasurementSource.meter =>
'Un compteur est rattaché : la mesure affichée sur la carte vient de lui, pas de '
'l\'appareil commandé. C\'est la seule configuration où une concordance entre '
'commande et mesure constitue une preuve.',
MeasurementSource.none =>
'La box ne publie AUCUNE mesure pour cette charge — un contact SG-Ready commande '
'un signal et ne voit pas le courant de la machine, et une topologie de relais '
'où un seul mesure ne mesure rien d\'exploitable. Un compteur dédié est donc '
'ici la seule voie vers une mesure.',
MeasurementSource.legacy =>
'Cette box ne publie pas encore l\'origine de la mesure (avant 1.15.2+etm26) : '
'la valeur affichée sur la carte est juste, mais ce qu\'un écart prouverait '
'reste indéterminé.',
};
/// Inclusion dans l'arbitrage — et ce que la désactivation fait vraiment. /// Inclusion dans l'arbitrage — et ce que la désactivation fait vraiment.
class _Arbitrage extends StatelessWidget { class _Arbitrage extends StatelessWidget {
final LoadConfigEntry entry; final LoadConfigEntry entry;
@ -1868,19 +1920,33 @@ class _BarreEnregistrement extends StatelessWidget {
// Habillage // Habillage
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
class _Carte extends StatelessWidget { class _Carte extends StatefulWidget {
final String titre; final String titre;
final String? badge; final String? badge;
final String? sousTitre; final String? sousTitre;
final Widget child; final Widget child;
/// Carte **repliée à l'ouverture** — un réglage qui existe sans être une étape.
///
/// Le contenu reste atteignable en un geste : replier n'est pas masquer. Un réglage
/// caché pour de bon finit par être réinventé ailleurs.
final bool replie;
const _Carte({ const _Carte({
required this.titre, required this.titre,
this.badge, this.badge,
this.sousTitre, this.sousTitre,
required this.child, required this.child,
this.replie = false,
}); });
@override
State<_Carte> createState() => _CarteState();
}
class _CarteState extends State<_Carte> {
late bool _ouverte = !widget.replie;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(
@ -1894,17 +1960,33 @@ class _Carte extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_SousTitre(titre, badge: badge), InkWell(
if (sousTitre != null) ...[ onTap: widget.replie
? () => setState(() => _ouverte = !_ouverte)
: null,
child: Row(children: [
Expanded(child: _SousTitre(widget.titre, badge: widget.badge)),
if (widget.replie)
Icon(
_ouverte
? Icons.expand_less_rounded
: Icons.expand_more_rounded,
size: 20,
color: EtmTokens.mutedOf(context)),
]),
),
if (widget.sousTitre != null) ...[
const SizedBox(height: 3), const SizedBox(height: 3),
Text(sousTitre!, Text(widget.sousTitre!,
style: TextStyle( style: TextStyle(
fontSize: 11.5, fontSize: 11.5,
height: 1.35, height: 1.35,
color: EtmTokens.mutedOf(context))), color: EtmTokens.mutedOf(context))),
], ],
const SizedBox(height: 12), if (_ouverte) ...[
child, const SizedBox(height: 12),
widget.child,
],
], ],
), ),
); );

View File

@ -138,7 +138,8 @@ class _BugReportScreenState extends State<BugReportScreen> {
for (final l in tel.loads) { for (final l in tel.loads) {
final m = l.mechanism; final m = l.mechanism;
b.writeln(' - ${l.loadId} · alloué ${l.allocatedW.round()} W' b.writeln(' - ${l.loadId} · alloué ${l.allocatedW.round()} W'
'${l.measuredW != null ? " · mesuré ${l.measuredW!.round()} W" : ""}' '${l.measurement.mesuree ? " · mesuré ${l.measurement.powerW!.round()} W"
" (${l.measurement.source.name})" : ""}'
' · ${l.funding ?? "financement omis"}' ' · ${l.funding ?? "financement omis"}'
' · motif ${l.decision.code}${formatParamsBrut(l.decision.params)}' ' · motif ${l.decision.code}${formatParamsBrut(l.decision.params)}'
'${l.inFault ? " · EN DÉFAUT ${l.faultCode ?? ""}" : ""}' '${l.inFault ? " · EN DÉFAUT ${l.faultCode ?? ""}" : ""}'

View File

@ -199,12 +199,22 @@ class LoadLiveBlock extends StatelessWidget {
// c'est la seule chose que la mesure autorise à conclure. Une charge sans compteur // c'est la seule chose que la mesure autorise à conclure. Une charge sans compteur
// n'affiche RIEN — un zéro ferait passer l'absence de mesure pour une absence de // n'affiche RIEN — un zéro ferait passer l'absence de mesure pour une absence de
// consommation. // consommation.
if (live.measuredW != null) ...[ // ── LA MESURE, et ce que sa SOURCE autorise à en conclure ──────────
//
// Le bloc est masqué sous `none` — un état positif : « cette charge n'est pas
// mesurable ». Afficher un vide, ou pire un zéro, confondrait ce cas avec un
// compteur en panne. Et surtout : ne PAS aller chercher une puissance nominale
// dans `mechanism` pour combler — elle produirait un écart identiquement nul à
// tous les cycles, c'est-à-dire un « tout concorde » permanent qui ne signale
// plus rien.
if (live.measurement.mesuree) ...[
const SizedBox(height: 2), const SizedBox(height: 2),
Row(children: [ Row(children: [
Icon(Icons.speed_rounded, size: 13, color: tone), Icon(Icons.speed_rounded, size: 13, color: tone),
const SizedBox(width: 4), const SizedBox(width: 4),
Text('Mesuré ${live.measuredW!.round()} W', // `{device, 0}` est une MESURE, pas une absence : une borne branchée qui ne
// charge pas mesure zéro watt, et c'est une information.
Text('Mesuré ${live.measurement.powerW!.round()} W',
style: TextStyle( style: TextStyle(
fontSize: 12, fontWeight: FontWeight.w600, color: tone)), fontSize: 12, fontWeight: FontWeight.w600, color: tone)),
const SizedBox(width: 6), const SizedBox(width: 6),
@ -216,6 +226,11 @@ class LoadLiveBlock extends StatelessWidget {
), ),
), ),
]), ]),
if (_porteeMesure(live.measurement) != null) ...[
const SizedBox(height: 1),
Text(_porteeMesure(live.measurement)!,
style: TextStyle(fontSize: 10.5, height: 1.25, color: tone)),
],
], ],
const SizedBox(height: 2), const SizedBox(height: 2),
Text(decisionText(t, live.decision), Text(decisionText(t, live.decision),
@ -676,9 +691,30 @@ String _etatBorne(L10n t, LoadMechanism? mech) {
/// est partie, l'effet n'est pas là ». Deux causes, deux gestes chez le client — l'une /// est partie, l'effet n'est pas là ». Deux causes, deux gestes chez le client — l'une
/// envoie au bus, l'autre au disjoncteur divisionnaire ou à la résistance. L'app constate, /// envoie au bus, l'autre au disjoncteur divisionnaire ou à la résistance. L'app constate,
/// elle ne diagnostique pas. /// elle ne diagnostique pas.
/// **Ce que la source autorise à conclure** — dit à l'écran, parce que ça change le sens
/// de ce qui est affiché juste au-dessus.
///
/// L'asymétrie n'est pas « le compteur mesure mieux » : sous `device`, une **concordance**
/// ne prouve rien, plusieurs wallbox renvoyant leur consigne en guise de puissance. Elle
/// détecte la désobéissance, jamais le mensonge. Sous `meter`, la mesure est indépendante
/// de l'organe commandé — la concordance devient une preuve, et un écart ouvre trois
/// hypothèses (charge, compteur, câblage) qu'il ne tranche pas.
///
/// `null` sous `legacy` : une box antérieure à `+etm26` ne publie pas de régime, et
/// inventer une portée serait pire que se taire.
String? _porteeMesure(LoadMeasurement m) => switch (m.source) {
MeasurementSource.meter =>
'Compteur dédié : mesure indépendante de la commande — une concordance est une '
'preuve, un écart met en cause la charge, le compteur ou le câblage.',
MeasurementSource.device =>
'Mesure publiée par l\'appareil : un écart montre qu\'il n\'obéit pas, mais une '
'concordance ne prouve rien — certaines bornes renvoient leur consigne.',
MeasurementSource.none || MeasurementSource.legacy => null,
};
String _ecartMesure(LoadTelemetryEntry live) { String _ecartMesure(LoadTelemetryEntry live) {
final m = live.measuredW; final m = live.measurement.powerW;
if (m == null) return ''; if (!live.measurement.mesuree || m == null) return '';
final a = live.allocatedW; final a = live.allocatedW;
if (a <= 0) return m > 20 ? '· alloué 0 W' : ''; if (a <= 0) return m > 20 ? '· alloué 0 W' : '';
final ecart = (m - a).abs(); final ecart = (m - a).abs();

View File

@ -99,14 +99,17 @@ NymeaService _serviceAvecBorne({String mode = 'ChargingModeNormal'}) {
} }
Widget _harness(String loadId, Widget _harness(String loadId,
{List<Map<String, dynamic>>? config, NymeaService? service}) => {List<Map<String, dynamic>>? config,
NymeaService? service,
Map<String, dynamic>? telemetry}) =>
MultiProvider( MultiProvider(
providers: [ providers: [
ChangeNotifierProvider.value( ChangeNotifierProvider.value(
value: LoadConfigProvider()..adoptForTest(config ?? _banc())), value: LoadConfigProvider()..adoptForTest(config ?? _banc())),
ChangeNotifierProvider.value( ChangeNotifierProvider.value(
value: LoadTelemetryProvider() value: LoadTelemetryProvider()
..adoptForTest({'degradedMode': false, 'loads': const []})), ..adoptForTest(
telemetry ?? {'degradedMode': false, 'loads': const []})),
ChangeNotifierProvider.value(value: SchemaProvider()), ChangeNotifierProvider.value(value: SchemaProvider()),
ChangeNotifierProvider.value(value: service ?? NymeaService()), ChangeNotifierProvider.value(value: service ?? NymeaService()),
], ],
@ -125,7 +128,10 @@ Widget _harness(String loadId,
/// sont jamais bâtis — et `find` ne trouve rien, ce qui se lit comme un succès quand on /// sont jamais bâtis — et `find` ne trouve rien, ce qui se lit comme un succès quand on
/// cherche une absence. C'est le piège exact que ce fichier éprouve ailleurs. /// cherche une absence. C'est le piège exact que ce fichier éprouve ailleurs.
Future<void> _poser(WidgetTester tester, Widget w) async { Future<void> _poser(WidgetTester tester, Widget w) async {
await tester.binding.setSurfaceSize(const Size(1000, 4000)); // 8000 px : les écrans relais et SG-Ready sont bien plus hauts que celui d'une borne,
// et le compteur y tombait hors du viewport — donc jamais construit, donc « absent »
// pour `find`. Une absence qui n'en est pas une : le piège que ce fichier traque.
await tester.binding.setSurfaceSize(const Size(1000, 8000));
addTearDown(() => tester.binding.setSurfaceSize(null)); addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(w); await tester.pumpWidget(w);
await tester.pump(); await tester.pump();
@ -168,7 +174,9 @@ void main() {
// Le compteur, lui, garde un sens : c'est le seul chemin par lequel `measuredW` // Le compteur, lui, garde un sens : c'est le seul chemin par lequel `measuredW`
// apparaît, et une borne peut se désigner elle-même (elle publie `currentPower`). // apparaît, et une borne peut se désigner elle-même (elle publie `currentPower`).
expect(find.text('Compteur dédié'), findsOneWidget); expect(find.text('Compteur dédié'), findsOneWidget);
expect(find.textContaining('aucun compteur séparé n\'est nécessaire'), // Hors télémétrie, la box ne dit pas d'où viendrait la mesure : l'écran ne le
// devine pas non plus.
expect(find.textContaining('pas dans la télémétrie du dernier cycle'),
findsOneWidget); findsOneWidget);
}); });
@ -222,4 +230,55 @@ void main() {
expect(find.widgetWithText(OutlinedButton, 'PV'), findsOneWidget); expect(find.widgetWithText(OutlinedButton, 'PV'), findsOneWidget);
expect(find.textContaining('lu : PV'), findsOneWidget); expect(find.textContaining('lu : PV'), findsOneWidget);
}); });
group('le compteur dédié se décide sur la SOURCE, jamais sur l\'adaptateur', () {
/// Une trame où la charge visée publie la source donnée.
Map<String, dynamic> tel(String id, String source) => {
'degradedMode': false,
'loads': [
{
'loadId': id,
'allocatedW': 3000,
'available': true,
'decision': {'code': 'SURPLUS_SETPOINT', 'params': const {}},
'measurement': source == 'none'
? {'source': 'none'}
: {'source': source, 'powerW': 2950.0},
}
],
};
testWidgets('source `device` : le compteur passe en réglage avancé, replié',
(tester) async {
await _poser(tester, _harness(kV2c, telemetry: tel(kV2c, 'device')));
// La carte reste — replier n'est pas masquer, et un réglage caché pour de bon
// finit par être réinventé ailleurs.
expect(find.text('Compteur dédié'), findsOneWidget);
expect(find.textContaining('publie déjà sa puissance'), findsOneWidget);
// Mais son contenu n'est pas déployé : ni le sélecteur, ni les aides.
expect(find.textContaining('n\'entre pas dans le budget'), findsNothing);
});
testWidgets('source `none` : le compteur est PROPOSÉ, visible', (tester) async {
// Le cas d'une PAC SG-Ready — `none` par construction (LM-1105-b) — et celui d'une
// topologie de relais mixte. Masquer le champ y couperait la seule voie de mesure.
await _poser(
tester, _harness('pac-terrain', telemetry: tel('pac-terrain', 'none')));
expect(find.text('Compteur dédié'), findsOneWidget);
// Carte DÉPLOYÉE : son contenu est monté, pas seulement son titre.
expect(find.textContaining('n\'entre pas dans le budget'), findsOneWidget);
expect(find.textContaining('seule voie vers une mesure'), findsOneWidget);
});
testWidgets('source `meter` : visible aussi, et l\'écran dit ce qu\'elle prouve',
(tester) async {
await _poser(tester,
_harness('chauffe-eau', telemetry: tel('chauffe-eau', 'meter')));
expect(find.textContaining('n\'entre pas dans le budget'), findsOneWidget);
expect(find.textContaining('constitue une preuve'), findsOneWidget);
});
});
} }

View File

@ -0,0 +1,193 @@
/// Le contrat `measurement` de `1.15.2+etm26` — **écrit avant son déploiement**.
///
/// `measuredW` portait trois régimes sous un seul nom et jetait la distinction : une borne
/// rendait une mesure, un routeur de relais une mesure **ou** son palier nominal, un
/// SG-Ready une puissance toujours déclarée. Le régime se publie désormais :
/// `measurement: {source: "meter" | "device" | "none", powerW}`.
///
/// Ces tests figent les trois pièges d'affichage nommés dans le §7 du brief, et la
/// tolérance temporaire vers `measuredW`.
library;
import 'package:etm_powersync_app/l10n/app_localizations.dart';
import 'package:etm_powersync_app/models/load_telemetry.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:etm_powersync_app/providers/load_telemetry_provider.dart';
import 'package:etm_powersync_app/widgets/load_cards.dart';
LoadTelemetryEntry _entree(Map<String, dynamic> extra) =>
LoadTelemetryEntry.fromJson({
'loadId': 'x',
'allocatedW': 3000,
'available': true,
'decision': {'code': 'SURPLUS_SETPOINT', 'params': const {}},
...extra,
});
void main() {
group('le régime est LU, jamais déduit', () {
test('les trois sources se lisent telles que la box les publie', () {
expect(
_entree({
'measurement': {'source': 'meter', 'powerW': 3980.0}
}).measurement.source,
MeasurementSource.meter);
expect(
_entree({
'measurement': {'source': 'device', 'powerW': 12.0}
}).measurement.source,
MeasurementSource.device);
expect(
_entree({
'measurement': {'source': 'none'}
}).measurement.source,
MeasurementSource.none);
});
test('`none` n\'a PAS de puissance — même si la box en met une', () {
// Le régime est ce qui est publié, pas la valeur : sous `none`, il n'y a rien à
// afficher, et un nombre qui traînerait ne doit pas ressusciter le bloc.
final m = _entree({
'measurement': {'source': 'none', 'powerW': 500.0}
}).measurement;
expect(m.powerW, isNull);
expect(m.mesuree, isFalse);
});
test('{device, 0} est une MESURE, pas une absence', () {
// Une borne branchée qui ne charge pas mesure zéro watt, et c'est une information.
// Si la source basculait vers `none` à l'extinction, le bloc de mesure
// apparaîtrait et disparaîtrait au rythme du thermostat.
final m = _entree({
'measurement': {'source': 'device', 'powerW': 0.0}
}).measurement;
expect(m.mesuree, isTrue);
expect(m.powerW, 0.0);
});
test('une source inconnue n\'est pas devinée', () {
// Une box plus récente que l'app : on affichera la valeur sans prétendre savoir ce
// qu'un écart prouverait.
final m = _entree({
'measurement': {'source': 'futur', 'powerW': 100.0}
}).measurement;
expect(m.source, MeasurementSource.legacy);
expect(m.mesuree, isTrue);
expect(m.concordanceProbante, isFalse);
});
});
group('tolérance temporaire vers measuredW — à retirer avec +etm26 sur .75', () {
test('sans `measurement`, l\'ancienne clé est encore lue', () {
// Sans cet alias, la mesure disparaîtrait des écrans entre ce livrable et le
// déploiement du moteur.
final m = _entree({'measuredW': 1500.0}).measurement;
expect(m.source, MeasurementSource.legacy);
expect(m.powerW, 1500.0);
});
test('`measurement` GAGNE quand les deux sont là', () {
// Deux noms pour une chose divergent : le nouveau fait foi, sans exception.
final m = _entree({
'measuredW': 1500.0,
'measurement': {'source': 'meter', 'powerW': 3980.0}
}).measurement;
expect(m.source, MeasurementSource.meter);
expect(m.powerW, 3980.0);
});
test('ni l\'un ni l\'autre : pas mesurable', () {
expect(_entree(const {}).measurement.mesuree, isFalse);
});
});
group('ce qu\'une concordance prouve dépend de la source', () {
test('seul `meter` rend une concordance probante', () {
// Plusieurs wallbox renvoient leur consigne en guise de puissance : sous `device`,
// « commandé 3000, mesuré 3000 » peut n'être qu'un écho.
for (final (src, attendu) in [
('meter', true),
('device', false),
('none', false),
]) {
expect(
_entree({
'measurement': {'source': src, 'powerW': 3000.0}
}).measurement.concordanceProbante,
attendu,
reason: src);
}
});
});
group('à l\'écran', () {
Widget carte(Map<String, dynamic> extra) {
final tel = LoadTelemetryProvider()
..adoptForTest({
'degradedMode': false,
'loads': [
{
'loadId': 'x',
'allocatedW': 3000,
'available': true,
'decision': {'code': 'SURPLUS_SETPOINT', 'params': const {}},
...extra,
}
],
});
return MaterialApp(
// `LoadLiveBlock` lit `context.l10n` : sans délégués, il explose avant d'afficher.
locale: const Locale('fr'),
localizationsDelegates: L10n.localizationsDelegates,
supportedLocales: L10n.supportedLocales,
home: Scaffold(
body: LoadLiveBlock(
live: tel.entryFor('x')!, stale: false, telemetry: tel),
),
);
}
testWidgets('`none` MASQUE le bloc — pas de vide, pas de zéro', (tester) async {
await tester.pumpWidget(carte({
'measurement': {'source': 'none'}
}));
expect(find.textContaining('Mesuré'), findsNothing);
// Et surtout aucun « écart » : « 0 W d'écart » serait la pire des réponses.
expect(find.textContaining('conforme'), findsNothing);
expect(find.textContaining('consigne'), findsNothing);
});
testWidgets('`device` affiche la mesure ET ce qu\'elle ne prouve pas',
(tester) async {
await tester.pumpWidget(carte({
'measurement': {'source': 'device', 'powerW': 2950.0}
}));
expect(find.textContaining('Mesuré 2950 W'), findsOneWidget);
expect(find.textContaining('une concordance ne prouve rien'), findsOneWidget);
});
testWidgets('`meter` dit que la mesure est indépendante', (tester) async {
await tester.pumpWidget(carte({
'measurement': {'source': 'meter', 'powerW': 2950.0}
}));
expect(find.textContaining('Mesuré 2950 W'), findsOneWidget);
expect(find.textContaining('mesure indépendante'), findsOneWidget);
});
testWidgets('un zéro mesuré s\'affiche — c\'est une information', (tester) async {
await tester.pumpWidget(carte({
'measurement': {'source': 'device', 'powerW': 0.0}
}));
expect(find.textContaining('Mesuré 0 W'), findsOneWidget);
});
testWidgets('sur une box ancienne, la valeur s\'affiche SANS portée',
(tester) async {
await tester.pumpWidget(carte({'measuredW': 1500.0}));
expect(find.textContaining('Mesuré 1500 W'), findsOneWidget);
expect(find.textContaining('concordance'), findsNothing);
expect(find.textContaining('indépendante'), findsNothing);
});
});
}