feat(config): lot C — un seul écran de configuration, et le grisage vient du schéma
FUSION. L'écran « Ordre de service » est supprimé ; sa route redirige. C'est son CHEMIN DE DONNÉES (LoadConfigProvider) qui a migré dans la présentation de « Rôles & appareils », jamais l'inverse. EmsRole fige six rôles dont dhw ET heatPump séparés — or LM-201 dit qu'une PAC sans ballon séparé est UNE charge : le modèle des rôles aurait exigé deux charges sur un canal unique, ce que validateSet() refuse à raison. Les charges viennent donc de GetLoadConfig. Les compteurs restent sur EnergySetupProvider (autre contrat, SetRootMeter). La note « le pilote peut bousculer l'ordre les jours Tempo rouge ou à l'approche d'une échéance » est retirée : aucun de ces comportements n'existe dans le moteur. ANNONCE AVANT ÉCRITURE. Miroir exact de EnergyArbitrator::sameHardware(). Le piège, vérifié sur .75 : la comparaison des relais est INDEXÉE, donc réordonner relays[] sans rien changer d'autre reconstruit l'adaptateur — contacts ouverts (applySafeState, force) et verrous réarmés à froid. Un glisser-déposer dans la liste des contacteurs est un geste matériel. L'écran le nomme, avec la durée de réarmement. GRISAGE PAR INTROSPECTION. RpcSchema lit JSONRPC.Introspect à la connexion ; trois états et non deux — écrivable, r: (mesure, pas fonction manquante), absent — plus « inconnu » quand le schéma n'a pas pu être lu. Chaque champ grisé porte sa raison et le nom du champ manquant. La version affichée est celle de l'API d'expérience (NymeaEnergy 0.8) : le paquet +etm15 n'est exposé par aucune méthode. CORRIGÉ AU PASSAGE — les modes de recharge étaient INOPÉRANTS. setChargingInfo() appelait EnergyPlugin.SetChargingInfo (namespace inexistant) avec mode/targetSoc/endTime/minCurrent au lieu de chargingMode/targetPercentage/endDateTime, et minCurrent n'existe dans aucun champ. Vérifié par appel réel : « Missing required key: chargingMode ». Le refus était de plus avalé et l'état local mis à jour quand même. Désormais l'état ne suit que si la box accepte, et le refus remonte sans en inventer la cause. ZONES — constaté, pas corrigé. L'hypothèse « champs grisés » est fausse : AirConditioning publie 8 méthodes toutes en écriture. ac_screen.dart n'en appelle aucune, ses 4 pièces sont des constantes de maquette, et .75 déclare zéro zone. getZones() ajouté + bandeau qui lit le vrai compte et annonce la maquette. Le câblage est un lot en soi, non exerçable sans zone. Tests 80/80, analyze 0 erreur. Relevé : docs/RELEVE_LOTC.md — dont §6, ce qui n'a PAS pu être vérifié : l'IU n'a pas pu être pilotée sur l'appareil, MIUI refusant INJECT_EVENTS. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
This commit is contained in:
parent
eac1dde4ee
commit
2a8ad95120
247
docs/RELEVE_LOTC.md
Normal file
247
docs/RELEVE_LOTC.md
Normal file
@ -0,0 +1,247 @@
|
||||
# RELEVÉ — lot C / app : écrans de configuration par charge
|
||||
|
||||
Box : **`.75` (`hems`)**, plugin **`1.15.2+etm15`**, nymea `1.15.2+202606191336~trixie1`,
|
||||
API `NymeaEnergy 0.8` · `AirConditioning 1.1` · Appareil : **Redmi Note 9S**, Android 11,
|
||||
USB · Date : **2026-08-25**.
|
||||
|
||||
> **La colonne « où » est ce qui compte.** Un extrait de test unitaire ne vaut pas
|
||||
> vérification machine et est annoncé comme tel. Ce qui n'a **pas** pu être vérifié est au
|
||||
> §6, nommément, avec la raison.
|
||||
|
||||
---
|
||||
|
||||
## 0. Ce que la box dit d'elle-même — et deux points du brief à corriger
|
||||
|
||||
`JSONRPC.Introspect` sur `.75` : **20** méthodes `NymeaEnergy.*`, **8** méthodes
|
||||
`AirConditioning.*`.
|
||||
|
||||
**(a) Le `CLAUDE.md` était déjà corrigé — le CODE ne l'était pas.** Le brief signale que les
|
||||
noms de `ChargingInfo` écrits dans `CLAUDE.md` ne sont pas ceux de la box. Vérification
|
||||
faite : `CLAUDE.md` porte bien `chargingMode` / `targetPercentage` / `endDateTime`, exacts.
|
||||
C'est `nymea_service.dart` qui était resté en arrière, et bien plus gravement — voir §3.
|
||||
|
||||
**(b) Les zones ne sont pas une surface réduite à griser.** L'hypothèse du brief était que
|
||||
la box n'expose pas grand-chose. C'est l'inverse : `AirConditioning` publie **huit méthodes,
|
||||
toutes en écriture** — `AddZone`, `RemoveZone`, `SetZoneName`, `SetZoneThings`,
|
||||
`SetZoneStandbySetpoint`, `SetZoneSetpointOverride`, `SetZoneWeekSchedule`, `GetZones`. Une
|
||||
zone porte thermostats, vannes, sondes intérieures/extérieures/ouvrants, consigne de veille,
|
||||
dérogation (mode + durée) et programme hebdomadaire complet.
|
||||
|
||||
Les champs de `ZoneInfo` sont tous `r:`, mais **ce n'est pas « lecture seule » au sens
|
||||
fonctionnel** : ils se règlent par les setters dédiés. Confondre les deux aurait grisé un
|
||||
écran en réalité pleinement pilotable. **Il n'y a rien à griser côté zones : c'est le
|
||||
câblage de l'écran qui manque** (§5).
|
||||
|
||||
**(c) La version du paquet n'est pas sur le fil.** `1.15.2+etm15` n'est exposée par aucune
|
||||
méthode — elle ne se lit que par `dpkg`. Ce que le fil porte, c'est la version de nymea et
|
||||
les **versions d'API d'expérience** (`NymeaEnergy 0.8`). C'est de toute façon le bon
|
||||
discriminant : il décrit le contrat, pas le paquet. Les écrans annoncent donc celle-là.
|
||||
|
||||
---
|
||||
|
||||
## 1. Vérifié sur `.75` — comportements d'écriture
|
||||
|
||||
### (a) La notification précède l'accusé — confirmé, systématiquement
|
||||
|
||||
Quatre `SetLoadConfig` consécutifs, en mesurant l'ordre d'arrivée **sur le fil** :
|
||||
|
||||
| Écriture | `energyError` | accusé | `LoadConfigChanged` | notification avant accusé |
|
||||
|---|---|---|---|---|
|
||||
| libellé (en place) | NoError | 262 ms | 262 ms | **oui** |
|
||||
| `relays[]` réordonnés | NoError | 267 ms | 267 ms | **oui** |
|
||||
| `enabled: false` | NoError | 264 ms | 264 ms | **oui** |
|
||||
| restauration | NoError | 264 ms | 264 ms | **oui** |
|
||||
|
||||
La règle §5-2 est donc confirmée : **l'enregistrement se confirme sur `LoadConfigChanged`**,
|
||||
et le provider était déjà écrit ainsi.
|
||||
|
||||
### (b) Réordonner `relays[]` reconstruit la charge — le piège du lot
|
||||
|
||||
Même ensemble de contacteurs, mêmes Things, mêmes puissances, **seul l'ordre change**
|
||||
(500/1000/2000 → 2000/1000/500). Journal de la box :
|
||||
|
||||
```
|
||||
[RelayRouter] chauffe-eau — reprise au démarrage : palier 0 W [correspondance exacte] …
|
||||
[EnergyArbitrator] relay-router construit depuis config: "chauffe-eau" ( 3 relais)
|
||||
[RelayRouter] "chauffe-eau" → … (force) | "Charge désactivée — mise en état sûr … (ECS-413)"
|
||||
[EnergyArbitrator] 2 charge(s) active(s) — 1 créée(s), 1 inchangée(s), 1 retirée(s)
|
||||
```
|
||||
|
||||
La comparaison de `sameHardware()` est **indexée**, pas ensembliste. Un glisser-déposer dans
|
||||
la liste des contacteurs est donc un **geste matériel** : contacts ouverts, verrous réarmés à
|
||||
froid. C'est le cas le moins intuitif du lot, et l'écran l'annonce nommément.
|
||||
|
||||
**Corollaire mesuré** : revenir en arrière coûte une **seconde** reconstruction. L'annulation
|
||||
n'est pas gratuite.
|
||||
|
||||
### (c) `enabled: false` ouvre bien les contacts — mais pas par le drapeau
|
||||
|
||||
Premier passage **inconcluant** et signalé comme tel : les contacts étaient déjà ouverts (PAC
|
||||
en état 2, surplus négatif). Repris en attendant un état 4 réel :
|
||||
|
||||
```
|
||||
20:56:19 état=4 surplus=8252 W contacts={'Relay4': True, 'Relay5': True}
|
||||
# SetLoadConfig enabled:false → EnergyErrorNoError
|
||||
# contacts APRÈS : {'Relay4': False, 'Relay5': False}
|
||||
# VERDICT : contacts ouverts par enabled:false ? OUI
|
||||
```
|
||||
|
||||
**Nuance tenue dans l'UI** : ce n'est pas le drapeau qui protège, c'est le **retrait de
|
||||
l'adaptateur** (ECS-413) — la charge sort d'une configuration vivante et passe par
|
||||
`applySafeState()`. Ce chemin n'existe pas au redémarrage du service, et pas non plus pour une
|
||||
charge déjà désactivée. L'infobulle de l'interrupteur dit exactement cela.
|
||||
|
||||
### (d) `loads[]` ne suit qu'au cycle suivant
|
||||
|
||||
Juste après `enabled: false`, `GetLoadTelemetry` contient **encore** la PAC : le `Get` rend le
|
||||
dernier cycle **publié**, il ne recalcule pas. Elle en sort au cycle suivant. Un client qui
|
||||
attend une disparition immédiate se trompe de contrat — c'est écrit sur la carte.
|
||||
|
||||
### (e) Les paliers atteignables ne sont PAS exposés
|
||||
|
||||
`GetLoadConfig` rend `"powerLevels": []` sur `chauffe-eau` (trois contacteurs), et
|
||||
`GetLoadTelemetry` n'expose que `maxStageW` et le `stageW` courant. **La liste des paliers
|
||||
dérivés n'est nulle part sur le RPC.** Conformément au brief, l'app **ne les recalcule pas** :
|
||||
elle affiche la somme câblée et dit franchement que le détail n'est pas publié. Deux
|
||||
implémentations de la même combinatoire divergeraient, et c'est l'app qui aurait tort.
|
||||
|
||||
---
|
||||
|
||||
## 2. Vérifié sur l'appareil (USB)
|
||||
|
||||
| Ce qui a été fait | Résultat |
|
||||
|---|---|
|
||||
| `flutter build apk --debug` (android-arm64) | ✔ construit |
|
||||
| `adb install` sur le Redmi Note 9S | ✔ installé |
|
||||
| Lancement de l'app | ✔ démarre, écran « Installations » |
|
||||
| Le téléphone (192.168.1.121) joint le banc | ✔ ping 4-20 ms |
|
||||
| L'app liste `nymea-dev-rpi` → `nymea://192.168.1.75:4444` | ✔ (capture d'écran) |
|
||||
|
||||
**Et c'est tout — l'IU n'a PAS pu être pilotée.** Voir §6.
|
||||
|
||||
---
|
||||
|
||||
## 3. ⚠️ Trouvé au passage : les boutons de mode de recharge étaient INOPÉRANTS
|
||||
|
||||
Hors du périmètre annoncé du lot, mais dans le chemin direct du §4 (« aucun champ grisé ne
|
||||
part dans une écriture »). Constaté, **puis corrigé**, parce que le lot touchait à ce même
|
||||
appel.
|
||||
|
||||
`nymea_service.setChargingInfo()` cumulait **trois** erreurs, chacune suffisante :
|
||||
|
||||
| Envoyé | Attendu par la box |
|
||||
|---|---|
|
||||
| namespace `EnergyPlugin.SetChargingInfo` | `NymeaEnergy.SetChargingInfo` — `EnergyPlugin.*` n'existe pas |
|
||||
| `mode: "Eco"` | `chargingMode: "ChargingModeEco"` (énumération complète) |
|
||||
| `targetSoc`, `endTime`, `minCurrent` | `targetPercentage`, `endDateTime` — et **`minCurrent` n'existe pas** |
|
||||
|
||||
Vérifié par appel réel sur `.75`, charge utile de l'app telle qu'elle était :
|
||||
|
||||
```json
|
||||
{"error": "Invalid params: Missing required key: chargingMode in
|
||||
NymeaEnergy.SetChargingInfo, param chargingInfo.chargingMode",
|
||||
"status": "error"}
|
||||
```
|
||||
|
||||
…et la charge utile corrigée : `{"energyError": "EnergyErrorNoError"}`.
|
||||
|
||||
**Les modes de recharge ne fonctionnaient donc pas du tout contre `+etm15`.** Le refus était
|
||||
en plus avalé silencieusement (`catch { _log }`) et l'état local mis à jour quand même :
|
||||
l'écran affichait un mode que la borne n'avait jamais reçu. Corrigé — l'état local ne suit
|
||||
que si la box accepte, et un refus remonte en `SnackBar` sans en inventer la cause.
|
||||
|
||||
**`minCurrent` est un cas de grisage, pas un oubli.** La box publie bien les modes
|
||||
`ChargingModeEcoWithMinCurrent` et `ChargingModeEcoMinWithTargetTime`, mais **aucun champ de
|
||||
courant** : le mode se choisit, son paramètre non. L'écran le dit au lieu d'offrir un réglage
|
||||
sans effet.
|
||||
|
||||
> Le banc a été remis **exactement** dans son état initial après ces essais :
|
||||
> `chargingMode: ChargingModeNormal`, `targetPercentage: 0`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Ce qui a été livré
|
||||
|
||||
| Écran / brique | État |
|
||||
|---|---|
|
||||
| Fusion « Ordre de service » → « Rôles & appareils » | ✔ l'écran clair est **supprimé**, sa route redirige |
|
||||
| Glisser-déposer deux niveaux (domaines, puis charges) | ✔ |
|
||||
| Télémétrie sur la carte de chaque charge | ✔ (widgets extraits, non dupliqués) |
|
||||
| `label`, `enabled`, `o:domain`, `priority` en écriture | ✔ |
|
||||
| Bouton **Configurer** par charge | ✔ vers l'écran du mécanisme |
|
||||
| Routeur de relais / Modulable / SG-Ready | ✔ trois formes distinctes, un seul écran hôte |
|
||||
| Grisage dérivé de `JSONRPC.Introspect` | ✔ `RpcSchema` + `SchemaGated`, chargé à la connexion |
|
||||
| Annonce d'impact avant écriture (`sameHardware`) | ✔ miroir exact du moteur, 12 tests |
|
||||
| Note « Tempo rouge / échéance » | ✔ **supprimée** — comportement inexistant dans le moteur |
|
||||
| Bandeau « tout est assigné » | ✔ conservé, c'est un état assumé |
|
||||
|
||||
**Le modèle des rôles ne survit pas comme source de vérité pour les charges.** `EmsRole` fige
|
||||
six rôles dont `dhw` **et** `heatPump` séparés — or LM-201 dit qu'une PAC sans ballon séparé
|
||||
est **une** charge. Le modèle des rôles aurait exigé d'en déclarer deux pour une machine,
|
||||
c'est-à-dire deux charges sur un canal unique, ce que `validateSet()` refuse à raison. Les
|
||||
charges viennent donc de `GetLoadConfig`. Les **compteurs**, « à configurer » et « sans
|
||||
appareil » restent sur `EnergySetupProvider` : ce sont des rôles inférés des Things, et le
|
||||
compteur principal relève d'un autre contrat (`Energy.SetRootMeter`).
|
||||
|
||||
Tests : **80 passent, 0 échec**, `flutter analyze` **0 erreur** sur les fichiers touchés.
|
||||
|
||||
---
|
||||
|
||||
## 5. Zones de climatisation — constaté, pas corrigé
|
||||
|
||||
`ac_screen.dart` (1009 lignes) n'appelle **aucune** méthode `AirConditioning.*` : les quatre
|
||||
pièces affichées sont des **constantes de maquette**. Et `.75` déclare **zéro zone**.
|
||||
|
||||
Le brief demandait des champs grisés ; §0-(b) montre qu'il n'y a rien à griser. Câbler
|
||||
l'écran sur les huit méthodes réelles est un travail à part entière, et **non exerçable en
|
||||
l'état** puisqu'aucune zone n'existe sur le banc. Je ne l'ai donc **pas** entrepris.
|
||||
|
||||
Ce qui a été fait, minimal et honnête : `getZones()` ajouté au service, et un bandeau en tête
|
||||
d'écran qui lit le **vrai** nombre de zones et annonce que les pièces affichées sont des
|
||||
exemples de maquette. Un écran qui invente quatre pièces sur une installation qui n'en déclare
|
||||
aucune est indistinguable d'une panne de lecture — c'était le risque immédiat.
|
||||
|
||||
**À décider** : câbler les zones est un lot en soi. Il demande au minimum une zone réelle sur
|
||||
le banc pour être vérifié.
|
||||
|
||||
---
|
||||
|
||||
## 6. Ce qui n'a PAS été vérifié, et pourquoi
|
||||
|
||||
1. **L'IU n'a pas pu être pilotée sur l'appareil.** `adb shell input tap` est refusé par la
|
||||
box Android :
|
||||
`SecurityException: Injecting to another application requires INJECT_EVENTS permission`.
|
||||
C'est le verrou MIUI sur le Redmi — il se lève dans les options développeur
|
||||
(« débogage USB (réglages de sécurité) »), ce qui exige un compte Xiaomi et un geste
|
||||
manuel. `xdotool` n'est pas installé sur la machine, donc la voie « bureau Linux » est
|
||||
fermée aussi. **Aucun écran de configuration n'a donc été vu s'afficher sur l'appareil,
|
||||
ni aucune écriture déclenchée depuis l'IU.** Ce qui a été vérifié sur l'appareil est au §2,
|
||||
et rien de plus.
|
||||
*Pour clore ce point* : activer le réglage sur le téléphone, ou piloter l'écran à la main
|
||||
en suivant §1 de ce document.
|
||||
2. **Les écritures depuis l'IU** n'ont donc été exercées qu'en **tests de widgets**, sur les
|
||||
dumps réels de `.75` (`merged_config_screen_test.dart`). Le rendu, l'annonce d'impact et le
|
||||
grisage y sont couverts ; le transport ne l'est pas.
|
||||
3. **Le grisage n'a pas été observé sur une box « ancienne »** — il n'y en a pas sous la main.
|
||||
Le comportement en champ absent est couvert en test à partir d'un schéma tronqué, pas d'une
|
||||
vraie box antérieure.
|
||||
4. **Batterie** : aucune charge `battery` n'existe sur `.75` (aucun `BatteryAdapter` côté
|
||||
moteur). Le point §6 du brief la concernant n'a pas pu être exercé.
|
||||
|
||||
---
|
||||
|
||||
## 7. Signalé au moteur — hors périmètre app, non corrigé
|
||||
|
||||
**Renommer une charge ne parvient pas à l'adaptateur.** `label` n'est pas dans
|
||||
`sameHardware()`, ce qui est correct — mais la mise à jour en place
|
||||
(`updateSoftConfig()`) ne porte que `priority` et `needs`. Un changement de libellé seul
|
||||
laisse donc l'adaptateur **inchangé** :
|
||||
|
||||
```
|
||||
[EnergyArbitrator] 2 charge(s) active(s) — 0 créée(s), 0 mise(s) à jour, 2 inchangée(s)
|
||||
```
|
||||
|
||||
`GetLoadConfig` rend bien le nouveau libellé (et c'est ce que l'app affiche), mais le
|
||||
**journal de la box continue d'afficher l'ancien** jusqu'à la prochaine reconstruction.
|
||||
Pour un installateur qui suit `journalctl` pendant une mise en service, c'est trompeur.
|
||||
Constaté sur `.75`, non corrigé : c'est du ressort du moteur.
|
||||
@ -10,12 +10,12 @@ import 'providers/installer_mode_provider.dart';
|
||||
import 'providers/load_config_provider.dart';
|
||||
import 'providers/load_telemetry_provider.dart';
|
||||
import 'providers/navigation_provider.dart';
|
||||
import 'providers/schema_provider.dart';
|
||||
import 'providers/scheduler_provider.dart';
|
||||
import 'providers/tariff_provider.dart';
|
||||
import 'screens/ac_screen.dart';
|
||||
import 'screens/dashboard_screen.dart';
|
||||
import 'screens/drawer/main_drawer.dart';
|
||||
import 'screens/energy/load_order_screen.dart';
|
||||
import 'screens/energy/roles_devices_screen.dart';
|
||||
import 'screens/energy/scheduler_screen.dart';
|
||||
import 'screens/energy/tariff_screen.dart';
|
||||
@ -110,9 +110,12 @@ GoRouter buildAppRouter(
|
||||
path: '/energy/setup',
|
||||
builder: (c, s) => const RolesDevicesScreen(),
|
||||
),
|
||||
// `/energy/loads` a été FUSIONNÉ dans `/energy/setup` : il n'existait qu'un seul
|
||||
// objet à configurer, décrit par deux écrans. La route est conservée et redirige,
|
||||
// pour ne pas casser les liens déjà posés ailleurs dans l'app.
|
||||
GoRoute(
|
||||
path: '/energy/loads',
|
||||
builder: (c, s) => const LoadOrderScreen(),
|
||||
redirect: (c, s) => '/energy/setup',
|
||||
),
|
||||
GoRoute(
|
||||
path: '/energy/scheduler',
|
||||
@ -188,8 +191,22 @@ void main() async {
|
||||
final tariff = TariffProvider();
|
||||
final loadConfig = LoadConfigProvider();
|
||||
final loadTelemetry = LoadTelemetryProvider();
|
||||
final rpcSchema = SchemaProvider();
|
||||
connectionManager.registerCacheClear(loadConfig.clearForSwitch);
|
||||
connectionManager.registerCacheClear(loadTelemetry.clearForSwitch);
|
||||
connectionManager.registerCacheClear(rpcSchema.clearForSwitch);
|
||||
|
||||
// Le schéma de la box décide de ce que les écrans activent ou grisent — y compris la
|
||||
// carte de recharge du tableau de bord, qui n'est pas sous l'écran de configuration.
|
||||
// Il se lit donc au niveau de l'app, dès que la connexion est établie, et non dans
|
||||
// l'initState d'un écran particulier : sinon un champ resterait gris jusqu'à ce que
|
||||
// l'utilisateur ait ouvert la bonne page.
|
||||
rpcSchema.attach(nymeaService);
|
||||
nymeaService.addListener(() {
|
||||
if (nymeaService.isConnected && !rpcSchema.schema.loaded && !rpcSchema.loading) {
|
||||
rpcSchema.load();
|
||||
}
|
||||
});
|
||||
connectionManager.registerCacheClear(energySetup.clearForSwitch);
|
||||
connectionManager.registerCacheClear(scheduler.clearForSwitch);
|
||||
connectionManager.registerCacheClear(tariff.clearForSwitch);
|
||||
@ -212,6 +229,7 @@ void main() async {
|
||||
ChangeNotifierProvider.value(value: tariff),
|
||||
ChangeNotifierProvider.value(value: loadConfig),
|
||||
ChangeNotifierProvider.value(value: loadTelemetry),
|
||||
ChangeNotifierProvider.value(value: rpcSchema),
|
||||
],
|
||||
child: NymeaEnergyApp(
|
||||
router: buildAppRouter(connectionManager, nymeaService, installerMode),
|
||||
|
||||
@ -29,6 +29,31 @@ class LoadRelay {
|
||||
final String thingId;
|
||||
final int powerW;
|
||||
const LoadRelay({required this.thingId, required this.powerW});
|
||||
|
||||
Map<String, dynamic> toMap() => {'thingId': thingId, 'powerW': powerW};
|
||||
}
|
||||
|
||||
/// Un état SG-Ready déclaré : `{state, relays[], estimatedPowerW}`.
|
||||
///
|
||||
/// [estimatedPowerW] est une **estimation**, jamais un engagement — une PAC ne consomme
|
||||
/// pas la même chose à −5 °C et à +12 °C. Elle sert à ordonner et à budgéter ; la mesure
|
||||
/// reste la source de vérité.
|
||||
class SgReadyState {
|
||||
final int state;
|
||||
final List<String> relays;
|
||||
final double estimatedPowerW;
|
||||
|
||||
const SgReadyState({
|
||||
required this.state,
|
||||
required this.relays,
|
||||
required this.estimatedPowerW,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toMap() => {
|
||||
'state': state,
|
||||
'relays': relays,
|
||||
'estimatedPowerW': estimatedPowerW,
|
||||
};
|
||||
}
|
||||
|
||||
/// Normalise un ThingId à la forme `{uuid}` en minuscules.
|
||||
@ -73,6 +98,60 @@ 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;
|
||||
|
||||
// ── Besoins (mise à jour EN PLACE côté moteur, jamais une reconstruction) ───
|
||||
String get dailyDeadline {
|
||||
final n = raw['needs'];
|
||||
return n is Map ? (n['dailyDeadline'] as String? ?? '') : '';
|
||||
}
|
||||
|
||||
int get minEnergyWhPerDay {
|
||||
final n = raw['needs'];
|
||||
return n is Map ? ((n['minEnergyWhPerDay'] as num?)?.toInt() ?? 0) : 0;
|
||||
}
|
||||
|
||||
/// Puissance nominale déclarée (mécanisme continu). **Saisie de l'installateur**,
|
||||
/// jamais une mesure : c'est une valeur de plaque.
|
||||
int get maxPowerW => (raw['maxPowerW'] as num?)?.toInt() ?? 0;
|
||||
|
||||
/// Paliers déclarés, entiers. Vides pour un routeur de relais — la box les **dérive**
|
||||
/// de `relays[]` et ne les republie pas ici (vérifié sur `.75` : `powerLevels: []`
|
||||
/// sur une charge à trois contacteurs).
|
||||
List<int> get powerLevelsInt {
|
||||
final l = raw['powerLevels'];
|
||||
if (l is! List) return const [];
|
||||
return [for (final v in l) if (v is num) v.toInt()];
|
||||
}
|
||||
|
||||
// ── Charge utile sg-ready ──────────────────────────────────────────────────
|
||||
Map<String, dynamic> get _sgReadyMap {
|
||||
final sg = raw['sgReady'];
|
||||
return sg is Map ? Map<String, dynamic>.from(sg) : const {};
|
||||
}
|
||||
|
||||
/// Durée de maintien d'état de la PAC. Une **reconstruction la réarme à froid**
|
||||
/// (ECS-412) : c'est la valeur à annoncer avant d'enregistrer.
|
||||
int get minStateHoldS =>
|
||||
(_sgReadyMap['minStateHoldS'] as num?)?.toInt() ?? 0;
|
||||
|
||||
/// États SG-Ready déclarés, **dans l'ordre de la charge utile** — le moteur les
|
||||
/// compare index par index, donc cet ordre est significatif.
|
||||
List<SgReadyState> get sgReadyStates {
|
||||
final st = _sgReadyMap['states'];
|
||||
if (st is! List) return const [];
|
||||
return [
|
||||
for (final m in st.whereType<Map>())
|
||||
SgReadyState(
|
||||
state: (m['state'] as num?)?.toInt() ?? 0,
|
||||
relays: (m['relays'] as List?)?.whereType<String>().toList() ?? const [],
|
||||
estimatedPowerW: (m['estimatedPowerW'] as num?)?.toDouble() ?? 0,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
List<LoadRelay> get relays {
|
||||
final list = raw['relays'];
|
||||
if (list is! List) return const [];
|
||||
@ -112,11 +191,38 @@ class LoadConfigEntry {
|
||||
///
|
||||
/// Toute clé absente des paramètres est transportée telle quelle — c'est ce qui
|
||||
/// préserve les charges utiles des mécanismes non modélisés par l'app.
|
||||
Map<String, dynamic> patched({int? priority, String? domain}) {
|
||||
///
|
||||
/// Les charges utiles de mécanisme ([relays], [sgReady], [powerLevels]) se patchent
|
||||
/// **entières** : ce sont des unions discriminées côté plugin (LM-300), et y remplacer
|
||||
/// une sous-clé isolée produirait un état que `isValid()` refuse en bloc.
|
||||
Map<String, dynamic> patched({
|
||||
int? priority,
|
||||
String? domain,
|
||||
String? label,
|
||||
bool? enabled,
|
||||
int? minOnS,
|
||||
int? minOffS,
|
||||
int? maxPowerW,
|
||||
String? mode,
|
||||
List<Map<String, dynamic>>? relays,
|
||||
List<int>? powerLevels,
|
||||
Map<String, dynamic>? sgReady,
|
||||
Map<String, dynamic>? needs,
|
||||
}) {
|
||||
return {
|
||||
...raw,
|
||||
'priority': ?priority,
|
||||
'domain': ?domain,
|
||||
'label': ?label,
|
||||
'enabled': ?enabled,
|
||||
'minOnS': ?minOnS,
|
||||
'minOffS': ?minOffS,
|
||||
'maxPowerW': ?maxPowerW,
|
||||
'mode': ?mode,
|
||||
'relays': ?relays,
|
||||
'powerLevels': ?powerLevels,
|
||||
'sgReady': ?sgReady,
|
||||
'needs': ?needs,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@ import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../models/load_config_entry.dart';
|
||||
import '../services/load_hardware.dart';
|
||||
import '../services/load_priority.dart';
|
||||
import '../services/nymea_service.dart';
|
||||
|
||||
@ -180,6 +181,73 @@ class LoadConfigProvider extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Renomme une charge. **Brouillon** : rien ne part avant [save].
|
||||
///
|
||||
/// Le libellé n'est pas du matériel : le moteur ne le compare pas dans
|
||||
/// `sameHardware()`, donc renommer ne reconstruit rien et ne réarme aucun verrou.
|
||||
/// Conséquence mesurée sur `.75` le 2026-08-25, et qui surprend : l'adaptateur est
|
||||
/// **réutilisé tel quel**, si bien que le journal de la box continue d'afficher
|
||||
/// l'ANCIEN libellé jusqu'à la prochaine reconstruction. `GetLoadConfig`, lui, rend
|
||||
/// le nouveau immédiatement — c'est ce que l'app montre, et c'est le bon.
|
||||
void setLabel(String loadId, String label) {
|
||||
_patchEntry(loadId, (e) => e.patched(label: label));
|
||||
}
|
||||
|
||||
/// Fait entrer ou sortir une charge de l'arbitrage. **Brouillon**.
|
||||
///
|
||||
/// `enabled: false` n'est pas un sectionnement, et l'UI ne doit pas le présenter
|
||||
/// ainsi. Ce qui met le matériel en sécurité, c'est le **retrait de l'adaptateur**
|
||||
/// (ECS-413) : la charge sort d'une configuration vivante, passe par
|
||||
/// `applySafeState()`, puis est détruite. Vérifié sur `.75` le 2026-08-25 — PAC en
|
||||
/// état 4, les deux contacts fermés, `enabled: false` posé : **les contacts se sont
|
||||
/// ouverts**. La nuance vaut quand même d'être tenue : le drapeau ne protège rien par
|
||||
/// lui-même, c'est le chemin de retrait qui agit, et il n'existe pas au redémarrage
|
||||
/// du service.
|
||||
void setEnabled(String loadId, bool enabled) {
|
||||
_patchEntry(loadId, (e) => e.patched(enabled: enabled));
|
||||
}
|
||||
|
||||
/// Remplace la charge utile d'une charge — sortie des écrans de mécanisme.
|
||||
///
|
||||
/// [raw] doit venir de `LoadConfigEntry.patched()`, donc de la map reçue de la box.
|
||||
void replaceEntry(String loadId, Map<String, dynamic> raw) {
|
||||
_patchEntry(loadId, (_) => raw);
|
||||
}
|
||||
|
||||
/// Applique un patch à une charge du brouillon, en préservant sa place.
|
||||
void _patchEntry(String loadId, Map<String, dynamic> Function(LoadConfigEntry) f) {
|
||||
final current = [for (final g in groups) g.copyWith(loads: [...g.loads])];
|
||||
var touched = false;
|
||||
for (final g in current) {
|
||||
final i = g.loads.indexWhere((e) => e.id == loadId);
|
||||
if (i < 0) continue;
|
||||
g.loads[i] = LoadConfigEntry(f(g.loads[i]));
|
||||
touched = true;
|
||||
break;
|
||||
}
|
||||
if (!touched) return;
|
||||
_draft = current;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Ce que l'enregistrement en brouillon va provoquer, charge par charge.
|
||||
///
|
||||
/// **À montrer AVANT d'écrire.** Une reconstruction ouvre les contacts et réarme les
|
||||
/// verrous à froid ; sur une PAC à `minStateHoldS` de 900 s, l'installateur qui
|
||||
/// corrige une ligne fige sa machine un quart d'heure. Le cas le moins intuitif est le
|
||||
/// simple réordonnancement de `relays[]` — la comparaison du moteur est indexée.
|
||||
List<LoadImpact> pendingImpact() {
|
||||
final draft = _draft;
|
||||
if (draft == null) return const [];
|
||||
return impactOfSave(
|
||||
avant: _entries,
|
||||
apres: [for (final g in draft) ...g.loads],
|
||||
);
|
||||
}
|
||||
|
||||
/// Vrai si l'enregistrement en brouillon touche au matériel d'au moins une charge.
|
||||
bool get pendingNeedsWarning => pendingImpact().any((i) => i.needsWarning);
|
||||
|
||||
/// Prépare une normalisation — **ne l'écrit pas**. L'installateur doit confirmer.
|
||||
void prepareNormalization() {
|
||||
final g = _grouped;
|
||||
@ -263,20 +331,30 @@ class LoadConfigProvider extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Signature = suite `id:priority:domain` dans l'ordre de service. C'est exactement
|
||||
/// ce que ce lot est autorisé à modifier ; comparer plus large ferait crier à la
|
||||
/// divergence sur un champ que l'app n'a pas touché.
|
||||
/// Signature de ce que l'app est autorisée à écrire : `id:priority:domain:label:
|
||||
/// enabled`, plus une empreinte du matériel. Comparer plus large ferait crier à la
|
||||
/// divergence sur un champ que l'app n'a pas touché ; comparer plus étroit laisserait
|
||||
/// passer une écriture de mécanisme silencieusement refusée.
|
||||
static String _signatureOfPayload(List<Map<String, dynamic>> payload) {
|
||||
final rows = [
|
||||
for (final m in payload)
|
||||
'${m['id']}:${m['priority']}:${m['domain'] ?? ''}',
|
||||
_rowOf(LoadConfigEntry(m)),
|
||||
]..sort();
|
||||
return rows.join('|');
|
||||
}
|
||||
|
||||
/// Empreinte d'une charge : ce que l'app écrit, matériel compris.
|
||||
static String _rowOf(LoadConfigEntry e) {
|
||||
final relays = [for (final r in e.relays) '${normalizeThingId(r.thingId)}@${r.powerW}'];
|
||||
final sg = [for (final s in e.sgReadyStates) '${s.state}:${s.estimatedPowerW}'];
|
||||
return '${e.id}:${e.priority}:${e.domain ?? ''}:${e.label}:${e.enabled}'
|
||||
':${e.minOnS}/${e.minOffS}:${e.maxPowerW}:${relays.join(",")}'
|
||||
':${e.minStateHoldS}:${sg.join(",")}';
|
||||
}
|
||||
|
||||
static String _signatureOfEntries(List<LoadConfigEntry> entries) {
|
||||
final rows = [
|
||||
for (final e in entries) '${e.id}:${e.priority}:${e.domain ?? ''}',
|
||||
for (final e in entries) _rowOf(e),
|
||||
]..sort();
|
||||
return rows.join('|');
|
||||
}
|
||||
|
||||
@ -169,6 +169,16 @@ class LoadTelemetryProvider extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Injecte une charge utile de télémétrie sans transport — **tests uniquement**.
|
||||
///
|
||||
/// Même porte que `LoadConfigProvider.adoptForTest` : éprouver l'affichage sur un dump
|
||||
/// RÉEL de la box sans monter un faux WebSocket.
|
||||
@visibleForTesting
|
||||
void adoptForTest(Map<String, dynamic> payload) {
|
||||
_adopt(payload);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _adopt(Map<String, dynamic> payload) {
|
||||
final t = LoadTelemetry.fromJson(payload);
|
||||
final now = DateTime.now();
|
||||
|
||||
90
lib/providers/schema_provider.dart
Normal file
90
lib/providers/schema_provider.dart
Normal file
@ -0,0 +1,90 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../services/nymea_service.dart';
|
||||
import '../services/rpc_schema.dart';
|
||||
|
||||
/// Porte le [RpcSchema] de la box courante et le tient à jour au changement
|
||||
/// d'installation.
|
||||
///
|
||||
/// ## Trois états, jamais deux
|
||||
///
|
||||
/// Un écran qui grise ses champs doit distinguer :
|
||||
///
|
||||
/// - **schéma lu, champ absent** → « cette box ne l'expose pas encore » ;
|
||||
/// - **schéma lu, champ `r:`** → mesure affichable, jamais éditable ;
|
||||
/// - **schéma non lu** → on ne sait pas, et il faut le dire.
|
||||
///
|
||||
/// Écraser le troisième cas sur l'un des deux premiers est exactement l'erreur que ce
|
||||
/// provider existe pour empêcher : un champ grisé sans raison se lit comme un bug ou un
|
||||
/// droit manquant, et un champ actif qui part en écriture vers une box qui n'en veut pas
|
||||
/// fait rejeter **tout** l'appel (nymea refuse la requête entière sur clé inconnue).
|
||||
class SchemaProvider extends ChangeNotifier {
|
||||
NymeaService? _service;
|
||||
|
||||
RpcSchema _schema = RpcSchema.unknown;
|
||||
bool _loading = false;
|
||||
String? _error;
|
||||
|
||||
RpcSchema get schema => _schema;
|
||||
bool get loading => _loading;
|
||||
|
||||
/// Message d'échec de lecture, s'il y en a eu un. Non nul ⇒ [RpcSchema.loaded] faux.
|
||||
String? get error => _error;
|
||||
|
||||
void attach(NymeaService service) {
|
||||
if (identical(_service, service)) return;
|
||||
_service = service;
|
||||
}
|
||||
|
||||
/// Lit le schéma. À appeler une fois la connexion établie — le résultat ne change pas
|
||||
/// tant que la box ne redémarre pas avec un autre paquet.
|
||||
Future<void> load() async {
|
||||
final service = _service;
|
||||
if (service == null) return;
|
||||
_loading = true;
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
try {
|
||||
final raw = await service.introspect();
|
||||
if (raw == null) {
|
||||
_schema = RpcSchema.unknown;
|
||||
_error = 'introspection indisponible';
|
||||
} else {
|
||||
_schema = RpcSchema.fromIntrospect(raw, hello: service.helloPayload);
|
||||
_error = null;
|
||||
}
|
||||
} catch (e) {
|
||||
_schema = RpcSchema.unknown;
|
||||
_error = e.toString();
|
||||
} finally {
|
||||
_loading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Ce qu'un écran peut annoncer honnêtement comme « version de la box ».
|
||||
///
|
||||
/// **Ce n'est pas la version du paquet du moteur.** `1.15.2+etm15` n'est exposée par
|
||||
/// aucune méthode JSON-RPC ; elle ne se lit que par `dpkg` sur la box. Ce que le fil
|
||||
/// porte, c'est la version d'API de l'expérience — qui est de toute façon le bon
|
||||
/// discriminant, puisqu'elle décrit le contrat et non le paquet qui l'implémente.
|
||||
String versionLine() {
|
||||
final parts = <String>[];
|
||||
final nymea = _schema.nymeaVersion ?? _service?.nymeaVersion;
|
||||
if (nymea != null && nymea.isNotEmpty) parts.add('nymea $nymea');
|
||||
final energy = _schema.experienceVersions['NymeaEnergy'];
|
||||
if (energy != null) parts.add('NymeaEnergy $energy');
|
||||
final ac = _schema.experienceVersions['AirConditioning'];
|
||||
if (ac != null) parts.add('AirConditioning $ac');
|
||||
if (parts.isEmpty) return 'version de la box inconnue';
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
/// Remet à zéro au changement d'installation — le schéma appartient à la box quittée.
|
||||
void clearForSwitch() {
|
||||
_schema = RpcSchema.unknown;
|
||||
_error = null;
|
||||
_loading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../providers/schema_provider.dart';
|
||||
import '../services/nymea_service.dart';
|
||||
import '../widgets/schema_gated.dart';
|
||||
import '../theme/etm_tokens.dart';
|
||||
import '../main.dart' show DrawerMenuButton;
|
||||
|
||||
@ -72,6 +77,9 @@ class _ACScreenState extends State<ACScreen> {
|
||||
const _Zone(name: 'Cuisine', currentTemp: 21.5, targetTemp: 21.0, mode: ACMode.auto, isOn: false),
|
||||
];
|
||||
|
||||
/// Nombre de zones RÉELLEMENT déclarées sur la box. `null` tant qu'on n'a pas lu.
|
||||
int? _zonesSurLaBox;
|
||||
|
||||
SGReadyState _sgReady = SGReadyState.forced;
|
||||
bool _sgAuto = true; // Héos pilote SG-Ready
|
||||
|
||||
@ -82,6 +90,16 @@ class _ACScreenState extends State<ACScreen> {
|
||||
double _climCurrent = 24.0;
|
||||
double _climTarget = 22.0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
if (!mounted) return;
|
||||
final zones = await context.read<NymeaService>().getZones();
|
||||
if (mounted) setState(() => _zonesSurLaBox = zones.length);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
@ -114,6 +132,9 @@ class _ACScreenState extends State<ACScreen> {
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildListDelegate([
|
||||
|
||||
_EtatDuCablage(zonesSurLaBox: _zonesSurLaBox),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
// ── ① Thermostats par pièce ─────────────────────────────────
|
||||
...List.generate(_zones.length, (i) {
|
||||
final zone = _zones[i];
|
||||
@ -1007,3 +1028,76 @@ class _SectionLabel extends StatelessWidget {
|
||||
return Text(text, style: EtmTokens.sectionLabel());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Dit franchement ce que cet écran montre — et ce qu'il ne montre pas encore.
|
||||
///
|
||||
/// ## Pourquoi ce bandeau plutôt qu'un grisage
|
||||
///
|
||||
/// Le lot prévoyait de **griser** les champs que la box n'expose pas. L'introspection de
|
||||
/// `.75` a montré l'inverse de l'hypothèse : `AirConditioning` publie **huit** méthodes,
|
||||
/// toutes en écriture — nom, thermostats, vannes, sondes, consigne de veille, dérogation
|
||||
/// avec mode et durée, programme hebdomadaire. Il n'y a donc **rien à griser** : la box
|
||||
/// est prête, c'est le câblage de cet écran qui manque.
|
||||
///
|
||||
/// Griser des champs ici aurait raconté une histoire fausse — « la box ne sait pas
|
||||
/// faire » au lieu de « l'app ne le demande pas encore ». Et les quatre pièces affichées
|
||||
/// plus bas sont des **exemples de maquette**, pas des zones lues : le dire est le
|
||||
/// minimum, puisqu'un écran qui invente quatre pièces sur une installation qui n'en
|
||||
/// déclare aucune est indistinguable d'une panne de lecture.
|
||||
class _EtatDuCablage extends StatelessWidget {
|
||||
final int? zonesSurLaBox;
|
||||
|
||||
const _EtatDuCablage({required this.zonesSurLaBox});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final schema = context.watch<SchemaProvider>();
|
||||
final n = zonesSurLaBox;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SchemaVersionBanner(
|
||||
versionLine: schema.versionLine(),
|
||||
schemaLoaded: schema.schema.loaded,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: EtmTokens.solar.withValues(alpha: 0.10),
|
||||
borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(Icons.construction_rounded, size: 16, color: EtmTokens.heat),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
n == null
|
||||
? 'Zones non lues sur la box. Les pièces ci-dessous sont des '
|
||||
'exemples de maquette, pas des zones réelles.'
|
||||
: n == 0
|
||||
? 'Cette box ne déclare aucune zone de climatisation. Les '
|
||||
'pièces ci-dessous sont des exemples de maquette, pas des '
|
||||
'zones réelles — cet écran n\'est pas encore branché sur '
|
||||
'AirConditioning.'
|
||||
: '$n zone(s) déclarée(s) sur la box, mais cet écran n\'est '
|
||||
'pas encore branché sur AirConditioning : les pièces '
|
||||
'ci-dessous restent des exemples de maquette.',
|
||||
style: EtmTokens.sans(
|
||||
size: 11.5,
|
||||
color: EtmTokens.mutedOf(context),
|
||||
height: 1.35),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
675
lib/screens/energy/load_mechanism_screen.dart
Normal file
675
lib/screens/energy/load_mechanism_screen.dart
Normal file
@ -0,0 +1,675 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../models/load_config_entry.dart';
|
||||
import '../../providers/load_config_provider.dart';
|
||||
import '../../providers/schema_provider.dart';
|
||||
import '../../services/load_hardware.dart';
|
||||
import '../../services/rpc_schema.dart';
|
||||
import '../../theme/etm_tokens.dart';
|
||||
import '../../widgets/schema_gated.dart';
|
||||
|
||||
/// Détail d'une charge — **un écran par mécanisme**, pas un formulaire unique.
|
||||
///
|
||||
/// Les trois contrats ne se ressemblent pas et n'ont pas à se ressembler : un routeur de
|
||||
/// relais déclare des contacteurs et laisse la box **dériver** ses paliers ; une charge
|
||||
/// continue déclare une puissance nominale et rien d'autre ; une PAC SG-Ready déclare un
|
||||
/// encodage à quatre états normés. Les fondre dans un formulaire commun obligerait à
|
||||
/// inventer des champs vides pour les deux tiers des cas.
|
||||
///
|
||||
/// ## Tout ce qui se règle ici est du MATÉRIEL
|
||||
///
|
||||
/// C'est la différence avec l'écran de liste, où l'on ne touche qu'au rang, au domaine,
|
||||
/// au libellé et à l'activation. Ici, chaque champ entre dans la comparaison
|
||||
/// `sameHardware()` du moteur : l'enregistrer **reconstruit l'adaptateur**, ce qui amène
|
||||
/// les contacts à l'état sûr et **réarme les verrous à froid**. L'écran l'annonce avant
|
||||
/// d'écrire — jamais après.
|
||||
class LoadMechanismScreen extends StatelessWidget {
|
||||
final String loadId;
|
||||
|
||||
const LoadMechanismScreen({super.key, required this.loadId});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final provider = context.watch<LoadConfigProvider>();
|
||||
final schema = context.watch<SchemaProvider>();
|
||||
|
||||
LoadConfigEntry? entry;
|
||||
for (final g in provider.groups) {
|
||||
for (final e in g.loads) {
|
||||
if (e.id == loadId) entry = e;
|
||||
}
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: EtmTokens.bgOf(context),
|
||||
appBar: AppBar(
|
||||
title: Text(entry?.label ?? 'Charge'),
|
||||
backgroundColor: EtmTokens.surfaceOf(context),
|
||||
foregroundColor: EtmTokens.inkOf(context),
|
||||
elevation: 0,
|
||||
),
|
||||
body: entry == null
|
||||
? const Center(child: Text('Charge introuvable dans la configuration.'))
|
||||
: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
|
||||
children: [
|
||||
SchemaVersionBanner(
|
||||
versionLine: schema.versionLine(),
|
||||
schemaLoaded: schema.schema.loaded,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
if (entry.isRelayRouter)
|
||||
RelayRouterForm(entry: entry, provider: provider, schema: schema)
|
||||
else if (entry.isSgReady)
|
||||
SgReadyForm(entry: entry, provider: provider, schema: schema)
|
||||
else
|
||||
VariableLoadForm(entry: entry, provider: provider, schema: schema),
|
||||
const SizedBox(height: 16),
|
||||
_HardwareWarning(provider: provider, loadId: loadId),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Routeur de relais
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Un contacteur par ligne, et la **puissance de la résistance câblée derrière**.
|
||||
///
|
||||
/// ## Les paliers atteignables ne sont PAS dans le contrat
|
||||
///
|
||||
/// La box les dérive de `relays[]` — toutes les combinaisons, dédupliquées — mais elle
|
||||
/// **ne les republie pas**. Vérifié sur `.75` (plugin `1.15.2+etm15`) : `GetLoadConfig`
|
||||
/// rend `"powerLevels": []` sur la charge `chauffe-eau` à trois contacteurs, et
|
||||
/// `GetLoadTelemetry` n'expose que `maxStageW` et le `stageW` courant — jamais la liste.
|
||||
///
|
||||
/// **On ne les recalcule donc pas ici**, et c'est délibéré : deux implémentations de la
|
||||
/// même combinatoire divergeraient, et c'est l'app qui aurait tort. L'écran affiche ce
|
||||
/// que la box publie — le maximum atteignable — et dit franchement que le détail des
|
||||
/// paliers n'est pas exposé.
|
||||
class RelayRouterForm extends StatelessWidget {
|
||||
final LoadConfigEntry entry;
|
||||
final LoadConfigProvider provider;
|
||||
final SchemaProvider schema;
|
||||
|
||||
const RelayRouterForm({
|
||||
super.key,
|
||||
required this.entry,
|
||||
required this.provider,
|
||||
required this.schema,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final support = schema.schema
|
||||
.paramSupport('NymeaEnergy.SetLoadConfig', ['loadConfigs', '[]', 'relays']);
|
||||
final total = entry.relays.fold<int>(0, (a, r) => a + r.powerW);
|
||||
|
||||
return _Section(
|
||||
title: 'Contacteurs',
|
||||
subtitle: 'Un contacteur par ligne, et la puissance de la résistance câblée '
|
||||
'derrière lui.',
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SchemaGated(
|
||||
support: support,
|
||||
fieldName: 'relays',
|
||||
label: 'Contacteurs',
|
||||
child: Column(
|
||||
children: [
|
||||
for (var i = 0; i < entry.relays.length; i++)
|
||||
_RelayRow(
|
||||
index: i,
|
||||
thingId: entry.relays[i].thingId,
|
||||
powerW: entry.relays[i].powerW,
|
||||
onChanged: support.isWritable
|
||||
? (w) => _setPower(i, w)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
_Note(
|
||||
'Somme câblée : $total W. Les **paliers atteignables** sont calculés par la '
|
||||
'box à partir de ces contacteurs — elle ne les publie pas au RPC '
|
||||
'(`powerLevels` revient vide sur ce mécanisme). Ils ne sont donc pas '
|
||||
'affichés ici : les recalculer côté app ferait diverger deux implémentations '
|
||||
'de la même combinatoire.',
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_Note(
|
||||
'⚠️ **L\'ordre des lignes compte.** Le moteur compare les contacteurs '
|
||||
'position par position : déplacer une ligne sans rien changer d\'autre '
|
||||
'suffit à reconstruire la charge, donc à ouvrir les contacts et à réarmer '
|
||||
'les temporisations. Vérifié sur le banc.',
|
||||
warn: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _setPower(int index, int powerW) {
|
||||
final relays = [
|
||||
for (var i = 0; i < entry.relays.length; i++)
|
||||
{
|
||||
'thingId': entry.relays[i].thingId,
|
||||
'powerW': i == index ? powerW : entry.relays[i].powerW,
|
||||
}
|
||||
];
|
||||
provider.replaceEntry(entry.id, entry.patched(relays: relays));
|
||||
}
|
||||
}
|
||||
|
||||
class _RelayRow extends StatelessWidget {
|
||||
final int index;
|
||||
final String thingId;
|
||||
final int powerW;
|
||||
final void Function(int)? onChanged;
|
||||
|
||||
const _RelayRow({
|
||||
required this.index,
|
||||
required this.thingId,
|
||||
required this.powerW,
|
||||
this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final muted = EtmTokens.mutedOf(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 26,
|
||||
height: 26,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: EtmTokens.brand.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text('${index + 1}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: EtmTokens.brand)),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Le ThingId est l'identité du contacteur ; le nom lisible vient du
|
||||
// catalogue de Things, pas de la config de charge.
|
||||
Text(thingId,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontFamily: 'monospace',
|
||||
color: muted)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
SizedBox(
|
||||
width: 92,
|
||||
child: TextFormField(
|
||||
initialValue: '$powerW',
|
||||
enabled: onChanged != null,
|
||||
keyboardType: TextInputType.number,
|
||||
textAlign: TextAlign.right,
|
||||
decoration: const InputDecoration(
|
||||
isDense: true,
|
||||
suffixText: 'W',
|
||||
),
|
||||
onFieldSubmitted: (v) {
|
||||
final n = int.tryParse(v.trim());
|
||||
if (n != null && n >= 0) onChanged?.call(n);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Charge modulable (etmvariableload)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// **Un seul appareil, une consigne continue, aucun contacteur.**
|
||||
class VariableLoadForm extends StatelessWidget {
|
||||
final LoadConfigEntry entry;
|
||||
final LoadConfigProvider provider;
|
||||
final SchemaProvider schema;
|
||||
|
||||
const VariableLoadForm({
|
||||
super.key,
|
||||
required this.entry,
|
||||
required this.provider,
|
||||
required this.schema,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final support = schema.schema
|
||||
.paramSupport('NymeaEnergy.SetLoadConfig', ['loadConfigs', '[]', 'maxPowerW']);
|
||||
|
||||
return _Section(
|
||||
title: 'Puissance nominale',
|
||||
subtitle: 'Un seul appareil, piloté par une consigne continue. Aucun contacteur.',
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SchemaGated(
|
||||
support: support,
|
||||
fieldName: 'maxPowerW',
|
||||
label: 'Puissance nominale',
|
||||
child: TextFormField(
|
||||
initialValue: '${entry.maxPowerW}',
|
||||
enabled: support.isWritable,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
isDense: true,
|
||||
labelText: 'Puissance nominale',
|
||||
suffixText: 'W',
|
||||
),
|
||||
onFieldSubmitted: (v) {
|
||||
final n = int.tryParse(v.trim());
|
||||
if (n != null && n >= 0) {
|
||||
provider.replaceEntry(entry.id, entry.patched(maxPowerW: n));
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
_Note(
|
||||
'C\'est une **valeur de plaque que vous saisissez**, jamais une mesure et '
|
||||
'jamais une déduction — « résistance ECS = 3000 W ». Elle n\'est pas '
|
||||
'seulement indicative : le moteur s\'en sert pour recréditer le budget, si '
|
||||
'bien qu\'une valeur fausse ne fausse pas que cette charge — elle décale '
|
||||
'l\'allocation rendue à **tout le reste de la cascade**.',
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_Note(
|
||||
'Changer d\'appareil, c\'est changer de charge : l\'identifiant de cette '
|
||||
'charge **est** le ThingId piloté (`${entry.id}`). Une substitution passe '
|
||||
'donc par la suppression de la charge — qui amène l\'appareil à son état '
|
||||
'sûr — puis par la création de la nouvelle.',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// SG-Ready
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Table des quatre états normés et de leurs deux contacts.
|
||||
///
|
||||
/// ## Le tableau SG1/SG2 n'est pas éditable, et c'est un choix
|
||||
///
|
||||
/// L'encodage des quatre états SG-Ready est **normé** : ce sont deux contacts secs dont
|
||||
/// les combinaisons ont un sens fixé par la norme, pas par l'installation. Rendre la
|
||||
/// table éditable permettrait de saisir un encodage non standard — auquel cas les
|
||||
/// étiquettes (« blocage », « normal », « recommandation », « forcé ») ne voudraient plus
|
||||
/// rien dire. Et attribuer une étiquette au hasard serait pire que de ne rien afficher.
|
||||
///
|
||||
/// Ce qui se règle ici, c'est la **puissance estimée** de chaque état et la durée de
|
||||
/// maintien — pas la sémantique des contacts.
|
||||
class SgReadyForm extends StatelessWidget {
|
||||
final LoadConfigEntry entry;
|
||||
final LoadConfigProvider provider;
|
||||
final SchemaProvider schema;
|
||||
|
||||
const SgReadyForm({
|
||||
super.key,
|
||||
required this.entry,
|
||||
required this.provider,
|
||||
required this.schema,
|
||||
});
|
||||
|
||||
/// Étiquettes normées des quatre états SG-Ready.
|
||||
static const _stateLabels = {
|
||||
1: 'Blocage',
|
||||
2: 'Normal (repos)',
|
||||
3: 'Recommandation de marche',
|
||||
4: 'Marche forcée',
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final sgSupport = schema.schema
|
||||
.paramSupport('NymeaEnergy.SetLoadConfig', ['loadConfigs', '[]', 'sgReady']);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_Section(
|
||||
title: 'États SG-Ready',
|
||||
subtitle: 'Deux contacts secs, quatre états normés. L\'encodage est fixé par '
|
||||
'la norme : il s\'affiche, il ne se modifie pas.',
|
||||
child: SchemaGated(
|
||||
support: sgSupport,
|
||||
fieldName: 'sgReady',
|
||||
label: 'États SG-Ready',
|
||||
child: Column(
|
||||
children: [
|
||||
for (final s in entry.sgReadyStates)
|
||||
_SgStateRow(
|
||||
state: s.state,
|
||||
label: _stateLabels[s.state] ?? 'État ${s.state}',
|
||||
relayCount: s.relays.length,
|
||||
estimatedPowerW: s.estimatedPowerW,
|
||||
onChanged: sgSupport.isWritable
|
||||
? (w) => _setEstimate(s.state, w)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
_Section(
|
||||
title: 'Protection du compresseur',
|
||||
subtitle: 'Durée minimale de maintien d\'un état.',
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextFormField(
|
||||
initialValue: '${entry.minStateHoldS}',
|
||||
enabled: sgSupport.isWritable,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
isDense: true,
|
||||
labelText: 'Maintien minimal',
|
||||
suffixText: 's',
|
||||
),
|
||||
onFieldSubmitted: (v) {
|
||||
final n = int.tryParse(v.trim());
|
||||
if (n != null && n >= 0) _setHold(n);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_Note(
|
||||
'Toute modification de cette page **reconstruit la charge** et réarme ce '
|
||||
'verrou pour sa durée entière — la PAC reste figée dans son état courant '
|
||||
'le temps qu\'il expire. Sur une machine réelle, cette durée vaut plutôt '
|
||||
'900 s que 300.',
|
||||
warn: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
// Le banc porte des estimations INVENTÉES. L'écran ne doit pas les présenter
|
||||
// comme une donnée d'installation.
|
||||
if (_looksLikeBenchDefaults)
|
||||
_Note(
|
||||
'⚠️ **Puissances non mesurées.** Les valeurs {état 3 : 1500 W, '
|
||||
'état 4 : 3000 W} sont celles du banc d\'essai — elles décrivent une PAC '
|
||||
'fictive et **aucune machine réelle**. À remplacer par les valeurs de la '
|
||||
'machine câblée (plaque signalétique, puis mesure sous les deux régimes) '
|
||||
'avant toute conclusion transposable à une installation.',
|
||||
warn: true,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Vrai si la configuration porte encore les estimations fictives du banc.
|
||||
bool get _looksLikeBenchDefaults {
|
||||
final byState = {for (final s in entry.sgReadyStates) s.state: s.estimatedPowerW};
|
||||
return byState[3] == 1500.0 && byState[4] == 3000.0;
|
||||
}
|
||||
|
||||
void _setEstimate(int state, double w) {
|
||||
final states = [
|
||||
for (final s in entry.sgReadyStates)
|
||||
{
|
||||
'state': s.state,
|
||||
'relays': s.relays,
|
||||
'estimatedPowerW': s.state == state ? w : s.estimatedPowerW,
|
||||
}
|
||||
];
|
||||
provider.replaceEntry(
|
||||
entry.id,
|
||||
entry.patched(sgReady: {
|
||||
'minStateHoldS': entry.minStateHoldS,
|
||||
'states': states,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
void _setHold(int seconds) {
|
||||
provider.replaceEntry(
|
||||
entry.id,
|
||||
entry.patched(sgReady: {
|
||||
'minStateHoldS': seconds,
|
||||
'states': [for (final s in entry.sgReadyStates) s.toMap()],
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SgStateRow extends StatelessWidget {
|
||||
final int state;
|
||||
final String label;
|
||||
final int relayCount;
|
||||
final double estimatedPowerW;
|
||||
final void Function(double)? onChanged;
|
||||
|
||||
const _SgStateRow({
|
||||
required this.state,
|
||||
required this.label,
|
||||
required this.relayCount,
|
||||
required this.estimatedPowerW,
|
||||
this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final muted = EtmTokens.mutedOf(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 26,
|
||||
height: 26,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: EtmTokens.brand.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text('$state',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: EtmTokens.brand)),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: EtmTokens.inkOf(context))),
|
||||
Text(
|
||||
relayCount == 0
|
||||
? 'aucun contact fermé'
|
||||
: '$relayCount contact${relayCount > 1 ? 's' : ''} fermé'
|
||||
'${relayCount > 1 ? 's' : ''}',
|
||||
style: TextStyle(fontSize: 11, color: muted)),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 96,
|
||||
child: TextFormField(
|
||||
initialValue: estimatedPowerW.round().toString(),
|
||||
enabled: onChanged != null,
|
||||
keyboardType: TextInputType.number,
|
||||
textAlign: TextAlign.right,
|
||||
decoration: const InputDecoration(isDense: true, suffixText: 'W'),
|
||||
onFieldSubmitted: (v) {
|
||||
final n = double.tryParse(v.trim().replaceAll(',', '.'));
|
||||
if (n != null && n >= 0) onChanged?.call(n);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Communs
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Annonce d'impact + enregistrement, propre à l'écran de mécanisme.
|
||||
class _HardwareWarning extends StatelessWidget {
|
||||
final LoadConfigProvider provider;
|
||||
final String loadId;
|
||||
|
||||
const _HardwareWarning({required this.provider, required this.loadId});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!provider.hasPendingChanges) {
|
||||
return _Note(
|
||||
'Aucune modification en attente. Tout ce qui se règle sur cette page est du '
|
||||
'matériel : l\'enregistrer reconstruira la charge.',
|
||||
);
|
||||
}
|
||||
final impact = provider
|
||||
.pendingImpact()
|
||||
.where((i) => i.loadId == loadId)
|
||||
.toList();
|
||||
final rebuild = impact.any((i) => i.impact == LoadChangeImpact.rebuild);
|
||||
final rearm = impact
|
||||
.map((i) => i.rearmSeconds)
|
||||
.whereType<int>()
|
||||
.fold<int>(0, (a, b) => a > b ? a : b);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (rebuild)
|
||||
_Note(
|
||||
'À l\'enregistrement : les contacts de cette charge passent par leur état '
|
||||
'sûr, l\'adaptateur est reconstruit, et son verrou se réarme'
|
||||
'${rearm > 0 ? ' pendant ${rearm >= 60 ? '${(rearm / 60).round()} min' : '$rearm s'}' : ''}.',
|
||||
warn: true,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(children: [
|
||||
TextButton(
|
||||
onPressed: provider.discardDraft,
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
const Spacer(),
|
||||
FilledButton(
|
||||
onPressed: provider.saveState == LoadSaveState.pending
|
||||
? null
|
||||
: provider.save,
|
||||
child: Text(provider.saveState == LoadSaveState.pending
|
||||
? 'Envoi…'
|
||||
: 'Enregistrer'),
|
||||
),
|
||||
]),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Section extends StatelessWidget {
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final Widget child;
|
||||
|
||||
const _Section({
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: EtmTokens.surfaceOf(context),
|
||||
borderRadius: BorderRadius.circular(EtmTokens.radiusCard),
|
||||
boxShadow: EtmTokens.cardShadowOf(context),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: EtmTokens.inkOf(context))),
|
||||
const SizedBox(height: 3),
|
||||
Text(subtitle,
|
||||
style: TextStyle(
|
||||
fontSize: 11.5,
|
||||
height: 1.35,
|
||||
color: EtmTokens.mutedOf(context))),
|
||||
const SizedBox(height: 12),
|
||||
child,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Note explicative. Le `**gras**` markdown y est rendu littéralement — volontairement :
|
||||
/// ces textes sont relus par des installateurs sur un écran de téléphone, et une
|
||||
/// dépendance de rendu riche pour trois mots en gras ne se justifie pas.
|
||||
class _Note extends StatelessWidget {
|
||||
final String text;
|
||||
final bool warn;
|
||||
|
||||
const _Note(this.text, {this.warn = false});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = warn ? EtmTokens.orange : EtmTokens.mutedOf(context);
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(warn ? Icons.warning_amber_rounded : Icons.info_outline,
|
||||
size: 15, color: color),
|
||||
const SizedBox(width: 7),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text.replaceAll('**', ''),
|
||||
style: TextStyle(
|
||||
fontSize: 11.5,
|
||||
height: 1.4,
|
||||
color: warn ? EtmTokens.inkOf(context) : color),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,905 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../l10n/l10n_ext.dart';
|
||||
import '../../models/load_config_entry.dart';
|
||||
import '../../models/load_telemetry.dart';
|
||||
import '../../providers/load_config_provider.dart';
|
||||
import '../../providers/load_telemetry_provider.dart';
|
||||
import '../../services/load_priority.dart';
|
||||
import '../../services/nymea_service.dart';
|
||||
import '../../services/telemetry_text.dart';
|
||||
|
||||
/// Ordre de service des charges pilotables — écran installateur.
|
||||
///
|
||||
/// Deux niveaux : l'ordre des domaines entre eux, puis l'ordre des appareils dans un
|
||||
/// domaine. Ce que l'app envoie reste un `priority` plat : la box ne sait rien des
|
||||
/// groupes.
|
||||
///
|
||||
/// L'écran superpose deux sources qu'il ne faut pas confondre :
|
||||
///
|
||||
/// - **la configuration** (`GetLoadConfig`) — ce que l'installateur a déclaré, éditable
|
||||
/// ici pour le rang et le domaine ;
|
||||
/// - **la télémétrie** (`GetLoadTelemetry`) — ce que l'arbitre a réellement fait au
|
||||
/// dernier cycle, en lecture seule et jamais reconstruite côté app.
|
||||
///
|
||||
/// Depuis le lot plugin B-bis, toute charge publiée en télémétrie a une entrée en
|
||||
/// configuration. L'inverse reste faux : une charge configurée absente de la télémétrie
|
||||
/// est **désactivée**, jamais perdue.
|
||||
class LoadOrderScreen extends StatefulWidget {
|
||||
const LoadOrderScreen({super.key});
|
||||
|
||||
@override
|
||||
State<LoadOrderScreen> createState() => _LoadOrderScreenState();
|
||||
}
|
||||
|
||||
class _LoadOrderScreenState extends State<LoadOrderScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final service = context.read<NymeaService>();
|
||||
final config = context.read<LoadConfigProvider>();
|
||||
final telemetry = context.read<LoadTelemetryProvider>();
|
||||
config.attach(service);
|
||||
telemetry.attach(service);
|
||||
config.load();
|
||||
// Instantané au chargement ; la suite arrive en `LoadTelemetryChanged`, sans
|
||||
// rechargement manuel.
|
||||
telemetry.load();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.l10n;
|
||||
final p = context.watch<LoadConfigProvider>();
|
||||
final tp = context.watch<LoadTelemetryProvider>();
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF0F2F5),
|
||||
appBar: AppBar(
|
||||
title: Text(t.installerLoadsTitle),
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: const Color(0xFF1A1A2E),
|
||||
elevation: 0,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
onPressed: p.loading
|
||||
? null
|
||||
: () {
|
||||
p.load();
|
||||
tp.load();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: _SaveBar(provider: p),
|
||||
body: p.loading && p.entries.isEmpty
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: p.error != null
|
||||
? _Message(icon: Icons.error_outline_rounded, text: t.loadsLoadError(p.error!))
|
||||
: p.entries.isEmpty
|
||||
? _Message(icon: Icons.inbox_rounded, text: t.loadsEmpty)
|
||||
: _Body(provider: p, telemetry: tp),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Body extends StatelessWidget {
|
||||
final LoadConfigProvider provider;
|
||||
final LoadTelemetryProvider telemetry;
|
||||
const _Body({required this.provider, required this.telemetry});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.l10n;
|
||||
final groups = provider.groups;
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 24),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 0, 4, 12),
|
||||
child: Text(t.installerLoadsSubtitle,
|
||||
style: const TextStyle(color: Color(0xFF6B7280), fontSize: 13)),
|
||||
),
|
||||
|
||||
// ── Ordre non groupé : signaler, montrer le réel, ne rien écrire ────────
|
||||
if (provider.isNotGrouped) _NotGroupedBanner(provider: provider),
|
||||
|
||||
// ── État de l'arbitrage ────────────────────────────────────────────────
|
||||
if (!telemetry.supported)
|
||||
const _TelemetryUnavailableCard()
|
||||
else
|
||||
_TelemetryHeader(telemetry: telemetry),
|
||||
|
||||
for (var gi = 0; gi < groups.length; gi++)
|
||||
_DomainSection(
|
||||
key: ValueKey('domain-${groups[gi].domain}'),
|
||||
group: groups[gi],
|
||||
index: gi,
|
||||
total: groups.length,
|
||||
provider: provider,
|
||||
telemetry: telemetry,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Bandeau « ordre non groupé » ─────────────────────────────────────────────
|
||||
class _NotGroupedBanner extends StatelessWidget {
|
||||
final LoadConfigProvider provider;
|
||||
const _NotGroupedBanner({required this.provider});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.l10n;
|
||||
return Card(
|
||||
color: const Color(0xFFFFF4E5),
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(children: [
|
||||
const Icon(Icons.warning_amber_rounded, color: Color(0xFFB26A00), size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Text(t.orderNotGroupedTitle,
|
||||
style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 14)),
|
||||
]),
|
||||
const SizedBox(height: 8),
|
||||
Text(t.orderNotGroupedBody, style: const TextStyle(fontSize: 13)),
|
||||
const SizedBox(height: 8),
|
||||
Text(t.orderNotGroupedWarning,
|
||||
style: const TextStyle(fontSize: 12, color: Color(0xFF8A5300))),
|
||||
const SizedBox(height: 10),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: OutlinedButton.icon(
|
||||
icon: const Icon(Icons.low_priority_rounded, size: 18),
|
||||
label: Text(t.orderNotGroupedAction),
|
||||
onPressed: () => _confirm(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _confirm(BuildContext context) async {
|
||||
final t = context.l10n;
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (c) => AlertDialog(
|
||||
title: Text(t.orderNotGroupedAction),
|
||||
content: Text(t.orderNotGroupedWarning),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(c, false), child: Text(t.cancel)),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(c, true),
|
||||
child: Text(t.orderNormalizeConfirm)),
|
||||
],
|
||||
),
|
||||
);
|
||||
// La normalisation ne fait que préparer un brouillon : rien ne part tant que
|
||||
// l'installateur n'a pas enregistré.
|
||||
if (ok == true) provider.prepareNormalization();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Télémétrie absente de cette box ──────────────────────────────────────────
|
||||
class _TelemetryUnavailableCard extends StatelessWidget {
|
||||
const _TelemetryUnavailableCard();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.l10n;
|
||||
return Card(
|
||||
color: const Color(0xFFEFF3F7),
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
const Icon(Icons.sensors_off_rounded, size: 20, color: Color(0xFF5B6B7B)),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(t.telemetryUnavailableTitle,
|
||||
style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 13)),
|
||||
const SizedBox(height: 4),
|
||||
Text(t.telemetryUnavailableBody,
|
||||
style: const TextStyle(fontSize: 12, color: Color(0xFF5B6B7B))),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── En-tête : fraîcheur, mode dégradé, budget du cycle ───────────────────────
|
||||
class _TelemetryHeader extends StatelessWidget {
|
||||
final LoadTelemetryProvider telemetry;
|
||||
const _TelemetryHeader({required this.telemetry});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.l10n;
|
||||
final snapshot = telemetry.telemetry;
|
||||
if (snapshot == null) {
|
||||
return telemetry.loading
|
||||
? const Padding(
|
||||
padding: EdgeInsets.only(bottom: 12),
|
||||
child: LinearProgressIndicator(minHeight: 2))
|
||||
: const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final age = telemetry.displayedAge;
|
||||
final stale = telemetry.stale;
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 12, 14, 14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(children: [
|
||||
Icon(
|
||||
stale ? Icons.history_toggle_off_rounded : Icons.sensors_rounded,
|
||||
size: 20,
|
||||
color: stale ? const Color(0xFFB26A00) : const Color(0xFF2E7D32),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(t.telemetryTitle,
|
||||
style:
|
||||
const TextStyle(fontWeight: FontWeight.w700, fontSize: 14)),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Fraîcheur — un arbitre figé ne doit pas ressembler à une installation
|
||||
// stable. L'absence de cycle est dite pour elle-même, jamais rendue par
|
||||
// un âge de zéro.
|
||||
if (snapshot.hasNeverRun)
|
||||
_Note(
|
||||
icon: Icons.hourglass_empty_rounded,
|
||||
color: const Color(0xFFB26A00),
|
||||
text: t.telemetryNeverRan)
|
||||
else if (stale)
|
||||
_Note(
|
||||
icon: Icons.warning_amber_rounded,
|
||||
color: const Color(0xFFB26A00),
|
||||
text: t.telemetryStale(age?.inSeconds ?? 0))
|
||||
else
|
||||
_Note(
|
||||
icon: Icons.schedule_rounded,
|
||||
color: const Color(0xFF6B7280),
|
||||
text: t.telemetryLastCycle(age?.inSeconds ?? 0)),
|
||||
|
||||
if (snapshot.degradedMode) ...[
|
||||
const SizedBox(height: 6),
|
||||
_Note(
|
||||
icon: Icons.report_gmailerrorred_rounded,
|
||||
color: const Color(0xFFC62828),
|
||||
text: t.telemetryDegradedMode),
|
||||
],
|
||||
|
||||
const SizedBox(height: 12),
|
||||
Text(t.telemetryBudgetTitle,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
color: Color(0xFF6B7280))),
|
||||
const SizedBox(height: 6),
|
||||
|
||||
// `budget` absent = repli L2 : il n'existe AUCUN budget pour ce cycle.
|
||||
// Afficher des zéros se lirait « arbitrage exécuté, rien à allouer ».
|
||||
if (snapshot.budget == null)
|
||||
Text(t.telemetryBudgetAbsent,
|
||||
style: const TextStyle(fontSize: 12, color: Color(0xFFC62828)))
|
||||
else
|
||||
_BudgetTable(budget: snapshot.budget!),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BudgetTable extends StatelessWidget {
|
||||
final TelemetryBudget budget;
|
||||
const _BudgetTable({required this.budget});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.l10n;
|
||||
final rows = <(String, double)>[
|
||||
(t.budgetSurplus, budget.surplusW),
|
||||
(t.budgetEvReserved, budget.evReservedW),
|
||||
(t.budgetRecredited, budget.recreditedW),
|
||||
(t.budgetAllocated, budget.allocatedW),
|
||||
(t.budgetRemaining, budget.remainingW),
|
||||
];
|
||||
return Column(
|
||||
children: [
|
||||
for (final (label, value) in rows)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(children: [
|
||||
Expanded(
|
||||
child: Text(label,
|
||||
style: const TextStyle(fontSize: 12, color: Color(0xFF6B7280))),
|
||||
),
|
||||
Text(t.watts(value.round()),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFeatures: [FontFeature.tabularFigures()])),
|
||||
]),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Un domaine et ses charges ────────────────────────────────────────────────
|
||||
class _DomainSection extends StatelessWidget {
|
||||
final DomainGroup group;
|
||||
final int index;
|
||||
final int total;
|
||||
final LoadConfigProvider provider;
|
||||
final LoadTelemetryProvider telemetry;
|
||||
|
||||
const _DomainSection({
|
||||
super.key,
|
||||
required this.group,
|
||||
required this.index,
|
||||
required this.total,
|
||||
required this.provider,
|
||||
required this.telemetry,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.l10n;
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 12, 6, 4),
|
||||
child: Row(children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
domainLabel(t, group.domain),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 14,
|
||||
color: group.isUnclassified
|
||||
? const Color(0xFF9CA3AF)
|
||||
: const Color(0xFF1A1A2E),
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.keyboard_arrow_up_rounded),
|
||||
tooltip: 'Monter',
|
||||
onPressed: index == 0
|
||||
? null
|
||||
: () => provider.reorderDomains(index, index - 1),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.keyboard_arrow_down_rounded),
|
||||
tooltip: 'Descendre',
|
||||
onPressed: index >= total - 1
|
||||
? null
|
||||
: () => provider.reorderDomains(index, index + 2),
|
||||
),
|
||||
]),
|
||||
),
|
||||
ReorderableListView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
buildDefaultDragHandles: false,
|
||||
itemCount: group.loads.length,
|
||||
onReorder: (o, n) => provider.reorderWithinDomain(group.domain, o, n),
|
||||
itemBuilder: (context, i) => _LoadTile(
|
||||
key: ValueKey('load-${group.loads[i].id}'),
|
||||
entry: group.loads[i],
|
||||
handleIndex: i,
|
||||
provider: provider,
|
||||
telemetry: telemetry,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Libellé d'un domaine. Un domaine inconnu de cette version d'app (box plus récente)
|
||||
/// a un repli lisible : le code brut, jamais une phrase vide.
|
||||
String domainLabel(L10n t, String domain) => switch (domain) {
|
||||
'ev' => t.domainEv,
|
||||
'ecs' => t.domainEcs,
|
||||
'hvac' => t.domainHvac,
|
||||
'heating' => t.domainHeating,
|
||||
'battery' => t.domainBattery,
|
||||
kUnclassifiedDomain => t.sectionUnclassified,
|
||||
_ => t.domainUnknown(domain),
|
||||
};
|
||||
|
||||
// ── Une charge ───────────────────────────────────────────────────────────────
|
||||
class _LoadTile extends StatelessWidget {
|
||||
final LoadConfigEntry entry;
|
||||
final int handleIndex;
|
||||
final LoadConfigProvider provider;
|
||||
final LoadTelemetryProvider telemetry;
|
||||
|
||||
const _LoadTile({
|
||||
super.key,
|
||||
required this.entry,
|
||||
required this.handleIndex,
|
||||
required this.provider,
|
||||
required this.telemetry,
|
||||
});
|
||||
|
||||
String _adapterLabel(L10n t) => switch (entry.adapter) {
|
||||
'relay-router' => t.adapterRelayRouter,
|
||||
'etmvariableload' => t.adapterEtmVariableLoad,
|
||||
'sg-ready' => t.adapterSgReady,
|
||||
'evcharger' => t.adapterEvCharger,
|
||||
_ => t.adapterUnknown(entry.adapter),
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.l10n;
|
||||
final details = <String>[
|
||||
_adapterLabel(t),
|
||||
if (entry.isRelayRouter) t.loadRelayCount(entry.relays.length),
|
||||
if (entry.minOnS > 0) t.loadMinOn(entry.minOnS),
|
||||
if (entry.minOffS > 0) t.loadMinOff(entry.minOffS),
|
||||
];
|
||||
|
||||
final snapshot = telemetry.telemetry;
|
||||
final live = telemetry.entryFor(entry.id);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 10),
|
||||
child: ReorderableDragStartListener(
|
||||
index: handleIndex,
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||
child:
|
||||
Icon(Icons.drag_handle_rounded, size: 22, color: Color(0xFF9CA3AF)),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
entry.label,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: entry.enabled
|
||||
? const Color(0xFF1A1A2E)
|
||||
: const Color(0xFF9CA3AF),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (!entry.enabled)
|
||||
_Chip(text: t.loadDisabled, color: const Color(0xFF9CA3AF)),
|
||||
]),
|
||||
const SizedBox(height: 2),
|
||||
Text(details.join(' · '),
|
||||
style: const TextStyle(fontSize: 12, color: Color(0xFF6B7280))),
|
||||
const SizedBox(height: 2),
|
||||
// Emplacement réservé : LoadConfig n'a AUCUN champ pour rattacher un
|
||||
// compteur à une charge. Rien n'est inventé côté app.
|
||||
Text(t.loadMeterSlot,
|
||||
style: const TextStyle(fontSize: 11, color: Color(0xFFB0B7BF))),
|
||||
|
||||
// ── Ce que l'arbitre a fait au dernier cycle ────────────────
|
||||
if (telemetry.supported && snapshot != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
if (live == null)
|
||||
Text(t.telemetryNotArbitrated,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontStyle: FontStyle.italic,
|
||||
color: Color(0xFF9CA3AF)))
|
||||
else
|
||||
_LiveBlock(
|
||||
live: live,
|
||||
stale: telemetry.stale,
|
||||
telemetry: telemetry,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
const SizedBox(height: 6),
|
||||
_Chip(text: t.loadPriority(entry.priority), color: const Color(0xFF2E75B6)),
|
||||
_DomainMenu(entry: entry, provider: provider),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Choix du domaine — énumération fermée côté plugin, donc liste fermée ici.
|
||||
class _DomainMenu extends StatelessWidget {
|
||||
final LoadConfigEntry entry;
|
||||
final LoadConfigProvider provider;
|
||||
const _DomainMenu({required this.entry, required this.provider});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.l10n;
|
||||
final current = entry.domain ?? kUnclassifiedDomain;
|
||||
|
||||
return PopupMenuButton<String>(
|
||||
tooltip: t.domainAssignAction,
|
||||
icon: const Icon(Icons.label_outline_rounded, size: 20, color: Color(0xFF6B7280)),
|
||||
onSelected: (d) => provider.assignDomain(entry.id, d),
|
||||
itemBuilder: (c) => [
|
||||
PopupMenuItem<String>(
|
||||
enabled: false,
|
||||
child: Text(t.domainAssignTitle(entry.label),
|
||||
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
const PopupMenuDivider(),
|
||||
for (final d in kAssignableDomains)
|
||||
CheckedPopupMenuItem<String>(
|
||||
value: d,
|
||||
checked: current == d,
|
||||
child: Text(domainLabel(t, d)),
|
||||
),
|
||||
const PopupMenuDivider(),
|
||||
PopupMenuItem<String>(
|
||||
value: kUnclassifiedDomain,
|
||||
child: Text(t.domainAssignNone),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Bloc télémétrie d'une charge ─────────────────────────────────────────────
|
||||
class _LiveBlock extends StatelessWidget {
|
||||
final LoadTelemetryEntry live;
|
||||
final bool stale;
|
||||
final LoadTelemetryProvider telemetry;
|
||||
|
||||
const _LiveBlock({
|
||||
required this.live,
|
||||
required this.stale,
|
||||
required this.telemetry,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.l10n;
|
||||
final mech = live.mechanism;
|
||||
final mechText = mech == null ? null : mechanismText(t, mech);
|
||||
final lock = live.lock;
|
||||
|
||||
// Les données anciennes restent affichées — les masquer priverait du seul état
|
||||
// connu — mais grisées : elles décrivent un cycle, pas l'instant présent.
|
||||
final tone = stale ? const Color(0xFF9CA3AF) : const Color(0xFF374151);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(children: [
|
||||
Icon(Icons.bolt_rounded,
|
||||
size: 14,
|
||||
color: stale ? const Color(0xFFB0B7BF) : const Color(0xFF2E7D32)),
|
||||
const SizedBox(width: 4),
|
||||
Text(t.telemetryAllocated(live.allocatedW.round()),
|
||||
style: TextStyle(
|
||||
fontSize: 12, fontWeight: FontWeight.w600, color: tone)),
|
||||
if (mechText != null) ...[
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: Text('· $mechText',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(fontSize: 12, color: tone)),
|
||||
),
|
||||
],
|
||||
]),
|
||||
const SizedBox(height: 2),
|
||||
Text(decisionText(t, live.decision),
|
||||
style: TextStyle(fontSize: 11, color: tone)),
|
||||
if (lock != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Row(children: [
|
||||
Icon(Icons.lock_clock_rounded, size: 13, color: tone),
|
||||
const SizedBox(width: 4),
|
||||
Flexible(
|
||||
child: Text(
|
||||
// Les secondes affichées datent du cycle publié : l'app ne les décompte
|
||||
// pas, le plugin exclut volontairement ce champ de sa détection de
|
||||
// changement.
|
||||
'${lockText(t, lock)} (${t.lockAsOfCycle})',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(fontSize: 11, color: tone),
|
||||
),
|
||||
),
|
||||
]),
|
||||
],
|
||||
if (live.inFault) ...[
|
||||
const SizedBox(height: 6),
|
||||
_FaultBox(live: live, telemetry: telemetry),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Charge en défaut, et levée du verrou.
|
||||
///
|
||||
/// Le bouton n'existe **que** sur une charge dont `available` est faux, et son résultat
|
||||
/// n'est jamais annoncé sur le retour du RPC : `ClearLoadFault` répond
|
||||
/// `EnergyErrorNoError` même sur une charge saine (vérifié sur `.75`). Le verdict vient
|
||||
/// de la télémétrie relue.
|
||||
class _FaultBox extends StatelessWidget {
|
||||
final LoadTelemetryEntry live;
|
||||
final LoadTelemetryProvider telemetry;
|
||||
|
||||
const _FaultBox({required this.live, required this.telemetry});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.l10n;
|
||||
final state = telemetry.clearStateOf(live.loadId);
|
||||
final code = live.faultCode;
|
||||
|
||||
final (String? message, Color messageColor) = switch (state) {
|
||||
FaultClearState.pending => (t.faultClearPending, const Color(0xFF6B7280)),
|
||||
FaultClearState.cleared => (t.faultCleared, const Color(0xFF2E7D32)),
|
||||
FaultClearState.stillFaulty => (t.faultClearStillFaulty, const Color(0xFFC62828)),
|
||||
FaultClearState.rejected => (
|
||||
t.faultClearRejected(telemetry.clearErrorOf(live.loadId) ?? '?'),
|
||||
const Color(0xFFC62828)
|
||||
),
|
||||
FaultClearState.idle => (null, Colors.transparent),
|
||||
};
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFDECEA),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(children: [
|
||||
const Icon(Icons.error_outline_rounded, size: 15, color: Color(0xFFC62828)),
|
||||
const SizedBox(width: 6),
|
||||
Text(t.faultTitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Color(0xFFC62828))),
|
||||
]),
|
||||
const SizedBox(height: 4),
|
||||
Text(code == null ? t.faultUnknown('—') : faultText(t, code),
|
||||
style: const TextStyle(fontSize: 11, color: Color(0xFF7F1D1D))),
|
||||
const SizedBox(height: 4),
|
||||
Text(t.faultClearHint,
|
||||
style: const TextStyle(fontSize: 10, color: Color(0xFF9A5B5B))),
|
||||
if (message != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(message, style: TextStyle(fontSize: 11, color: messageColor)),
|
||||
],
|
||||
const SizedBox(height: 4),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: OutlinedButton.icon(
|
||||
icon: state == FaultClearState.pending
|
||||
? const SizedBox(
|
||||
width: 12,
|
||||
height: 12,
|
||||
child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Icon(Icons.lock_open_rounded, size: 16),
|
||||
label: Text(t.faultClearAction),
|
||||
onPressed: state == FaultClearState.pending
|
||||
? null
|
||||
: () => telemetry.clearFault(live.loadId),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Note extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final String text;
|
||||
const _Note({required this.icon, required this.color, required this.text});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Icon(icon, size: 15, color: color),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(text, style: TextStyle(fontSize: 12, color: color)),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
class _Chip extends StatelessWidget {
|
||||
final String text;
|
||||
final Color color;
|
||||
const _Chip({required this.text, required this.color});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(text,
|
||||
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: color)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Barre d'enregistrement ───────────────────────────────────────────────────
|
||||
class _SaveBar extends StatelessWidget {
|
||||
final LoadConfigProvider provider;
|
||||
const _SaveBar({required this.provider});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.l10n;
|
||||
|
||||
final (String? message, Color color) = switch (provider.saveState) {
|
||||
LoadSaveState.pending => (t.saveWaitingBox, const Color(0xFF6B7280)),
|
||||
LoadSaveState.confirmed => (t.saveConfirmed, const Color(0xFF2E7D32)),
|
||||
LoadSaveState.diverged => (t.saveDiverged, const Color(0xFFB26A00)),
|
||||
LoadSaveState.rejected => (
|
||||
_rejectionText(t),
|
||||
const Color(0xFFC62828),
|
||||
),
|
||||
LoadSaveState.idle => (null, Colors.transparent),
|
||||
};
|
||||
|
||||
return SafeArea(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 10, 16, 10),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border(top: BorderSide(color: Color(0xFFE5E7EB))),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (message != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(children: [
|
||||
Icon(
|
||||
provider.saveState == LoadSaveState.confirmed
|
||||
? Icons.check_circle_outline_rounded
|
||||
: Icons.info_outline_rounded,
|
||||
size: 16,
|
||||
color: color,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(message,
|
||||
style: TextStyle(fontSize: 12, color: color)),
|
||||
),
|
||||
]),
|
||||
),
|
||||
Row(children: [
|
||||
if (provider.hasPendingChanges)
|
||||
TextButton(
|
||||
onPressed: provider.saveState == LoadSaveState.pending
|
||||
? null
|
||||
: provider.discardDraft,
|
||||
child: Text(t.cancel),
|
||||
),
|
||||
const Spacer(),
|
||||
FilledButton.icon(
|
||||
icon: provider.saveState == LoadSaveState.pending
|
||||
? const SizedBox(
|
||||
width: 14, height: 14,
|
||||
child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Icon(Icons.save_rounded, size: 18),
|
||||
label: Text(provider.saveState == LoadSaveState.pending
|
||||
? t.savePending
|
||||
: t.saveOrder),
|
||||
// Aucune écriture possible sans geste explicite : le bouton n'est
|
||||
// actif que s'il existe un brouillon.
|
||||
onPressed: (!provider.hasPendingChanges ||
|
||||
provider.saveState == LoadSaveState.pending)
|
||||
? null
|
||||
: provider.save,
|
||||
),
|
||||
]),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _rejectionText(L10n t) {
|
||||
final c = provider.conflict;
|
||||
if (c == null) return t.saveRejected(provider.saveError ?? '?');
|
||||
return switch (c.kind) {
|
||||
'duplicateId' => t.validationDuplicateId(c.id ?? '?'),
|
||||
'thingClaimedTwice' => t.validationThingClaimedTwice(
|
||||
c.thingId ?? '?', c.firstLabel ?? '?', c.secondLabel ?? '?'),
|
||||
_ => t.saveRejected(provider.saveError ?? '?'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class _Message extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String text;
|
||||
const _Message({required this.icon, required this.text});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
Icon(icon, size: 44, color: Colors.grey.shade400),
|
||||
const SizedBox(height: 14),
|
||||
Text(text,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Color(0xFF6B7280))),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,17 +1,38 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../models/load_config_entry.dart';
|
||||
import '../../models/nymea_models.dart';
|
||||
import '../../providers/energy_setup_provider.dart';
|
||||
import '../../providers/load_config_provider.dart';
|
||||
import '../../providers/load_telemetry_provider.dart';
|
||||
import '../../providers/schema_provider.dart';
|
||||
import '../../services/nymea_service.dart';
|
||||
import '../../theme/etm_tokens.dart';
|
||||
import '../../widgets/controllable_loads_section.dart';
|
||||
import '../../widgets/schema_gated.dart';
|
||||
import 'load_mechanism_screen.dart';
|
||||
|
||||
/// Écran 1 — **Rôles & appareils**.
|
||||
/// **Rôles & appareils** — l'unique écran de configuration des charges.
|
||||
///
|
||||
/// Trois zones (contrat) : Compteurs (mesure, hors priorité) · Charges
|
||||
/// pilotables (réordonnables → écrit `priority`) · À configurer (tri-état).
|
||||
/// L'édition des paliers ECS vit dans l'écran 2 (Configurer) : ici, seul un
|
||||
/// **résumé read-only** (`LoadConfig.summary`) est affiché.
|
||||
/// ## Fusion : l'ancien « Ordre de service » a disparu ici
|
||||
///
|
||||
/// Deux écrans décrivaient la même chose par deux chemins différents. Celui-ci portait la
|
||||
/// présentation ; l'autre portait le **chemin de données correct**. C'est donc le chemin
|
||||
/// de données qui a migré, jamais l'inverse — le modèle des rôles ne survit pas comme
|
||||
/// source de vérité pour les charges.
|
||||
///
|
||||
/// La raison est de fond, pas d'hygiène. `EmsRole` fige six rôles, dont `dhw` et
|
||||
/// `heatPump` **séparés**. Or LM-201 — *un canal de commande, une charge* — dit qu'une PAC
|
||||
/// air/eau sans ballon séparé est **une seule** charge : le SG-Ready commande la machine
|
||||
/// entière. Le modèle des rôles aurait exigé d'en déclarer deux pour une seule machine,
|
||||
/// c'est-à-dire deux charges se disputant un canal unique — ce que `validateSet()` refuse
|
||||
/// côté box, et à raison.
|
||||
///
|
||||
/// **Ce qui reste sur `EnergySetupProvider`** : les compteurs, « à configurer » et « sans
|
||||
/// appareil ». Ce sont des rôles d'inférence sur les Things, et le compteur principal
|
||||
/// relève d'un autre contrat (`Energy.SetRootMeter`). Seules les **charges** viennent
|
||||
/// désormais de `LoadConfigProvider`, donc de `GetLoadConfig`.
|
||||
class RolesDevicesScreen extends StatefulWidget {
|
||||
const RolesDevicesScreen({super.key});
|
||||
|
||||
@ -30,11 +51,28 @@ class _RolesDevicesScreenState extends State<RolesDevicesScreen> {
|
||||
final svc = context.read<NymeaService>();
|
||||
_svc = svc;
|
||||
context.read<EnergySetupProvider>().attach(svc);
|
||||
|
||||
// Chemin de données des CHARGES : la configuration de la box, et rien d'autre.
|
||||
context.read<LoadConfigProvider>()..attach(svc)..load();
|
||||
context.read<LoadTelemetryProvider>()..attach(svc)..load();
|
||||
// Le schéma décide de ce qui s'active ou se grise, ici comme dans les écrans de
|
||||
// mécanisme. Lu une fois : il ne change pas tant que la box ne redémarre pas.
|
||||
context.read<SchemaProvider>()..attach(svc)..load();
|
||||
|
||||
svc.addListener(_sync); // re-inférer quand les things se chargent
|
||||
_sync();
|
||||
});
|
||||
}
|
||||
|
||||
/// Ouvre le détail du mécanisme. Il n'y a **pas** de formulaire unique : les trois
|
||||
/// contrats (routeur de relais, charge continue, SG-Ready) ne se ressemblent pas, et
|
||||
/// l'écran de détail choisit lui-même sa forme selon `adapter`.
|
||||
void _openMechanism(BuildContext context, LoadConfigEntry entry) {
|
||||
Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (_) => LoadMechanismScreen(loadId: entry.id),
|
||||
));
|
||||
}
|
||||
|
||||
void _sync() {
|
||||
if (!mounted) return;
|
||||
context.read<EnergySetupProvider>().syncFromThings(context.read<NymeaService>());
|
||||
@ -52,7 +90,6 @@ class _RolesDevicesScreenState extends State<RolesDevicesScreen> {
|
||||
final svc = context.watch<NymeaService>();
|
||||
|
||||
final meters = setup.meterRoles;
|
||||
final loads = setup.controllableRoles;
|
||||
final todo = setup.toConfigureRoles;
|
||||
final absent = setup.absentRoles;
|
||||
|
||||
@ -85,35 +122,18 @@ class _RolesDevicesScreenState extends State<RolesDevicesScreen> {
|
||||
],
|
||||
],
|
||||
|
||||
// ── Charges pilotables (drag = priorité) ─────────────────────────
|
||||
if (loads.isNotEmpty) ...[
|
||||
const SizedBox(height: 10),
|
||||
const _SectionHeader('Charges pilotables',
|
||||
subtitle: 'Glissez pour l\'ordre de service du surplus'),
|
||||
const SizedBox(height: 10),
|
||||
ReorderableListView.builder(
|
||||
shrinkWrap: true,
|
||||
primary: false,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
buildDefaultDragHandles: false,
|
||||
itemCount: loads.length,
|
||||
onReorder: setup.reorderControllableLoads,
|
||||
itemBuilder: (context, i) {
|
||||
final role = loads[i];
|
||||
return Padding(
|
||||
key: ValueKey(role),
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: _LoadCard(
|
||||
role: role,
|
||||
index: i,
|
||||
setup: setup,
|
||||
service: svc,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const _Legend(),
|
||||
],
|
||||
// ── Charges pilotables — depuis GetLoadConfig, pas depuis les rôles ──
|
||||
const SizedBox(height: 10),
|
||||
const _SectionHeader('Charges pilotables',
|
||||
subtitle: 'Glissez pour l\'ordre de service du surplus'),
|
||||
const SizedBox(height: 10),
|
||||
SchemaVersionBanner(
|
||||
versionLine: context.watch<SchemaProvider>().versionLine(),
|
||||
schemaLoaded: context.watch<SchemaProvider>().schema.loaded,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ControllableLoadsSection(onConfigure: _openMechanism),
|
||||
const _Legend(),
|
||||
|
||||
// ── À configurer ─────────────────────────────────────────────────
|
||||
if (todo.isNotEmpty) ...[
|
||||
@ -296,118 +316,10 @@ class _MeterCard extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _LoadCard extends StatelessWidget {
|
||||
final EmsRole role;
|
||||
final int index;
|
||||
final EnergySetupProvider setup;
|
||||
final NymeaService service;
|
||||
const _LoadCard({
|
||||
required this.role,
|
||||
required this.index,
|
||||
required this.setup,
|
||||
required this.service,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final thing = _thingById(service, setup.thingIdOf(role));
|
||||
final detected = setup.isDetected(role);
|
||||
final sub = [
|
||||
if (thing != null) thing.name,
|
||||
if (thing != null) _thingClassName(service, thing),
|
||||
if (detected) 'détecté',
|
||||
].where((s) => s.isNotEmpty).join(' · ');
|
||||
|
||||
// Résumé READ-ONLY dérivé de la déclaration — l'édition des paliers est
|
||||
// dans l'écran 2 (Configurer), jamais ici.
|
||||
final summary = setup.loadConfigOf(role)?.summary;
|
||||
final enabled = setup.isEnabled(role);
|
||||
|
||||
return _CardShell(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Poignée de drag + rang de priorité
|
||||
ReorderableDragStartListener(
|
||||
index: index,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 12, top: 2),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(Icons.drag_indicator_rounded,
|
||||
size: 18, color: EtmTokens.faintOf(context)),
|
||||
const SizedBox(height: 6),
|
||||
Container(
|
||||
width: 22,
|
||||
height: 22,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: EtmTokens.eco.withValues(alpha: 0.14),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Text('${setup.priorityOf(role) ?? index + 1}',
|
||||
style: EtmTokens.mono(
|
||||
size: 12,
|
||||
weight: FontWeight.w700,
|
||||
color: EtmTokens.eco)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
_Emoji(role.icon),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(role.label,
|
||||
style: EtmTokens.sans(
|
||||
size: 14.5,
|
||||
weight: FontWeight.w700,
|
||||
color: EtmTokens.inkOf(context))),
|
||||
if (sub.isNotEmpty) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(sub,
|
||||
style: EtmTokens.sans(
|
||||
size: 12,
|
||||
color: EtmTokens.mutedOf(context))),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
Switch(
|
||||
value: enabled,
|
||||
activeThumbColor: EtmTokens.eco,
|
||||
onChanged: (v) => setup.setEnabled(role, v),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: [
|
||||
if (summary != null)
|
||||
_Chip(summary)
|
||||
else
|
||||
_Chip('À déclarer dans Configurer', muted: true),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
// _LoadCard a été retiré à la fusion : il affichait un EmsRole, c'est-à-dire un rôle
|
||||
// INFÉRÉ des Things, là où une charge est désormais lue dans `GetLoadConfig`. Son
|
||||
// remplaçant est `LoadConfigCard` (widgets/controllable_loads_section.dart), qui porte en
|
||||
// plus la télémétrie, le renommage, l'activation et l'accès au mécanisme.
|
||||
|
||||
class _TodoCard extends StatelessWidget {
|
||||
final EmsRole role;
|
||||
@ -645,11 +557,15 @@ class _Legend extends StatelessWidget {
|
||||
size: 11.5,
|
||||
weight: FontWeight.w700,
|
||||
color: EtmTokens.inkOf(context))),
|
||||
// La phrase qui suivait annonçait que « le pilote peut bousculer
|
||||
// l'ordre les jours Tempo rouge ou à l'approche d'une échéance ».
|
||||
// AUCUN de ces deux comportements n'existe dans le moteur : l'ordre
|
||||
// de service est celui que l'installateur pose, point. Promettre une
|
||||
// intelligence absente est pire que de ne rien dire — l'installateur
|
||||
// aurait attribué au pilote un arbitrage qu'il aurait dû configurer.
|
||||
const TextSpan(
|
||||
text:
|
||||
'Il fixe le service par défaut du surplus. Le pilote '
|
||||
'peut le bousculer les jours Tempo rouge ou à l\'approche '
|
||||
'd\'une échéance de recharge.'),
|
||||
text: 'Il fixe le service du surplus : chaque charge se sert '
|
||||
'dans cet ordre, sur ce que la précédente a laissé.'),
|
||||
],
|
||||
),
|
||||
),
|
||||
@ -728,29 +644,7 @@ class _Badge extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _Chip extends StatelessWidget {
|
||||
final String text;
|
||||
final bool muted;
|
||||
const _Chip(this.text, {this.muted = false});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: EtmTokens.bg2Of(context),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(text,
|
||||
style: EtmTokens.sans(
|
||||
size: 11.5,
|
||||
weight: muted ? FontWeight.w400 : FontWeight.w600,
|
||||
color: muted
|
||||
? EtmTokens.faintOf(context)
|
||||
: EtmTokens.mutedOf(context))),
|
||||
);
|
||||
}
|
||||
}
|
||||
// _Chip est parti avec _LoadCard : il n'affichait que le résumé d'un EmsRole.
|
||||
|
||||
class _Btn extends StatelessWidget {
|
||||
final String label;
|
||||
|
||||
249
lib/services/load_hardware.dart
Normal file
249
lib/services/load_hardware.dart
Normal file
@ -0,0 +1,249 @@
|
||||
/// Miroir client de `EnergyArbitrator::sameHardware()` — pour **annoncer avant
|
||||
/// d'enregistrer**, jamais pour décider à la place de la box.
|
||||
///
|
||||
/// ## Ce que la distinction change vraiment
|
||||
///
|
||||
/// `SetLoadConfig` remplace l'ensemble des charges, mais le moteur ne reconstruit pas
|
||||
/// tout : il compare chaque charge à celle qui a servi à bâtir l'adaptateur en place
|
||||
/// (`rebuildLoadAdapters()`, ECS-412). Deux issues, très différentes pour l'installation :
|
||||
///
|
||||
/// - **mise à jour en place** — `priority`, `needs` : l'adaptateur garde son palier
|
||||
/// courant et ses verrous. Rien ne bouge sur le matériel.
|
||||
/// - **reconstruction** — tout ce que [sameHardware] compare : l'ancien adaptateur passe
|
||||
/// par `applySafeState()` (**contacts ouverts, `force`**), le nouveau relit l'état réel,
|
||||
/// et ses verrous se **réarment à froid** pour leur durée configurée (ECS-412).
|
||||
///
|
||||
/// Sur un ballon à `minOnS` de 60 s c'est anecdotique. Sur une PAC à `minStateHoldS` de
|
||||
/// 900 s, l'installateur qui corrige une ligne se retrouve avec une machine figée un quart
|
||||
/// d'heure — et rien ne le lui aura dit. D'où cet écran d'annonce.
|
||||
///
|
||||
/// ## Le piège mesuré sur `.75` le 2026-08-25
|
||||
///
|
||||
/// La comparaison des relais est **indexée**, pas ensembliste : `relays[i].thingId` et
|
||||
/// `relays[i].powerW`, un à un. **Réordonner la liste sans rien changer d'autre est donc
|
||||
/// une reconstruction.** Vérifié sur le banc — `relays[]` inversé (2000/1000/500 au lieu
|
||||
/// de 500/1000/2000), même ensemble, mêmes Things, mêmes puissances :
|
||||
///
|
||||
/// ```text
|
||||
/// [RelayRouter] chauffe-eau — reprise au démarrage : palier 0 W […]
|
||||
/// [EnergyArbitrator] relay-router construit depuis config: "chauffe-eau" ( 3 relais)
|
||||
/// [RelayRouter] "chauffe-eau" → […] (force) | "Charge désactivée — mise en état sûr […]"
|
||||
/// [EnergyArbitrator] 2 charge(s) active(s) — 1 créée(s), 1 inchangée(s), 1 retirée(s)
|
||||
/// ```
|
||||
///
|
||||
/// Un glisser-déposer dans la liste des contacteurs est donc un geste **matériel**. Il
|
||||
/// doit s'annoncer comme tel, et c'est le cas le moins intuitif de tous.
|
||||
library;
|
||||
|
||||
import '../models/load_config_entry.dart';
|
||||
|
||||
/// Ce qu'un enregistrement va provoquer pour une charge donnée.
|
||||
enum LoadChangeImpact {
|
||||
/// Rien ne part pour cette charge.
|
||||
none,
|
||||
|
||||
/// Mise à jour en place : rang, besoins. Verrous et palier courant préservés.
|
||||
inPlace,
|
||||
|
||||
/// Reconstruction de l'adaptateur : état sûr, relecture, verrous réarmés à froid.
|
||||
rebuild,
|
||||
|
||||
/// La charge entre dans l'arbitrage (`enabled` faux → vrai, ou charge nouvelle).
|
||||
created,
|
||||
|
||||
/// La charge en sort (`enabled` vrai → faux, ou charge retirée) : elle passe par
|
||||
/// `applySafeState()` avant destruction (ECS-413).
|
||||
removed,
|
||||
}
|
||||
|
||||
/// Verdict d'impact pour une charge, avec de quoi le formuler à l'écran.
|
||||
class LoadImpact {
|
||||
final String loadId;
|
||||
final String label;
|
||||
final LoadChangeImpact impact;
|
||||
|
||||
/// Champs matériels qui ont changé (`relays`, `sgReady`, `minOnS`, …). Vide hors
|
||||
/// reconstruction. Sert à dire **ce qui** a déclenché, pas seulement qu'il y a eu.
|
||||
final List<String> changedFields;
|
||||
|
||||
/// Durée du verrou qui va se réarmer, en secondes, si elle est connue.
|
||||
///
|
||||
/// `minStateHoldS` pour une charge `sg-ready`, `max(minOnS, minOffS)` pour un
|
||||
/// routeur de relais. `null` quand le mécanisme n'en déclare pas.
|
||||
final int? rearmSeconds;
|
||||
|
||||
const LoadImpact({
|
||||
required this.loadId,
|
||||
required this.label,
|
||||
required this.impact,
|
||||
this.changedFields = const [],
|
||||
this.rearmSeconds,
|
||||
});
|
||||
|
||||
/// Vrai si l'installateur doit être averti avant que ça parte.
|
||||
bool get needsWarning =>
|
||||
impact == LoadChangeImpact.rebuild || impact == LoadChangeImpact.removed;
|
||||
}
|
||||
|
||||
/// Miroir **exact** de `EnergyArbitrator::sameHardware()` (energyarbitrator.cpp).
|
||||
///
|
||||
/// Toute divergence ferait mentir l'annonce — dans un sens comme dans l'autre. Les champs
|
||||
/// comparés, dans l'ordre du moteur : `adapter`, `mode`, `minOnS`, `minOffS`, `maxPowerW`,
|
||||
/// `powerLevels`, la charge utile `sgReady` entière, puis `relays[]` **index par index**.
|
||||
///
|
||||
/// \return vrai si le moteur gardera l'adaptateur en place.
|
||||
bool sameHardware(LoadConfigEntry a, LoadConfigEntry b) =>
|
||||
hardwareDiff(a, b).isEmpty;
|
||||
|
||||
/// Les champs matériels qui diffèrent. Vide ⇔ [sameHardware].
|
||||
///
|
||||
/// Rendre la liste plutôt qu'un booléen permet de dire *ce qui* impose la reconstruction :
|
||||
/// « vous avez changé l'ordre des contacteurs » est actionnable, « la charge sera
|
||||
/// reconstruite » ne l'est pas.
|
||||
List<String> hardwareDiff(LoadConfigEntry a, LoadConfigEntry b) {
|
||||
final diff = <String>[];
|
||||
|
||||
if (a.adapter != b.adapter) diff.add('adapter');
|
||||
if (a.mode != b.mode) diff.add('mode');
|
||||
if (a.minOnS != b.minOnS) diff.add('minOnS');
|
||||
if (a.minOffS != b.minOffS) diff.add('minOffS');
|
||||
if (a.maxPowerW != b.maxPowerW) diff.add('maxPowerW');
|
||||
if (!_sameIntList(a.powerLevelsInt, b.powerLevelsInt)) diff.add('powerLevels');
|
||||
if (!_sameSgReady(a, b)) diff.add('sgReady');
|
||||
|
||||
// Comparaison INDEXÉE, comme le moteur : un réordonnancement compte.
|
||||
final ra = a.relays, rb = b.relays;
|
||||
var relaysDiffer = ra.length != rb.length;
|
||||
if (!relaysDiffer) {
|
||||
for (var i = 0; i < ra.length; i++) {
|
||||
if (normalizeThingId(ra[i].thingId) != normalizeThingId(rb[i].thingId) ||
|
||||
ra[i].powerW != rb[i].powerW) {
|
||||
relaysDiffer = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (relaysDiffer) diff.add('relays');
|
||||
|
||||
return diff;
|
||||
}
|
||||
|
||||
/// Impact d'un enregistrement, charge par charge.
|
||||
///
|
||||
/// [avant] est l'état de la box (celui de `GetLoadConfig`), [apres] ce qui va partir.
|
||||
/// Les charges sont appariées par `id` — c'est la clé du moteur.
|
||||
List<LoadImpact> impactOfSave({
|
||||
required List<LoadConfigEntry> avant,
|
||||
required List<LoadConfigEntry> apres,
|
||||
}) {
|
||||
final parAvant = {for (final e in avant) e.id: e};
|
||||
final parApres = {for (final e in apres) e.id: e};
|
||||
final out = <LoadImpact>[];
|
||||
|
||||
for (final e in apres) {
|
||||
final old = parAvant[e.id];
|
||||
|
||||
if (old == null) {
|
||||
out.add(LoadImpact(
|
||||
loadId: e.id, label: e.label, impact: LoadChangeImpact.created));
|
||||
continue;
|
||||
}
|
||||
if (!old.enabled && e.enabled) {
|
||||
out.add(LoadImpact(
|
||||
loadId: e.id, label: e.label, impact: LoadChangeImpact.created));
|
||||
continue;
|
||||
}
|
||||
if (old.enabled && !e.enabled) {
|
||||
out.add(LoadImpact(
|
||||
loadId: e.id, label: e.label, impact: LoadChangeImpact.removed));
|
||||
continue;
|
||||
}
|
||||
if (!e.enabled) {
|
||||
// Désactivée avant comme après : aucun adaptateur n'existe, rien à reconstruire.
|
||||
out.add(LoadImpact(
|
||||
loadId: e.id, label: e.label, impact: LoadChangeImpact.none));
|
||||
continue;
|
||||
}
|
||||
|
||||
final diff = hardwareDiff(old, e);
|
||||
if (diff.isNotEmpty) {
|
||||
out.add(LoadImpact(
|
||||
loadId: e.id,
|
||||
label: e.label,
|
||||
impact: LoadChangeImpact.rebuild,
|
||||
changedFields: diff,
|
||||
rearmSeconds: _rearmSecondsOf(e),
|
||||
));
|
||||
continue;
|
||||
}
|
||||
|
||||
// `label` et `domain` ne sont PAS dans la comparaison du moteur : ils ne déclenchent
|
||||
// ni reconstruction ni mise à jour en place — l'adaptateur est réutilisé tel quel.
|
||||
// Seuls `priority` et `needs` provoquent un updateSoftConfig().
|
||||
final soft = old.priority != e.priority ||
|
||||
old.dailyDeadline != e.dailyDeadline ||
|
||||
old.minEnergyWhPerDay != e.minEnergyWhPerDay;
|
||||
out.add(LoadImpact(
|
||||
loadId: e.id,
|
||||
label: e.label,
|
||||
impact: soft ? LoadChangeImpact.inPlace : LoadChangeImpact.none,
|
||||
));
|
||||
}
|
||||
|
||||
for (final e in avant) {
|
||||
if (parApres.containsKey(e.id)) continue;
|
||||
out.add(LoadImpact(
|
||||
loadId: e.id,
|
||||
label: e.label,
|
||||
impact: e.enabled ? LoadChangeImpact.removed : LoadChangeImpact.none,
|
||||
));
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/// Durée du verrou qui se réarmera après reconstruction, si le mécanisme en déclare une.
|
||||
int? _rearmSecondsOf(LoadConfigEntry e) {
|
||||
if (e.isSgReady) {
|
||||
final h = e.minStateHoldS;
|
||||
return h > 0 ? h : null;
|
||||
}
|
||||
final m = e.minOnS > e.minOffS ? e.minOnS : e.minOffS;
|
||||
return m > 0 ? m : null;
|
||||
}
|
||||
|
||||
bool _sameIntList(List<int> a, List<int> b) {
|
||||
if (a.length != b.length) return false;
|
||||
for (var i = 0; i < a.length; i++) {
|
||||
if (a[i] != b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Compare la charge utile `sg-ready` comme le moteur : `minStateHoldS`, le NOMBRE
|
||||
/// d'états, puis chaque état index par index (`state`, `relays`, `estimatedPowerW`).
|
||||
bool _sameSgReady(LoadConfigEntry a, LoadConfigEntry b) {
|
||||
if (a.minStateHoldS != b.minStateHoldS) return false;
|
||||
final sa = a.sgReadyStates, sb = b.sgReadyStates;
|
||||
if (sa.length != sb.length) return false;
|
||||
for (var i = 0; i < sa.length; i++) {
|
||||
if (sa[i].state != sb[i].state) return false;
|
||||
if (!_sameStringList(
|
||||
sa[i].relays.map(normalizeThingId).toList(),
|
||||
sb[i].relays.map(normalizeThingId).toList())) {
|
||||
return false;
|
||||
}
|
||||
// Le moteur compare en qFuzzyCompare : une égalité stricte de doubles suffit ici,
|
||||
// les deux valeurs venant du même aller-retour JSON.
|
||||
if (sa[i].estimatedPowerW != sb[i].estimatedPowerW) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool _sameStringList(List<String> a, List<String> b) {
|
||||
if (a.length != b.length) return false;
|
||||
for (var i = 0; i < a.length; i++) {
|
||||
if (a[i] != b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@ -39,6 +39,9 @@ class NymeaService extends ChangeNotifier {
|
||||
int _port = 2222;
|
||||
String? _connectionError;
|
||||
String? _nymeaVersion;
|
||||
|
||||
/// Dernière charge utile de `JSONRPC.Hello`. Porte `experiences[]` et `version`.
|
||||
Map<String, dynamic>? _helloPayload;
|
||||
Timer? _heartbeatTimer;
|
||||
Timer? _simulationTimer;
|
||||
bool _isSimulation = false;
|
||||
@ -153,6 +156,9 @@ class NymeaService extends ChangeNotifier {
|
||||
int get port => _port;
|
||||
String? get connectionError => _connectionError;
|
||||
String? get nymeaVersion => _nymeaVersion;
|
||||
|
||||
/// Charge utile de `JSONRPC.Hello`, pour les versions d'API d'expérience.
|
||||
Map<String, dynamic>? get helloPayload => _helloPayload;
|
||||
String get username => _username;
|
||||
String get password => _password;
|
||||
|
||||
@ -375,6 +381,11 @@ class NymeaService extends ChangeNotifier {
|
||||
?? p['serverVersion'] as String?
|
||||
?? 'nymea';
|
||||
|
||||
// Charge utile de Hello conservée telle quelle : elle porte `experiences[]`, seul
|
||||
// endroit du fil où se lisent les versions d'API (`NymeaEnergy 0.8`). La version du
|
||||
// PAQUET du moteur (`1.15.2+etm15`) n'y est pas — voir [introspect].
|
||||
_helloPayload = Map<String, dynamic>.from(p);
|
||||
|
||||
// ── Identité & état d'auth — capturés AVANT toute décision d'auth.
|
||||
// `initialSetupRequired` tranche créer-admin vs login (consommé Étape 5).
|
||||
// nymea renvoie l'UUID entre accolades ({3943…}) ; on normalise une fois
|
||||
@ -1017,60 +1028,96 @@ class NymeaService extends ChangeNotifier {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Send EnergyPlugin.SetChargingInfo with full mode + optional deadline params.
|
||||
/// `NymeaEnergy.SetChargingInfo` — mode de recharge du véhicule.
|
||||
///
|
||||
/// [mode] : UI mode (pv → Eco, minPv → EcoWithMinCurrent, boost → Normal)
|
||||
/// [deadline] : activate deadline variant (*WithTargetTime) — ignored for boost
|
||||
/// [targetSoc] : target battery SOC % (1-100), required when deadline=true
|
||||
/// [endTime] : desired arrival/completion time, required when deadline=true
|
||||
Future<void> setChargingInfo({
|
||||
/// ## Ce bloc envoyait une requête que la box REJETTE
|
||||
///
|
||||
/// Corrigé le 2026-08-25 contre `JSONRPC.Introspect` puis vérifié par un appel réel
|
||||
/// sur `.75` (plugin `1.15.2+etm15`). Trois erreurs se cumulaient, et chacune suffisait
|
||||
/// à faire échouer l'appel :
|
||||
///
|
||||
/// - le **namespace** `EnergyPlugin.*` n'existe pas — il n'y a que `NymeaEnergy.*` ;
|
||||
/// - le champ s'appelle `chargingMode`, pas `mode`, et porte l'énumération **complète**
|
||||
/// (`ChargingModeEco`), pas son suffixe (`Eco`) ;
|
||||
/// - `targetSoc` et `endTime` s'appellent `targetPercentage` et `endDateTime`.
|
||||
///
|
||||
/// Réponse mesurée de la box sur l'ancienne charge utile :
|
||||
/// `Invalid params: Missing required key: chargingMode`. Les boutons de mode de
|
||||
/// recharge étaient donc **inopérants** contre cette version.
|
||||
///
|
||||
/// ## `minCurrent` n'existe pas dans ce contrat
|
||||
///
|
||||
/// L'ancien code envoyait `minCurrent: 6`. Le schéma de `ChargingInfo` ne porte
|
||||
/// **aucun** champ de courant : la box publie bien les modes
|
||||
/// `ChargingModeEcoWithMinCurrent` et `ChargingModeEcoMinWithTargetTime`, mais la
|
||||
/// valeur du courant minimal ne se règle pas ici. Le mode se choisit, son paramètre
|
||||
/// non — et l'écran doit le dire au lieu d'offrir un réglage sans effet.
|
||||
///
|
||||
/// \return `null` si la box a accepté, sinon de quoi l'annoncer. **Ne rien inventer**
|
||||
/// sur la cause : le détail reste au journal de la box.
|
||||
Future<String?> setChargingInfo({
|
||||
required ChargingMode mode,
|
||||
bool deadline = false,
|
||||
int targetSoc = 80,
|
||||
DateTime? endTime,
|
||||
}) async {
|
||||
// Map (mode, deadline) → API mode string + optional minCurrent
|
||||
// (mode d'UI, échéance) → énumération publiée par la box.
|
||||
final String apiMode;
|
||||
int? minCurrent;
|
||||
|
||||
if (mode == ChargingMode.boost) {
|
||||
apiMode = 'Normal';
|
||||
apiMode = 'ChargingModeNormal';
|
||||
} else if (mode == ChargingMode.pv && !deadline) {
|
||||
apiMode = 'Eco';
|
||||
apiMode = 'ChargingModeEco';
|
||||
} else if (mode == ChargingMode.minPv && !deadline) {
|
||||
apiMode = 'EcoWithMinCurrent';
|
||||
minCurrent = 6;
|
||||
apiMode = 'ChargingModeEcoWithMinCurrent';
|
||||
} else if (mode == ChargingMode.pv && deadline) {
|
||||
apiMode = 'EcoWithTargetTime';
|
||||
apiMode = 'ChargingModeEcoWithTargetTime';
|
||||
} else {
|
||||
// minPv + deadline
|
||||
apiMode = 'EcoMinWithTargetTime';
|
||||
minCurrent = 6;
|
||||
apiMode = 'ChargingModeEcoMinWithTargetTime';
|
||||
}
|
||||
|
||||
String? error;
|
||||
if (_connected && !_isSimulation) {
|
||||
final evChargerId = _findEvChargerId();
|
||||
if (evChargerId != null) {
|
||||
if (evChargerId == null) {
|
||||
_log('[Recharge] SetChargingInfo: aucune borne trouvée', force: true);
|
||||
error = 'NoEvCharger';
|
||||
} else {
|
||||
try {
|
||||
final info = <String, dynamic>{
|
||||
'evChargerId': evChargerId,
|
||||
'mode': apiMode,
|
||||
'chargingMode': apiMode,
|
||||
};
|
||||
if (minCurrent != null) info['minCurrent'] = minCurrent;
|
||||
// Rien qui ne soit au schéma ne part : `minCurrent` n'existe pas, et une clé
|
||||
// inconnue ferait rejeter TOUT l'appel (nymea valide avant le handler).
|
||||
if (deadline && mode != ChargingMode.boost) {
|
||||
info['targetSoc'] = targetSoc;
|
||||
info['endTime'] = endTime != null
|
||||
? endTime.millisecondsSinceEpoch ~/ 1000
|
||||
: null;
|
||||
info['targetPercentage'] = targetSoc;
|
||||
if (endTime != null) {
|
||||
info['endDateTime'] = endTime.millisecondsSinceEpoch ~/ 1000;
|
||||
}
|
||||
}
|
||||
await _sendRequest('EnergyPlugin.SetChargingInfo', {'chargingInfo': info});
|
||||
} catch (e) { _log('SetChargingInfo: $e'); }
|
||||
} else {
|
||||
_log('SetChargingInfo: no EV charger thing found');
|
||||
final r = await _sendRequest(
|
||||
'NymeaEnergy.SetChargingInfo', {'chargingInfo': info});
|
||||
final err = r['params']?['energyError'] as String?;
|
||||
if (r['status'] != 'success') {
|
||||
error = (r['error'] as String?) ?? r['status'] as String?;
|
||||
} else if (err != null && err != 'EnergyErrorNoError') {
|
||||
error = err;
|
||||
}
|
||||
_log('[Recharge] SetChargingInfo($apiMode) ← ${error ?? 'OK'}', force: true);
|
||||
} catch (e) {
|
||||
_log('[Recharge] SetChargingInfo: $e', force: true);
|
||||
error = e.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
_energyData = _energyData.copyWith(chargingMode: mode);
|
||||
notifyListeners();
|
||||
|
||||
// L'état local ne suit que si la box a accepté — sinon l'UI montrerait un mode
|
||||
// que la borne n'a jamais reçu.
|
||||
if (error == null) {
|
||||
_energyData = _energyData.copyWith(chargingMode: mode);
|
||||
notifyListeners();
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
@ -1182,6 +1229,81 @@ class NymeaService extends ChangeNotifier {
|
||||
bool _loadTelemetrySupported = true;
|
||||
bool get loadTelemetrySupported => _loadTelemetrySupported;
|
||||
|
||||
/// `AirConditioning.GetZones` — les zones de climatisation déclarées sur la box.
|
||||
///
|
||||
/// ## Ce que l'introspection a révélé, et qui contredit une hypothèse de travail
|
||||
///
|
||||
/// L'expérience `AirConditioning` n'est pas une surface réduite qu'il faudrait griser :
|
||||
/// elle publie **huit** méthodes, toutes en écriture — `AddZone`, `RemoveZone`,
|
||||
/// `SetZoneName`, `SetZoneThings`, `SetZoneStandbySetpoint`,
|
||||
/// `SetZoneSetpointOverride`, `SetZoneWeekSchedule`, `GetZones` (relevé sur `.75`,
|
||||
/// `AirConditioning 1.1`). Une zone porte ses thermostats, ses vannes, ses sondes,
|
||||
/// une consigne de veille, une dérogation avec mode et durée, et un programme
|
||||
/// hebdomadaire complet.
|
||||
///
|
||||
/// Les champs de `ZoneInfo` sont tous `r:` — mais cela ne veut PAS dire « lecture
|
||||
/// seule » au sens fonctionnel : ils se règlent par les setters dédiés ci-dessus, pas
|
||||
/// en réécrivant l'objet. Confondre les deux ferait griser un écran entier qui est en
|
||||
/// réalité pleinement pilotable.
|
||||
///
|
||||
/// **Et c'est bien là que vivent thermostats et vannes** — pas dans `LoadConfig`.
|
||||
/// LM-202 : un thermostat ouvre une vanne, il ne porte pas la consommation, qui reste
|
||||
/// à la machine. Les déclarer comme charges produirait des entrées à 0 W dans le
|
||||
/// waterfall pendant que la PAC est comptée ailleurs. Les zones décrivent **où va la
|
||||
/// chaleur**, pas **combien on en achète**.
|
||||
///
|
||||
/// \return les zones, ou liste vide. Sur `.75` au 2026-08-25 : **aucune zone déclarée**.
|
||||
Future<List<Map<String, dynamic>>> getZones() async {
|
||||
if (_isSimulation || !_connected) return const [];
|
||||
try {
|
||||
final r = await _sendRequest('AirConditioning.GetZones', {});
|
||||
final raw = (r['params']?['zones'] as List?) ?? const [];
|
||||
final out = raw
|
||||
.whereType<Map>()
|
||||
.map((m) => Map<String, dynamic>.from(m))
|
||||
.toList();
|
||||
_log('[Zones] GetZones → ${out.length} zone(s)', force: true);
|
||||
return out;
|
||||
} catch (e) {
|
||||
_log('[Zones] GetZones: $e', force: true);
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
/// `JSONRPC.Introspect` — le **schéma que cette box accepte**, tel qu'elle le publie.
|
||||
///
|
||||
/// C'est la seule source honnête pour décider ce qu'un écran active ou grise. Le parc
|
||||
/// est hétérogène : une table de versions codée en dur mentirait dans les deux sens —
|
||||
/// champ disponible resté gris sur une box récente, champ absent présenté comme actif
|
||||
/// sur une ancienne. Dérivé du schéma, un champ que le plugin se met à exposer s'active
|
||||
/// **seul**, sans nouvelle version de l'app.
|
||||
///
|
||||
/// Relevé sur `.75` (plugin `1.15.2+etm15`) : 20 méthodes `NymeaEnergy.*`, 8 méthodes
|
||||
/// `AirConditioning.*`. Les préfixes de champ portent le sens — `o:` optionnel et
|
||||
/// écrivable, `r:` lecture seule.
|
||||
///
|
||||
/// \return la charge utile brute d'introspection, ou `null` si elle n'a pas pu être
|
||||
/// lue. `null` ne veut PAS dire « champs absents » : il veut dire « on ne sait pas »,
|
||||
/// et les écrans doivent l'annoncer ainsi plutôt que de griser au hasard.
|
||||
Future<Map<String, dynamic>?> introspect() async {
|
||||
if (_isSimulation || !_connected) return null;
|
||||
try {
|
||||
final r = await _sendRequest('JSONRPC.Introspect', {});
|
||||
final params = r['params'];
|
||||
if (r['status'] != 'success' || params is! Map) {
|
||||
_log('[Schéma] Introspect ← ${r['error'] ?? r['status']}', force: true);
|
||||
return null;
|
||||
}
|
||||
final out = Map<String, dynamic>.from(params);
|
||||
final methods = (out['methods'] as Map?)?.length ?? 0;
|
||||
_log('[Schéma] Introspect → $methods méthode(s)', force: true);
|
||||
return out;
|
||||
} catch (e) {
|
||||
_log('[Schéma] Introspect: $e', force: true);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// `NymeaEnergy.ClearLoadFault` — lève le verrou de défaut d'une charge (ECS-410).
|
||||
///
|
||||
/// **L'acquittement ne prouve rien.** Vérifié sur `.75` le 2026-08-25 : appelée sur une
|
||||
|
||||
271
lib/services/rpc_schema.dart
Normal file
271
lib/services/rpc_schema.dart
Normal file
@ -0,0 +1,271 @@
|
||||
/// Lecture du **schéma publié par la box** (`JSONRPC.Introspect`), et rien d'autre.
|
||||
///
|
||||
/// ## Pourquoi le schéma, et pas une table de versions
|
||||
///
|
||||
/// Un écran qui grise ses champs à partir d'une liste codée en dur se trompe deux fois :
|
||||
/// il ment sur les box plus récentes (champ disponible, resté gris) et sur les plus
|
||||
/// anciennes (champ absent, présenté comme actif). Le parc est hétérogène et le restera.
|
||||
///
|
||||
/// En dérivant le grisage du schéma, un champ que le plugin se met à exposer **s'active
|
||||
/// seul**, sur les box à jour, sans nouvelle version de l'app — et reste gris sur les
|
||||
/// autres. C'est aussi la seule façon de tenir la promesse inverse : **rien de ce qui
|
||||
/// n'est pas au schéma ne sort de l'app**.
|
||||
///
|
||||
/// ## Ce que la box publie vraiment — relevé sur `.75`, 2026-08-25
|
||||
///
|
||||
/// `JSONRPC.Introspect` rend quatre sections : `methods`, `notifications`, `types`,
|
||||
/// `enums`. Les noms de champs y portent des préfixes qui sont le cœur de ce fichier :
|
||||
///
|
||||
/// - `o:` — champ **optionnel** ; présent au schéma, il s'écrit.
|
||||
/// - `r:` — champ en **lecture seule** dans la charge utile. Il existe, il s'affiche,
|
||||
/// mais l'écrire est une erreur. C'est un état DISTINCT de l'absence, et les confondre
|
||||
/// afficherait « la box ne l'expose pas encore » sur une mesure parfaitement lisible.
|
||||
/// - sans préfixe — requis, et en écriture sur une méthode d'écriture.
|
||||
///
|
||||
/// ## La version du plugin n'est PAS sur le fil
|
||||
///
|
||||
/// `JSONRPC.Hello` rend la version de **nymea** (`1.15.2+202606191336~trixie1`) et la
|
||||
/// version d'**API des expériences** (`NymeaEnergy 0.8`, `AirConditioning 1.1`). La
|
||||
/// version du paquet Debian du moteur — `1.15.2+etm15` — n'est exposée par aucune
|
||||
/// méthode : elle ne se lit que par `dpkg` sur la box. Ce qu'un écran peut donc annoncer
|
||||
/// honnêtement, c'est la version d'API, qui est de toute façon le bon discriminant : elle
|
||||
/// décrit le contrat, pas le paquet qui l'implémente.
|
||||
library;
|
||||
|
||||
/// Ce que le schéma de **cette** box dit d'un champ.
|
||||
enum FieldSupport {
|
||||
/// Au schéma, et en écriture.
|
||||
writable,
|
||||
|
||||
/// Au schéma, mais `r:` — affichable, jamais écrit.
|
||||
readOnly,
|
||||
|
||||
/// **Pas** au schéma de cette box. C'est le seul cas qui se lit « pas encore exposé ».
|
||||
absent,
|
||||
|
||||
/// Le schéma n'a pas pu être lu. On ne sait pas — et on le dit, plutôt que de
|
||||
/// supposer l'un ou l'autre.
|
||||
unknown,
|
||||
}
|
||||
|
||||
extension FieldSupportX on FieldSupport {
|
||||
/// Vrai seulement si le champ peut partir dans une écriture.
|
||||
bool get isWritable => this == FieldSupport.writable;
|
||||
|
||||
/// Vrai si le champ doit apparaître grisé plutôt qu'actif.
|
||||
bool get isGreyed => this != FieldSupport.writable;
|
||||
}
|
||||
|
||||
/// Un nœud du schéma : une map de champs, une liste, ou un scalaire.
|
||||
///
|
||||
/// Enveloppe volontairement mince — elle ne modélise pas les types, elle répond à une
|
||||
/// seule question : *ce champ est-il là, et puis-je l'écrire ?*
|
||||
class SchemaNode {
|
||||
final Object? _raw;
|
||||
final RpcSchema _schema;
|
||||
|
||||
const SchemaNode(this._raw, this._schema);
|
||||
|
||||
/// Vrai si ce nœud n'existe pas dans le schéma lu.
|
||||
bool get isMissing => _raw == null;
|
||||
|
||||
/// Descend dans l'élément d'une liste (`[{...}]` → `{...}`).
|
||||
SchemaNode get item {
|
||||
final r = _raw;
|
||||
if (r is List && r.isNotEmpty) return _schema._resolve(r.first);
|
||||
return SchemaNode(null, _schema);
|
||||
}
|
||||
|
||||
/// Cherche [name] parmi les clés, en tolérant les préfixes `o:` et `r:`.
|
||||
///
|
||||
/// \return le support du champ, et le sous-nœud s'il en a un.
|
||||
({FieldSupport support, SchemaNode node}) field(String name) {
|
||||
final r = _raw;
|
||||
if (r is! Map) {
|
||||
return (support: FieldSupport.absent, node: SchemaNode(null, _schema));
|
||||
}
|
||||
for (final entry in r.entries) {
|
||||
final key = entry.key.toString();
|
||||
final (prefix, bare) = _split(key);
|
||||
if (bare != name) continue;
|
||||
final support = switch (prefix) {
|
||||
'r' => FieldSupport.readOnly,
|
||||
_ => FieldSupport.writable, // 'o' (optionnel) et sans préfixe s'écrivent
|
||||
};
|
||||
return (support: support, node: _schema._resolve(entry.value));
|
||||
}
|
||||
return (support: FieldSupport.absent, node: SchemaNode(null, _schema));
|
||||
}
|
||||
|
||||
/// Support d'un champ atteint par un chemin (`['loadConfigs', '[]', 'sgReady']`).
|
||||
///
|
||||
/// `'[]'` descend dans l'élément d'une liste. Un maillon absent rend [FieldSupport.absent]
|
||||
/// pour tout le chemin — on ne devine pas ce qu'il y aurait eu dessous.
|
||||
FieldSupport supportAt(List<String> path) {
|
||||
var node = this;
|
||||
var support = FieldSupport.writable;
|
||||
for (final step in path) {
|
||||
if (step == '[]') {
|
||||
node = node.item;
|
||||
if (node.isMissing) return FieldSupport.absent;
|
||||
continue;
|
||||
}
|
||||
final r = node.field(step);
|
||||
if (r.support == FieldSupport.absent) return FieldSupport.absent;
|
||||
// Un `r:` rencontré en chemin contamine la suite : on n'écrit pas dans une
|
||||
// sous-structure en lecture seule.
|
||||
if (r.support == FieldSupport.readOnly) support = FieldSupport.readOnly;
|
||||
node = r.node;
|
||||
}
|
||||
return support;
|
||||
}
|
||||
|
||||
/// Noms de champs de ce nœud, préfixes retirés.
|
||||
List<String> get fieldNames {
|
||||
final r = _raw;
|
||||
if (r is! Map) return const [];
|
||||
return [for (final k in r.keys) _split(k.toString()).$2];
|
||||
}
|
||||
|
||||
static (String?, String) _split(String key) {
|
||||
final i = key.indexOf(':');
|
||||
if (i <= 0) return (null, key);
|
||||
final prefix = key.substring(0, i);
|
||||
if (prefix == 'o' || prefix == 'r') return (prefix, key.substring(i + 1));
|
||||
return (null, key);
|
||||
}
|
||||
}
|
||||
|
||||
/// Le schéma d'une box, tel que `JSONRPC.Introspect` l'a rendu.
|
||||
class RpcSchema {
|
||||
final Map<String, dynamic> _methods;
|
||||
final Map<String, dynamic> _types;
|
||||
final Map<String, dynamic> _enums;
|
||||
final Map<String, dynamic> _notifications;
|
||||
|
||||
/// Versions d'API des expériences, lues dans `JSONRPC.Hello` (`{NymeaEnergy: "0.8"}`).
|
||||
final Map<String, String> experienceVersions;
|
||||
|
||||
/// Version de **nymea** (pas celle du plugin — voir l'en-tête de ce fichier).
|
||||
final String? nymeaVersion;
|
||||
|
||||
/// Vrai si l'introspection a réellement abouti. Faux → tout est [FieldSupport.unknown],
|
||||
/// et les écrans doivent le dire au lieu de griser ou d'activer au hasard.
|
||||
final bool loaded;
|
||||
|
||||
const RpcSchema._({
|
||||
required Map<String, dynamic> methods,
|
||||
required Map<String, dynamic> types,
|
||||
required Map<String, dynamic> enums,
|
||||
required Map<String, dynamic> notifications,
|
||||
required this.experienceVersions,
|
||||
required this.nymeaVersion,
|
||||
required this.loaded,
|
||||
}) : _methods = methods,
|
||||
_types = types,
|
||||
_enums = enums,
|
||||
_notifications = notifications;
|
||||
|
||||
/// Schéma vide — état avant lecture, ou après un échec d'introspection.
|
||||
static const RpcSchema unknown = RpcSchema._(
|
||||
methods: {},
|
||||
types: {},
|
||||
enums: {},
|
||||
notifications: {},
|
||||
experienceVersions: {},
|
||||
nymeaVersion: null,
|
||||
loaded: false,
|
||||
);
|
||||
|
||||
/// Construit depuis la charge utile de `JSONRPC.Introspect`, et éventuellement
|
||||
/// celle de `JSONRPC.Hello` pour les versions d'API.
|
||||
factory RpcSchema.fromIntrospect(
|
||||
Map<String, dynamic> introspect, {
|
||||
Map<String, dynamic>? hello,
|
||||
}) {
|
||||
Map<String, dynamic> section(String k) {
|
||||
final v = introspect[k];
|
||||
return v is Map ? Map<String, dynamic>.from(v) : <String, dynamic>{};
|
||||
}
|
||||
|
||||
final versions = <String, String>{};
|
||||
final exps = hello?['experiences'];
|
||||
if (exps is List) {
|
||||
for (final e in exps.whereType<Map>()) {
|
||||
final n = e['name'], v = e['version'];
|
||||
if (n is String && v is String) versions[n] = v;
|
||||
}
|
||||
}
|
||||
|
||||
return RpcSchema._(
|
||||
methods: section('methods'),
|
||||
types: section('types'),
|
||||
enums: section('enums'),
|
||||
notifications: section('notifications'),
|
||||
experienceVersions: versions,
|
||||
nymeaVersion: hello?['version'] as String?,
|
||||
loaded: introspect['methods'] is Map,
|
||||
);
|
||||
}
|
||||
|
||||
/// Vrai si cette box déclare la méthode. Une méthode absente n'est pas une panne :
|
||||
/// c'est une box plus ancienne, et l'écran doit le dire.
|
||||
bool hasMethod(String name) => _methods.containsKey(name);
|
||||
|
||||
/// Vrai si cette box déclare la notification.
|
||||
bool hasNotification(String name) => _notifications.containsKey(name);
|
||||
|
||||
/// Valeurs d'une énumération publiée (`ChargingMode` → `[ChargingModeNormal, …]`).
|
||||
///
|
||||
/// **Ne jamais coder ces listes en dur** : une box plus récente peut en ajouter, et un
|
||||
/// sélecteur figé refuserait une valeur que la box accepte.
|
||||
List<String> enumValues(String name) {
|
||||
final v = _enums[name];
|
||||
return v is List ? v.whereType<String>().toList() : const [];
|
||||
}
|
||||
|
||||
/// Racine des **paramètres** d'une méthode — ce qui part en écriture.
|
||||
SchemaNode params(String method) {
|
||||
final m = _methods[method];
|
||||
if (m is! Map) return SchemaNode(null, this);
|
||||
return _resolve(m['params']);
|
||||
}
|
||||
|
||||
/// Racine du **retour** d'une méthode.
|
||||
SchemaNode returns(String method) {
|
||||
final m = _methods[method];
|
||||
if (m is! Map) return SchemaNode(null, this);
|
||||
return _resolve(m['returns']);
|
||||
}
|
||||
|
||||
/// Racine d'un type nommé (`ChargingInfo`, `ZoneInfo`, …).
|
||||
SchemaNode type(String name) => _resolve(_types[name]);
|
||||
|
||||
/// Support d'un champ des paramètres d'une méthode.
|
||||
///
|
||||
/// Rend [FieldSupport.unknown] si le schéma n'a pas été lu — ne jamais confondre
|
||||
/// « je ne sais pas » avec « la box ne l'expose pas ».
|
||||
FieldSupport paramSupport(String method, List<String> path) {
|
||||
if (!loaded) return FieldSupport.unknown;
|
||||
if (!hasMethod(method)) return FieldSupport.absent;
|
||||
return params(method).supportAt(path);
|
||||
}
|
||||
|
||||
/// Support d'un champ d'un type nommé.
|
||||
FieldSupport typeSupport(String typeName, List<String> path) {
|
||||
if (!loaded) return FieldSupport.unknown;
|
||||
final t = type(typeName);
|
||||
if (t.isMissing) return FieldSupport.absent;
|
||||
return t.supportAt(path);
|
||||
}
|
||||
|
||||
/// Suit les `$ref:Nom` jusqu'au type concret.
|
||||
SchemaNode _resolve(Object? node) {
|
||||
var cur = node;
|
||||
var hops = 0;
|
||||
while (cur is String && cur.startsWith(r'$ref:') && hops++ < 8) {
|
||||
cur = _types[cur.substring(5)];
|
||||
}
|
||||
return SchemaNode(cur, this);
|
||||
}
|
||||
}
|
||||
609
lib/widgets/controllable_loads_section.dart
Normal file
609
lib/widgets/controllable_loads_section.dart
Normal file
@ -0,0 +1,609 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../l10n/l10n_ext.dart';
|
||||
import '../models/load_config_entry.dart';
|
||||
import '../providers/load_config_provider.dart';
|
||||
import '../providers/load_telemetry_provider.dart';
|
||||
import '../services/load_hardware.dart';
|
||||
import '../services/load_priority.dart';
|
||||
import '../theme/etm_tokens.dart';
|
||||
import 'load_cards.dart';
|
||||
|
||||
/// Les **charges pilotables** de l'écran « Rôles & appareils », telles que la box les
|
||||
/// déclare.
|
||||
///
|
||||
/// ## Le sens de la fusion
|
||||
///
|
||||
/// Cette section remplace l'ancien écran « Ordre de service » — c'est son **chemin de
|
||||
/// données** qui a migré ici, pas l'inverse. La distinction n'est pas de forme : l'écran
|
||||
/// des rôles reposait sur une énumération figée de six rôles
|
||||
/// (`gridMeter`, `solarMeter`, `evCharger`, `dhw`, `heatPump`, `battery`), et une
|
||||
/// énumération figée ne peut pas décrire une installation.
|
||||
///
|
||||
/// **LM-201 le dit sans détour : une charge existe quand il y a un canal de commande
|
||||
/// distinct pour un service.** Un canal, une charge. Une PAC air/eau qui fait chauffage
|
||||
/// ET ECS sans ballon séparé est **une** charge — le SG-Ready commande la machine
|
||||
/// entière et la répartition appartient à sa régulation. Le modèle des rôles, lui, aurait
|
||||
/// exigé d'en déclarer deux (`dhw` **et** `heatPump`) pour une seule machine, donc deux
|
||||
/// entrées se disputant un canal unique. C'est exactement ce que `validateSet()` refuse
|
||||
/// côté box, et ce refus n'est pas une limite subie : c'est le modèle.
|
||||
///
|
||||
/// La liste affichée vient donc de `GetLoadConfig`, avec ses identifiants et ses domaines
|
||||
/// tels quels — y compris ceux qu'aucune version d'app ne connaît encore.
|
||||
///
|
||||
/// ## Deux sources qu'il ne faut pas confondre
|
||||
///
|
||||
/// - **la configuration** (`GetLoadConfig`) — ce que l'installateur a déclaré, éditable ;
|
||||
/// - **la télémétrie** (`GetLoadTelemetry`) — ce que l'arbitre a fait au dernier cycle,
|
||||
/// en lecture seule et jamais reconstruite côté app.
|
||||
///
|
||||
/// Depuis le lot plugin B-bis, toute charge publiée en télémétrie a une entrée en
|
||||
/// configuration. L'inverse reste faux : une charge configurée absente de la télémétrie
|
||||
/// est **désactivée**, jamais perdue — et « non arbitrée » n'est donc pas une erreur.
|
||||
class ControllableLoadsSection extends StatelessWidget {
|
||||
/// Ouvre l'écran de mécanisme d'une charge. Fourni par l'écran hôte, qui connaît la
|
||||
/// navigation ; les trois contrats ne se ressemblent pas, donc il n'y a pas de
|
||||
/// formulaire unique à ouvrir.
|
||||
final void Function(BuildContext, LoadConfigEntry) onConfigure;
|
||||
|
||||
const ControllableLoadsSection({super.key, required this.onConfigure});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final provider = context.watch<LoadConfigProvider>();
|
||||
final telemetry = context.watch<LoadTelemetryProvider>();
|
||||
final groups = provider.groups;
|
||||
|
||||
if (provider.loading && groups.isEmpty) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
if (groups.isEmpty) {
|
||||
return _EmptyNote(error: provider.error);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (var i = 0; i < groups.length; i++)
|
||||
_DomainBlock(
|
||||
key: ValueKey('domain-${groups[i].domain}'),
|
||||
group: groups[i],
|
||||
index: i,
|
||||
total: groups.length,
|
||||
provider: provider,
|
||||
telemetry: telemetry,
|
||||
onConfigure: onConfigure,
|
||||
),
|
||||
if (provider.hasPendingChanges) ...[
|
||||
const SizedBox(height: 4),
|
||||
ImpactSaveBar(provider: provider),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
final DomainGroup group;
|
||||
final int index;
|
||||
final int total;
|
||||
final LoadConfigProvider provider;
|
||||
final LoadTelemetryProvider telemetry;
|
||||
final void Function(BuildContext, LoadConfigEntry) onConfigure;
|
||||
|
||||
const _DomainBlock({
|
||||
super.key,
|
||||
required this.group,
|
||||
required this.index,
|
||||
required this.total,
|
||||
required this.provider,
|
||||
required this.telemetry,
|
||||
required this.onConfigure,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.l10n;
|
||||
final muted = EtmTokens.mutedOf(context);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
domainLabel(t, group.domain),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 13,
|
||||
letterSpacing: 0.2,
|
||||
color: group.isUnclassified ? muted : EtmTokens.inkOf(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
visualDensity: VisualDensity.compact,
|
||||
icon: const Icon(Icons.keyboard_arrow_up_rounded, size: 20),
|
||||
tooltip: 'Monter ce domaine',
|
||||
onPressed:
|
||||
index == 0 ? null : () => provider.reorderDomains(index, index - 1),
|
||||
),
|
||||
IconButton(
|
||||
visualDensity: VisualDensity.compact,
|
||||
icon: const Icon(Icons.keyboard_arrow_down_rounded, size: 20),
|
||||
tooltip: 'Descendre ce domaine',
|
||||
onPressed: index >= total - 1
|
||||
? null
|
||||
: () => provider.reorderDomains(index, index + 2),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 6),
|
||||
ReorderableListView.builder(
|
||||
shrinkWrap: true,
|
||||
primary: false,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
buildDefaultDragHandles: false,
|
||||
itemCount: group.loads.length,
|
||||
onReorder: (o, n) => provider.reorderWithinDomain(group.domain, o, n),
|
||||
itemBuilder: (context, i) => Padding(
|
||||
key: ValueKey('load-${group.loads[i].id}'),
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: LoadConfigCard(
|
||||
entry: group.loads[i],
|
||||
handleIndex: i,
|
||||
provider: provider,
|
||||
telemetry: telemetry,
|
||||
onConfigure: onConfigure,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Carte d'une charge : identité, classement, rang, télémétrie, et l'accès au mécanisme.
|
||||
class LoadConfigCard extends StatelessWidget {
|
||||
final LoadConfigEntry entry;
|
||||
final int handleIndex;
|
||||
final LoadConfigProvider provider;
|
||||
final LoadTelemetryProvider telemetry;
|
||||
final void Function(BuildContext, LoadConfigEntry) onConfigure;
|
||||
|
||||
const LoadConfigCard({
|
||||
super.key,
|
||||
required this.entry,
|
||||
required this.handleIndex,
|
||||
required this.provider,
|
||||
required this.telemetry,
|
||||
required this.onConfigure,
|
||||
});
|
||||
|
||||
/// Libellé du mécanisme. Code inconnu → repli lisible, jamais une phrase vide : une
|
||||
/// box plus récente peut publier un mécanisme que cette version d'app ignore.
|
||||
String _adapterLabel(L10n t) => switch (entry.adapter) {
|
||||
'relay-router' => t.adapterRelayRouter,
|
||||
'etmvariableload' => t.adapterEtmVariableLoad,
|
||||
'sg-ready' => t.adapterSgReady,
|
||||
'evcharger' => t.adapterEvCharger,
|
||||
_ => t.adapterUnknown(entry.adapter),
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.l10n;
|
||||
final muted = EtmTokens.mutedOf(context);
|
||||
final live = telemetry.entryFor(entry.id);
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: EtmTokens.surfaceOf(context),
|
||||
borderRadius: BorderRadius.circular(EtmTokens.radiusCard),
|
||||
boxShadow: EtmTokens.cardShadowOf(context),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ReorderableDragStartListener(
|
||||
index: handleIndex,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 2, right: 8),
|
||||
child: Icon(Icons.drag_indicator_rounded, size: 20, color: muted),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
entry.label,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: entry.enabled
|
||||
? EtmTokens.inkOf(context)
|
||||
: muted,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text('${_adapterLabel(t)} · rang ${entry.priority}',
|
||||
style: TextStyle(fontSize: 11.5, color: muted)),
|
||||
],
|
||||
),
|
||||
),
|
||||
// `enabled` : entrée/sortie de l'arbitrage. Le libellé de l'aide dit ce que
|
||||
// le drapeau fait VRAIMENT — voir _EnabledSwitch.
|
||||
_EnabledSwitch(entry: entry, provider: provider),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(children: [
|
||||
DomainMenu(entry: entry, provider: provider),
|
||||
const Spacer(),
|
||||
TextButton.icon(
|
||||
onPressed: () => onConfigure(context, entry),
|
||||
icon: const Icon(Icons.tune_rounded, size: 17),
|
||||
label: const Text('Configurer'),
|
||||
style: TextButton.styleFrom(
|
||||
visualDensity: VisualDensity.compact,
|
||||
foregroundColor: EtmTokens.brand,
|
||||
),
|
||||
),
|
||||
]),
|
||||
_RenameRow(entry: entry, provider: provider),
|
||||
const SizedBox(height: 8),
|
||||
Divider(height: 1, color: EtmTokens.lineOf(context)),
|
||||
const SizedBox(height: 8),
|
||||
if (live != null)
|
||||
LoadLiveBlock(
|
||||
live: live,
|
||||
stale: telemetry.stale,
|
||||
telemetry: telemetry,
|
||||
)
|
||||
else
|
||||
// Absente de `loads[]` : désactivée, pas perdue. Le dire explicitement —
|
||||
// un vide se lirait comme une panne.
|
||||
Row(children: [
|
||||
Icon(Icons.pause_circle_outline_rounded, size: 14, color: muted),
|
||||
const SizedBox(width: 5),
|
||||
Expanded(
|
||||
child: Text(
|
||||
entry.enabled
|
||||
? 'Non arbitrée à ce cycle — configurée, mais absente de la '
|
||||
'télémétrie du dernier cycle.'
|
||||
: 'Désactivée — rôle déclaré, exclue de l\'arbitrage.',
|
||||
style: TextStyle(fontSize: 11.5, color: muted, height: 1.3),
|
||||
),
|
||||
),
|
||||
]),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Interrupteur d'entrée dans l'arbitrage, avec ce que le geste fait réellement.
|
||||
class _EnabledSwitch extends StatelessWidget {
|
||||
final LoadConfigEntry entry;
|
||||
final LoadConfigProvider provider;
|
||||
|
||||
const _EnabledSwitch({required this.entry, required this.provider});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Tooltip(
|
||||
// Ce texte est le résultat d'une mesure, pas une précaution rédactionnelle :
|
||||
// sur `.75`, PAC en état 4 (contacts fermés), `enabled: false` a bien OUVERT les
|
||||
// contacts — par le retrait de l'adaptateur (ECS-413), pas par le drapeau.
|
||||
message: entry.enabled
|
||||
? 'Désactiver retire la charge de l\'arbitrage et l\'amène à son état sûr '
|
||||
'(contacts ouverts) avant de la retirer.\n'
|
||||
'Ce n\'est pas un sectionnement : la sécurité vient du retrait, pas du '
|
||||
'drapeau.'
|
||||
: 'Activer fait entrer la charge dans l\'arbitrage au prochain cycle.',
|
||||
child: Switch.adaptive(
|
||||
value: entry.enabled,
|
||||
onChanged: (v) => provider.setEnabled(entry.id, v),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Renommage en ligne. Le libellé n'est pas du matériel — aucun verrou n'est réarmé.
|
||||
class _RenameRow extends StatefulWidget {
|
||||
final LoadConfigEntry entry;
|
||||
final LoadConfigProvider provider;
|
||||
|
||||
const _RenameRow({required this.entry, required this.provider});
|
||||
|
||||
@override
|
||||
State<_RenameRow> createState() => _RenameRowState();
|
||||
}
|
||||
|
||||
class _RenameRowState extends State<_RenameRow> {
|
||||
bool _editing = false;
|
||||
late final TextEditingController _c =
|
||||
TextEditingController(text: widget.entry.label);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_c.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!_editing) {
|
||||
return Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton.icon(
|
||||
onPressed: () => setState(() => _editing = true),
|
||||
icon: const Icon(Icons.edit_outlined, size: 15),
|
||||
label: const Text('Renommer'),
|
||||
style: TextButton.styleFrom(
|
||||
visualDensity: VisualDensity.compact,
|
||||
foregroundColor: EtmTokens.mutedOf(context),
|
||||
textStyle: const TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Row(children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _c,
|
||||
autofocus: true,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
decoration: const InputDecoration(
|
||||
isDense: true,
|
||||
labelText: 'Libellé',
|
||||
helperText: 'Sans effet sur le matériel — aucun verrou réarmé.',
|
||||
helperMaxLines: 2,
|
||||
),
|
||||
onSubmitted: (_) => _commit(),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.check_rounded, size: 20),
|
||||
onPressed: _commit,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close_rounded, size: 20),
|
||||
onPressed: () => setState(() {
|
||||
_c.text = widget.entry.label;
|
||||
_editing = false;
|
||||
}),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
void _commit() {
|
||||
final v = _c.text.trim();
|
||||
if (v.isNotEmpty && v != widget.entry.label) {
|
||||
widget.provider.setLabel(widget.entry.id, v);
|
||||
}
|
||||
setState(() => _editing = false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Barre d'enregistrement — et **annonce de ce que l'écriture va provoquer**.
|
||||
///
|
||||
/// C'est la règle §5-3 du lot : `priority`, `domain`, `label` et `enabled` se mettent à
|
||||
/// jour sans rien perturber, mais toucher `relays[]`, le mécanisme ou les Things
|
||||
/// **reconstruit l'adaptateur** — contacts ouverts, verrous réarmés à froid. Un
|
||||
/// installateur qui change un relais ne doit pas découvrir après coup que sa PAC est
|
||||
/// bloquée un quart d'heure.
|
||||
class ImpactSaveBar extends StatelessWidget {
|
||||
final LoadConfigProvider provider;
|
||||
|
||||
const ImpactSaveBar({super.key, required this.provider});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final impacts = provider.pendingImpact();
|
||||
final warnings = impacts.where((i) => i.needsWarning).toList();
|
||||
final pending = provider.saveState == LoadSaveState.pending;
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: EtmTokens.surfaceOf(context),
|
||||
borderRadius: BorderRadius.circular(EtmTokens.radiusCard),
|
||||
border: Border.all(
|
||||
color: warnings.isEmpty
|
||||
? EtmTokens.lineOf(context)
|
||||
: EtmTokens.orange.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (warnings.isNotEmpty) ...[
|
||||
for (final w in warnings) _ImpactLine(impact: w),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
Row(children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
warnings.isEmpty
|
||||
? 'Modifications en attente — rien n\'est parti vers la box.'
|
||||
: 'À confirmer : l\'enregistrement touche au matériel.',
|
||||
style: TextStyle(
|
||||
fontSize: 12, color: EtmTokens.mutedOf(context), height: 1.3),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: pending ? null : provider.discardDraft,
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
FilledButton(
|
||||
onPressed: pending ? null : provider.save,
|
||||
child: Text(pending ? 'Envoi…' : 'Enregistrer'),
|
||||
),
|
||||
]),
|
||||
if (provider.saveState == LoadSaveState.rejected)
|
||||
_RejectionNote(provider: provider),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Une ligne d'avertissement d'impact, qui nomme **ce qui** l'a déclenché.
|
||||
///
|
||||
/// « Vous avez changé l'ordre des contacteurs » est actionnable ; « la charge sera
|
||||
/// reconstruite » ne l'est pas.
|
||||
class _ImpactLine extends StatelessWidget {
|
||||
final LoadImpact impact;
|
||||
|
||||
const _ImpactLine({required this.impact});
|
||||
|
||||
static const _fieldNames = {
|
||||
'relays': 'la liste des contacteurs',
|
||||
'sgReady': 'l\'encodage SG-Ready',
|
||||
'minOnS': 'la temporisation de marche',
|
||||
'minOffS': 'la temporisation d\'arrêt',
|
||||
'maxPowerW': 'la puissance nominale',
|
||||
'powerLevels': 'les paliers déclarés',
|
||||
'adapter': 'le mécanisme',
|
||||
'mode': 'le mode',
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final quoi = impact.changedFields
|
||||
.map((f) => _fieldNames[f] ?? f)
|
||||
.join(', ');
|
||||
final rearm = impact.rearmSeconds;
|
||||
final duree = rearm == null
|
||||
? ''
|
||||
: rearm >= 60
|
||||
? ' pendant ${(rearm / 60).round()} min'
|
||||
: ' pendant $rearm s';
|
||||
|
||||
final texte = switch (impact.impact) {
|
||||
LoadChangeImpact.rebuild =>
|
||||
'« ${impact.label} » — vous avez modifié $quoi. La charge sera reconstruite : '
|
||||
'ses contacts passent par l\'état sûr, puis son verrou se réarme$duree.',
|
||||
LoadChangeImpact.removed =>
|
||||
'« ${impact.label} » quitte l\'arbitrage. Elle est amenée à son état sûr '
|
||||
'(contacts ouverts) avant retrait.',
|
||||
_ => '',
|
||||
};
|
||||
if (texte.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(Icons.warning_amber_rounded, size: 16, color: EtmTokens.orange),
|
||||
const SizedBox(width: 7),
|
||||
Expanded(
|
||||
child: Text(texte,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
height: 1.35,
|
||||
color: EtmTokens.inkOf(context))),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Refus de la box. **Ne rien inventer** : `SetLoadConfig` rend un code sans motif, et
|
||||
/// la règle enfreinte reste au journal de la box. On affiche le refus et on y renvoie.
|
||||
class _RejectionNote extends StatelessWidget {
|
||||
final LoadConfigProvider provider;
|
||||
|
||||
const _RejectionNote({required this.provider});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final conflit = provider.conflict;
|
||||
final err = provider.saveError;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 10),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: EtmTokens.danger.withValues(alpha: 0.09),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
conflit != null
|
||||
? 'Deux charges revendiquent le même appareil.'
|
||||
: 'La box a refusé l\'enregistrement${err == null ? '' : ' ($err)'}.',
|
||||
style: const TextStyle(
|
||||
fontSize: 12.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: EtmTokens.danger),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
conflit != null
|
||||
// LM-201 : deux charges sur un canal unique se contrediraient, et le
|
||||
// budget compterait la même puissance deux fois. Ce n'est pas une
|
||||
// limite technique — c'est le modèle.
|
||||
? 'Un appareil ne peut appartenir qu\'à une seule charge : deux '
|
||||
'charges sur un même canal de commande se contrediraient.'
|
||||
: 'Le refus porte sur l\'ensemble : rien n\'a été modifié. La règle '
|
||||
'enfreinte et le libellé en cause sont dans le journal de la box '
|
||||
'(journalctl -u nymead).',
|
||||
style: TextStyle(
|
||||
fontSize: 11.5,
|
||||
height: 1.35,
|
||||
color: EtmTokens.mutedOf(context)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyNote extends StatelessWidget {
|
||||
final String? error;
|
||||
|
||||
const _EmptyNote({this.error});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final muted = EtmTokens.mutedOf(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 18),
|
||||
child: Text(
|
||||
error == null
|
||||
? 'Aucune charge pilotable déclarée sur cette box.'
|
||||
: 'Configuration illisible : $error',
|
||||
style: TextStyle(fontSize: 12.5, color: muted, height: 1.35),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../models/energy_data.dart';
|
||||
import '../providers/schema_provider.dart';
|
||||
import '../services/nymea_service.dart';
|
||||
import '../services/rpc_schema.dart';
|
||||
import '../theme/etm_tokens.dart';
|
||||
import 'schema_gated.dart';
|
||||
|
||||
class EVChargingCard extends StatefulWidget {
|
||||
final EnergyData data;
|
||||
@ -28,31 +33,60 @@ class _EVChargingCardState extends State<EVChargingCard> {
|
||||
if (mode == ChargingMode.boost && _deadlineEnabled) {
|
||||
setState(() => _deadlineEnabled = false);
|
||||
}
|
||||
widget.service.setChargingInfo(
|
||||
_send(
|
||||
mode: mode,
|
||||
deadline: mode != ChargingMode.boost && _deadlineEnabled,
|
||||
);
|
||||
}
|
||||
|
||||
/// Envoie, et **annonce le refus** au lieu de le perdre. La cause exacte reste au
|
||||
/// journal de la box : on ne la devine pas.
|
||||
Future<void> _send({required ChargingMode mode, required bool deadline}) async {
|
||||
final err = await widget.service.setChargingInfo(
|
||||
mode: mode,
|
||||
deadline: deadline,
|
||||
targetSoc: _targetSoc,
|
||||
endTime: _endTime,
|
||||
);
|
||||
if (err == null || !mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text('La box a refusé le mode de recharge ($err). '
|
||||
'Détail dans son journal.'),
|
||||
));
|
||||
}
|
||||
|
||||
void _toggleDeadline(bool enabled) {
|
||||
setState(() => _deadlineEnabled = enabled);
|
||||
widget.service.setChargingInfo(
|
||||
mode: widget.data.chargingMode,
|
||||
deadline: enabled,
|
||||
targetSoc: _targetSoc,
|
||||
endTime: _endTime,
|
||||
);
|
||||
_send(mode: widget.data.chargingMode, deadline: enabled);
|
||||
}
|
||||
|
||||
void _applyDeadline() {
|
||||
widget.service.setChargingInfo(
|
||||
mode: widget.data.chargingMode,
|
||||
deadline: _deadlineEnabled,
|
||||
targetSoc: _targetSoc,
|
||||
endTime: _endTime,
|
||||
);
|
||||
_send(mode: widget.data.chargingMode, deadline: _deadlineEnabled);
|
||||
}
|
||||
|
||||
/// Support des champs d'échéance, lu dans le schéma de la box.
|
||||
///
|
||||
/// Les deux vont ensemble : une échéance sans cible de charge n'a pas de sens, et
|
||||
/// l'inverse non plus. On grise donc le bloc entier dès que l'un manque.
|
||||
FieldSupport _socSupport(BuildContext context) {
|
||||
final schema = context.watch<SchemaProvider>().schema;
|
||||
final soc = schema.typeSupport('ChargingInfo', ['targetPercentage']);
|
||||
final end = schema.typeSupport('ChargingInfo', ['endDateTime']);
|
||||
if (soc == FieldSupport.unknown || end == FieldSupport.unknown) {
|
||||
return FieldSupport.unknown;
|
||||
}
|
||||
if (soc == FieldSupport.absent || end == FieldSupport.absent) {
|
||||
return FieldSupport.absent;
|
||||
}
|
||||
return FieldSupport.writable;
|
||||
}
|
||||
|
||||
/// Vrai si le contrat de recharge ne porte aucun champ de courant minimal.
|
||||
bool _minCurrentAbsent(BuildContext context) {
|
||||
final schema = context.watch<SchemaProvider>().schema;
|
||||
if (!schema.loaded) return false;
|
||||
return schema.typeSupport('ChargingInfo', ['minCurrent']) ==
|
||||
FieldSupport.absent;
|
||||
}
|
||||
|
||||
String _statusLabel(ChargingMode mode) {
|
||||
@ -208,23 +242,47 @@ class _EVChargingCardState extends State<EVChargingCard> {
|
||||
],
|
||||
),
|
||||
|
||||
// Deadline option — visible pour PV et Min+PV
|
||||
// Échéance — visible pour PV et Min+PV, et **active seulement si la box
|
||||
// porte les champs**. Le grisage vient du schéma, jamais d'une table de
|
||||
// versions : le jour où le plugin expose `endDateTime`, le réglage s'active
|
||||
// seul sur les box à jour et reste gris sur les autres.
|
||||
if (showDeadlineOption) ...[
|
||||
const SizedBox(height: 8),
|
||||
_DeadlineToggleRow(
|
||||
enabled: _deadlineEnabled,
|
||||
onChanged: _toggleDeadline,
|
||||
SchemaGated(
|
||||
support: _socSupport(context),
|
||||
fieldName: 'targetPercentage / endDateTime',
|
||||
label: 'Échéance de recharge',
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_DeadlineToggleRow(
|
||||
enabled: _deadlineEnabled,
|
||||
onChanged: _toggleDeadline,
|
||||
),
|
||||
if (_deadlineEnabled) ...[
|
||||
const SizedBox(height: 10),
|
||||
_DeadlineParamsRow(
|
||||
targetSoc: _targetSoc,
|
||||
endTime: _endTime,
|
||||
onSocChanged: (v) {
|
||||
setState(() => _targetSoc = v);
|
||||
_applyDeadline();
|
||||
},
|
||||
onPickTime: _pickEndTime,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_deadlineEnabled) ...[
|
||||
const SizedBox(height: 10),
|
||||
_DeadlineParamsRow(
|
||||
targetSoc: _targetSoc,
|
||||
endTime: _endTime,
|
||||
onSocChanged: (v) {
|
||||
setState(() => _targetSoc = v);
|
||||
_applyDeadline();
|
||||
},
|
||||
onPickTime: _pickEndTime,
|
||||
// Le mode « minimum garanti » existe côté box, mais la VALEUR du courant
|
||||
// minimal n'est dans aucun champ de `ChargingInfo` : elle ne se règle pas
|
||||
// ici. Le dire, plutôt que d'offrir un réglage sans effet.
|
||||
if (_minCurrentAbsent(context)) ...[
|
||||
const SizedBox(height: 8),
|
||||
_AbsentFieldNote(
|
||||
'Le courant minimal du mode « Min + PV » n\'est pas réglable depuis '
|
||||
'l\'app : la box choisit le mode, mais n\'expose aucun champ '
|
||||
'« minCurrent » dans son contrat de recharge.',
|
||||
),
|
||||
],
|
||||
],
|
||||
@ -580,4 +638,26 @@ class _TimePicker extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Note d'un champ que la box n'expose pas — **avec sa raison**, jamais un simple gris.
|
||||
class _AbsentFieldNote extends StatelessWidget {
|
||||
final String text;
|
||||
|
||||
const _AbsentFieldNote(this.text);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final muted = EtmTokens.mutedOf(context);
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.cloud_off_outlined, size: 13, color: muted),
|
||||
const SizedBox(width: 5),
|
||||
Expanded(
|
||||
child: Text(text,
|
||||
style: TextStyle(fontSize: 11.5, color: muted, height: 1.3)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
238
lib/widgets/load_cards.dart
Normal file
238
lib/widgets/load_cards.dart
Normal file
@ -0,0 +1,238 @@
|
||||
/// Briques d'affichage d'une charge pilotable, **partagées** par les écrans de
|
||||
/// configuration.
|
||||
///
|
||||
/// Extraites de l'ancien écran « Ordre de service » au moment de sa fusion dans
|
||||
/// « Rôles & appareils ». C'est la moitié de l'écran clair qu'il fallait sauver : le
|
||||
/// chemin de données correct et l'affichage fidèle de la télémétrie. Les dupliquer
|
||||
/// aurait laissé deux rendus du même contrat diverger.
|
||||
///
|
||||
/// Règle qui traverse tout ce fichier : **des codes, jamais des libellés en dur.**
|
||||
/// `domain`, `adapter`, `decision.code`, `faultCode` sont des clés ARB ; un domaine
|
||||
/// inconnu de cette version d'app (box plus récente) tombe sur un repli lisible qui
|
||||
/// montre le code brut, jamais une phrase vide.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../l10n/l10n_ext.dart';
|
||||
import '../models/load_config_entry.dart';
|
||||
import '../models/load_telemetry.dart';
|
||||
import '../providers/load_config_provider.dart';
|
||||
import '../providers/load_telemetry_provider.dart';
|
||||
import '../services/load_priority.dart';
|
||||
import '../services/telemetry_text.dart';
|
||||
|
||||
/// Libellé d'un domaine. Un domaine inconnu de cette version d'app (box plus récente)
|
||||
/// a un repli lisible : le code brut, jamais une phrase vide.
|
||||
String domainLabel(L10n t, String domain) => switch (domain) {
|
||||
'ev' => t.domainEv,
|
||||
'ecs' => t.domainEcs,
|
||||
'hvac' => t.domainHvac,
|
||||
'heating' => t.domainHeating,
|
||||
'battery' => t.domainBattery,
|
||||
kUnclassifiedDomain => t.sectionUnclassified,
|
||||
_ => t.domainUnknown(domain),
|
||||
};
|
||||
|
||||
// ── Choix du domaine ─────────────────────────────────────────────────────────
|
||||
/// Choix du domaine — énumération fermée côté plugin, donc liste fermée ici.
|
||||
class DomainMenu extends StatelessWidget {
|
||||
final LoadConfigEntry entry;
|
||||
final LoadConfigProvider provider;
|
||||
const DomainMenu({super.key, required this.entry, required this.provider});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.l10n;
|
||||
final current = entry.domain ?? kUnclassifiedDomain;
|
||||
|
||||
return PopupMenuButton<String>(
|
||||
tooltip: t.domainAssignAction,
|
||||
icon: const Icon(Icons.label_outline_rounded, size: 20, color: Color(0xFF6B7280)),
|
||||
onSelected: (d) => provider.assignDomain(entry.id, d),
|
||||
itemBuilder: (c) => [
|
||||
PopupMenuItem<String>(
|
||||
enabled: false,
|
||||
child: Text(t.domainAssignTitle(entry.label),
|
||||
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
const PopupMenuDivider(),
|
||||
for (final d in kAssignableDomains)
|
||||
CheckedPopupMenuItem<String>(
|
||||
value: d,
|
||||
checked: current == d,
|
||||
child: Text(domainLabel(t, d)),
|
||||
),
|
||||
const PopupMenuDivider(),
|
||||
PopupMenuItem<String>(
|
||||
value: kUnclassifiedDomain,
|
||||
child: Text(t.domainAssignNone),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Bloc télémétrie d'une charge ─────────────────────────────────────────────
|
||||
|
||||
// ── Bloc télémétrie d'une charge ─────────────────────────────────────────────
|
||||
class LoadLiveBlock extends StatelessWidget {
|
||||
final LoadTelemetryEntry live;
|
||||
final bool stale;
|
||||
final LoadTelemetryProvider telemetry;
|
||||
|
||||
const LoadLiveBlock({
|
||||
super.key,
|
||||
required this.live,
|
||||
required this.stale,
|
||||
required this.telemetry,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.l10n;
|
||||
final mech = live.mechanism;
|
||||
final mechText = mech == null ? null : mechanismText(t, mech);
|
||||
final lock = live.lock;
|
||||
|
||||
// Les données anciennes restent affichées — les masquer priverait du seul état
|
||||
// connu — mais grisées : elles décrivent un cycle, pas l'instant présent.
|
||||
final tone = stale ? const Color(0xFF9CA3AF) : const Color(0xFF374151);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(children: [
|
||||
Icon(Icons.bolt_rounded,
|
||||
size: 14,
|
||||
color: stale ? const Color(0xFFB0B7BF) : const Color(0xFF2E7D32)),
|
||||
const SizedBox(width: 4),
|
||||
Text(t.telemetryAllocated(live.allocatedW.round()),
|
||||
style: TextStyle(
|
||||
fontSize: 12, fontWeight: FontWeight.w600, color: tone)),
|
||||
if (mechText != null) ...[
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: Text('· $mechText',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(fontSize: 12, color: tone)),
|
||||
),
|
||||
],
|
||||
]),
|
||||
const SizedBox(height: 2),
|
||||
Text(decisionText(t, live.decision),
|
||||
style: TextStyle(fontSize: 11, color: tone)),
|
||||
if (lock != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Row(children: [
|
||||
Icon(Icons.lock_clock_rounded, size: 13, color: tone),
|
||||
const SizedBox(width: 4),
|
||||
Flexible(
|
||||
child: Text(
|
||||
// Les secondes affichées datent du cycle publié : l'app ne les décompte
|
||||
// pas, le plugin exclut volontairement ce champ de sa détection de
|
||||
// changement.
|
||||
'${lockText(t, lock)} (${t.lockAsOfCycle})',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(fontSize: 11, color: tone),
|
||||
),
|
||||
),
|
||||
]),
|
||||
],
|
||||
if (live.inFault) ...[
|
||||
const SizedBox(height: 6),
|
||||
LoadFaultBox(live: live, telemetry: telemetry),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Charge en défaut, et levée du verrou.
|
||||
///
|
||||
/// Le bouton n'existe **que** sur une charge dont `available` est faux, et son résultat
|
||||
/// n'est jamais annoncé sur le retour du RPC : `ClearLoadFault` répond
|
||||
/// `EnergyErrorNoError` même sur une charge saine (vérifié sur `.75`). Le verdict vient
|
||||
/// de la télémétrie relue.
|
||||
|
||||
/// Charge en défaut, et levée du verrou.
|
||||
///
|
||||
/// Le bouton n'existe **que** sur une charge dont `available` est faux, et son résultat
|
||||
/// n'est jamais annoncé sur le retour du RPC : `ClearLoadFault` répond
|
||||
/// `EnergyErrorNoError` même sur une charge saine (vérifié sur `.75`). Le verdict vient
|
||||
/// de la télémétrie relue.
|
||||
class LoadFaultBox extends StatelessWidget {
|
||||
final LoadTelemetryEntry live;
|
||||
final LoadTelemetryProvider telemetry;
|
||||
|
||||
const LoadFaultBox({super.key,required this.live, required this.telemetry});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.l10n;
|
||||
final state = telemetry.clearStateOf(live.loadId);
|
||||
final code = live.faultCode;
|
||||
|
||||
final (String? message, Color messageColor) = switch (state) {
|
||||
FaultClearState.pending => (t.faultClearPending, const Color(0xFF6B7280)),
|
||||
FaultClearState.cleared => (t.faultCleared, const Color(0xFF2E7D32)),
|
||||
FaultClearState.stillFaulty => (t.faultClearStillFaulty, const Color(0xFFC62828)),
|
||||
FaultClearState.rejected => (
|
||||
t.faultClearRejected(telemetry.clearErrorOf(live.loadId) ?? '?'),
|
||||
const Color(0xFFC62828)
|
||||
),
|
||||
FaultClearState.idle => (null, Colors.transparent),
|
||||
};
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFDECEA),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(children: [
|
||||
const Icon(Icons.error_outline_rounded, size: 15, color: Color(0xFFC62828)),
|
||||
const SizedBox(width: 6),
|
||||
Text(t.faultTitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Color(0xFFC62828))),
|
||||
]),
|
||||
const SizedBox(height: 4),
|
||||
Text(code == null ? t.faultUnknown('—') : faultText(t, code),
|
||||
style: const TextStyle(fontSize: 11, color: Color(0xFF7F1D1D))),
|
||||
const SizedBox(height: 4),
|
||||
Text(t.faultClearHint,
|
||||
style: const TextStyle(fontSize: 10, color: Color(0xFF9A5B5B))),
|
||||
if (message != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(message, style: TextStyle(fontSize: 11, color: messageColor)),
|
||||
],
|
||||
const SizedBox(height: 4),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: OutlinedButton.icon(
|
||||
icon: state == FaultClearState.pending
|
||||
? const SizedBox(
|
||||
width: 12,
|
||||
height: 12,
|
||||
child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Icon(Icons.lock_open_rounded, size: 16),
|
||||
label: Text(t.faultClearAction),
|
||||
onPressed: state == FaultClearState.pending
|
||||
? null
|
||||
: () => telemetry.clearFault(live.loadId),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
144
lib/widgets/schema_gated.dart
Normal file
144
lib/widgets/schema_gated.dart
Normal file
@ -0,0 +1,144 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../services/rpc_schema.dart';
|
||||
import '../theme/etm_tokens.dart';
|
||||
|
||||
/// Enveloppe un champ dont la disponibilité vient du **schéma de la box**.
|
||||
///
|
||||
/// ## Un champ grisé sans raison se lit comme un bug
|
||||
///
|
||||
/// C'est la règle qui justifie ce widget. « Inactif » tout court laisse l'installateur
|
||||
/// devant trois hypothèses également plausibles — panne, droit manquant, ou fonction
|
||||
/// absente — et il choisira la mauvaise. On affiche donc **toujours** la raison, et le
|
||||
/// nom du champ manquant quand on l'a : c'est ce nom qui rend le rapport actionnable.
|
||||
///
|
||||
/// ## Trois états, pas deux
|
||||
///
|
||||
/// - [FieldSupport.writable] — actif, l'enfant est rendu tel quel ;
|
||||
/// - [FieldSupport.readOnly] — le champ existe et se lit, mais l'écrire est une erreur :
|
||||
/// c'est une **mesure**, pas une fonction manquante ;
|
||||
/// - [FieldSupport.absent] — cette box ne l'expose pas ;
|
||||
/// - [FieldSupport.unknown] — le schéma n'a pas pu être lu ; on ne prétend ni l'un ni
|
||||
/// l'autre.
|
||||
class SchemaGated extends StatelessWidget {
|
||||
/// Ce que le schéma dit du champ.
|
||||
final FieldSupport support;
|
||||
|
||||
/// Nom du champ tel qu'il apparaîtrait au schéma (`endDateTime`, `minCurrent`…).
|
||||
/// Affiché dans la raison — c'est lui qui permet de remonter un manque utilement.
|
||||
final String fieldName;
|
||||
|
||||
/// Le champ actif.
|
||||
final Widget child;
|
||||
|
||||
/// Libellé humain, pour la ligne de raison.
|
||||
final String label;
|
||||
|
||||
const SchemaGated({
|
||||
super.key,
|
||||
required this.support,
|
||||
required this.fieldName,
|
||||
required this.label,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (support.isWritable) return child;
|
||||
|
||||
final muted = EtmTokens.mutedOf(context);
|
||||
return Opacity(
|
||||
opacity: 0.55,
|
||||
child: IgnorePointer(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
child,
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(_icon, size: 13, color: muted),
|
||||
const SizedBox(width: 5),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_reason,
|
||||
style: TextStyle(fontSize: 11.5, color: muted, height: 1.3),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
IconData get _icon => switch (support) {
|
||||
FieldSupport.readOnly => Icons.visibility_outlined,
|
||||
FieldSupport.absent => Icons.cloud_off_outlined,
|
||||
_ => Icons.help_outline,
|
||||
};
|
||||
|
||||
String get _reason => switch (support) {
|
||||
FieldSupport.readOnly =>
|
||||
'Lecture seule — « $fieldName » est une mesure publiée par la box, '
|
||||
'elle ne se règle pas ici.',
|
||||
FieldSupport.absent =>
|
||||
'Cette box ne l\'expose pas encore — champ « $fieldName » absent de son '
|
||||
'schéma. Le réglage s\'activera seul après mise à jour du moteur.',
|
||||
_ => 'Disponibilité inconnue — le schéma de la box n\'a pas pu être lu. '
|
||||
'« $fieldName » est laissé inactif par prudence.',
|
||||
};
|
||||
}
|
||||
|
||||
/// Bandeau d'en-tête annonçant sur quoi l'écran s'appuie.
|
||||
///
|
||||
/// Dès qu'un écran se comporte différemment selon la version, il faut pouvoir dire
|
||||
/// **laquelle on regarde** — sans quoi un rapport de terrain (« le champ est gris chez
|
||||
/// moi ») n'est pas exploitable.
|
||||
class SchemaVersionBanner extends StatelessWidget {
|
||||
/// Ligne de version déjà composée (`SchemaProvider.versionLine()`).
|
||||
final String versionLine;
|
||||
|
||||
/// Faux si le schéma n'a pas pu être lu — le bandeau le dit alors franchement.
|
||||
final bool schemaLoaded;
|
||||
|
||||
const SchemaVersionBanner({
|
||||
super.key,
|
||||
required this.versionLine,
|
||||
required this.schemaLoaded,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final muted = EtmTokens.mutedOf(context);
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
|
||||
decoration: BoxDecoration(
|
||||
color: EtmTokens.surfaceOf(context).withValues(alpha: 0.6),
|
||||
borderRadius: BorderRadius.circular(EtmTokens.radiusCard),
|
||||
border: Border.all(color: EtmTokens.lineOf(context)),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(schemaLoaded ? Icons.memory : Icons.help_outline,
|
||||
size: 15, color: muted),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
schemaLoaded
|
||||
? '$versionLine — les réglages disponibles sont déduits du schéma '
|
||||
'publié par cette box.'
|
||||
: '$versionLine — schéma illisible : les réglages sont laissés '
|
||||
'inactifs par prudence, pas parce qu\'ils manquent.',
|
||||
style: TextStyle(fontSize: 11.5, color: muted, height: 1.35),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
677
test/fixtures/schema_hems75_etm15.json
vendored
Normal file
677
test/fixtures/schema_hems75_etm15.json
vendored
Normal file
@ -0,0 +1,677 @@
|
||||
{
|
||||
"_source": "JSONRPC.Introspect sur 192.168.1.75, 2026-08-25 — plugin 1.15.2+etm15 (lot B-bis).",
|
||||
"_note": "Tranche : methodes NymeaEnergy.* et AirConditioning.*, types et enums utiles. Les prefixes portent le sens : 'o:' optionnel-ecrivable, 'r:' lecture seule. La version du PAQUET (+etm15) n'est exposee par aucune methode ; JSONRPC.Hello rend la version nymea et les versions d'API d'experience (NymeaEnergy 0.8).",
|
||||
"_hello_experiences": {
|
||||
"NymeaEnergy": "0.8",
|
||||
"AirConditioning": "1.1",
|
||||
"Energy": "1.0"
|
||||
},
|
||||
"_hello_version": "1.15.2+202606191336~trixie1",
|
||||
"methods": {
|
||||
"AirConditioning.AddZone": {
|
||||
"description": "Create a zones.",
|
||||
"params": {
|
||||
"name": "String",
|
||||
"o:indoorSensors": [
|
||||
"Uuid"
|
||||
],
|
||||
"o:notifications": [
|
||||
"Uuid"
|
||||
],
|
||||
"o:outdoorSensors": [
|
||||
"Uuid"
|
||||
],
|
||||
"o:thermostats": [
|
||||
"Uuid"
|
||||
],
|
||||
"o:valves": [
|
||||
"Uuid"
|
||||
],
|
||||
"o:windowSensors": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"permissionScope": "PermissionScopeAdmin",
|
||||
"returns": {
|
||||
"airConditioningError": "$ref:AirConditioningError",
|
||||
"o:zone": "$ref:ZoneInfo"
|
||||
}
|
||||
},
|
||||
"AirConditioning.GetZones": {
|
||||
"description": "Get all Zones.",
|
||||
"params": {
|
||||
"o:zoneId": "Uuid"
|
||||
},
|
||||
"permissionScope": "PermissionScopeControlThings",
|
||||
"returns": {
|
||||
"airConditioningError": "$ref:AirConditioningError",
|
||||
"zones": "$ref:ZoneInfos"
|
||||
}
|
||||
},
|
||||
"AirConditioning.RemoveZone": {
|
||||
"description": "Remove a zone.",
|
||||
"params": {
|
||||
"zoneId": "Uuid"
|
||||
},
|
||||
"permissionScope": "PermissionScopeAdmin",
|
||||
"returns": {
|
||||
"airConditioningError": "$ref:AirConditioningError"
|
||||
}
|
||||
},
|
||||
"AirConditioning.SetZoneName": {
|
||||
"description": "Set zone name.",
|
||||
"params": {
|
||||
"name": "String",
|
||||
"zoneId": "Uuid"
|
||||
},
|
||||
"permissionScope": "PermissionScopeAdmin",
|
||||
"returns": {
|
||||
"airConditioningError": "$ref:AirConditioningError"
|
||||
}
|
||||
},
|
||||
"AirConditioning.SetZoneSetpointOverride": {
|
||||
"description": "Set zone setpoint override temperature. Parameter minutes gives the minutes until the setpoint should return to the standby/schedule.",
|
||||
"params": {
|
||||
"mode": "$ref:SetpointOverrideMode",
|
||||
"o:minutes": "Int",
|
||||
"setpointOverride": "Double",
|
||||
"zoneId": "Uuid"
|
||||
},
|
||||
"permissionScope": "PermissionScopeControlThings",
|
||||
"returns": {
|
||||
"airConditioningError": "$ref:AirConditioningError"
|
||||
}
|
||||
},
|
||||
"AirConditioning.SetZoneStandbySetpoint": {
|
||||
"description": "Set zone standby setpoint temperature.",
|
||||
"params": {
|
||||
"standbySetpoint": "Double",
|
||||
"zoneId": "Uuid"
|
||||
},
|
||||
"permissionScope": "PermissionScopeAdmin",
|
||||
"returns": {
|
||||
"airConditioningError": "$ref:AirConditioningError"
|
||||
}
|
||||
},
|
||||
"AirConditioning.SetZoneThings": {
|
||||
"description": "Set Zone things",
|
||||
"params": {
|
||||
"o:indoorSensors": [
|
||||
"Uuid"
|
||||
],
|
||||
"o:notifications": [
|
||||
"Uuid"
|
||||
],
|
||||
"o:outdoorSensors": [
|
||||
"Uuid"
|
||||
],
|
||||
"o:thermostats": [
|
||||
"Uuid"
|
||||
],
|
||||
"o:valves": [
|
||||
"Uuid"
|
||||
],
|
||||
"o:windowSensors": [
|
||||
"Uuid"
|
||||
],
|
||||
"zoneId": "Uuid"
|
||||
},
|
||||
"permissionScope": "PermissionScopeAdmin",
|
||||
"returns": {
|
||||
"airConditioningError": "$ref:AirConditioningError"
|
||||
}
|
||||
},
|
||||
"AirConditioning.SetZoneWeekSchedule": {
|
||||
"description": "Set the time schedule for a zone.",
|
||||
"params": {
|
||||
"weekSchedule": "$ref:TemperatureWeekSchedule",
|
||||
"zoneId": "Uuid"
|
||||
},
|
||||
"permissionScope": "PermissionScopeControlThings",
|
||||
"returns": {
|
||||
"airConditioningError": "$ref:AirConditioningError"
|
||||
}
|
||||
},
|
||||
"NymeaEnergy.ClearLoadFault": {
|
||||
"description": "Clear the fault latch of a controllable load (ECS-410). Deliberate operator action: the fault never clears by itself.",
|
||||
"params": {
|
||||
"loadId": "String"
|
||||
},
|
||||
"permissionScope": "PermissionScopeControlThings",
|
||||
"returns": {
|
||||
"energyError": "$ref:EnergyError"
|
||||
}
|
||||
},
|
||||
"NymeaEnergy.GetAcquisitionTolerance": {
|
||||
"description": "Get the acquisition tolerance. This value will control how much solar surplus is required to start the charging.",
|
||||
"params": {},
|
||||
"permissionScope": "PermissionScopeControlThings",
|
||||
"returns": {
|
||||
"acquisitionTolerance": "Double"
|
||||
}
|
||||
},
|
||||
"NymeaEnergy.GetAvailableSpotMarketProviders": {
|
||||
"description": "Get the list of available spot market data providers.",
|
||||
"params": {},
|
||||
"permissionScope": "PermissionScopeControlThings",
|
||||
"returns": {
|
||||
"providers": [
|
||||
"$ref:SpotMarketProviderInfo"
|
||||
]
|
||||
}
|
||||
},
|
||||
"NymeaEnergy.GetBatteryLevelConsideration": {
|
||||
"description": "Get the battery level consideration. This value will control how much the energy storage is taking in account",
|
||||
"params": {},
|
||||
"permissionScope": "PermissionScopeAdmin",
|
||||
"returns": {
|
||||
"batteryLevelConsideration": "Double"
|
||||
}
|
||||
},
|
||||
"NymeaEnergy.GetChargingInfos": {
|
||||
"description": "Get the charging info for all or a single EV charger",
|
||||
"params": {
|
||||
"o:evChargerId": "Uuid"
|
||||
},
|
||||
"permissionScope": "PermissionScopeControlThings",
|
||||
"returns": {
|
||||
"chargingInfos": [
|
||||
"$ref:ChargingInfo"
|
||||
]
|
||||
}
|
||||
},
|
||||
"NymeaEnergy.GetChargingSchedules": {
|
||||
"description": "Get the planed charging schedules, and whether the L2 degraded mode is currently active (meter silent > 90s, planning suspended).",
|
||||
"params": {},
|
||||
"permissionScope": "PermissionScopeControlThings",
|
||||
"returns": {
|
||||
"chargingSchedules": [
|
||||
"$ref:ChargingSchedule"
|
||||
],
|
||||
"degradedMode": "Bool"
|
||||
}
|
||||
},
|
||||
"NymeaEnergy.GetEnergyRatios": {
|
||||
"description": "Get the current energy ratios (self-consumption / autonomy) in percent, derived from the energy manager's cumulative counters over the current local day. A rate field is omitted (never null) when not available (denominator <= 0).",
|
||||
"params": {},
|
||||
"permissionScope": "PermissionScopeControlThings",
|
||||
"returns": {
|
||||
"o:autonomyRate": "Double",
|
||||
"o:selfConsumptionRate": "Double"
|
||||
}
|
||||
},
|
||||
"NymeaEnergy.GetLoadConfig": {
|
||||
"description": "Get the configured controllable loads (etmvariableload).",
|
||||
"params": {},
|
||||
"permissionScope": "PermissionScopeControlThings",
|
||||
"returns": {
|
||||
"loadConfigs": [
|
||||
"$ref:LoadConfig"
|
||||
]
|
||||
}
|
||||
},
|
||||
"NymeaEnergy.GetLoadTelemetry": {
|
||||
"description": "Get a snapshot of the arbitration runtime state: surplus budget, per-load allocation, availability, fault code, active lock (seconds remaining), mechanism payload and decision reason. Reason and fault are CODES with numeric params - never human-readable sentences: the client renders them in the user's language. Identical payload to the LoadTelemetryChanged notification. 'timestamp' is the end of the last arbitration cycle and is omitted while no cycle has run yet.",
|
||||
"params": {},
|
||||
"permissionScope": "PermissionScopeControlThings",
|
||||
"returns": {
|
||||
"degradedMode": "Bool",
|
||||
"loads": [
|
||||
{
|
||||
"allocatedW": "Double",
|
||||
"available": "Bool",
|
||||
"decision": {
|
||||
"code": "String",
|
||||
"params": "Variant"
|
||||
},
|
||||
"loadId": "String",
|
||||
"o:faultCode": "String",
|
||||
"o:lock": {
|
||||
"kind": "String",
|
||||
"remainingS": "Int"
|
||||
},
|
||||
"o:mechanism": {
|
||||
"kind": "String",
|
||||
"o:chargingEnabled": "Bool",
|
||||
"o:currentA": "Double",
|
||||
"o:estimatedPowerW": "Double",
|
||||
"o:maxPowerW": "Double",
|
||||
"o:maxStageW": "Double",
|
||||
"o:percent": "Double",
|
||||
"o:phaseCount": "Uint",
|
||||
"o:pluggedIn": "Bool",
|
||||
"o:setpointW": "Double",
|
||||
"o:stageW": "Double",
|
||||
"o:state": "Int"
|
||||
}
|
||||
}
|
||||
],
|
||||
"o:budget": {
|
||||
"allocatedW": "Double",
|
||||
"evReservedW": "Double",
|
||||
"recreditedW": "Double",
|
||||
"remainingW": "Double",
|
||||
"surplusW": "Double"
|
||||
},
|
||||
"o:timestamp": "String"
|
||||
}
|
||||
},
|
||||
"NymeaEnergy.GetLockOnUnplug": {
|
||||
"description": "Get the lock on unplug setting.",
|
||||
"params": {},
|
||||
"permissionScope": "PermissionScopeControlThings",
|
||||
"returns": {
|
||||
"lockOnUnplug": "Bool"
|
||||
}
|
||||
},
|
||||
"NymeaEnergy.GetPhasePowerLimit": {
|
||||
"description": "Get the phase power consumption limit. 0 if unset. This needs to be set in order for the smart charging to do anything.",
|
||||
"params": {},
|
||||
"permissionScope": "PermissionScopeControlThings",
|
||||
"returns": {
|
||||
"phasePowerLimit": "Uint"
|
||||
}
|
||||
},
|
||||
"NymeaEnergy.GetSpotMarketConfiguration": {
|
||||
"description": "Get the current spotmarket configuration.",
|
||||
"params": {},
|
||||
"permissionScope": "PermissionScopeControlThings",
|
||||
"returns": {
|
||||
"available": "Bool",
|
||||
"enabled": "Bool",
|
||||
"o:providerId": "Uuid"
|
||||
}
|
||||
},
|
||||
"NymeaEnergy.GetSpotMarketScoreEntries": {
|
||||
"description": "Get the current score entries from the current spot market. If spot market is not enabled or not available, the list will be empty. The weighting of the available score entries goes from 0 (worst) to 1.0 (best).",
|
||||
"params": {},
|
||||
"permissionScope": "PermissionScopeControlThings",
|
||||
"returns": {
|
||||
"spotMarketScoreEntries": [
|
||||
"$ref:ScoreEntry"
|
||||
]
|
||||
}
|
||||
},
|
||||
"NymeaEnergy.SetAcquisitionTolerance": {
|
||||
"description": "Set the acquisition tolerance for the charging.",
|
||||
"params": {
|
||||
"acquisitionTolerance": "Double"
|
||||
},
|
||||
"permissionScope": "PermissionScopeControlThings",
|
||||
"returns": {
|
||||
"energyError": "$ref:EnergyError"
|
||||
}
|
||||
},
|
||||
"NymeaEnergy.SetBatteryLevelConsideration": {
|
||||
"description": "Set the battery level consideration for the charging.",
|
||||
"params": {
|
||||
"batteryLevelConsideration": "Double"
|
||||
},
|
||||
"permissionScope": "PermissionScopeAdmin",
|
||||
"returns": {
|
||||
"energyError": "$ref:EnergyError"
|
||||
}
|
||||
},
|
||||
"NymeaEnergy.SetChargingInfo": {
|
||||
"description": "Set the charging info for an EV charger. Only given properties will be set, others will be untouched.",
|
||||
"params": {
|
||||
"chargingInfo": "$ref:ChargingInfo"
|
||||
},
|
||||
"permissionScope": "PermissionScopeControlThings",
|
||||
"returns": {
|
||||
"energyError": "$ref:EnergyError"
|
||||
}
|
||||
},
|
||||
"NymeaEnergy.SetLoadConfig": {
|
||||
"description": "Replace the full set of controllable load configs. Rejected as a whole (energyError) if any entry is invalid; persisted and applied atomically.",
|
||||
"params": {
|
||||
"loadConfigs": [
|
||||
{
|
||||
"adapter": "String",
|
||||
"enabled": "Bool",
|
||||
"id": "String",
|
||||
"label": "String",
|
||||
"mode": "String",
|
||||
"o:domain": "String",
|
||||
"o:maxPowerW": "Uint",
|
||||
"o:minOffS": "Uint",
|
||||
"o:minOnS": "Uint",
|
||||
"o:needs": {
|
||||
"o:dailyDeadline": "String",
|
||||
"o:minEnergyWhPerDay": "Uint"
|
||||
},
|
||||
"o:powerLevels": [
|
||||
"Uint"
|
||||
],
|
||||
"o:relays": [
|
||||
{
|
||||
"powerW": "Uint",
|
||||
"thingId": "String"
|
||||
}
|
||||
],
|
||||
"o:sgReady": {
|
||||
"o:minStateHoldS": "Uint",
|
||||
"o:states": [
|
||||
{
|
||||
"estimatedPowerW": "Double",
|
||||
"relays": [
|
||||
"String"
|
||||
],
|
||||
"state": "Uint"
|
||||
}
|
||||
]
|
||||
},
|
||||
"priority": "Uint"
|
||||
}
|
||||
]
|
||||
},
|
||||
"permissionScope": "PermissionScopeControlThings",
|
||||
"returns": {
|
||||
"energyError": "$ref:EnergyError"
|
||||
}
|
||||
},
|
||||
"NymeaEnergy.SetLockOnUnplug": {
|
||||
"description": "Set the lock on unplug setting.",
|
||||
"params": {
|
||||
"lockOnUnplug": "Bool"
|
||||
},
|
||||
"permissionScope": "PermissionScopeControlThings",
|
||||
"returns": {
|
||||
"energyError": "$ref:EnergyError"
|
||||
}
|
||||
},
|
||||
"NymeaEnergy.SetPhasePowerLimit": {
|
||||
"description": "Set the phase power consumption limit. This is the maximum allowed Ampere per phase. A value of 0 means unset. All smart charging will be disabled.",
|
||||
"params": {
|
||||
"phasePowerLimit": "Uint"
|
||||
},
|
||||
"permissionScope": "PermissionScopeAdmin",
|
||||
"returns": {
|
||||
"energyError": "$ref:EnergyError"
|
||||
}
|
||||
},
|
||||
"NymeaEnergy.SetSpotMarketConfiguration": {
|
||||
"description": "Set the current spotmarket configuration. If enabled, the provideId must be valid. You can get the available providers using GetAvailableSpotMarketProviders.",
|
||||
"params": {
|
||||
"enabled": "Bool",
|
||||
"o:providerId": "Uuid"
|
||||
},
|
||||
"permissionScope": "PermissionScopeControlThings",
|
||||
"returns": {
|
||||
"energyError": "$ref:EnergyError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"AirConditioning.ZoneAdded": {
|
||||
"description": "Emitted whenever a zone is added",
|
||||
"params": {
|
||||
"zone": "$ref:ZoneInfo"
|
||||
}
|
||||
},
|
||||
"AirConditioning.ZoneChanged": {
|
||||
"description": "Emitted whenever a zone changes",
|
||||
"params": {
|
||||
"zone": "$ref:ZoneInfo"
|
||||
}
|
||||
},
|
||||
"AirConditioning.ZoneRemoved": {
|
||||
"description": "Emitted whenever a zone is removed",
|
||||
"params": {
|
||||
"zoneId": "Uuid"
|
||||
}
|
||||
},
|
||||
"NymeaEnergy.AcquisitionToleranceChanged": {
|
||||
"description": "Emitted whenever the acquisition tolerance changes.",
|
||||
"params": {
|
||||
"acquisitionTolerance": "Double"
|
||||
}
|
||||
},
|
||||
"NymeaEnergy.BatteryLevelConsiderationChanged": {
|
||||
"description": "Emitted whenever the battery level consideration changes.",
|
||||
"params": {
|
||||
"batteryLevelConsideration": "Double"
|
||||
}
|
||||
},
|
||||
"NymeaEnergy.ChargingInfoAdded": {
|
||||
"description": "Emitted whenever a charging info is added.",
|
||||
"params": {
|
||||
"chargingInfo": "$ref:ChargingInfo"
|
||||
}
|
||||
},
|
||||
"NymeaEnergy.ChargingInfoChanged": {
|
||||
"description": "Emitted whenever a charging info changes.",
|
||||
"params": {
|
||||
"chargingInfo": "$ref:ChargingInfo"
|
||||
}
|
||||
},
|
||||
"NymeaEnergy.ChargingInfoRemoved": {
|
||||
"description": "Emitted whenever a charging info is removed.",
|
||||
"params": {
|
||||
"evChargerThingId": "Uuid"
|
||||
}
|
||||
},
|
||||
"NymeaEnergy.ChargingSchedulesChanged": {
|
||||
"description": "Emitted whenever the planed charging schedules have changed.",
|
||||
"params": {
|
||||
"chargingSchedules": [
|
||||
"$ref:ChargingSchedule"
|
||||
],
|
||||
"o:degradedMode": "Bool"
|
||||
}
|
||||
},
|
||||
"NymeaEnergy.EnergyRatiosChanged": {
|
||||
"description": "Emitted whenever the energy ratios (self-consumption / autonomy) change. A rate field is omitted (never null) when not available.",
|
||||
"params": {
|
||||
"o:autonomyRate": "Double",
|
||||
"o:selfConsumptionRate": "Double"
|
||||
}
|
||||
},
|
||||
"NymeaEnergy.LoadConfigChanged": {
|
||||
"description": "Emitted whenever the controllable load configuration changed.",
|
||||
"params": {
|
||||
"loadConfigs": [
|
||||
"$ref:LoadConfig"
|
||||
]
|
||||
}
|
||||
},
|
||||
"NymeaEnergy.LoadTelemetryChanged": {
|
||||
"description": "Emitted whenever the arbitration runtime state changed significantly, and periodically (heartbeat) even when unchanged, so that a frozen arbitrator is detectable by a timestamp that stops advancing. Same payload as GetLoadTelemetry.",
|
||||
"params": {
|
||||
"degradedMode": "Bool",
|
||||
"loads": [
|
||||
{
|
||||
"allocatedW": "Double",
|
||||
"available": "Bool",
|
||||
"decision": {
|
||||
"code": "String",
|
||||
"params": "Variant"
|
||||
},
|
||||
"loadId": "String",
|
||||
"o:faultCode": "String",
|
||||
"o:lock": {
|
||||
"kind": "String",
|
||||
"remainingS": "Int"
|
||||
},
|
||||
"o:mechanism": {
|
||||
"kind": "String",
|
||||
"o:chargingEnabled": "Bool",
|
||||
"o:currentA": "Double",
|
||||
"o:estimatedPowerW": "Double",
|
||||
"o:maxPowerW": "Double",
|
||||
"o:maxStageW": "Double",
|
||||
"o:percent": "Double",
|
||||
"o:phaseCount": "Uint",
|
||||
"o:pluggedIn": "Bool",
|
||||
"o:setpointW": "Double",
|
||||
"o:stageW": "Double",
|
||||
"o:state": "Int"
|
||||
}
|
||||
}
|
||||
],
|
||||
"o:budget": {
|
||||
"allocatedW": "Double",
|
||||
"evReservedW": "Double",
|
||||
"recreditedW": "Double",
|
||||
"remainingW": "Double",
|
||||
"surplusW": "Double"
|
||||
},
|
||||
"o:timestamp": "String"
|
||||
}
|
||||
},
|
||||
"NymeaEnergy.LockOnUnplugChanged": {
|
||||
"description": "Emitted whenever the lock on unplug setting changes.",
|
||||
"params": {
|
||||
"lockOnUnplug": "Bool"
|
||||
}
|
||||
},
|
||||
"NymeaEnergy.PhasePowerLimitChanged": {
|
||||
"description": "Emitted whenever the phase power limit configuration changes.",
|
||||
"params": {
|
||||
"phasePowerLimit": "Uint"
|
||||
}
|
||||
},
|
||||
"NymeaEnergy.SpotMarketConfigurationChanged": {
|
||||
"description": "Emitted whenever a spot market configuration has changed.",
|
||||
"params": {
|
||||
"available": "Bool",
|
||||
"enabled": "Bool",
|
||||
"o:providerId": "Uuid"
|
||||
}
|
||||
},
|
||||
"NymeaEnergy.SpotMarketScoreEntriesChanged": {
|
||||
"description": "Emitted whenever the score entries of the current spot market provider changed. The weighting of the available score entries goes from 0 (worst) to 1.0 (best).",
|
||||
"params": {
|
||||
"spotMarketScoreEntries": [
|
||||
"$ref:ScoreEntry"
|
||||
]
|
||||
}
|
||||
},
|
||||
"NymeaEnergy.SpotMarketStatusChanged": {
|
||||
"description": "Emitted whenever the spot market manager status changed.",
|
||||
"params": {
|
||||
"available": "Bool",
|
||||
"enabled": "Bool"
|
||||
}
|
||||
}
|
||||
},
|
||||
"types": {
|
||||
"LoadConfig": {
|
||||
"adapter": "String",
|
||||
"domain": "String",
|
||||
"enabled": "Bool",
|
||||
"id": "String",
|
||||
"label": "String",
|
||||
"maxPowerW": "Int",
|
||||
"minOffS": "Int",
|
||||
"minOnS": "Int",
|
||||
"mode": "String",
|
||||
"needs": "$ref:LoadConfigNeeds",
|
||||
"powerLevels": [
|
||||
"Variant"
|
||||
],
|
||||
"priority": "Int",
|
||||
"relays": [
|
||||
"Variant"
|
||||
],
|
||||
"sgReady": "Object"
|
||||
},
|
||||
"LoadConfigNeeds": {
|
||||
"dailyDeadline": "String",
|
||||
"minEnergyWhPerDay": "Int"
|
||||
},
|
||||
"ChargingInfo": {
|
||||
"chargingMode": "$ref:ChargingMode",
|
||||
"evChargerId": "Uuid",
|
||||
"o:assignedCarId": "Uuid",
|
||||
"o:dailySpotMarketPercentage": "Uint",
|
||||
"o:endDateTime": "Uint",
|
||||
"o:repeatDays": [
|
||||
"Int"
|
||||
],
|
||||
"o:spotMarketChargingEnabled": "Bool",
|
||||
"o:targetPercentage": "Uint",
|
||||
"r:chargingState": "$ref:ChargingState"
|
||||
},
|
||||
"ZoneInfo": {
|
||||
"r:currentSetpoint": "Double",
|
||||
"r:humidity": "Double",
|
||||
"r:id": "Uuid",
|
||||
"r:indoorSensors": [
|
||||
"Uuid"
|
||||
],
|
||||
"r:name": "String",
|
||||
"r:notifications": [
|
||||
"Uuid"
|
||||
],
|
||||
"r:outdoorSensors": [
|
||||
"Uuid"
|
||||
],
|
||||
"r:pm25": "Double",
|
||||
"r:setpointOverride": "Double",
|
||||
"r:setpointOverrideEnd": "Uint",
|
||||
"r:setpointOverrideMode": "$ref:SetpointOverrideMode",
|
||||
"r:standbySetpoint": "Double",
|
||||
"r:temperature": "Double",
|
||||
"r:thermostats": [
|
||||
"Uuid"
|
||||
],
|
||||
"r:valves": [
|
||||
"Uuid"
|
||||
],
|
||||
"r:voc": "Uint",
|
||||
"r:weekSchedule": "$ref:TemperatureWeekSchedule",
|
||||
"r:windowSensors": [
|
||||
"Uuid"
|
||||
],
|
||||
"r:zoneStatus": "$ref:ZoneStatus"
|
||||
},
|
||||
"ZoneInfos": [
|
||||
"$ref:ZoneInfo"
|
||||
],
|
||||
"TemperatureSchedule": {
|
||||
"endTime": "Time",
|
||||
"startTime": "Time",
|
||||
"temperature": "Double"
|
||||
},
|
||||
"TemperatureDaySchedule": [
|
||||
"$ref:TemperatureSchedule"
|
||||
],
|
||||
"TemperatureWeekSchedule": [
|
||||
"$ref:TemperatureDaySchedule"
|
||||
]
|
||||
},
|
||||
"enums": {
|
||||
"ChargingMode": [
|
||||
"ChargingModeNormal",
|
||||
"ChargingModeEco",
|
||||
"ChargingModeEcoWithTargetTime",
|
||||
"ChargingModeEcoWithMinCurrent",
|
||||
"ChargingModeEcoMinWithTargetTime"
|
||||
],
|
||||
"ChargingState": [
|
||||
"ChargingStateIdle",
|
||||
"ChargingStateSurplusCharging",
|
||||
"ChargingStateSpotMarketCharging",
|
||||
"ChargingStateTimeRequirement"
|
||||
],
|
||||
"EnergyError": [
|
||||
"EnergyErrorNoError",
|
||||
"EnergyErrorMissingParameter",
|
||||
"EnergyErrorInvalidParameter"
|
||||
],
|
||||
"AirConditioningError": [
|
||||
"AirConditioningErrorNoError",
|
||||
"AirConditioningErrorZoneNotFound",
|
||||
"AirConditioningErrorInvalidTimeSpec",
|
||||
"AirConditioningErrorThingNotFound",
|
||||
"AirConditioningErrorInvalidThingType"
|
||||
],
|
||||
"SetpointOverrideMode": [
|
||||
"SetpointOverrideModeNone",
|
||||
"SetpointOverrideModeTimed",
|
||||
"SetpointOverrideModeUnlimited",
|
||||
"SetpointOverrideModeEventual"
|
||||
]
|
||||
}
|
||||
}
|
||||
220
test/load_hardware_test.dart
Normal file
220
test/load_hardware_test.dart
Normal file
@ -0,0 +1,220 @@
|
||||
/// Ce que `SetLoadConfig` va **réellement** provoquer — miroir de
|
||||
/// `EnergyArbitrator::sameHardware()`.
|
||||
///
|
||||
/// L'enjeu n'est pas cosmétique : une reconstruction ouvre les contacts et réarme les
|
||||
/// verrous à froid. Sur une PAC à 900 s, l'installateur qui corrige une ligne fige sa
|
||||
/// machine un quart d'heure. Ces tests fixent la frontière exacte entre ce qui se met à
|
||||
/// jour en place et ce qui reconstruit.
|
||||
///
|
||||
/// Le cas le moins intuitif — et il est **vérifié sur `.75`** — est le simple
|
||||
/// réordonnancement de `relays[]`.
|
||||
library;
|
||||
|
||||
import 'package:etm_powersync_app/models/load_config_entry.dart';
|
||||
import 'package:etm_powersync_app/services/load_hardware.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
/// Charge `relay-router` à trois contacteurs, calquée sur `chauffe-eau` de `.75`.
|
||||
Map<String, dynamic> chauffeEau({
|
||||
List<Map<String, dynamic>>? relays,
|
||||
int priority = 1,
|
||||
String label = 'chauffe-eau',
|
||||
bool enabled = true,
|
||||
int minOnS = 60,
|
||||
int minOffS = 60,
|
||||
String domain = 'ecs',
|
||||
}) =>
|
||||
{
|
||||
'adapter': 'relay-router',
|
||||
'domain': domain,
|
||||
'enabled': enabled,
|
||||
'id': 'chauffe-eau',
|
||||
'label': label,
|
||||
'maxPowerW': 0,
|
||||
'minOffS': minOffS,
|
||||
'minOnS': minOnS,
|
||||
'mode': 'fixed',
|
||||
'needs': {'dailyDeadline': '', 'minEnergyWhPerDay': 0},
|
||||
'powerLevels': <dynamic>[],
|
||||
'priority': priority,
|
||||
'relays': 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}'},
|
||||
],
|
||||
'sgReady': <String, dynamic>{},
|
||||
};
|
||||
|
||||
/// Charge `sg-ready` calquée sur `pac-terrain` de `.75`.
|
||||
Map<String, dynamic> pacTerrain({
|
||||
int minStateHoldS = 300,
|
||||
double p4 = 3000,
|
||||
int priority = 2,
|
||||
bool enabled = true,
|
||||
}) =>
|
||||
{
|
||||
'adapter': 'sg-ready',
|
||||
'domain': 'hvac',
|
||||
'enabled': enabled,
|
||||
'id': 'pac-terrain',
|
||||
'label': 'PAC banc',
|
||||
'maxPowerW': 0,
|
||||
'minOffS': 0,
|
||||
'minOnS': 0,
|
||||
'mode': 'fixed',
|
||||
'needs': {'dailyDeadline': '', 'minEnergyWhPerDay': 0},
|
||||
'powerLevels': <dynamic>[],
|
||||
'priority': priority,
|
||||
'relays': <dynamic>[],
|
||||
'sgReady': {
|
||||
'minStateHoldS': minStateHoldS,
|
||||
'states': [
|
||||
{'state': 1, 'relays': ['{beaf92e1-aedc-4b84-9ce4-e423648638cc}'], 'estimatedPowerW': 0.0},
|
||||
{'state': 2, 'relays': <String>[], 'estimatedPowerW': 0.0},
|
||||
{'state': 3, 'relays': ['{bf236e64-5ae7-4bf1-82a6-4ede03de75a6}'], 'estimatedPowerW': 1500.0},
|
||||
{
|
||||
'state': 4,
|
||||
'relays': [
|
||||
'{beaf92e1-aedc-4b84-9ce4-e423648638cc}',
|
||||
'{bf236e64-5ae7-4bf1-82a6-4ede03de75a6}',
|
||||
],
|
||||
'estimatedPowerW': p4,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
void main() {
|
||||
group('sameHardware — ce qui NE reconstruit PAS', () {
|
||||
test('le rang seul est une mise à jour en place', () {
|
||||
final a = LoadConfigEntry(chauffeEau(priority: 1));
|
||||
final b = LoadConfigEntry(chauffeEau(priority: 3));
|
||||
expect(sameHardware(a, b), isTrue);
|
||||
expect(hardwareDiff(a, b), isEmpty);
|
||||
|
||||
final impacts = impactOfSave(avant: [a], apres: [b]);
|
||||
expect(impacts.single.impact, LoadChangeImpact.inPlace);
|
||||
expect(impacts.single.needsWarning, isFalse);
|
||||
});
|
||||
|
||||
test('le libellé et le domaine ne touchent pas au matériel', () {
|
||||
final a = LoadConfigEntry(chauffeEau());
|
||||
final b = LoadConfigEntry(chauffeEau(label: 'Ballon 200 L', domain: 'heating'));
|
||||
expect(sameHardware(a, b), isTrue);
|
||||
|
||||
// Ni reconstruction NI mise à jour en place : le moteur réutilise l'adaptateur
|
||||
// tel quel. Vérifié sur `.75` — un changement de libellé seul journalise
|
||||
// « 2 inchangée(s) », et le journal continue d'afficher l'ANCIEN libellé
|
||||
// jusqu'à la reconstruction suivante.
|
||||
final impacts = impactOfSave(avant: [a], apres: [b]);
|
||||
expect(impacts.single.impact, LoadChangeImpact.none);
|
||||
});
|
||||
});
|
||||
|
||||
group('sameHardware — ce qui RECONSTRUIT', () {
|
||||
test('RÉORDONNER les relais suffit — la comparaison du moteur est indexée', () {
|
||||
final origine = chauffeEau();
|
||||
final inverse = chauffeEau(
|
||||
relays: (origine['relays'] as List)
|
||||
.cast<Map<String, dynamic>>()
|
||||
.reversed
|
||||
.toList(),
|
||||
);
|
||||
final a = LoadConfigEntry(origine);
|
||||
final b = LoadConfigEntry(inverse);
|
||||
|
||||
// Même ensemble, mêmes Things, mêmes puissances — et pourtant :
|
||||
expect(sameHardware(a, b), isFalse,
|
||||
reason: 'vérifié sur .75 : relays[] inversé → adaptateur reconstruit, '
|
||||
'ancien passé par applySafeState (contacts ouverts, force)');
|
||||
expect(hardwareDiff(a, b), ['relays']);
|
||||
|
||||
final impacts = impactOfSave(avant: [a], apres: [b]);
|
||||
expect(impacts.single.impact, LoadChangeImpact.rebuild);
|
||||
expect(impacts.single.needsWarning, isTrue);
|
||||
expect(impacts.single.rearmSeconds, 60); // max(minOnS, minOffS)
|
||||
});
|
||||
|
||||
test('changer une puissance de contacteur reconstruit', () {
|
||||
final a = LoadConfigEntry(chauffeEau());
|
||||
final b = LoadConfigEntry(chauffeEau(relays: [
|
||||
{'powerW': 800, 'thingId': '{8538782f-2c8c-4a30-bfce-c8140f791c9b}'},
|
||||
{'powerW': 1000, 'thingId': '{2ebe6bef-829b-4695-9d11-ddb4c16c5448}'},
|
||||
{'powerW': 2000, 'thingId': '{b033b212-1adb-4df0-ba2b-8fa477de52a2}'},
|
||||
]));
|
||||
expect(hardwareDiff(a, b), ['relays']);
|
||||
});
|
||||
|
||||
test('les temporisations sont du matériel au sens du moteur', () {
|
||||
final a = LoadConfigEntry(chauffeEau(minOnS: 60));
|
||||
final b = LoadConfigEntry(chauffeEau(minOnS: 120));
|
||||
expect(hardwareDiff(a, b), ['minOnS']);
|
||||
expect(impactOfSave(avant: [a], apres: [b]).single.rearmSeconds, 120);
|
||||
});
|
||||
|
||||
test('une estimation SG-Ready revue reconstruit, et réarme minStateHoldS', () {
|
||||
final a = LoadConfigEntry(pacTerrain(p4: 3000));
|
||||
final b = LoadConfigEntry(pacTerrain(p4: 2800));
|
||||
expect(hardwareDiff(a, b), ['sgReady']);
|
||||
|
||||
final impact = impactOfSave(avant: [a], apres: [b]).single;
|
||||
expect(impact.impact, LoadChangeImpact.rebuild);
|
||||
// C'est CE nombre qu'il faut annoncer : sur une vraie PAC il vaut 900.
|
||||
expect(impact.rearmSeconds, 300);
|
||||
});
|
||||
|
||||
test('changer de mécanisme reconstruit', () {
|
||||
final a = LoadConfigEntry(chauffeEau());
|
||||
final b = LoadConfigEntry({...chauffeEau(), 'adapter': 'etmvariableload'});
|
||||
expect(hardwareDiff(a, b), contains('adapter'));
|
||||
});
|
||||
});
|
||||
|
||||
group('entrée et sortie de l\'arbitrage', () {
|
||||
test('enabled vrai → faux : la charge SORT, elle passe par l\'état sûr', () {
|
||||
final a = LoadConfigEntry(pacTerrain(enabled: true));
|
||||
final b = LoadConfigEntry(pacTerrain(enabled: false));
|
||||
final impact = impactOfSave(avant: [a], apres: [b]).single;
|
||||
expect(impact.impact, LoadChangeImpact.removed);
|
||||
expect(impact.needsWarning, isTrue);
|
||||
});
|
||||
|
||||
test('enabled faux → vrai : la charge ENTRE', () {
|
||||
final a = LoadConfigEntry(pacTerrain(enabled: false));
|
||||
final b = LoadConfigEntry(pacTerrain(enabled: true));
|
||||
expect(impactOfSave(avant: [a], apres: [b]).single.impact,
|
||||
LoadChangeImpact.created);
|
||||
});
|
||||
|
||||
test('désactivée avant comme après : aucun adaptateur, donc rien à reconstruire', () {
|
||||
final a = LoadConfigEntry(pacTerrain(enabled: false, minStateHoldS: 300));
|
||||
final b = LoadConfigEntry(pacTerrain(enabled: false, minStateHoldS: 900));
|
||||
expect(impactOfSave(avant: [a], apres: [b]).single.impact,
|
||||
LoadChangeImpact.none);
|
||||
});
|
||||
|
||||
test('une charge retirée de l\'ensemble sort par l\'état sûr', () {
|
||||
final a = LoadConfigEntry(chauffeEau());
|
||||
final b = LoadConfigEntry(pacTerrain());
|
||||
final impacts = impactOfSave(avant: [a, b], apres: [b]);
|
||||
final ce = impacts.firstWhere((i) => i.loadId == 'chauffe-eau');
|
||||
expect(ce.impact, LoadChangeImpact.removed);
|
||||
});
|
||||
});
|
||||
|
||||
test('un enregistrement mixte rend un verdict par charge', () {
|
||||
final avant = [LoadConfigEntry(chauffeEau()), LoadConfigEntry(pacTerrain())];
|
||||
final apres = [
|
||||
LoadConfigEntry(chauffeEau(priority: 5)), // en place
|
||||
LoadConfigEntry(pacTerrain(minStateHoldS: 900)), // reconstruction
|
||||
];
|
||||
final impacts = {
|
||||
for (final i in impactOfSave(avant: avant, apres: apres)) i.loadId: i
|
||||
};
|
||||
expect(impacts['chauffe-eau']!.impact, LoadChangeImpact.inPlace);
|
||||
expect(impacts['pac-terrain']!.impact, LoadChangeImpact.rebuild);
|
||||
expect(impacts['pac-terrain']!.rearmSeconds, 900);
|
||||
expect(impacts.values.where((i) => i.needsWarning).length, 1);
|
||||
});
|
||||
}
|
||||
214
test/merged_config_screen_test.dart
Normal file
214
test/merged_config_screen_test.dart
Normal file
@ -0,0 +1,214 @@
|
||||
/// L'écran de configuration FUSIONNÉ, éprouvé sur les dumps **réels** de `.75`.
|
||||
///
|
||||
/// > ⚠️ **Ce sont des tests de widgets, pas une vérification machine.** Ils montent les
|
||||
/// > vrais widgets sur les vraies charges utiles de la box (`test/fixtures/*_v3.json`,
|
||||
/// > plugin `1.15.2+etm15`), ce qui couvre le rendu et les écritures préparées — mais
|
||||
/// > **aucune** ne part vers `.75` ici. Ce que ces tests ne prouvent pas est écrit dans
|
||||
/// > le journal de session : l'IU n'a pas pu être pilotée sur l'appareil, l'injection
|
||||
/// > d'événements ADB étant refusée par MIUI.
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:etm_powersync_app/l10n/app_localizations.dart';
|
||||
import 'package:etm_powersync_app/models/load_config_entry.dart';
|
||||
import 'package:etm_powersync_app/providers/load_config_provider.dart';
|
||||
import 'package:etm_powersync_app/providers/load_telemetry_provider.dart';
|
||||
import 'package:etm_powersync_app/services/load_hardware.dart';
|
||||
import 'package:etm_powersync_app/services/rpc_schema.dart';
|
||||
import 'package:etm_powersync_app/widgets/controllable_loads_section.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
Map<String, dynamic> _fixture(String name) =>
|
||||
jsonDecode(File('test/fixtures/$name').readAsStringSync())
|
||||
as Map<String, dynamic>;
|
||||
|
||||
/// Config réelle de `.75`, avec les domaines tels qu'ils y sont posés aujourd'hui
|
||||
/// (`chauffe-eau → ecs`, `pac-terrain → hvac`) — le multi-domaines du banc.
|
||||
List<Map<String, dynamic>> _benchConfig() {
|
||||
final raw = (_fixture('loadconfig_hems75_v3.json')['loadConfigs'] as List)
|
||||
.cast<Map<String, dynamic>>();
|
||||
return [
|
||||
for (final c in raw)
|
||||
{...c, 'domain': c['id'] == 'chauffe-eau' ? 'ecs' : 'hvac'},
|
||||
];
|
||||
}
|
||||
|
||||
Widget _harness({
|
||||
required LoadConfigProvider config,
|
||||
required LoadTelemetryProvider telemetry,
|
||||
}) =>
|
||||
MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider.value(value: config),
|
||||
ChangeNotifierProvider.value(value: telemetry),
|
||||
],
|
||||
child: MaterialApp(
|
||||
locale: const Locale('fr'),
|
||||
localizationsDelegates: L10n.localizationsDelegates,
|
||||
supportedLocales: L10n.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: SingleChildScrollView(
|
||||
child: ControllableLoadsSection(onConfigure: (_, __) {}),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
void main() {
|
||||
group('fusion — la liste vient de GetLoadConfig, pas des rôles', () {
|
||||
testWidgets('les deux charges du banc apparaissent, chacune dans son domaine',
|
||||
(tester) async {
|
||||
final config = LoadConfigProvider()..adoptForTest(_benchConfig());
|
||||
final telemetry = LoadTelemetryProvider()
|
||||
..adoptForTest(_fixture('loadtelemetry_hems75_v3.json')['telemetry']
|
||||
as Map<String, dynamic>);
|
||||
|
||||
await tester.pumpWidget(_harness(config: config, telemetry: telemetry));
|
||||
await tester.pump();
|
||||
|
||||
// Les identifiants viennent de la box — pas d'une énumération figée de rôles.
|
||||
expect(find.text('chauffe-eau'), findsWidgets);
|
||||
expect(find.text('PAC banc'), findsWidgets);
|
||||
|
||||
// Deux mécanismes distincts, deux écrans de détail : un bouton par charge.
|
||||
expect(find.text('Configurer'), findsNWidgets(2));
|
||||
});
|
||||
|
||||
testWidgets('une charge désactivée se lit « désactivée », jamais « perdue »',
|
||||
(tester) async {
|
||||
final cfg = _benchConfig();
|
||||
final desactivee = [
|
||||
for (final c in cfg)
|
||||
if (c['id'] == 'pac-terrain') {...c, 'enabled': false} else c,
|
||||
];
|
||||
final config = LoadConfigProvider()..adoptForTest(desactivee);
|
||||
// Télémétrie VIDE : c'est exactement le cas d'une charge hors arbitrage.
|
||||
final telemetry = LoadTelemetryProvider()
|
||||
..adoptForTest({'degradedMode': false, 'loads': const []});
|
||||
|
||||
await tester.pumpWidget(_harness(config: config, telemetry: telemetry));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.textContaining('Désactivée'), findsWidgets);
|
||||
expect(find.textContaining('perdue'), findsNothing);
|
||||
});
|
||||
});
|
||||
|
||||
group('annonce d\'impact avant écriture', () {
|
||||
test('réordonner les contacteurs déclenche un avertissement, pas un silence', () {
|
||||
final config = LoadConfigProvider()..adoptForTest(_benchConfig());
|
||||
|
||||
final avant = config.entries.firstWhere((e) => e.id == 'chauffe-eau');
|
||||
final inverse = avant.patched(relays: [
|
||||
for (final r in avant.relays.reversed)
|
||||
{'thingId': r.thingId, 'powerW': r.powerW}
|
||||
]);
|
||||
config.replaceEntry('chauffe-eau', inverse);
|
||||
|
||||
expect(config.hasPendingChanges, isTrue);
|
||||
expect(config.pendingNeedsWarning, isTrue,
|
||||
reason: 'la comparaison du moteur est indexée — vérifié sur .75');
|
||||
|
||||
final impact = config
|
||||
.pendingImpact()
|
||||
.firstWhere((i) => i.loadId == 'chauffe-eau');
|
||||
expect(impact.impact, LoadChangeImpact.rebuild);
|
||||
expect(impact.changedFields, ['relays']);
|
||||
});
|
||||
|
||||
test('renommer ou reclasser n\'avertit de rien', () {
|
||||
final config = LoadConfigProvider()..adoptForTest(_benchConfig());
|
||||
config.setLabel('chauffe-eau', 'Ballon 200 L');
|
||||
expect(config.hasPendingChanges, isTrue);
|
||||
expect(config.pendingNeedsWarning, isFalse);
|
||||
});
|
||||
|
||||
test('désactiver la PAC avertit — elle passe par son état sûr', () {
|
||||
final config = LoadConfigProvider()..adoptForTest(_benchConfig());
|
||||
config.setEnabled('pac-terrain', false);
|
||||
final impact =
|
||||
config.pendingImpact().firstWhere((i) => i.loadId == 'pac-terrain');
|
||||
expect(impact.impact, LoadChangeImpact.removed);
|
||||
expect(impact.needsWarning, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('grisage dérivé du schéma RÉEL de .75', () {
|
||||
late RpcSchema schema;
|
||||
|
||||
setUp(() {
|
||||
final f = _fixture('schema_hems75_etm15.json');
|
||||
schema = RpcSchema.fromIntrospect(f, hello: {
|
||||
'version': f['_hello_version'],
|
||||
'experiences': [
|
||||
for (final e in (f['_hello_experiences'] as Map).entries)
|
||||
{'name': e.key, 'version': e.value},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('le schéma se lit, et les versions d\'API avec lui', () {
|
||||
expect(schema.loaded, isTrue);
|
||||
expect(schema.experienceVersions['NymeaEnergy'], '0.8');
|
||||
// La version du PAQUET (+etm15) n'est PAS sur le fil : ce qu'on annonce est
|
||||
// la version d'API, qui décrit le contrat.
|
||||
expect(schema.versionLineContains('etm15'), isFalse);
|
||||
});
|
||||
|
||||
test('ChargingInfo : les champs d\'échéance existent, minCurrent NON', () {
|
||||
expect(schema.typeSupport('ChargingInfo', ['targetPercentage']),
|
||||
FieldSupport.writable);
|
||||
expect(schema.typeSupport('ChargingInfo', ['endDateTime']),
|
||||
FieldSupport.writable);
|
||||
// Le mode existe, son paramètre non — c'est ce que l'écran doit dire.
|
||||
expect(schema.typeSupport('ChargingInfo', ['minCurrent']),
|
||||
FieldSupport.absent);
|
||||
expect(schema.enumValues('ChargingMode'),
|
||||
contains('ChargingModeEcoWithMinCurrent'));
|
||||
});
|
||||
|
||||
test('ChargingInfo : chargingState est une MESURE, pas un champ manquant', () {
|
||||
expect(schema.typeSupport('ChargingInfo', ['chargingState']),
|
||||
FieldSupport.readOnly);
|
||||
});
|
||||
|
||||
test('SetLoadConfig : les charges utiles de mécanisme sont au schéma', () {
|
||||
for (final champ in ['relays', 'sgReady', 'maxPowerW', 'domain']) {
|
||||
expect(
|
||||
schema.paramSupport('NymeaEnergy.SetLoadConfig',
|
||||
['loadConfigs', '[]', champ]),
|
||||
FieldSupport.writable,
|
||||
reason: '$champ doit être écrivable sur .75',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('AirConditioning est pleinement pilotable — il n\'y a rien à griser', () {
|
||||
for (final m in [
|
||||
'AirConditioning.AddZone',
|
||||
'AirConditioning.SetZoneThings',
|
||||
'AirConditioning.SetZoneWeekSchedule',
|
||||
'AirConditioning.SetZoneSetpointOverride',
|
||||
]) {
|
||||
expect(schema.hasMethod(m), isTrue, reason: '$m est publiée par .75');
|
||||
}
|
||||
});
|
||||
|
||||
test('un schéma NON LU ne dit pas « absent » — il dit « je ne sais pas »', () {
|
||||
expect(RpcSchema.unknown.typeSupport('ChargingInfo', ['targetPercentage']),
|
||||
FieldSupport.unknown);
|
||||
expect(RpcSchema.unknown.paramSupport('NymeaEnergy.SetLoadConfig', ['x']),
|
||||
FieldSupport.unknown);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
extension on RpcSchema {
|
||||
bool versionLineContains(String s) =>
|
||||
(nymeaVersion ?? '').contains(s) ||
|
||||
experienceVersions.values.any((v) => v.contains(s));
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user