fix(rôles): le compteur RÉSEAU n'est plus détecté comme compteur solaire

Signalé sur banc après changement de matériel : Fronius SunSpec retiré, Eastron
SDM72 posé en rootMeter et SDM120 en compteur de production. L'écran affichait le
SDM72 dans les DEUX rôles, avec « auto · lecture seule » — donc sans moyen de le
corriger.

La box était juste (`GetRootMeter` rend bien le SDM72) et les interfaces
distinguaient les deux sans ambiguïté :

  Grid / sdm72          : energymeter, smartmeter, connectable
  PV   / sdm120Producer : smartmeterproducer, smartmeter, connectable

C'est notre liste qui ne discriminait pas : le rôle solaire acceptait
`energymeter`, `meter` et `smartmeter` — portées par TOUT compteur, réseau
compris — et ignorait `smartmeterproducer`, la seule qui dise « ce compteur
mesure une production ».

Deux corrections, la seconde tenant même si une future classe déclare des
interfaces trop larges :

- le rôle solaire exige désormais une interface de production ;
- le Thing acté comme compteur RACINE n'est plus candidat à aucun autre rôle.

Reste ouvert, et c'est le vrai fond du signalement : une détection automatique
ne devrait pas être un cul-de-sac. Les lignes de compteur n'ont aujourd'hui
AUCUN geste — pas de sélecteur, quel que soit l'état. Le badge « lecture seule »
est honnête, mais une inférence fausse doit pouvoir se corriger.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
This commit is contained in:
Patrick Schurig 2026-09-06 12:31:59 +02:00
parent b1c5336bbe
commit a6dd319ef2
6 changed files with 167 additions and 5 deletions

View File

