feat(3g-2): les bornes sont des charges configurées — une seule liste, un seul rang
+etm23 rend à chaque borne détectée une entrée GetLoadConfig, et l'invariant loads[] ⊆ GetLoadConfig est rétabli. Sur .75 : 2 entrées → 4. L'app s'appuyait déjà sur cet invariant pour résoudre libellé, domaine et rang de toute charge publiée. Vérifié sur machine avant d'écrire quoi que ce soit : - l'aller-retour verbatim reste NEUTRE avec les quatre entrées (loadconfig_roundtrip.dart, verdict IDENTIQUE) ; - une entrée evcharger porte id/label/domain/priority/enabled, mode dynamic, et AUCUNE charge utile de mécanisme. Trois défauts que ce contrat révèle côté app : 1. isVariableLoad était défini par défaut (« ni relais ni sg-ready »), donc une borne y tombait. L'écran de mécanisme lui proposait une puissance nominale, des paliers et des temporisations — des réglages qui n'existent pas pour elle et dont l'écriture partirait quand même. Pire, le sélecteur offrait de la basculer en routeur de relais : une bascule que basculerMecanisme() sait construire et qu'aucun écran ne sait défaire. Une borne a maintenant sa section, en lecture seule, qui dit pourquoi il n'y a rien à régler. 2. BorneSection existait pour rattraper des bornes qui n'apparaissaient nulle part. Elles apparaissent maintenant dans leur domaine : la section les afficherait DEUX fois, en affirmant qu'elles n'ont « ni rang ni domaine » et qu'elles ne passent pas par SetLoadConfig. Trois affirmations devenues fausses. Retirée ; son contenu de runtime vit désormais dans l'écran de mécanisme, où il a un sens. 3. Le rang par défaut d'une borne est un ARTEFACT, et l'écran le disait comme un réglage. À 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 en égalité — trois charges à 1 sur le banc. Le tri reste total (l'id départage), donc l'ordre est reproductible, et c'est précisément ce qui le rend crédible : l'installateur lit un classement plausible et n'y touche pas, validant un ordre que personne n'a posé. Une note le nomme, et disparaît dès le premier vrai réglage. Le glissement, lui, n'a demandé aucun cas particulier — c'était tout l'intérêt d'avoir une seule liste. Vérifié par test, y compris l'inversion borne / charge pilotée. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
This commit is contained in:
parent
4b42b732dc
commit
a44d64d6c2
72
Prompt 3g2 arbitre commande seul.md
Normal file
72
Prompt 3g2 arbitre commande seul.md
Normal file
@ -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`
|
||||
@ -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<String> 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};
|
||||
}
|
||||
|
||||
@ -86,7 +86,14 @@ class _LoadMechanismScreenState extends State<LoadMechanismScreen> {
|
||||
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<LoadMechanismScreen> {
|
||||
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 = <String>[];
|
||||
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
|
||||
|
||||
@ -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<String> 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<LoadConfigEntry> tiedByPriority(List<LoadConfigEntry> entries) {
|
||||
final counts = <int, int>{};
|
||||
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
|
||||
|
||||
@ -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<LoadConfigEntry> 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<NymeaService>();
|
||||
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<LoadTelemetryProvider>().entryFor(thingId);
|
||||
final mech = live?.mechanism;
|
||||
if (mech == null) return const SizedBox.shrink();
|
||||
|
||||
final muted = EtmTokens.mutedOf(context);
|
||||
final lignes = <String>[];
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
161
test/evcharger_config_test.dart
Normal file
161
test/evcharger_config_test.dart
Normal file
@ -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<LoadConfigEntry> _banc() {
|
||||
final raw = File('test/fixtures/loadconfig_hems75_evcharger.json').readAsStringSync();
|
||||
final list = (jsonDecode(raw) as Map<String, dynamic>)['loadConfigs'] as List;
|
||||
return [
|
||||
for (final m in list.whereType<Map>()) LoadConfigEntry(Map<String, dynamic>.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);
|
||||
});
|
||||
});
|
||||
}
|
||||
138
test/fixtures/loadconfig_hems75_evcharger.json
vendored
Normal file
138
test/fixtures/loadconfig_hems75_evcharger.json
vendored
Normal file
@ -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": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user