diff --git a/Prompt 3g2 arbitre commande seul.md b/Prompt 3g2 arbitre commande seul.md new file mode 100644 index 0000000..c96db22 --- /dev/null +++ b/Prompt 3g2 arbitre commande seul.md @@ -0,0 +1,72 @@ +# Prompt agent plugin — 3g-2 : l'arbitre commande seul + +> À coller tel quel dans l'agent `powersync-plugin`. +> Contexte : `.75` en `1.15.2+etm22`, seuil de réserve batterie à 0,4. + +--- + +**3g-2 — l'arbitre commande seul.** + +Aujourd'hui `adjustEvChargers()` re-décide après le waterfall : l'arbitre refuse, la ligne +suivante commande 6 A. La transplantation ne commande donc rien, et si la Terra AC n'avait pas +été hors ligne, l'ordre serait parti sur du matériel réel. + +**La règle à établir : une charge, un commandeur.** Pour une borne arbitrée par le waterfall, +`adjustEvChargers()` ne doit plus émettre d'action de surplus. Ce qui lui reste, et qui doit +être délimité explicitement : les actions **financées au réseau** — marché de gros, échéance de +départ — puisque l'échéance est restée au proxy par décision assumée. Le champ `funding` que tu +viens d'ajouter est le discriminant naturel. + +**Deux pièges à ne pas laisser ouverts.** + +Le trou de commande : si le waterfall alloue et que le proxy ne commande plus, vérifie que +**quelqu'un commande**. Le pire résultat serait une borne qui n'obéit à personne — l'arbitre +croit avoir servi, rien ne se passe, et le budget est perdu comme dans le cas `evFloorW`. + +La transition : une borne qui charge sous l'ancien régime au moment du déploiement ne doit pas +rester bloquée dans un état commandé par un chemin qui n'existe plus. Même précaution qu'à la +suppression du bloc SG-Ready codé en dur — passer par l'état sûr. + +**Traite `evFloorW()` dans le même lot.** `acquisitionTolerance` est un seuil de déclenchement, +pas un plancher : 2 070 W publiés là où 4 140 W sont exigés, donc 2 828 W retirés du budget et +donnés à personne. Un plancher faux est pire qu'un plancher absent — le waterfall croit avoir +servi. Et le plancher publié doit correspondre au nombre de phases visé, pas au courant du +moment. + +**Et le rang d'une borne doit devenir configurable** — `priority = 100` en dur, `std::sort` non +stable, donc le test d'inversion est impossible tel quel. Sans lui, LM-1204-b reste hors +d'atteinte même avec deux bornes en ligne. + +Vérification : `.75` est à `+etm22`, seuil 0,4, la wallbox simulée répond, la Terra AC est hors +ligne (`192.168.1.132:502`, `ThingErrorTimeout`) — à regarder avant, c'est le seul obstacle +matériel restant à LM-1204-b. + +Ton brief est commité en local dans le dépôt app (`e84f59d`) mais non poussé, faute +d'identifiants HTTPS. L'agent app pousse en SSH ; signale-le-lui plutôt que de le laisser +dormir. + +--- + +## Rappels d'état, au cas où la session redémarre à froid + +| | | +|---|---| +| Banc `.75` | `1.15.2+etm22`, seuil réserve batterie **0,4** (délibéré — 0,9 gèle tout à 50 % de SoC) | +| Plugin SunSpec | `1.15.2+etm11` — la batterie déclare enfin `energystorage`, signe vérifié dans les 3 états | +| Banc `.131` | six conteneurs sur la même source, `deploy.sh --check-only` sort 0 | +| Terra AC | **hors ligne**, `192.168.1.132:502`, `ThingErrorTimeout` — seul obstacle matériel à LM-1204-b | +| Déploiement | **toujours** `etm-powersync-deploy/deploy.sh`, jamais `dpkg -i` | +| Accès banc | `ssh etm@192.168.1.75` (ni `root` ni `nymea`) · journal `journalctl -u nymead` | + +**Non franchi :** LM-1204-b — jamais exercé, c'est le critère qui justifie tout le lot. + +**Pour l'agent app**, en attente dans `docs/BRIEF_depuis_plugin.md` : `SetChargingInfo` +substitue silencieusement `targetPercentage = 80` à un champ absent · `loads[].funding` +(surplus/grid) et l'entrée des bornes dans la télémétrie · `kCodesReserveBatterie` vide alors +que `BATTERY_RESERVE` publie `socPercent`/`reservePercent`/`withheldW` · et la mise en garde : +pour une borne, `allocatedW` et son motif sont la décision de l'arbitre, **pas** l'état de la +borne, tant que 3g-2 n'est pas livré. + +Côté app, `origin` est en HTTPS sans credential helper — d'où des `origin/…` périmés qui font +croire à des commits manquants. À basculer en SSH : +`git remote set-url origin gitea-lan:pakutz79/etm-powersync-app.git` diff --git a/lib/models/load_config_entry.dart b/lib/models/load_config_entry.dart index 0d2d881..317055c 100644 --- a/lib/models/load_config_entry.dart +++ b/lib/models/load_config_entry.dart @@ -98,9 +98,27 @@ class LoadConfigEntry { bool get isRelayRouter => adapter == 'relay-router'; bool get isSgReady => adapter == 'sg-ready'; - /// Vrai pour le mécanisme continu — c'est le **défaut** du schéma, donc tout ce qui - /// n'est ni relais ni sg-ready. - bool get isVariableLoad => !isRelayRouter && !isSgReady; + /// Borne de recharge. **Entrée créée d'office par le plugin** depuis `+etm23`, pour que + /// l'invariant `loads[] ⊆ GetLoadConfig` tienne à nouveau : les bornes étaient entrées + /// en télémétrie sans figurer nulle part en configuration, et l'app s'appuie sur cet + /// invariant pour résoudre libellé, domaine et rang de toute charge publiée. + /// + /// **Elle ne porte AUCUNE charge utile de mécanisme** — ni `relays`, ni `sgReady`, ni + /// `powerLevels`/`maxPowerW`/`minPowerW`, ni `minOnS`/`minOffS`. Ce n'est pas un oubli : + /// les limites d'une borne viennent du Thing et **changent avec le véhicule branché**. + /// Les déclarer en configuration en ferait une seconde source de vérité, qui divergerait + /// dès qu'une autre voiture se branche. + /// + /// Ce qu'elle porte, c'est ce qui est commun à toutes les charges : `id` (le ThingId de + /// la borne), `label`, `domain` (`ev`), `priority`, `enabled`, `mode: "dynamic"`. + bool get isEvCharger => adapter == 'evcharger'; + + /// Vrai pour le mécanisme continu — c'est le **défaut** du schéma. + /// + /// La borne en est exclue explicitement : elle tombait dans ce repli par construction, + /// et l'écran de mécanisme lui proposait alors une puissance nominale et des paliers, + /// c'est-à-dire des réglages qui n'existent pas pour elle. + bool get isVariableLoad => !isRelayRouter && !isSgReady && !isEvCharger; // ── Besoins (mise à jour EN PLACE côté moteur, jamais une reconstruction) ─── String get dailyDeadline { @@ -176,6 +194,12 @@ class LoadConfigEntry { /// Miroir exact de `LoadConfig::claimedThingIds()` (`loadconfig.cpp:150-166`) : la liste /// dépend du mécanisme. `etmvariableload` pilote le Thing qui porte son propre `id` ; /// les deux autres nomment leurs relais. + /// + /// **La borne suit la même règle que le modulable** — son `id` EST le ThingId de la + /// borne — et sans code dédié, des deux côtés. Ce n'est pas une commodité : c'est ce qui + /// interdit de déclarer une même borne à la fois comme `evcharger` et comme charge + /// pilotée. Deux commandeurs sur un même organe, exactement ce que LM-201 refuse, et + /// [validateSet] le voit avant l'envoi. Set get claimedThingIds { if (isSgReady) { final sg = raw['sgReady']; @@ -192,7 +216,7 @@ class LoadConfigEntry { return relays.map((r) => normalizeThingId(r.thingId)).toSet() ..removeWhere((e) => e.isEmpty); } - // etmvariableload : l'id de la charge EST le ThingId piloté. + // etmvariableload ET evcharger : l'id de la charge EST le ThingId piloté. final n = normalizeThingId(id); return n.isEmpty ? const {} : {n}; } diff --git a/lib/screens/energy/load_mechanism_screen.dart b/lib/screens/energy/load_mechanism_screen.dart index 269896f..88ec4dd 100644 --- a/lib/screens/energy/load_mechanism_screen.dart +++ b/lib/screens/energy/load_mechanism_screen.dart @@ -86,7 +86,14 @@ class _LoadMechanismScreenState extends State { const SizedBox(height: 14), _Mecanisme(entry: e, provider: provider), const SizedBox(height: 14), - if (e.isRelayRouter) + // Une borne ne porte AUCUNE charge utile de mécanisme, et pas non plus + // de temporisation : `minOnS`/`minOffS` valent 0 et ne se règlent pas + // ici. Le repli `_SectionModulable` lui proposait une puissance nominale + // et des paliers — des réglages qui n'existent pas pour elle, et dont + // l'écriture partirait quand même dans `SetLoadConfig`. + if (e.isEvCharger) + _SectionBorne(entry: e, telemetry: telemetry) + else if (e.isRelayRouter) _SectionRelais( entry: e, provider: provider, @@ -102,7 +109,9 @@ class _LoadMechanismScreenState extends State { schema: schema, telemetry: telemetry), const SizedBox(height: 14), - if (e.isSgReady) + if (e.isEvCharger) + const SizedBox.shrink() + else if (e.isSgReady) _Temporisation( titre: 'Anti court-cycle', sousTitre: 'Durée avant tout changement d\'état.', @@ -297,6 +306,29 @@ class _Mecanisme extends StatelessWidget { @override Widget build(BuildContext context) { + // Une BORNE n'est pas un mécanisme au choix. Son entrée est créée d'office par le + // plugin à la détection du Thing, et `adapter: "evcharger"` n'est pas dans + // [kMecanismes] : proposer les trois autres reviendrait à offrir de transformer une + // borne en routeur de relais — une bascule que `basculerMecanisme()` sait construire + // et qui détruirait le rattachement, sans qu'aucun écran n'ait de quoi le défaire. + if (entry.isEvCharger) { + return _Carte( + titre: 'Mécanisme', + badge: 'imposé · evcharger', + sousTitre: 'La borne est déclarée par la box à la détection de l\'appareil.', + child: Text( + 'Une borne ne porte aucun réglage de mécanisme : ni contacts, ni paliers, ni ' + 'puissance nominale. Ses limites viennent de la borne et de la voiture ' + 'branchée, et changent avec elle — les figer ici en ferait une seconde source ' + 'de vérité, fausse dès le prochain véhicule.\n\n' + 'Ce qui se règle depuis cet écran : le libellé, le domaine, le rang. Le mode de ' + 'recharge, lui, se règle depuis la carte véhicule du tableau de bord.', + style: TextStyle( + fontSize: 12, height: 1.4, color: EtmTokens.mutedOf(context)), + ), + ); + } + return _Carte( titre: 'Mécanisme', badge: 'enregistré · adapter', @@ -350,6 +382,85 @@ class _Mecanisme extends StatelessWidget { } } +/// Borne de recharge — **rien à régler ici, et le dire vaut mieux que se taire**. +/// +/// L'entrée `evcharger` est créée d'office par le plugin à la détection du Thing, pour que +/// l'invariant `loads[] ⊆ GetLoadConfig` tienne : une charge publiée en télémétrie doit +/// pouvoir résoudre son libellé, son domaine et son rang quelque part. Elle ne porte +/// **aucune** charge utile de mécanisme. +/// +/// Ce que cette section montre est donc du **runtime**, en lecture seule : le nombre de +/// phases, le courant, le plancher, et la présence d'un véhicule. Les afficher n'est pas +/// décoratif — c'est ici que se lit l'écart entre ce que l'arbitre a commandé et ce que la +/// borne fait, écart que l'allocation seule ne montre jamais. +/// +/// Et le plancher d'une borne n'est pas un réglage : il vaut `max(borne, voiture)` et +/// change avec le véhicule branché. Le proposer à la saisie créerait une seconde source de +/// vérité, fausse dès la voiture suivante. +class _SectionBorne extends StatelessWidget { + final LoadConfigEntry entry; + final LoadTelemetryProvider telemetry; + + const _SectionBorne({required this.entry, required this.telemetry}); + + @override + Widget build(BuildContext context) { + final muted = EtmTokens.mutedOf(context); + final live = telemetry.entryFor(entry.id); + final mech = live?.mechanism; + + final lignes = []; + if (mech != null) { + 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('plancher ${mech.minPowerW!.round()} W'); + } + if (mech.pluggedIn != null) { + lignes.add(mech.pluggedIn! ? 'véhicule branché' : 'aucun véhicule'); + } + } + + return _Carte( + titre: 'Borne', + badge: 'lecture seule · runtime', + sousTitre: 'Ce que la box publie pour cette borne au dernier cycle.', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (lignes.isEmpty) + // Absente de la télémétrie : configurée, mais hors arbitrage EN CE MOMENT. + // Depuis +etm24, c'est notamment le cas quand aucun véhicule n'est branché. + // « Perdue » serait faux, et « désactivée » aussi. + Text( + 'Hors arbitrage en ce moment — la box ne publie rien pour elle au dernier ' + 'cycle. C\'est le cas sans véhicule branché, sans voiture assignée, ou en ' + 'mode manuel. La borne n\'est ni perdue ni désactivée : son entrée de ' + 'configuration est là, au-dessus.', + style: TextStyle(fontSize: 12, height: 1.4, color: muted), + ) + else ...[ + Text(lignes.join(' · '), + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: EtmTokens.inkOf(context))), + const SizedBox(height: 6), + Text( + 'Mesuré, pas réglé. Le plancher d\'une borne vaut max(borne, voiture) et ' + 'suit le véhicule branché : il n\'y a pas de champ à saisir, et il ne faut ' + 'pas en fabriquer un.', + style: TextStyle(fontSize: 11.5, height: 1.35, color: muted), + ), + ], + ], + ), + ); + } +} + /// Configuration **héritée** : `etmvariableload` en multipalier natif (`fixed`). /// /// L'app ne propose plus ce mode — le plugin lui-même le qualifie de *legacy* — mais elle diff --git a/lib/services/load_priority.dart b/lib/services/load_priority.dart index d9d8fe5..cc7890e 100644 --- a/lib/services/load_priority.dart +++ b/lib/services/load_priority.dart @@ -31,12 +31,17 @@ import '../models/load_config_entry.dart'; /// La **batterie** n'y figure pas : sa place s'exprime en cible de SOC, pas en enveloppe /// de watts. /// -/// Deux limites à ne pas présenter comme du pilotage : -/// - le rang de `ev` est **décoratif** tant que l'EV est décidé en proxy AVANT le -/// waterfall (transplantation 3g non faite) ; -/// - le couplage clim → baisse de demande du chauffage passe par l'inertie du bâti ; il -/// n'est ni mesuré ni vérifiable avec le modèle actuel. C'est une stratégie **posée par -/// l'ordre de priorité**, pas un asservissement. +/// Le rang de `ev` **n'est plus décoratif** : depuis le lot plugin 3g-2 (`+etm23`), les +/// bornes sont arbitrées par le waterfall comme les autres charges et portent un +/// `LoadConfig.priority` ordinaire. Il n'existe **pas** de second système de priorité pour +/// les bornes, et il n'y en aura pas : un classement propre aux bornes ne saurait pas +/// exprimer « VE1 > ECS > VE2 », et deux classements qui ne peuvent pas s'interclasser +/// sont un défaut, pas deux fonctionnalités. +/// +/// Une limite reste, à ne pas présenter comme du pilotage : le couplage clim → baisse de +/// demande du chauffage passe par l'inertie du bâti ; il n'est ni mesuré ni vérifiable +/// avec le modèle actuel. C'est une stratégie **posée par l'ordre de priorité**, pas un +/// asservissement. const List kDefaultDomainOrder = ['ev', 'ecs', 'hvac', 'heating']; /// Domaines que l'app a le droit de **proposer à l'affectation**. @@ -98,6 +103,32 @@ class GroupedOrder { }); } +/// Charges dont le rang est **à égalité** avec au moins une autre. +/// +/// Le tri reste **total** — l'identifiant départage, donc l'ordre affiché ne bouge pas +/// d'une lecture à l'autre. Mais reproductible n'est pas choisi, et la distinction compte +/// à la mise en service. +/// +/// À la création automatique d'une borne (`+etm23`), le plugin lui donne « le plus petit +/// rang existant moins un, borné à 1 ». Quand une charge occupe déjà le rang 1 — c'est le +/// cas dès qu'une installation a été réglée — l'intention « en tête » **dégénère en +/// égalité** : sur le banc, les deux bornes et le chauffe-eau sont tous à 1. +/// +/// Conséquence pour l'écran : **l'ordre affiché avant tout réglage de l'installateur est +/// un artefact, pas un choix.** Le présenter comme un réglage ferait valider par +/// inadvertance un classement que personne n'a posé. Le point n'est pas tranché côté +/// moteur ; les vrais rangs se posent à la mise en service, ici. +List tiedByPriority(List entries) { + final counts = {}; + for (final e in entries) { + counts[e.priority] = (counts[e.priority] ?? 0) + 1; + } + return [ + for (final e in entries) + if ((counts[e.priority] ?? 0) > 1) e, + ]; +} + /// Reconstitue les deux niveaux depuis la liste brute de `GetLoadConfig`. /// /// Le rang de domaine est l'**ordre d'apparition** dans l'ordre à plat trié par diff --git a/lib/widgets/controllable_loads_section.dart b/lib/widgets/controllable_loads_section.dart index 4af8b71..c1cfcc6 100644 --- a/lib/widgets/controllable_loads_section.dart +++ b/lib/widgets/controllable_loads_section.dart @@ -8,7 +8,6 @@ import '../providers/load_config_provider.dart'; import '../providers/load_telemetry_provider.dart'; import '../services/load_hardware.dart'; import '../services/load_priority.dart'; -import '../services/nymea_service.dart'; import '../theme/etm_tokens.dart'; import 'load_cards.dart'; @@ -74,6 +73,7 @@ class ControllableLoadsSection extends StatelessWidget { // Le budget du cycle AVANT les charges : « Alloué 3 000 W » ne veut rien dire // tant qu'on ne sait pas s'il y avait 3 100 ou 12 000 W à distribuer. TelemetryHeader(telemetry: telemetry), + _NoteRangsExAequo(entries: provider.entries), // Le niveau glissable est celui des DOMAINES, pas des cartes. // // Deux ReorderableListView imbriqués se disputent le geste — l'extérieur gagne et @@ -99,10 +99,11 @@ class ControllableLoadsSection extends StatelessWidget { onConfigure: onConfigure, ), ), - // La borne n'est PAS une LoadConfig : elle n'apparaît donc dans aucun domaine - // ci-dessus, et sans cette section elle serait tout simplement invisible — ce - // qu'un installateur qui vient de la configurer lit comme une perte. - const BorneSection(), + // Plus de section séparée pour les bornes : depuis `+etm23` elles ONT une entrée + // `GetLoadConfig` (`adapter: "evcharger"`, `domain: "ev"`, un rang), donc elles + // apparaissent dans leur domaine comme n'importe quelle charge. La section qui + // existait pour les rattraper les ferait maintenant apparaître DEUX fois, en + // affirmant qu'elles n'ont « ni rang ni domaine » — ce qui n'est plus vrai. if (provider.hasPendingChanges) ...[ const SizedBox(height: 4), ImpactSaveBar(provider: provider), @@ -112,6 +113,62 @@ class ControllableLoadsSection extends StatelessWidget { } } +/// **L'ordre affiché n'est pas un choix quand des rangs sont à égalité.** +/// +/// À la détection d'une borne, le plugin lui attribue « le plus petit rang existant moins +/// un, borné à 1 ». Dès qu'une charge occupe déjà le rang 1 — le cas de toute installation +/// déjà réglée — l'intention « en tête » dégénère en égalité, et plusieurs charges se +/// retrouvent au même rang. +/// +/// Le tri reste **total** : l'identifiant départage, donc l'ordre ne bouge pas d'un cycle +/// à l'autre. Mais reproductible n'est pas choisi, et c'est toute la différence pour qui +/// regarde cette liste à la mise en service. Sans cette note, l'installateur lit un +/// classement, le trouve plausible, et n'y touche pas — validant par inadvertance un +/// ordre que personne n'a posé. +/// +/// Elle disparaît d'elle-même dès que les rangs sont distincts, c'est-à-dire dès le +/// premier vrai réglage. +class _NoteRangsExAequo extends StatelessWidget { + final List entries; + + const _NoteRangsExAequo({required this.entries}); + + @override + Widget build(BuildContext context) { + final exAequo = tiedByPriority(entries); + if (exAequo.isEmpty) return const SizedBox.shrink(); + + final noms = exAequo.map((e) => e.label).join(', '); + return 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: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Icon(Icons.low_priority_rounded, + size: 15, color: EtmTokens.inkOf(context)), + const SizedBox(width: 7), + Expanded( + child: Text( + 'Rangs à égalité : $noms. L\'ordre affiché entre elles est stable, mais ' + 'ce n\'est pas un réglage — c\'est ce qui reste quand personne n\'a ' + 'tranché. Les bornes détectées arrivent au rang 1 par défaut, sur lequel ' + 'une autre charge est souvent déjà posée. Classez-les avant la mise en ' + 'service.', + style: TextStyle( + fontSize: 11.5, height: 1.35, color: EtmTokens.inkOf(context)), + ), + ), + ]), + ), + ); + } +} + /// Un domaine et ses charges. Les domaines se déplacent entre eux, les charges à /// l'intérieur — les deux niveaux que l'ordre de service demande. class _DomainBlock extends StatelessWidget { @@ -706,121 +763,6 @@ class _EmptyNote extends StatelessWidget { } -/// Les bornes de recharge, qui **ne sont pas des charges pilotables**. -/// -/// ## Pourquoi elles ont leur propre section -/// -/// Une borne n'a ni `adapter`, ni `priority`, ni `domain` : elle ne passe pas par -/// `SetLoadConfig` mais par `SetChargingInfo`, un **autre contrat**. Et elle n'apparaît -/// pas dans `GetLoadTelemetry` en beta — elle est décidée **en amont** du waterfall, sa -/// puissance étant seulement réservée dans `budget.evReservedW`. Elle rejoindra `loads[]` -/// à l'étape 3g. -/// -/// Conséquence pratique, et c'est le défaut que cette section corrige : configurée ou non, -/// la borne n'apparaissait **nulle part** dans cet écran. Un installateur qui vient de la -/// régler et ne la retrouve pas conclut, raisonnablement, que son réglage s'est perdu. -class BorneSection extends StatelessWidget { - const BorneSection({super.key}); - - @override - Widget build(BuildContext context) { - final service = context.watch(); - final classesBorne = { - for (final c in service.thingClasses) - if (c.interfaces.contains('evcharger')) c.id - }; - final bornes = [ - for (final t in service.things) - if (classesBorne.contains(t.thingClassId)) t - ]; - if (bornes.isEmpty) return const SizedBox.shrink(); - - final muted = EtmTokens.mutedOf(context); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 6), - Text('Recharge véhicule', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w700, - 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, - margin: const EdgeInsets.only(bottom: 10), - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: EtmTokens.surfaceOf(context), - borderRadius: BorderRadius.circular(EtmTokens.radiusCard), - border: Border.all(color: EtmTokens.lineOf(context)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row(children: [ - Icon(Icons.ev_station_rounded, size: 19, color: muted), - const SizedBox(width: 9), - Expanded( - child: Text(b.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w700, - color: EtmTokens.inkOf(context))), - ), - ]), - const SizedBox(height: 6), - Text( - 'Pas une charge pilotable : la borne se règle par un autre contrat ' - '(SetChargingInfo) et n\'a ni rang ni domaine. Elle est décidée en ' - 'amont du waterfall — sa puissance est réservée dans le budget, pas ' - 'allouée depuis lui.', - style: TextStyle(fontSize: 11.5, height: 1.35, color: muted), - ), - const SizedBox(height: 4), - Text( - 'Le mode de recharge se règle depuis la carte véhicule du tableau de ' - '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), - ], - ), - ), - ], - ); - } -} - - /// Petite flèche de rang, à l'intérieur d'un domaine. /// /// Le glisser-déposer appartient au domaine ; à l'intérieur, deux charges se réordonnent @@ -850,68 +792,3 @@ 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/evcharger_config_test.dart b/test/evcharger_config_test.dart new file mode 100644 index 0000000..5c83e31 --- /dev/null +++ b/test/evcharger_config_test.dart @@ -0,0 +1,161 @@ +/// Les bornes **en configuration** — l'invariant `loads[] ⊆ GetLoadConfig`, rétabli. +/// +/// 3g-1 avait rompu l'inclusion sans le signaler : les bornes entraient dans `loads[]` de +/// `GetLoadTelemetry` sans figurer dans aucune `GetLoadConfig`. C'était la charge fantôme +/// que le lot B-bis avait supprimée, revenue par l'autre bout — et l'app s'appuie sur cet +/// invariant pour résoudre libellé, domaine et rang de **toute** charge publiée. +/// +/// Depuis `+etm23`, toute borne détectée reçoit d'office une entrée `LoadConfig`. La +/// fixture est le dump réel de `.75` en `+etm24` : quatre entrées là où il y en avait deux. +/// +/// Ce que ces tests protègent, et qui n'est pas évident : +/// +/// 1. une borne **ne porte aucune charge utile de mécanisme**, et ne doit donc pas tomber +/// dans le repli « modulable » — l'écran lui proposerait une puissance nominale et des +/// paliers qui n'existent pas pour elle ; +/// 2. l'aller-retour verbatim reste **neutre** avec ces entrées (vérifié aussi sur machine +/// le 2026-08-27, verdict « IDENTIQUE ») ; +/// 3. le glissement les traite **sans cas particulier** — c'est tout l'intérêt d'avoir une +/// seule liste ; +/// 4. le rang par défaut **dégénère en égalité**, et l'ordre affiché avant réglage est un +/// artefact qu'il faut nommer. +library; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:etm_powersync_app/models/load_config_entry.dart'; +import 'package:etm_powersync_app/services/load_mechanism_switch.dart'; +import 'package:etm_powersync_app/services/load_priority.dart'; +import 'package:flutter_test/flutter_test.dart'; + +List _banc() { + final raw = File('test/fixtures/loadconfig_hems75_evcharger.json').readAsStringSync(); + final list = (jsonDecode(raw) as Map)['loadConfigs'] as List; + return [ + for (final m in list.whereType()) LoadConfigEntry(Map.from(m)), + ]; +} + +void main() { + group('3g-2 — les bornes ont une entrée de configuration', () { + test('le dump du banc porte quatre charges, dont deux bornes', () { + final e = _banc(); + expect(e.length, 4); + final bornes = e.where((x) => x.isEvCharger).toList(); + expect(bornes.map((b) => b.label), + containsAll(['Simulated wallbox', 'Terra AC Charger (TCP)'])); + for (final b in bornes) { + expect(b.adapter, 'evcharger'); + expect(b.mode, 'dynamic'); + expect(b.domain, 'ev'); + expect(b.enabled, isTrue); + } + }); + + test('une borne ne tombe PAS dans le repli « modulable »', () { + // C'était le cas avant : `isVariableLoad` étant défini par défaut, l'écran de + // mécanisme offrait à une borne une puissance nominale et des paliers — des + // réglages qui n'existent pas pour elle, et dont l'écriture partirait quand même. + final borne = _banc().firstWhere((x) => x.isEvCharger); + expect(borne.isVariableLoad, isFalse); + expect(borne.isRelayRouter, isFalse); + expect(borne.isSgReady, isFalse); + // Et son adaptateur n'est pas dans la liste des mécanismes proposables : proposer + // une bascule reviendrait à offrir de transformer une borne en routeur de relais. + expect(kMecanismes, isNot(contains('evcharger'))); + }); + + test('elle ne porte AUCUNE charge utile de mécanisme', () { + // Délibéré côté plugin : les limites d'une borne viennent du Thing et changent avec + // le véhicule branché. Les déclarer ici en ferait une seconde source de vérité. + final borne = _banc().firstWhere((x) => x.isEvCharger); + expect(borne.relays, isEmpty); + expect(borne.sgReadyStates, isEmpty); + expect(borne.powerLevelsInt, isEmpty); + expect(borne.maxPowerW, 0); + expect(borne.minOnS, 0); + expect(borne.minOffS, 0); + }); + + test('son id EST le ThingId de la borne — donc elle revendique son Thing', () { + // Ce n'est pas une commodité : c'est ce qui interdit de déclarer la même borne à la + // fois comme evcharger et comme charge pilotée. Deux commandeurs sur un organe, + // exactement ce que LM-201 refuse. + final borne = _banc().firstWhere((x) => x.isEvCharger); + expect(borne.claimedThingIds, {normalizeThingId(borne.id)}); + + final doublon = LoadConfigEntry({ + ...borne.raw, + 'id': borne.id, + 'label': 'la même borne, déclarée deux fois', + }); + expect(validateSet([borne, doublon])?.kind, 'duplicateId'); + }); + + test('l\'aller-retour verbatim est neutre — rien n\'est amputé', () { + // `patched()` sans argument doit rendre la map d'origine à l'identique : c'est ce + // qui préserve les charges utiles que l'app ne modélise pas. + for (final e in _banc()) { + expect(e.patched(), e.raw, reason: 'aller-retour non neutre sur ${e.id}'); + } + }); + + test('le glissement traite les bornes sans cas particulier', () { + final groupes = groupByDomain(_banc()); + expect(groupes.isContiguous, isTrue, + reason: 'les deux bornes doivent former une plage continue dans le domaine ev'); + final ev = groupes.groups.firstWhere((g) => g.domain == 'ev'); + expect(ev.loads.length, 2); + + // Réaplatir renumérote 1..N sur TOUTE la liste, bornes comprises, et ne touche que + // `priority`. Aucune branche ne regarde l'adaptateur. + final charge = flattenToPayload(groupes.groups); + expect(charge.length, 4); + expect([for (final m in charge) m['priority']], [1, 2, 3, 4]); + for (final m in charge) { + final avant = _banc().firstWhere((e) => e.id == m['id']); + for (final k in avant.raw.keys) { + if (k == 'priority') continue; + expect(m[k], avant.raw[k], reason: 'clé $k altérée sur ${m['id']}'); + } + } + }); + + test('inverser une borne et une charge pilotée est un échange ordinaire', () { + final groupes = groupByDomain(_banc()).groups; + final inverse = [...groupes.reversed]; + final charge = flattenToPayload(inverse); + // Les domaines ont permuté, les rangs sont refaits, et rien d'autre n'a bougé. + expect([for (final m in charge) m['priority']], [1, 2, 3, 4]); + expect(charge.first['domain'], groupes.last.domain); + }); + }); + + group('3g-2 — le rang par défaut d\'une borne est un ARTEFACT', () { + test('trois charges du banc partagent le rang 1', () { + // À la création, le plugin donne « le plus petit rang existant moins un, borné à + // 1 ». Le chauffe-eau occupant déjà le rang 1, l'intention « en tête » dégénère. + final exAequo = tiedByPriority(_banc()); + expect(exAequo.length, 3); + expect(exAequo.map((e) => e.priority).toSet(), {1}); + expect(exAequo.where((e) => e.isEvCharger).length, 2); + }); + + test('l\'ordre reste reproductible — mais reproductible n\'est pas choisi', () { + // Le tri est total : l'id départage, donc l'écran ne bouge pas d'une lecture à + // l'autre. C'est exactement ce qui rend l'artefact crédible, et donc dangereux à + // taire. + final a = groupByDomain(_banc()).flatOrder.map((e) => e.id).toList(); + final b = groupByDomain(_banc().reversed.toList()).flatOrder.map((e) => e.id).toList(); + expect(a, b); + }); + + test('des rangs distincts font taire la note — dès le premier vrai réglage', () { + final regle = [ + for (final (i, e) in _banc().indexed) LoadConfigEntry(e.patched(priority: i + 1)), + ]; + expect(tiedByPriority(regle), isEmpty); + }); + }); +} diff --git a/test/fixtures/loadconfig_hems75_evcharger.json b/test/fixtures/loadconfig_hems75_evcharger.json new file mode 100644 index 0000000..cc9e53a --- /dev/null +++ b/test/fixtures/loadconfig_hems75_evcharger.json @@ -0,0 +1,138 @@ +{ + "_source": "dump reel HEMS 192.168.1.75, plugin 1.15.2+etm24, NymeaEnergy.GetLoadConfig, 2026-08-27", + "_note": "Le lot plugin 3g-2 (+etm23) cree d office une entree LoadConfig par borne detectee, pour retablir l invariant loads[] inclus dans GetLoadConfig. Deux entrees adapter=evcharger, mode=dynamic, domain=ev, SANS aucune charge utile de mecanisme. Noter les trois priority a 1 : rang par defaut degenere en egalite, artefact et non choix.", + "loadConfigs": [ + { + "adapter": "relay-router", + "domain": "ecs", + "enabled": true, + "id": "chauffe-eau", + "label": "chauffe-eau", + "maxPowerW": 0, + "meterThingId": "4938b26a-d862-437f-b681-aaff7bce23ff", + "minOffS": 60, + "minOnS": 60, + "minPowerW": 0, + "mode": "fixed", + "needs": { + "dailyDeadline": "", + "minEnergyWhPerDay": 0 + }, + "powerLevels": [], + "priority": 1, + "relays": [ + { + "powerW": 500, + "thingId": "{8538782f-2c8c-4a30-bfce-c8140f791c9b}" + }, + { + "powerW": 1000, + "thingId": "{2ebe6bef-829b-4695-9d11-ddb4c16c5448}" + }, + { + "powerW": 2000, + "thingId": "b033b212-1adb-4df0-ba2b-8fa477de52a2" + } + ], + "sensorThingId": "", + "sgReady": {} + }, + { + "adapter": "sg-ready", + "domain": "heating", + "enabled": true, + "id": "pac-terrain", + "label": "PAC banc", + "maxPowerW": 0, + "meterThingId": "e37be924-c5c5-4858-ad0c-c82650b96db0", + "minOffS": 0, + "minOnS": 0, + "minPowerW": 0, + "mode": "fixed", + "needs": { + "dailyDeadline": "", + "minEnergyWhPerDay": 0 + }, + "powerLevels": [], + "priority": 2, + "relays": [], + "sensorThingId": "", + "sgReady": { + "minStateHoldS": 300, + "states": [ + { + "estimatedPowerW": 0, + "relays": [ + "{beaf92e1-aedc-4b84-9ce4-e423648638cc}" + ], + "state": 1 + }, + { + "estimatedPowerW": 1200, + "relays": [], + "state": 2 + }, + { + "estimatedPowerW": 1500, + "relays": [ + "{bf236e64-5ae7-4bf1-82a6-4ede03de75a6}" + ], + "state": 3 + }, + { + "estimatedPowerW": 3000, + "relays": [ + "{beaf92e1-aedc-4b84-9ce4-e423648638cc}", + "{bf236e64-5ae7-4bf1-82a6-4ede03de75a6}" + ], + "state": 4 + } + ] + } + }, + { + "adapter": "evcharger", + "domain": "ev", + "enabled": true, + "id": "{09e520fd-d472-47c4-8887-6e7759372889}", + "label": "Simulated wallbox", + "maxPowerW": 0, + "meterThingId": "", + "minOffS": 0, + "minOnS": 0, + "minPowerW": 0, + "mode": "dynamic", + "needs": { + "dailyDeadline": "", + "minEnergyWhPerDay": 0 + }, + "powerLevels": [], + "priority": 1, + "relays": [], + "sensorThingId": "", + "sgReady": {} + }, + { + "adapter": "evcharger", + "domain": "ev", + "enabled": true, + "id": "{88160e45-7df2-48cc-9639-f0d15ae6e382}", + "label": "Terra AC Charger (TCP)", + "maxPowerW": 0, + "meterThingId": "", + "minOffS": 0, + "minOnS": 0, + "minPowerW": 0, + "mode": "dynamic", + "needs": { + "dailyDeadline": "", + "minEnergyWhPerDay": 0 + }, + "powerLevels": [], + "priority": 1, + "relays": [], + "sensorThingId": "", + "sgReady": {} + } + ] +}