@ -226,8 +226,12 @@ class _EnergyFlowCardState extends State<EnergyFlowCard>
switch (view) {
case _DrillView.overview:
final gridImport = (-d.gridPower).clamp(0.0, double.infinity);
final gridExport = d.gridPower.clamp(0.0, double.infinity);
// ⚠️ Ces deux lignes étaient INVERSÉES — elles lisaient « + = injection » là où la
// box publie « + = soutirage ». Une installation exportant 2,5 kW affichait donc
// « consommation réseau », flèche à l'envers (vu sur appareil le 2026-09-06). Le
// sens vit désormais dans `EnergyData`, nommé une fois, plutôt que relu ici.
final gridImport = d.gridImportW;
final gridExport = d.gridExportW;
final battCharge = d.batteryPower.clamp(0.0, double.infinity);
final battDisch = (-d.batteryPower).clamp(0.0, double.infinity);
return [

View File

@ -2,7 +2,21 @@ class EnergyData {
final double pvPower; // Watts - production solaire
final double homePower; // Watts - consommation maison
final double batteryPower; // Watts - puissance batterie (+ charge, - décharge)
final double gridPower; // Watts - réseau (+ injection, - soutif)
/// **Réseau, signé : `+` SOUTIRÉ, `−` INJECTÉ.**
///
/// ⚠️ Le commentaire précédent disait l'inverse — « + injection, − soutirage » — et la
/// carte de flux l'a cru : le 2026-09-06, une installation qui **exportait 2,5 kW**
/// affichait « consommation réseau », flèche à l'envers. Le sens de l'animation était
/// inversé, et rien dans les tests ne pouvait le voir.
///
/// La convention vient de la box et se vérifie à la sonde : `currentPowerAcquisition`
/// vaut **−2 511 W** pendant que la production (5,76 kW) dépasse la consommation
/// (3,25 kW) de 2,5 kW. Elle est transportée telle quelle (§7.1, signes bruts).
///
/// Ne pas relire ce signe à la main : passer par [gridImportW] / [gridExportW]. Le
/// dépôt portait DEUX conventions opposées dans deux widgets voisins, et celle qui était
/// à l'écran était la fausse.
final double gridPower;
final double batterySOC; // % - état de charge batterie
final double temperature; // °C
final String weatherIcon;
@ -60,6 +74,12 @@ class EnergyData {
this.gridLimitOk = true,
});
/// Puissance **soutirée** au réseau, ou 0. Voir [gridPower] pour la convention.
double get gridImportW => gridPower > 0 ? gridPower : 0;
/// Puissance **injectée** vers le réseau, ou 0.
double get gridExportW => gridPower < 0 ? -gridPower : 0;
EnergyData copyWith({
double? pvPower,
double? homePower,

View File

@ -46,7 +46,15 @@ extension EmsRoleExt on EmsRole {
EmsRole.dhw => ['simpleheatpump', 'relay', 'smartplug', 'powerswitch'],
EmsRole.heatPump => ['sgready', 'sgrelay', 'heatpump'],
EmsRole.battery => ['battery', 'energystorage', 'batterymonitor'],
EmsRole.solarMeter => ['solarinverter', 'energymeter', 'meter', 'inverter', 'smartmeter'],
// ⚠️ **Pas d'interface GÉNÉRIQUE de compteur ici.** `energymeter`, `meter` et
// `smartmeter` sont portées par **tout** compteur, y compris celui du réseau — et
// le 2026-09-06 sur `.75`, c'est exactement ce qui s'est produit : le SDM72 posé
// en compteur réseau (`energymeter`, `smartmeter`) a été détecté comme compteur
// SOLAIRE, pendant que le SDM120 (`smartmeterproducer`) restait ignoré.
//
// La seule interface qui dise « ce compteur mesure une PRODUCTION » est
// `smartmeterproducer`. Elle manquait.
EmsRole.solarMeter => ['smartmeterproducer', 'solarinverter', 'inverter'],
EmsRole.gridMeter => ['energymeter', 'smartmeter', 'meter'],
};
@ -503,7 +511,13 @@ class EnergySetupProvider extends ChangeNotifier {
// ── Filtrage des things compatibles avec un rôle ───────────────────────────
List<NymeaThing> compatibleThings(EmsRole role, NymeaService service) {
// Le compteur RACINE n'est candidat à aucun autre rôle. Une garde en plus des
// interfaces, et non à leur place : elle tient même si une future classe déclare des
// interfaces trop larges, et c'est ce qui manquait le 2026-09-06 — le compteur réseau
// s'est retrouvé proposé, puis retenu d'office, comme compteur solaire.
final racine = role == EmsRole.gridMeter ? null : _rootMeterThingId(service);
return service.things.where((thing) {
if (racine != null && thing.id == racine) return false;
try {
final cls =
service.thingClasses.firstWhere((c) => c.id == thing.thingClassId);
@ -515,6 +529,13 @@ class EnergySetupProvider extends ChangeNotifier {
}).toList();
}
/// Le Thing acté comme compteur racine, quelle qu'en soit la provenance — lu de la box
/// ou posé dans cette session. `null` quand aucun ne l'est encore.
String? _rootMeterThingId(NymeaService service) {
final s = _states[EmsRole.gridMeter];
return s is RolePresent ? s.thingId : null;
}
// ── Interne ────────────────────────────────────────────────────────────────
/// Insère une charge pilotable à sa place canonique si absente de l'ordre.

View File

@ -863,7 +863,9 @@ class NymeaService extends ChangeNotifier {
// Champs réels confirmés depuis les logs :
// currentPowerProduction : W solaire (NÉGATIF! ex: -1190 = 1190W PV)
// currentPowerConsumption : W maison (positif)
// currentPowerAcquisition : W réseau (positif = import, 0 = export)
// currentPowerAcquisition : W réseau (positif = SOUTIRÉ, négatif = INJECTÉ)
// ⚠️ « 0 = export » était faux : l'export sort en NÉGATIF, et sa valeur absolue est
// la puissance injectée. Mesuré le 2026-09-06 : -2 511 W pendant un export de 2,5 kW.
// currentPowerStorage : W batterie (+ charge, - décharge)
// totalProduction : kWh PV cumulé
// totalConsumption : kWh maison cumulé

View File

@ -0,0 +1,55 @@
/// **Le compteur réseau n'est pas un compteur solaire** — signalé sur banc, pas trouvé ici.
///
/// Le 2026-09-06, `.75` a changé de matériel : Fronius SunSpec retiré, **Eastron SDM72**
/// posé en compteur réseau (rootMeter) et **SDM120** en compteur de production. L'écran a
/// affiché le SDM72 dans les DEUX rôles — réseau *et* solaire — avec le badge
/// « auto · lecture seule », donc sans aucun moyen de le corriger.
///
/// La box, elle, était juste : `Energy.GetRootMeter` rendait bien le SDM72, et les
/// interfaces distinguaient les deux appareils sans ambiguïté :
///
/// | Thing | classe | interfaces |
/// |---|---|---|
/// | `Grid` | `sdm72` | `energymeter`, `smartmeter`, `connectable` |
/// | `PV` | `sdm120Producer` | **`smartmeterproducer`**, `smartmeter`, `connectable` |
///
/// La liste de l'app pour le rôle solaire contenait `energymeter`, `meter` et `smartmeter`
/// — portées par **tout** compteur, réseau compris — et **pas** `smartmeterproducer`, la
/// seule qui dise « ce compteur mesure une production ».
library;
import 'package:etm_powersync_app/providers/energy_setup_provider.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('les interfaces discriminent, ou elles ne servent à rien', () {
test('le rôle solaire exige une interface de PRODUCTION', () {
final solaire = EmsRole.solarMeter.compatibleInterfaces;
expect(solaire, contains('smartmeterproducer'));
// Les génériques sont exclues : elles matchent le compteur réseau.
expect(solaire, isNot(contains('energymeter')));
expect(solaire, isNot(contains('smartmeter')));
expect(solaire, isNot(contains('meter')));
});
test('le SDM72 du banc ne matche plus le rôle solaire', () {
// Interfaces réelles, relevées sur `.75`.
const sdm72 = ['energymeter', 'smartmeter', 'connectable'];
expect(
sdm72.any((i) => EmsRole.solarMeter.compatibleInterfaces.contains(i)),
isFalse);
});
test('le SDM120 du banc matche, lui', () {
const sdm120 = ['smartmeterproducer', 'smartmeter', 'connectable'];
expect(
sdm120.any((i) => EmsRole.solarMeter.compatibleInterfaces.contains(i)),
isTrue);
});
test('le rôle réseau garde ses génériques — c\'est là qu\'elles ont un sens', () {
expect(EmsRole.gridMeter.compatibleInterfaces, contains('energymeter'));
expect(EmsRole.gridMeter.compatibleInterfaces, contains('smartmeter'));
});
});
}

View File

@ -0,0 +1,60 @@
/// **Le sens du flux réseau** — signalé sur appareil, pas trouvé en test.
///
/// Le 2026-09-06 : 4,9 kW de production, 3,3 kW de consommation, le simulateur affichant
/// `Grid -1980 W` — donc une installation qui **injecte** — et l'app annonçait « consommation
/// réseau », flèche à l'envers.
///
/// La cause n'était pas un calcul mais une **convention écrite deux fois, en sens opposés** :
///
/// | endroit | ce qu'il disait | juste ? |
/// |---|---|---|
/// | `EnergyData.gridPower` (commentaire) | « + injection, − soutirage » | **non** |
/// | `energy_flow_widget.dart:180` | « gridPower > 0 = soutirage » | oui |
/// | `energy_flow_card.dart` (le code à l'écran) | + = injection | **non** |
///
/// Vérifié à la sonde : `currentPowerAcquisition` vaut **−2 511 W** pendant que la production
/// (5,76 kW) dépasse la consommation (3,25 kW) de 2,5 kW. Le signe négatif est donc l'export,
/// et sa valeur absolue la puissance injectée.
///
/// Ces tests portent sur les accesseurs nommés plutôt que sur le widget : c'est là que la
/// convention vit désormais, et c'est ce qui empêche qu'un troisième lecteur la réinvente.
library;
import 'package:etm_powersync_app/models/energy_data.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('la convention est celle de la BOX : + soutiré, − injecté', () {
test('export : le relevé du 2026-09-06', () {
// production 5 763 W, maison 3 252 W -> 2 511 W injectés.
const d = EnergyData(pvPower: -5763, homePower: 3252, gridPower: -2511);
expect(d.gridExportW, 2511);
expect(d.gridImportW, 0, reason: 'rien n\'est soutiré pendant qu\'on injecte');
});
test('import : le cas du soir, production nulle', () {
const d = EnergyData(pvPower: 0, homePower: 3250, gridPower: 3250);
expect(d.gridImportW, 3250);
expect(d.gridExportW, 0);
});
test('zéro : ni l\'un ni l\'autre — et surtout pas les deux', () {
const d = EnergyData(gridPower: 0);
expect(d.gridImportW, 0);
expect(d.gridExportW, 0);
});
test('les deux parts ne sont JAMAIS non nulles ensemble', () {
// C'est la garde qui aurait attrapé l'inversion : le code fautif calculait
// `import = -gridPower` et `export = gridPower`, donc l'un des deux était toujours
// faux — mais chacun pris seul restait plausible.
for (final w in [-9000.0, -1.0, 0.0, 1.0, 9000.0]) {
final d = EnergyData(gridPower: w);
expect(d.gridImportW * d.gridExportW, 0,
reason: 'à $w W, une seule direction peut être active');
expect(d.gridImportW + d.gridExportW, w.abs(),
reason: 'et leur somme est la puissance échangée');
}
});
});
}