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:
parent
b1c5336bbe
commit
a6dd319ef2
@ -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 [
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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é
|
||||
|
||||
55
test/detection_compteurs_test.dart
Normal file
55
test/detection_compteurs_test.dart
Normal 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'));
|
||||
});
|
||||
});
|
||||
}
|
||||
60
test/flux_sens_reseau_test.dart
Normal file
60
test/flux_sens_reseau_test.dart
Normal 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');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user