diff --git a/lib/features/dashboard/widgets/energy_flow_card.dart b/lib/features/dashboard/widgets/energy_flow_card.dart index 1b5495b..1b3b248 100644 --- a/lib/features/dashboard/widgets/energy_flow_card.dart +++ b/lib/features/dashboard/widgets/energy_flow_card.dart @@ -226,8 +226,12 @@ class _EnergyFlowCardState extends State 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 [ diff --git a/lib/models/energy_data.dart b/lib/models/energy_data.dart index f440c8b..c8b2caa 100644 --- a/lib/models/energy_data.dart +++ b/lib/models/energy_data.dart @@ -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, diff --git a/lib/providers/energy_setup_provider.dart b/lib/providers/energy_setup_provider.dart index 6270ac6..1dc45e6 100644 --- a/lib/providers/energy_setup_provider.dart +++ b/lib/providers/energy_setup_provider.dart @@ -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 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. diff --git a/lib/services/nymea_service.dart b/lib/services/nymea_service.dart index 7ea6716..4c834ea 100644 --- a/lib/services/nymea_service.dart +++ b/lib/services/nymea_service.dart @@ -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é diff --git a/test/detection_compteurs_test.dart b/test/detection_compteurs_test.dart new file mode 100644 index 0000000..6e06f0c --- /dev/null +++ b/test/detection_compteurs_test.dart @@ -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')); + }); + }); +} diff --git a/test/flux_sens_reseau_test.dart b/test/flux_sens_reseau_test.dart new file mode 100644 index 0000000..18cee55 --- /dev/null +++ b/test/flux_sens_reseau_test.dart @@ -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'); + } + }); + }); +}