diff --git a/lib/screens/energy/load_mechanism_screen.dart b/lib/screens/energy/load_mechanism_screen.dart index 21474b6..269896f 100644 --- a/lib/screens/energy/load_mechanism_screen.dart +++ b/lib/screens/energy/load_mechanism_screen.dart @@ -1772,6 +1772,20 @@ class _Aide extends StatelessWidget { /// `mechanism.minPowerW` est **en télémétrie** avant d'être configurable. On l'affiche /// donc en lecture, ce qui donne la valeur réellement en vigueur même quand le réglage, /// lui, n'est pas encore ouvert. +/// +/// ## Ce plancher-ci n'est PAS celui d'une borne de recharge +/// +/// Même mot, deux natures. Ici, une **propriété déclarée de l'appareil** : la consigne +/// non nulle la plus basse qu'il accepte, saisie une fois et stable. +/// +/// Le minimum d'une borne, lui, vaut `max(borne, voiture)` et **dépend du véhicule +/// branché** — il change quand la voiture change. Il dépend aussi du nombre de phases +/// visé, de l'ordre de 1,4 kW en monophasé contre 4,1 kW en triphasé. C'est du **runtime**, +/// il se constate et ne se règle pas ; il s'affiche donc sur la carte de la borne, en +/// lecture, tel que la box le donne — sans conversion côté app. +/// +/// Ce bloc ne vit que dans la section `etmvariableload`, ce qui l'écarte structurellement +/// des bornes : elles ne sont pas des `LoadConfig` et n'ouvrent pas cet écran. class _Plancher extends StatelessWidget { final LoadConfigEntry entry; final LoadConfigProvider provider; @@ -1799,8 +1813,9 @@ class _Plancher extends StatelessWidget { return _Carte( titre: 'Plancher de modulation', badge: support.isWritable ? 'enregistré · $champ' : 'pas encore supporté', - sousTitre: 'La consigne non nulle la plus basse que l\'appareil accepte — en ' - 'dessous, il vaut mieux ne rien commander du tout.', + sousTitre: 'La consigne non nulle la plus basse que CET appareil accepte — en ' + 'dessous, il vaut mieux ne rien commander du tout. Propriété déclarée, stable : ' + 'à ne pas confondre avec le minimum d\'une borne, qui suit le véhicule branché.', child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/lib/widgets/controllable_loads_section.dart b/lib/widgets/controllable_loads_section.dart index 9b57a5e..4af8b71 100644 --- a/lib/widgets/controllable_loads_section.dart +++ b/lib/widgets/controllable_loads_section.dart @@ -747,6 +747,28 @@ class BorneSection extends StatelessWidget { letterSpacing: 0.2, color: EtmTokens.inkOf(context))), const SizedBox(height: 8), + // Le budget n'est PAS divisé entre les bornes, et le dire évite une déduction + // fausse dès qu'il y en a deux à l'écran. + if (bornes.length > 1) + Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(11), + decoration: BoxDecoration( + color: EtmTokens.solar.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl), + ), + child: Text( + 'Deux bornes déclarées — le budget ne se partage pas entre elles. ' + 'Aujourd\'hui chacune reçoit l\'enveloppe entière, et ce sont le compteur ' + 'puis la protection de surcharge qui rattrapent. Une seule démarrera ' + 'quand l\'arbitrage prendra la recharge en charge.', + style: TextStyle( + fontSize: 11.5, height: 1.35, color: EtmTokens.inkOf(context)), + ), + ), + ), for (final b in bornes) Container( width: double.infinity, @@ -787,6 +809,9 @@ class BorneSection extends StatelessWidget { 'bord.', style: TextStyle(fontSize: 11.5, height: 1.35, color: muted), ), + // Les valeurs de RUNTIME, quand la box en publie. Read-only par nature : + // elles décrivent l'instant, elles ne se règlent pas. + _RuntimeBorne(thingId: b.id), ], ), ), @@ -826,3 +851,67 @@ class _FlecheRang extends StatelessWidget { } } + + +/// Ce que la box publie d'une borne **à l'instant** — jamais un réglage. +/// +/// ## Deux natures derrière un même mot +/// +/// Le « minimum » d'une borne n'est pas le **plancher déclaré** d'une charge modulable. +/// Celui-là est une propriété de l'appareil, saisie une fois ; celui-ci vaut +/// `max(borne, voiture)` et **dépend de la voiture branchée** — il change quand on change +/// de véhicule. Les ranger au même endroit, ou les rendre modifiables de la même façon, +/// ferait croire qu'on peut régler ce qui se constate. +/// +/// De même, le plancher effectif dépend du **nombre de phases visé** — de l'ordre de +/// 1,4 kW en monophasé contre 4,1 kW en triphasé. La valeur publiée correspond à ce nombre +/// de phases, pas au courant du moment. On l'affiche **telle que la box la donne** : la +/// recalculer ou la convertir côté app est précisément le genre de miroir qui finit par +/// diverger, et on en a déjà eu un avec la combinatoire des paliers. +/// +/// Tant que la recharge n'est pas entrée dans l'arbitrage, la box ne publie rien pour la +/// borne et ce bloc reste muet — ce qui est la bonne réponse, pas un trou. +class _RuntimeBorne extends StatelessWidget { + final String thingId; + + const _RuntimeBorne({required this.thingId}); + + @override + Widget build(BuildContext context) { + final live = context.watch().entryFor(thingId); + final mech = live?.mechanism; + if (mech == null) return const SizedBox.shrink(); + + final muted = EtmTokens.mutedOf(context); + final lignes = []; + if (mech.phaseCount != null) lignes.add('${mech.phaseCount} phase(s)'); + if (mech.currentA != null) { + lignes.add('${mech.currentA!.toStringAsFixed(1)} A'); + } + if (mech.minPowerW != null) { + lignes.add('minimum ${mech.minPowerW!.round()} W'); + } + if (mech.pluggedIn != null) { + lignes.add(mech.pluggedIn! ? 'véhicule branché' : 'aucun véhicule'); + } + if (lignes.isEmpty) return const SizedBox.shrink(); + + return Padding( + padding: const EdgeInsets.only(top: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.sensors_rounded, size: 13, color: muted), + const SizedBox(width: 5), + Expanded( + child: Text( + '${lignes.join(' · ')} — mesuré, pas réglé : le minimum d\'une borne vaut ' + 'max(borne, voiture) et suit le véhicule branché.', + style: TextStyle(fontSize: 11, height: 1.3, color: muted), + ), + ), + ], + ), + ); + } +} diff --git a/test/ev_runtime_test.dart b/test/ev_runtime_test.dart new file mode 100644 index 0000000..33c4132 --- /dev/null +++ b/test/ev_runtime_test.dart @@ -0,0 +1,102 @@ +/// Les valeurs de runtime d'une borne — **constatées, jamais réglées**. +/// +/// ## Le piège est un mot, pas un calcul +/// +/// « Minimum » désigne deux choses de natures différentes : +/// +/// - le **plancher déclaré** d'une charge modulable : une propriété de l'appareil, saisie +/// une fois, stable — elle se configure ; +/// - le **minimum d'une borne** : `max(borne, voiture)`, qui dépend du véhicule branché et +/// change avec lui. Il dépend aussi du nombre de phases visé — de l'ordre de 1,4 kW en +/// monophasé contre 4,1 kW en triphasé. +/// +/// Les ranger au même endroit, ou les rendre modifiables de la même façon, ferait croire +/// qu'on peut régler ce qui se constate. Ces tests figent la frontière. +/// +/// ## Et la valeur s'affiche telle que la box la donne +/// +/// Pas de conversion côté app entre ampères et watts, pas de recalcul selon les phases : +/// c'est exactement le genre de miroir qui finit par diverger, et le lot précédent en a +/// déjà retiré un — la combinatoire des paliers, désormais publiée par la box. +library; + +import 'package:etm_powersync_app/models/load_telemetry.dart'; +import 'package:flutter_test/flutter_test.dart'; + +LoadTelemetryEntry _borne(Map mech) => + LoadTelemetryEntry.fromJson({ + 'loadId': '{88160e45-7df2-48cc-9639-f0d15ae6e382}', + 'allocatedW': 0.0, + 'available': true, + 'decision': {'code': 'SURPLUS_INSUFFICIENT', 'params': {'budgetW': 0}}, + 'mechanism': mech, + }); + +void main() { + group('le minimum d\'une borne est du RUNTIME', () { + test('il est lu tel quel, sans conversion ni recalcul', () { + // Monophasé : la box publie ~1 400 W. L'app ne dérive pas cette valeur d'un courant + // et d'un nombre de phases — elle la prend. + final mono = _borne({ + 'kind': 'evcharger', + 'phaseCount': 1, + 'currentA': 6.0, + 'minPowerW': 1400.0, + 'pluggedIn': true, + }); + expect(mono.mechanism!.minPowerW, 1400.0); + expect(mono.mechanism!.phaseCount, 1); + + // Triphasé : ~4 100 W pour le MÊME courant. Si l'app convertissait elle-même, elle + // devrait deviner laquelle des deux règles s'applique — et se tromperait un jour. + final tri = _borne({ + 'kind': 'evcharger', + 'phaseCount': 3, + 'currentA': 6.0, + 'minPowerW': 4100.0, + 'pluggedIn': true, + }); + expect(tri.mechanism!.minPowerW, 4100.0); + expect(tri.mechanism!.currentA, mono.mechanism!.currentA, + reason: 'même courant, minimum différent : la conversion n\'appartient pas ' + 'à l\'app'); + }); + + test('sans véhicule branché, la valeur peut disparaître — et ce n\'est pas 0', () { + final vide = _borne({'kind': 'evcharger', 'pluggedIn': false}); + expect(vide.mechanism!.minPowerW, isNull, + reason: 'le minimum dépend de la voiture : sans voiture, il n\'y a pas de ' + 'valeur, et zéro serait un mensonge'); + expect(vide.mechanism!.pluggedIn, isFalse); + }); + + test('rien n\'est publié tant que la recharge n\'est pas arbitrée', () { + // Aujourd'hui la borne n'est pas dans loads[] : le bloc de runtime reste muet, ce + // qui est la bonne réponse et non un trou. + final sansMecanisme = LoadTelemetryEntry.fromJson({ + 'loadId': 'x', + 'allocatedW': 0.0, + 'available': true, + 'decision': {'code': 'X', 'params': {}}, + }); + expect(sansMecanisme.mechanism, isNull); + }); + }); + + test('le plancher DÉCLARÉ d\'une charge modulable est un autre objet', () { + // Même clé de télémétrie, autre nature : ici c'est une propriété de l'appareil, et + // elle a un champ de configuration correspondant (à venir au §12). Le minimum d'une + // borne n'en aura jamais — il n'y a rien à y régler. + final modulable = LoadTelemetryEntry.fromJson({ + 'loadId': 'ecs-modbus', + 'allocatedW': 1200.0, + 'available': true, + 'decision': {'code': 'SURPLUS_SETPOINT', 'params': {'budgetW': 1500}}, + 'mechanism': {'kind': 'variable', 'maxPowerW': 3000.0, 'minPowerW': 300.0}, + }); + expect(modulable.mechanism!.minPowerW, 300.0); + expect(modulable.mechanism!.phaseCount, isNull, + reason: 'une charge modulable n\'a pas de nombre de phases : c\'est bien ' + 'l\'absence de ce champ qui distingue les deux natures dans la charge utile'); + }); +}