From dee20896c52464df1733ec033a9cc852155edb37 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Tue, 25 Aug 2026 19:20:30 +0200 Subject: [PATCH] =?UTF-8?q?feat(t=C3=A9l=C3=A9m=C3=A9trie):=20lot=20B=20ap?= =?UTF-8?q?p=20=E2=80=94=20consommation=20de=20la=20t=C3=A9l=C3=A9m=C3=A9t?= =?UTF-8?q?rie=20d'arbitrage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le lot plugin B-bis (1.15.2+etm15) est sur .75 et GetLoadConfig/GetLoadTelemetry concordent : les critères 4 à 8 du brief deviennent atteignables. - models/load_telemetry.dart : vue en lecture. timestamp/budget/mechanism/lock/ faultCode nullables — omis porte un sens que zéro ne porterait pas. - services/telemetry_text.dart : rendu français des CODES (19 motifs, 3 défauts, 3 verrous, 4 mécanismes). Repli lisible sur un code inconnu ET sur un code connu privé d'un paramètre : une phrase trouée a l'air d'une donnée, le code brut a l'air de ce qu'il est. - providers/load_telemetry_provider.dart : instantané + LoadTelemetryChanged, fraîcheur à trois signaux (âge du timestamp, silence des trames, timestamp qui cesse d'avancer), machine à états de ClearLoadFault qui ne conclut JAMAIS sur l'acquittement du RPC. - load_config_provider : assignDomain() en brouillon, énumération fermée. - load_order_screen : budget, fraîcheur, mode dégradé, bloc par charge, encart défaut, menu de domaine. - nymea_service : getLoadTelemetry(), clearLoadFault(), aiguillage de la notification. Une box sans la méthode s'annonce comme telle, pas comme en panne. Seuil de fraîcheur à 180 s : le battement de cœur du plugin est de 60 s et le timestamp avance d'un cycle par minute. Un seuil plus court crierait « données anciennes » sur une installation vivante. Fixtures v3 prises sur .75 après déploiement, dont un cycle porteur d'un verrou — « lock » étant optionnel, un dump pris au mauvais moment ne l'éprouverait pas. 57 tests (30 avant), flutter analyze inchangé à 25 issues. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H --- .gitignore | 4 + lib/l10n/app_fr.arb | 182 +++++++ lib/l10n/app_localizations.dart | 330 ++++++++++++ lib/l10n/app_localizations_fr.dart | 325 ++++++++++++ lib/main.dart | 4 + lib/models/load_telemetry.dart | 302 +++++++++++ lib/providers/load_config_provider.dart | 48 ++ lib/providers/load_telemetry_provider.dart | 304 ++++++++++++ lib/screens/energy/load_order_screen.dart | 468 ++++++++++++++++-- lib/services/load_priority.dart | 13 + lib/services/nymea_service.dart | 81 +++ lib/services/telemetry_text.dart | 178 +++++++ test/fixtures/loadconfig_hems75_v3.json | 88 ++++ test/fixtures/loadtelemetry_hems75_v3.json | 52 ++ .../loadtelemetry_hems75_v3_locked.json | 38 ++ test/fixtures/schema_hems75_v3.json | 153 ++++++ test/load_domain_test.dart | 99 ++++ test/load_telemetry_test.dart | 277 +++++++++++ 18 files changed, 2918 insertions(+), 28 deletions(-) create mode 100644 lib/models/load_telemetry.dart create mode 100644 lib/providers/load_telemetry_provider.dart create mode 100644 lib/services/telemetry_text.dart create mode 100644 test/fixtures/loadconfig_hems75_v3.json create mode 100644 test/fixtures/loadtelemetry_hems75_v3.json create mode 100644 test/fixtures/loadtelemetry_hems75_v3_locked.json create mode 100644 test/fixtures/schema_hems75_v3.json create mode 100644 test/load_domain_test.dart create mode 100644 test/load_telemetry_test.dart diff --git a/.gitignore b/.gitignore index a26ba56..a569e73 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,7 @@ app.*.map.json # Artefact de génération l10n (gen-l10n) — pas du code source l10n_untranslated.json + +# Sauvegarde de configuration écrite par tools/rpc/fault_probe.dart avant écriture. +# Sa présence signale une sonde interrompue : la rejouer, ne pas la committer. +fault_probe_backup_*.json diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index fd2d13f..8f1fdb6 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -115,6 +115,188 @@ } }, + "telemetryTitle": "Arbitrage en direct", + "telemetryNeverRan": "Aucun cycle d'arbitrage depuis le démarrage du service.", + "@telemetryNeverRan": { + "description": "timestamp absent — l'absence de cycle est une information, pas un zéro" + }, + "telemetryLastCycle": "Dernier cycle il y a {seconds} s", + "@telemetryLastCycle": { + "placeholders": { "seconds": { "type": "int" } } + }, + "telemetryBudgetTitle": "Budget de surplus du cycle", + "telemetryBudgetAbsent": "Aucun budget publié pour ce cycle : la planification est suspendue.", + "budgetSurplus": "Surplus entrant", + "budgetEvReserved": "Réservé au véhicule", + "budgetRecredited": "Recrédité", + "budgetAllocated": "Alloué", + "budgetRemaining": "Reliquat", + "watts": "{watts} W", + "@watts": { + "placeholders": { "watts": { "type": "int", "format": "decimalPattern" } } + }, + "telemetryAllocated": "Alloué {watts} W", + "@telemetryAllocated": { + "placeholders": { "watts": { "type": "int", "format": "decimalPattern" } } + }, + "telemetryNotArbitrated": "Non arbitrée — absente du dernier cycle publié", + "@telemetryNotArbitrated": { + "description": "Charge présente dans GetLoadConfig mais absente de loads[] : elle est désactivée, jamais perdue" + }, + + "lockMinOn": "Maintien ON — {seconds} s restantes", + "@lockMinOn": { + "placeholders": { "seconds": { "type": "int" } } + }, + "lockMinOff": "Maintien OFF — {seconds} s restantes", + "@lockMinOff": { + "placeholders": { "seconds": { "type": "int" } } + }, + "lockMinStateHold": "Maintien d'état — {seconds} s restantes", + "@lockMinStateHold": { + "placeholders": { "seconds": { "type": "int" } } + }, + "lockUnknown": "Verrou « {kind} » — {seconds} s restantes", + "@lockUnknown": { + "placeholders": { + "kind": { "type": "String" }, + "seconds": { "type": "int" } + } + }, + "lockAsOfCycle": "au dernier cycle", + "@lockAsOfCycle": { + "description": "Les secondes restantes ne sont PAS décomptées côté app : elles datent du cycle publié" + }, + + "mechanismRelayStage": "Palier {stage} W sur {max} W", + "@mechanismRelayStage": { + "placeholders": { + "stage": { "type": "int", "format": "decimalPattern" }, + "max": { "type": "int", "format": "decimalPattern" } + } + }, + "mechanismVariableSetpoint": "Consigne {setpoint} W ({percent} %)", + "@mechanismVariableSetpoint": { + "placeholders": { + "setpoint": { "type": "int", "format": "decimalPattern" }, + "percent": { "type": "int" } + } + }, + "mechanismSgReadyState": "État SG-Ready {state} — estimation {estimated} W", + "@mechanismSgReadyState": { + "placeholders": { + "state": { "type": "int" }, + "estimated": { "type": "int", "format": "decimalPattern" } + } + }, + "mechanismEvCharger": "{current} A sur {phases} phase(s)", + "@mechanismEvCharger": { + "placeholders": { + "current": { "type": "double", "format": "decimalPattern" }, + "phases": { "type": "int" } + } + }, + "mechanismUnknown": "Mécanisme « {kind} »", + "@mechanismUnknown": { + "placeholders": { "kind": { "type": "String" } } + }, + + "decisionEvSurplus": "Véhicule rechargé sur le surplus photovoltaïque.", + "decisionEvSpotMarket": "Véhicule rechargé sur un créneau tarifaire favorable.", + "decisionEvDeadline": "Véhicule prioritaire : l'échéance approche.", + "decisionEvEcoMin": "Pas de surplus : le courant minimum est maintenu.", + "decisionEvIdle": "Pas de surplus : la recharge est suspendue.", + "decisionLoadUnavailable": "Charge en défaut : aucune commande n'est émise, et {frozen} W restent comptés au budget.", + "@decisionLoadUnavailable": { + "placeholders": { "frozen": { "type": "int", "format": "decimalPattern" } } + }, + "decisionLockMinOn": "Le maintien ON a relevé la consigne à {applied} W ; le budget en payait {budget} W.", + "@decisionLockMinOn": { + "placeholders": { + "applied": { "type": "int", "format": "decimalPattern" }, + "budget": { "type": "int", "format": "decimalPattern" } + } + }, + "decisionLockMinOff": "Le maintien OFF interdit l'enclenchement ; le budget payait pourtant {requested} W sur {budget} W disponibles.", + "@decisionLockMinOff": { + "placeholders": { + "requested": { "type": "int", "format": "decimalPattern" }, + "budget": { "type": "int", "format": "decimalPattern" } + } + }, + "decisionLockMinOffCapped": "Le maintien OFF plafonne la consigne à {applied} W ; {requested} W étaient demandés sur {budget} W de budget.", + "@decisionLockMinOffCapped": { + "placeholders": { + "applied": { "type": "int", "format": "decimalPattern" }, + "requested": { "type": "int", "format": "decimalPattern" }, + "budget": { "type": "int", "format": "decimalPattern" } + } + }, + "decisionSurplusSetpoint": "Consigne de {setpoint} W servie par le surplus ({budget} W de budget).", + "@decisionSurplusSetpoint": { + "placeholders": { + "setpoint": { "type": "int", "format": "decimalPattern" }, + "budget": { "type": "int", "format": "decimalPattern" } + } + }, + "decisionSurplusSetpointStepped": "Consigne de {setpoint} W servie par le surplus, arrondie sur un palier déclaré ({budget} W de budget).", + "@decisionSurplusSetpointStepped": { + "placeholders": { + "setpoint": { "type": "int", "format": "decimalPattern" }, + "budget": { "type": "int", "format": "decimalPattern" } + } + }, + "decisionSurplusInsufficient": "Le budget de {budget} W ne payait aucun palier — aucun verrou n'a mordu.", + "@decisionSurplusInsufficient": { + "placeholders": { "budget": { "type": "int", "format": "decimalPattern" } } + }, + "decisionLockMinStateHold": "État {state} maintenu par protection anti court-cycle.", + "@decisionLockMinStateHold": { + "placeholders": { "state": { "type": "int" } } + }, + "decisionSgForced": "Pompe à chaleur en état 4 (forcé) — estimation {estimated} W sur {budget} W de budget.", + "@decisionSgForced": { + "placeholders": { + "estimated": { "type": "int", "format": "decimalPattern" }, + "budget": { "type": "int", "format": "decimalPattern" } + } + }, + "decisionSgRecommended": "Pompe à chaleur en état 3 (recommandation) — estimation {estimated} W sur {budget} W de budget.", + "@decisionSgRecommended": { + "placeholders": { + "estimated": { "type": "int", "format": "decimalPattern" }, + "budget": { "type": "int", "format": "decimalPattern" } + } + }, + "decisionSgNormal": "Pompe à chaleur en état 2 (normal, sans consigne) — {budget} W de budget.", + "@decisionSgNormal": { + "placeholders": { "budget": { "type": "int", "format": "decimalPattern" } } + }, + "decisionDegradedL2": "Consigne de repli : le compteur est muet et le watchdog a suspendu la planification.", + "decisionSafeStateRelay": "État sûr avant retrait : tous les relais sont ouverts.", + "decisionSafeStateSetpoint": "État sûr avant retrait : consigne ramenée à 0 W.", + "decisionSafeStateSgReady": "État sûr avant retrait : état 2 (normal), jamais le blocage.", + + "faultWriteFailed": "Échec d'écriture : l'échelle de commande est épuisée, plus aucune commande n'est émise tant que le défaut n'est pas levé.", + "faultThingMissing": "L'appareil commandé est absent de la configuration nymea.", + "faultUnusableEncoding": "Encodage SG-Ready inexploitable : l'état 2 ne peut pas être exprimé, cette pompe à chaleur n'est pas pilotable.", + "faultClearPending": "Levée demandée — en attente de la télémétrie…", + "faultClearStillFaulty": "La charge est toujours en défaut après la levée.", + "faultClearRejected": "La box a refusé la levée : {reason}", + "@faultClearRejected": { + "placeholders": { "reason": { "type": "String" } } + }, + "faultClearHint": "La levée est une action délibérée : le défaut ne se lève jamais tout seul.", + + "domainBattery": "Batterie", + "domainAssignTitle": "Classer « {label} »", + "@domainAssignTitle": { + "placeholders": { "label": { "type": "String" } } + }, + "domainAssignAction": "Changer de domaine", + "domainAssignNone": "Retirer du classement", + "domainAssignHint": "Le domaine est une intention déclarée par l'installateur. L'arbitre ne la lit pas : elle ne sert qu'à grouper l'ordre de service.", + "localeRequested": "Locale demandée : {requested} · retenue par la box : {effective}", "@localeRequested": { "placeholders": { diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index ffa6c8b..6e3e96c 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -373,6 +373,336 @@ abstract class L10n { /// **'Décision « {code} »{params}'** String decisionUnknown(String code, String params); + /// No description provided for @telemetryTitle. + /// + /// In fr, this message translates to: + /// **'Arbitrage en direct'** + String get telemetryTitle; + + /// timestamp absent — l'absence de cycle est une information, pas un zéro + /// + /// In fr, this message translates to: + /// **'Aucun cycle d\'arbitrage depuis le démarrage du service.'** + String get telemetryNeverRan; + + /// No description provided for @telemetryLastCycle. + /// + /// In fr, this message translates to: + /// **'Dernier cycle il y a {seconds} s'** + String telemetryLastCycle(int seconds); + + /// No description provided for @telemetryBudgetTitle. + /// + /// In fr, this message translates to: + /// **'Budget de surplus du cycle'** + String get telemetryBudgetTitle; + + /// No description provided for @telemetryBudgetAbsent. + /// + /// In fr, this message translates to: + /// **'Aucun budget publié pour ce cycle : la planification est suspendue.'** + String get telemetryBudgetAbsent; + + /// No description provided for @budgetSurplus. + /// + /// In fr, this message translates to: + /// **'Surplus entrant'** + String get budgetSurplus; + + /// No description provided for @budgetEvReserved. + /// + /// In fr, this message translates to: + /// **'Réservé au véhicule'** + String get budgetEvReserved; + + /// No description provided for @budgetRecredited. + /// + /// In fr, this message translates to: + /// **'Recrédité'** + String get budgetRecredited; + + /// No description provided for @budgetAllocated. + /// + /// In fr, this message translates to: + /// **'Alloué'** + String get budgetAllocated; + + /// No description provided for @budgetRemaining. + /// + /// In fr, this message translates to: + /// **'Reliquat'** + String get budgetRemaining; + + /// No description provided for @watts. + /// + /// In fr, this message translates to: + /// **'{watts} W'** + String watts(int watts); + + /// No description provided for @telemetryAllocated. + /// + /// In fr, this message translates to: + /// **'Alloué {watts} W'** + String telemetryAllocated(int watts); + + /// Charge présente dans GetLoadConfig mais absente de loads[] : elle est désactivée, jamais perdue + /// + /// In fr, this message translates to: + /// **'Non arbitrée — absente du dernier cycle publié'** + String get telemetryNotArbitrated; + + /// No description provided for @lockMinOn. + /// + /// In fr, this message translates to: + /// **'Maintien ON — {seconds} s restantes'** + String lockMinOn(int seconds); + + /// No description provided for @lockMinOff. + /// + /// In fr, this message translates to: + /// **'Maintien OFF — {seconds} s restantes'** + String lockMinOff(int seconds); + + /// No description provided for @lockMinStateHold. + /// + /// In fr, this message translates to: + /// **'Maintien d\'état — {seconds} s restantes'** + String lockMinStateHold(int seconds); + + /// No description provided for @lockUnknown. + /// + /// In fr, this message translates to: + /// **'Verrou « {kind} » — {seconds} s restantes'** + String lockUnknown(String kind, int seconds); + + /// Les secondes restantes ne sont PAS décomptées côté app : elles datent du cycle publié + /// + /// In fr, this message translates to: + /// **'au dernier cycle'** + String get lockAsOfCycle; + + /// No description provided for @mechanismRelayStage. + /// + /// In fr, this message translates to: + /// **'Palier {stage} W sur {max} W'** + String mechanismRelayStage(int stage, int max); + + /// No description provided for @mechanismVariableSetpoint. + /// + /// In fr, this message translates to: + /// **'Consigne {setpoint} W ({percent} %)'** + String mechanismVariableSetpoint(int setpoint, int percent); + + /// No description provided for @mechanismSgReadyState. + /// + /// In fr, this message translates to: + /// **'État SG-Ready {state} — estimation {estimated} W'** + String mechanismSgReadyState(int state, int estimated); + + /// No description provided for @mechanismEvCharger. + /// + /// In fr, this message translates to: + /// **'{current} A sur {phases} phase(s)'** + String mechanismEvCharger(double current, int phases); + + /// No description provided for @mechanismUnknown. + /// + /// In fr, this message translates to: + /// **'Mécanisme « {kind} »'** + String mechanismUnknown(String kind); + + /// No description provided for @decisionEvSurplus. + /// + /// In fr, this message translates to: + /// **'Véhicule rechargé sur le surplus photovoltaïque.'** + String get decisionEvSurplus; + + /// No description provided for @decisionEvSpotMarket. + /// + /// In fr, this message translates to: + /// **'Véhicule rechargé sur un créneau tarifaire favorable.'** + String get decisionEvSpotMarket; + + /// No description provided for @decisionEvDeadline. + /// + /// In fr, this message translates to: + /// **'Véhicule prioritaire : l\'échéance approche.'** + String get decisionEvDeadline; + + /// No description provided for @decisionEvEcoMin. + /// + /// In fr, this message translates to: + /// **'Pas de surplus : le courant minimum est maintenu.'** + String get decisionEvEcoMin; + + /// No description provided for @decisionEvIdle. + /// + /// In fr, this message translates to: + /// **'Pas de surplus : la recharge est suspendue.'** + String get decisionEvIdle; + + /// No description provided for @decisionLoadUnavailable. + /// + /// In fr, this message translates to: + /// **'Charge en défaut : aucune commande n\'est émise, et {frozen} W restent comptés au budget.'** + String decisionLoadUnavailable(int frozen); + + /// No description provided for @decisionLockMinOn. + /// + /// In fr, this message translates to: + /// **'Le maintien ON a relevé la consigne à {applied} W ; le budget en payait {budget} W.'** + String decisionLockMinOn(int applied, int budget); + + /// No description provided for @decisionLockMinOff. + /// + /// In fr, this message translates to: + /// **'Le maintien OFF interdit l\'enclenchement ; le budget payait pourtant {requested} W sur {budget} W disponibles.'** + String decisionLockMinOff(int requested, int budget); + + /// No description provided for @decisionLockMinOffCapped. + /// + /// In fr, this message translates to: + /// **'Le maintien OFF plafonne la consigne à {applied} W ; {requested} W étaient demandés sur {budget} W de budget.'** + String decisionLockMinOffCapped(int applied, int requested, int budget); + + /// No description provided for @decisionSurplusSetpoint. + /// + /// In fr, this message translates to: + /// **'Consigne de {setpoint} W servie par le surplus ({budget} W de budget).'** + String decisionSurplusSetpoint(int setpoint, int budget); + + /// No description provided for @decisionSurplusSetpointStepped. + /// + /// In fr, this message translates to: + /// **'Consigne de {setpoint} W servie par le surplus, arrondie sur un palier déclaré ({budget} W de budget).'** + String decisionSurplusSetpointStepped(int setpoint, int budget); + + /// No description provided for @decisionSurplusInsufficient. + /// + /// In fr, this message translates to: + /// **'Le budget de {budget} W ne payait aucun palier — aucun verrou n\'a mordu.'** + String decisionSurplusInsufficient(int budget); + + /// No description provided for @decisionLockMinStateHold. + /// + /// In fr, this message translates to: + /// **'État {state} maintenu par protection anti court-cycle.'** + String decisionLockMinStateHold(int state); + + /// No description provided for @decisionSgForced. + /// + /// In fr, this message translates to: + /// **'Pompe à chaleur en état 4 (forcé) — estimation {estimated} W sur {budget} W de budget.'** + String decisionSgForced(int estimated, int budget); + + /// No description provided for @decisionSgRecommended. + /// + /// In fr, this message translates to: + /// **'Pompe à chaleur en état 3 (recommandation) — estimation {estimated} W sur {budget} W de budget.'** + String decisionSgRecommended(int estimated, int budget); + + /// No description provided for @decisionSgNormal. + /// + /// In fr, this message translates to: + /// **'Pompe à chaleur en état 2 (normal, sans consigne) — {budget} W de budget.'** + String decisionSgNormal(int budget); + + /// No description provided for @decisionDegradedL2. + /// + /// In fr, this message translates to: + /// **'Consigne de repli : le compteur est muet et le watchdog a suspendu la planification.'** + String get decisionDegradedL2; + + /// No description provided for @decisionSafeStateRelay. + /// + /// In fr, this message translates to: + /// **'État sûr avant retrait : tous les relais sont ouverts.'** + String get decisionSafeStateRelay; + + /// No description provided for @decisionSafeStateSetpoint. + /// + /// In fr, this message translates to: + /// **'État sûr avant retrait : consigne ramenée à 0 W.'** + String get decisionSafeStateSetpoint; + + /// No description provided for @decisionSafeStateSgReady. + /// + /// In fr, this message translates to: + /// **'État sûr avant retrait : état 2 (normal), jamais le blocage.'** + String get decisionSafeStateSgReady; + + /// No description provided for @faultWriteFailed. + /// + /// In fr, this message translates to: + /// **'Échec d\'écriture : l\'échelle de commande est épuisée, plus aucune commande n\'est émise tant que le défaut n\'est pas levé.'** + String get faultWriteFailed; + + /// No description provided for @faultThingMissing. + /// + /// In fr, this message translates to: + /// **'L\'appareil commandé est absent de la configuration nymea.'** + String get faultThingMissing; + + /// No description provided for @faultUnusableEncoding. + /// + /// In fr, this message translates to: + /// **'Encodage SG-Ready inexploitable : l\'état 2 ne peut pas être exprimé, cette pompe à chaleur n\'est pas pilotable.'** + String get faultUnusableEncoding; + + /// No description provided for @faultClearPending. + /// + /// In fr, this message translates to: + /// **'Levée demandée — en attente de la télémétrie…'** + String get faultClearPending; + + /// No description provided for @faultClearStillFaulty. + /// + /// In fr, this message translates to: + /// **'La charge est toujours en défaut après la levée.'** + String get faultClearStillFaulty; + + /// No description provided for @faultClearRejected. + /// + /// In fr, this message translates to: + /// **'La box a refusé la levée : {reason}'** + String faultClearRejected(String reason); + + /// No description provided for @faultClearHint. + /// + /// In fr, this message translates to: + /// **'La levée est une action délibérée : le défaut ne se lève jamais tout seul.'** + String get faultClearHint; + + /// No description provided for @domainBattery. + /// + /// In fr, this message translates to: + /// **'Batterie'** + String get domainBattery; + + /// No description provided for @domainAssignTitle. + /// + /// In fr, this message translates to: + /// **'Classer « {label} »'** + String domainAssignTitle(String label); + + /// No description provided for @domainAssignAction. + /// + /// In fr, this message translates to: + /// **'Changer de domaine'** + String get domainAssignAction; + + /// No description provided for @domainAssignNone. + /// + /// In fr, this message translates to: + /// **'Retirer du classement'** + String get domainAssignNone; + + /// No description provided for @domainAssignHint. + /// + /// In fr, this message translates to: + /// **'Le domaine est une intention déclarée par l\'installateur. L\'arbitre ne la lit pas : elle ne sert qu\'à grouper l\'ordre de service.'** + String get domainAssignHint; + /// No description provided for @localeRequested. /// /// In fr, this message translates to: diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index f6a2399..78aac1a 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -189,6 +189,331 @@ class L10nFr extends L10n { return 'Décision « $code »$params'; } + @override + String get telemetryTitle => 'Arbitrage en direct'; + + @override + String get telemetryNeverRan => + 'Aucun cycle d\'arbitrage depuis le démarrage du service.'; + + @override + String telemetryLastCycle(int seconds) { + return 'Dernier cycle il y a $seconds s'; + } + + @override + String get telemetryBudgetTitle => 'Budget de surplus du cycle'; + + @override + String get telemetryBudgetAbsent => + 'Aucun budget publié pour ce cycle : la planification est suspendue.'; + + @override + String get budgetSurplus => 'Surplus entrant'; + + @override + String get budgetEvReserved => 'Réservé au véhicule'; + + @override + String get budgetRecredited => 'Recrédité'; + + @override + String get budgetAllocated => 'Alloué'; + + @override + String get budgetRemaining => 'Reliquat'; + + @override + String watts(int watts) { + final intl.NumberFormat wattsNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String wattsString = wattsNumberFormat.format(watts); + + return '$wattsString W'; + } + + @override + String telemetryAllocated(int watts) { + final intl.NumberFormat wattsNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String wattsString = wattsNumberFormat.format(watts); + + return 'Alloué $wattsString W'; + } + + @override + String get telemetryNotArbitrated => + 'Non arbitrée — absente du dernier cycle publié'; + + @override + String lockMinOn(int seconds) { + return 'Maintien ON — $seconds s restantes'; + } + + @override + String lockMinOff(int seconds) { + return 'Maintien OFF — $seconds s restantes'; + } + + @override + String lockMinStateHold(int seconds) { + return 'Maintien d\'état — $seconds s restantes'; + } + + @override + String lockUnknown(String kind, int seconds) { + return 'Verrou « $kind » — $seconds s restantes'; + } + + @override + String get lockAsOfCycle => 'au dernier cycle'; + + @override + String mechanismRelayStage(int stage, int max) { + final intl.NumberFormat stageNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String stageString = stageNumberFormat.format(stage); + final intl.NumberFormat maxNumberFormat = intl.NumberFormat.decimalPattern( + localeName, + ); + final String maxString = maxNumberFormat.format(max); + + return 'Palier $stageString W sur $maxString W'; + } + + @override + String mechanismVariableSetpoint(int setpoint, int percent) { + final intl.NumberFormat setpointNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String setpointString = setpointNumberFormat.format(setpoint); + + return 'Consigne $setpointString W ($percent %)'; + } + + @override + String mechanismSgReadyState(int state, int estimated) { + final intl.NumberFormat estimatedNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String estimatedString = estimatedNumberFormat.format(estimated); + + return 'État SG-Ready $state — estimation $estimatedString W'; + } + + @override + String mechanismEvCharger(double current, int phases) { + final intl.NumberFormat currentNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String currentString = currentNumberFormat.format(current); + + return '$currentString A sur $phases phase(s)'; + } + + @override + String mechanismUnknown(String kind) { + return 'Mécanisme « $kind »'; + } + + @override + String get decisionEvSurplus => + 'Véhicule rechargé sur le surplus photovoltaïque.'; + + @override + String get decisionEvSpotMarket => + 'Véhicule rechargé sur un créneau tarifaire favorable.'; + + @override + String get decisionEvDeadline => + 'Véhicule prioritaire : l\'échéance approche.'; + + @override + String get decisionEvEcoMin => + 'Pas de surplus : le courant minimum est maintenu.'; + + @override + String get decisionEvIdle => 'Pas de surplus : la recharge est suspendue.'; + + @override + String decisionLoadUnavailable(int frozen) { + final intl.NumberFormat frozenNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String frozenString = frozenNumberFormat.format(frozen); + + return 'Charge en défaut : aucune commande n\'est émise, et $frozenString W restent comptés au budget.'; + } + + @override + String decisionLockMinOn(int applied, int budget) { + final intl.NumberFormat appliedNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String appliedString = appliedNumberFormat.format(applied); + final intl.NumberFormat budgetNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String budgetString = budgetNumberFormat.format(budget); + + return 'Le maintien ON a relevé la consigne à $appliedString W ; le budget en payait $budgetString W.'; + } + + @override + String decisionLockMinOff(int requested, int budget) { + final intl.NumberFormat requestedNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String requestedString = requestedNumberFormat.format(requested); + final intl.NumberFormat budgetNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String budgetString = budgetNumberFormat.format(budget); + + return 'Le maintien OFF interdit l\'enclenchement ; le budget payait pourtant $requestedString W sur $budgetString W disponibles.'; + } + + @override + String decisionLockMinOffCapped(int applied, int requested, int budget) { + final intl.NumberFormat appliedNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String appliedString = appliedNumberFormat.format(applied); + final intl.NumberFormat requestedNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String requestedString = requestedNumberFormat.format(requested); + final intl.NumberFormat budgetNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String budgetString = budgetNumberFormat.format(budget); + + return 'Le maintien OFF plafonne la consigne à $appliedString W ; $requestedString W étaient demandés sur $budgetString W de budget.'; + } + + @override + String decisionSurplusSetpoint(int setpoint, int budget) { + final intl.NumberFormat setpointNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String setpointString = setpointNumberFormat.format(setpoint); + final intl.NumberFormat budgetNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String budgetString = budgetNumberFormat.format(budget); + + return 'Consigne de $setpointString W servie par le surplus ($budgetString W de budget).'; + } + + @override + String decisionSurplusSetpointStepped(int setpoint, int budget) { + final intl.NumberFormat setpointNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String setpointString = setpointNumberFormat.format(setpoint); + final intl.NumberFormat budgetNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String budgetString = budgetNumberFormat.format(budget); + + return 'Consigne de $setpointString W servie par le surplus, arrondie sur un palier déclaré ($budgetString W de budget).'; + } + + @override + String decisionSurplusInsufficient(int budget) { + final intl.NumberFormat budgetNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String budgetString = budgetNumberFormat.format(budget); + + return 'Le budget de $budgetString W ne payait aucun palier — aucun verrou n\'a mordu.'; + } + + @override + String decisionLockMinStateHold(int state) { + return 'État $state maintenu par protection anti court-cycle.'; + } + + @override + String decisionSgForced(int estimated, int budget) { + final intl.NumberFormat estimatedNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String estimatedString = estimatedNumberFormat.format(estimated); + final intl.NumberFormat budgetNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String budgetString = budgetNumberFormat.format(budget); + + return 'Pompe à chaleur en état 4 (forcé) — estimation $estimatedString W sur $budgetString W de budget.'; + } + + @override + String decisionSgRecommended(int estimated, int budget) { + final intl.NumberFormat estimatedNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String estimatedString = estimatedNumberFormat.format(estimated); + final intl.NumberFormat budgetNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String budgetString = budgetNumberFormat.format(budget); + + return 'Pompe à chaleur en état 3 (recommandation) — estimation $estimatedString W sur $budgetString W de budget.'; + } + + @override + String decisionSgNormal(int budget) { + final intl.NumberFormat budgetNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String budgetString = budgetNumberFormat.format(budget); + + return 'Pompe à chaleur en état 2 (normal, sans consigne) — $budgetString W de budget.'; + } + + @override + String get decisionDegradedL2 => + 'Consigne de repli : le compteur est muet et le watchdog a suspendu la planification.'; + + @override + String get decisionSafeStateRelay => + 'État sûr avant retrait : tous les relais sont ouverts.'; + + @override + String get decisionSafeStateSetpoint => + 'État sûr avant retrait : consigne ramenée à 0 W.'; + + @override + String get decisionSafeStateSgReady => + 'État sûr avant retrait : état 2 (normal), jamais le blocage.'; + + @override + String get faultWriteFailed => + 'Échec d\'écriture : l\'échelle de commande est épuisée, plus aucune commande n\'est émise tant que le défaut n\'est pas levé.'; + + @override + String get faultThingMissing => + 'L\'appareil commandé est absent de la configuration nymea.'; + + @override + String get faultUnusableEncoding => + 'Encodage SG-Ready inexploitable : l\'état 2 ne peut pas être exprimé, cette pompe à chaleur n\'est pas pilotable.'; + + @override + String get faultClearPending => + 'Levée demandée — en attente de la télémétrie…'; + + @override + String get faultClearStillFaulty => + 'La charge est toujours en défaut après la levée.'; + + @override + String faultClearRejected(String reason) { + return 'La box a refusé la levée : $reason'; + } + + @override + String get faultClearHint => + 'La levée est une action délibérée : le défaut ne se lève jamais tout seul.'; + + @override + String get domainBattery => 'Batterie'; + + @override + String domainAssignTitle(String label) { + return 'Classer « $label »'; + } + + @override + String get domainAssignAction => 'Changer de domaine'; + + @override + String get domainAssignNone => 'Retirer du classement'; + + @override + String get domainAssignHint => + 'Le domaine est une intention déclarée par l\'installateur. L\'arbitre ne la lit pas : elle ne sert qu\'à grouper l\'ordre de service.'; + @override String localeRequested(String requested, String effective) { return 'Locale demandée : $requested · retenue par la box : $effective'; diff --git a/lib/main.dart b/lib/main.dart index b49b698..4dfd647 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,6 +8,7 @@ import 'providers/app_settings_provider.dart'; import 'providers/energy_setup_provider.dart'; 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/scheduler_provider.dart'; import 'providers/tariff_provider.dart'; @@ -186,7 +187,9 @@ void main() async { final scheduler = SchedulerProvider(); final tariff = TariffProvider(); final loadConfig = LoadConfigProvider(); + final loadTelemetry = LoadTelemetryProvider(); connectionManager.registerCacheClear(loadConfig.clearForSwitch); + connectionManager.registerCacheClear(loadTelemetry.clearForSwitch); connectionManager.registerCacheClear(energySetup.clearForSwitch); connectionManager.registerCacheClear(scheduler.clearForSwitch); connectionManager.registerCacheClear(tariff.clearForSwitch); @@ -208,6 +211,7 @@ void main() async { ChangeNotifierProvider.value(value: scheduler), ChangeNotifierProvider.value(value: tariff), ChangeNotifierProvider.value(value: loadConfig), + ChangeNotifierProvider.value(value: loadTelemetry), ], child: NymeaEnergyApp( router: buildAppRouter(connectionManager, nymeaService, installerMode), diff --git a/lib/models/load_telemetry.dart b/lib/models/load_telemetry.dart new file mode 100644 index 0000000..8ebd875 --- /dev/null +++ b/lib/models/load_telemetry.dart @@ -0,0 +1,302 @@ +/// Vue **en lecture** au-dessus de la charge utile de `NymeaEnergy.GetLoadTelemetry`. +/// +/// ## Ce que ce type n'est pas +/// +/// Ce n'est **pas** un état que l'app entretient. C'est l'instantané du dernier cycle +/// d'arbitrage **publié par la box**, transporté tel quel. Rien n'y est extrapolé entre +/// deux trames : ni les secondes restantes d'un verrou (elles décroissent sur la box, et +/// le plugin les exclut explicitement de sa détection de changement), ni une allocation +/// qui « devrait » avoir bougé. Une valeur affichée ici a été dite par la box. +/// +/// ## Omis, jamais nul +/// +/// Trois champs sont optionnels au schéma, et leur **absence porte un sens** que zéro ne +/// porterait pas : +/// +/// - `timestamp` absent = **aucun cycle d'arbitrage depuis le démarrage du service**. En +/// forger un se lirait « cycle exécuté, rien à allouer ». +/// - `budget` absent = **mode dégradé L2** : la planification est suspendue, il n'existe +/// aucun budget pour ce cycle. Publier des zéros se lirait « arbitrage exécuté, rien à +/// allouer ». +/// - `mechanism` absent = la charge n'a pas publié de charge utile de mécanisme ce +/// cycle-là. +/// +/// D'où l'usage systématique de types nullables plutôt que de valeurs par défaut. +/// +/// ## Codes, jamais des phrases +/// +/// `decision.code` et `faultCode` sont des **codes à catalogue fermé** accompagnés de +/// paramètres numériques. La box ne compose aucune phrase : la locale nymea est par +/// connexion, et `NymeaEnergyJsonHandler` est un `JsonHandler` sans `pluginId`, donc +/// structurellement hors du catalogue de traduction. Le rendu est à la charge de l'app +/// (`services/telemetry_text.dart`). +library; + +/// Seuil de fraîcheur — au-delà, l'instantané est présenté comme **ancien**. +/// +/// Calibré sur le battement de cœur du plugin : `LoadTelemetryChanged` est émise à +/// chaque changement significatif **et** périodiquement toutes les 60 s même sans +/// changement, et le `timestamp` avance d'un cycle par minute (vérifié sur `.75` : +/// 12:37 → 12:38 → 12:39). Un seuil ≤ 60 s crierait donc « données anciennes » sur une +/// installation parfaitement vivante, au premier battement un peu tardif. +/// +/// Trois cycles laissent passer un battement manqué et un aller-retour réseau lent, sans +/// laisser un arbitre réellement figé passer pour une installation stable plus de trois +/// minutes. +const Duration kTelemetryStaleAfter = Duration(seconds: 180); + +/// Décomposition du budget de surplus du cycle. Absent en mode dégradé. +class TelemetryBudget { + /// Budget entrant du waterfall non-EV : net signé − réservation EV. + final double surplusW; + + /// Puissance EV commandée ce cycle, pas encore vue au compteur. + final double evReservedW; + + /// Recrédits anti-clignotement rendus aux charges avant arrondi. + final double recreditedW; + + /// Somme des allocations du cycle. + final double allocatedW; + + /// Résidu de fin de cascade. + final double remainingW; + + const TelemetryBudget({ + required this.surplusW, + required this.evReservedW, + required this.recreditedW, + required this.allocatedW, + required this.remainingW, + }); + + factory TelemetryBudget.fromJson(Map m) => TelemetryBudget( + surplusW: _d(m['surplusW']), + evReservedW: _d(m['evReservedW']), + recreditedW: _d(m['recreditedW']), + allocatedW: _d(m['allocatedW']), + remainingW: _d(m['remainingW']), + ); + + /// Identité que le plugin déclare vérifiable : + /// `surplusW + recreditedW − allocatedW == remainingW`. + /// + /// `recreditedW` n'est pas décoratif : le waterfall rend à chaque charge sa propre + /// consommation de début de cycle avant d'arrondir. Sans ce terme, la somme serait + /// fausse dès qu'une charge consomme déjà — c'est-à-dire dans le cas normal. + /// + /// Tolérance 1 W : les valeurs voyagent en `Double`. + bool get identityHolds => + (surplusW + recreditedW - allocatedW - remainingW).abs() < 1.0; +} + +/// Verrou actif sur une charge. Absent quand aucun verrou ne mord. +class LoadLock { + /// `minOn` · `minOff` · `minStateHold`. + final String kind; + + /// Secondes restantes **au moment du cycle publié** — jamais décomptées côté app. + final int remainingS; + + const LoadLock({required this.kind, required this.remainingS}); + + factory LoadLock.fromJson(Map m) => LoadLock( + kind: m['kind'] as String? ?? '', + remainingS: (m['remainingS'] as num?)?.toInt() ?? 0, + ); +} + +/// Union discriminée par `kind` — chaque variante porte sa charge utile, et seulement la +/// sienne. Les clés sont toutes optionnelles au schéma (règle LM-302 du plugin) : une +/// variante ajoutée par une box plus récente ne doit pas faire échouer la lecture. +class LoadMechanism { + final Map raw; + + /// `relay` · `variable` · `sgReady` · `evcharger` (ce dernier réservé à l'étape 3g, + /// non émis en beta). + final String kind; + + const LoadMechanism(this.raw, this.kind); + + factory LoadMechanism.fromJson(Map m) => + LoadMechanism(m, m['kind'] as String? ?? ''); + + bool get isRelay => kind == 'relay'; + bool get isVariable => kind == 'variable'; + bool get isSgReady => kind == 'sgReady'; + bool get isEvCharger => kind == 'evcharger'; + + // relay + double? get stageW => _dn(raw['stageW']); + double? get maxStageW => _dn(raw['maxStageW']); + + // variable + double? get setpointW => _dn(raw['setpointW']); + double? get percent => _dn(raw['percent']); + double? get maxPowerW => _dn(raw['maxPowerW']); + + // sgReady — `estimatedPowerW` est une ESTIMATION déclarée, jamais un engagement. + int? get state => (raw['state'] as num?)?.toInt(); + double? get estimatedPowerW => _dn(raw['estimatedPowerW']); + + // evcharger (3g) + bool? get chargingEnabled => raw['chargingEnabled'] as bool?; + bool? get pluggedIn => raw['pluggedIn'] as bool?; + double? get currentA => _dn(raw['currentA']); + int? get phaseCount => (raw['phaseCount'] as num?)?.toInt(); +} + +/// Motif de décision : un code du catalogue fermé, et ses paramètres numériques. +/// +/// `params` ne contient que des nombres et des booléens ; le libellé de la charge n'y +/// figure jamais — le client le résout via `GetLoadConfig`. +class LoadDecision { + final String code; + final Map params; + + const LoadDecision({required this.code, required this.params}); + + factory LoadDecision.fromJson(Map? m) { + if (m == null) return const LoadDecision(code: '', params: {}); + final p = m['params']; + return LoadDecision( + code: m['code'] as String? ?? '', + params: p is Map ? Map.from(p) : const {}, + ); + } + + double? numParam(String key) => _dn(params[key]); + bool? boolParam(String key) => params[key] as bool?; +} + +/// État runtime d'**une** charge arbitrée par le waterfall. +class LoadTelemetryEntry { + final Map raw; + final String loadId; + + /// Ce que le waterfall a **alloué** — pas une mesure de compteur. + final double allocatedW; + + /// Faux = charge en défaut : aucune commande n'est émise, et le verrou ne se lève pas + /// tout seul (`ClearLoadFault` est une action délibérée d'exploitant). + final bool available; + + /// Absent quand la charge est saine. + final String? faultCode; + + final LoadLock? lock; + final LoadMechanism? mechanism; + final LoadDecision decision; + + const LoadTelemetryEntry({ + required this.raw, + required this.loadId, + required this.allocatedW, + required this.available, + required this.faultCode, + required this.lock, + required this.mechanism, + required this.decision, + }); + + factory LoadTelemetryEntry.fromJson(Map m) { + final lock = m['lock']; + final mech = m['mechanism']; + final fault = m['faultCode']; + return LoadTelemetryEntry( + raw: m, + loadId: m['loadId'] as String? ?? '', + allocatedW: _d(m['allocatedW']), + // Absent au schéma ne peut pas arriver (champ requis) ; en cas de box exotique, + // « disponible » est le repli qui n'invente pas un défaut. + available: m['available'] as bool? ?? true, + faultCode: (fault is String && fault.isNotEmpty) ? fault : null, + lock: lock is Map ? LoadLock.fromJson(Map.from(lock)) : null, + mechanism: mech is Map + ? LoadMechanism.fromJson(Map.from(mech)) + : null, + decision: LoadDecision.fromJson( + m['decision'] is Map ? Map.from(m['decision'] as Map) : null), + ); + } + + bool get inFault => !available; +} + +/// Instantané complet de l'arbitrage. +class LoadTelemetry { + final Map raw; + + /// `true` = watchdog L2 en repli (compteur muet > 90 s, planification suspendue). + final bool degradedMode; + + /// Fin du **dernier cycle d'arbitrage**. `null` = aucun cycle depuis le démarrage du + /// service : c'est la preuve de fraîcheur, et son absence est une information. + final DateTime? timestamp; + + /// `null` en mode dégradé — il n'existe alors aucun budget pour ce cycle. + final TelemetryBudget? budget; + + /// Uniquement les charges **arbitrées par le waterfall** (`relay-router`, + /// `etmvariableload`, `sg-ready`). Le VE n'y figure pas en beta : il est décidé en + /// amont et sa puissance est réservée dans `budget.evReservedW`, pas allouée depuis le + /// budget publié. + /// + /// Depuis le lot plugin B-bis, tout `loadId` publié ici a une entrée dans + /// `GetLoadConfig` — l'inverse n'est pas vrai : une charge configurée absente d'ici est + /// **désactivée**, jamais perdue. + final List loads; + + const LoadTelemetry({ + required this.raw, + required this.degradedMode, + required this.timestamp, + required this.budget, + required this.loads, + }); + + factory LoadTelemetry.fromJson(Map m) { + final ts = m['timestamp']; + final budget = m['budget']; + final loads = m['loads']; + return LoadTelemetry( + raw: m, + degradedMode: m['degradedMode'] as bool? ?? false, + timestamp: (ts is String && ts.isNotEmpty) ? DateTime.tryParse(ts) : null, + budget: budget is Map + ? TelemetryBudget.fromJson(Map.from(budget)) + : null, + loads: loads is List + ? [ + for (final l in loads.whereType()) + LoadTelemetryEntry.fromJson(Map.from(l)), + ] + : const [], + ); + } + + /// `true` tant qu'aucun cycle d'arbitrage n'a eu lieu depuis le démarrage du service. + bool get hasNeverRun => timestamp == null; + + LoadTelemetryEntry? entryFor(String loadId) { + for (final e in loads) { + if (e.loadId == loadId) return e; + } + return null; + } + + /// Âge du dernier cycle publié, mesuré contre l'horloge locale. + /// + /// `null` si aucun cycle n'a eu lieu. **Négatif ramené à zéro** : l'horloge de la box + /// et celle du téléphone ne sont pas synchronisées, et un âge négatif se lirait comme + /// une donnée du futur. + Duration? ageAt(DateTime now) { + final ts = timestamp; + if (ts == null) return null; + final d = now.toUtc().difference(ts.toUtc()); + return d.isNegative ? Duration.zero : d; + } +} + +double _d(Object? v) => (v as num?)?.toDouble() ?? 0.0; +double? _dn(Object? v) => (v as num?)?.toDouble(); diff --git a/lib/providers/load_config_provider.dart b/lib/providers/load_config_provider.dart index 5e0328a..478a806 100644 --- a/lib/providers/load_config_provider.dart +++ b/lib/providers/load_config_provider.dart @@ -104,6 +104,16 @@ class LoadConfigProvider extends ChangeNotifier { _conflict = null; } + /// Injecte un état de box sans transport — **tests uniquement**. + /// + /// Le chemin normal est `load()` ou la notification `LoadConfigChanged` ; cette porte + /// existe pour éprouver l'édition sans monter un faux WebSocket. + @visibleForTesting + void adoptForTest(List> raw) { + _adopt(raw); + notifyListeners(); + } + // ── Édition (brouillon, aucune écriture) ─────────────────────────────────── /// Déplace une charge à l'intérieur de son domaine. @@ -132,6 +142,44 @@ class LoadConfigProvider extends ChangeNotifier { notifyListeners(); } + /// Classe une charge dans un domaine — ou l'en retire (`domain` vide). + /// + /// Modifie le **brouillon** : rien ne part avant [save]. La charge quitte son groupe + /// et rejoint la fin du groupe cible, parce que l'insérer au milieu déciderait à la + /// place de l'installateur d'un rang qu'il n'a pas choisi. + /// + /// Le domaine est une **intention déclarée**, pure métadonnée : l'arbitre ne la lit + /// pas. Elle ne peut donc jamais être déduite du mécanisme — un relais pilote un + /// chauffe-eau comme une prise commandée de recharge. + void assignDomain(String loadId, String domain) { + final current = [for (final g in groups) g.copyWith(loads: [...g.loads])]; + + LoadConfigEntry? moved; + for (final g in current) { + final i = g.loads.indexWhere((e) => e.id == loadId); + if (i >= 0) { + moved = g.loads.removeAt(i); + break; + } + } + if (moved == null) return; + if ((moved.domain ?? kUnclassifiedDomain) == domain) return; + + // La charge utile repart de la map d'origine : seule la clé `domain` est réécrite. + final patched = LoadConfigEntry(moved.patched(domain: domain)); + + final gi = current.indexWhere((g) => g.domain == domain); + if (gi >= 0) { + current[gi] = current[gi].copyWith(loads: [...current[gi].loads, patched]); + } else { + current.add(DomainGroup(domain: domain, loads: [patched])); + } + + current.removeWhere((g) => g.loads.isEmpty); + _draft = current; + notifyListeners(); + } + /// Prépare une normalisation — **ne l'écrit pas**. L'installateur doit confirmer. void prepareNormalization() { final g = _grouped; diff --git a/lib/providers/load_telemetry_provider.dart b/lib/providers/load_telemetry_provider.dart new file mode 100644 index 0000000..0181782 --- /dev/null +++ b/lib/providers/load_telemetry_provider.dart @@ -0,0 +1,304 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import '../models/load_telemetry.dart'; +import '../services/nymea_service.dart'; + +/// Où en est une levée de défaut demandée par l'exploitant. +enum FaultClearState { + /// Rien en cours. + idle, + + /// `ClearLoadFault` acquitté — **rien n'est encore prouvé**. On attend que la + /// télémétrie relue montre `available: true`. + pending, + + /// Constaté sur la télémétrie : la charge est redevenue disponible. + cleared, + + /// Deux cycles d'arbitrage ont passé et la charge est toujours en défaut. + stillFaulty, + + /// La box a refusé l'appel (`loadId` inconnu, transport). + rejected, +} + +/// Télémétrie d'arbitrage — instantané, mise à jour poussée, fraîcheur, levée de défaut. +/// +/// ## Aucun état entretenu localement +/// +/// Ce provider ne calcule pas d'arbitrage, ne décompte pas les verrous, ne reconstruit +/// rien depuis `Integrations.*`. Il transporte ce que la box publie. Le palier d'une +/// charge relais serait reconstructible depuis l'état `power` des Things × les puissances +/// de `LoadConfig` — mais cette reconstruction ne dit ni pourquoi, ni si un verrou agit, +/// ni si la charge est en défaut, et elle diverge de la vérité au premier écart. +/// +/// ## Fraîcheur — trois signaux, pas un +/// +/// Un arbitre figé ne doit pas ressembler à une installation stable. Trois indices +/// concourent, et aucun ne suffit seul : +/// +/// 1. **L'âge du `timestamp`** — le seul qui vaille à l'instant du premier instantané : +/// un `Get` rend le dernier cycle **publié**, qui peut dater d'une heure. +/// 2. **Le temps depuis la dernière trame reçue** — le battement de cœur du plugin est de +/// 60 s ; son silence est un signal, et il est immunisé contre l'écart d'horloge entre +/// la box et le téléphone. +/// 3. **Le temps depuis que le `timestamp` a cessé d'avancer** — c'est le cas que le +/// battement de cœur existe pour rendre détectable : les trames continuent d'arriver +/// tandis que le moteur ne tourne plus. +class LoadTelemetryProvider extends ChangeNotifier { + NymeaService? _service; + void Function()? _unsubscribe; + + LoadTelemetry? _telemetry; + bool _supported = true; + bool _loading = false; + String? _error; + + /// Horloge **locale** de réception de la dernière trame (instantané ou notification). + DateTime? _lastFrameAt; + + /// Horloge locale à laquelle le `timestamp` courant a été vu pour la première fois. + DateTime? _cycleFirstSeenAt; + DateTime? _seenCycle; + + Timer? _ticker; + + final Map _clearStates = {}; + final Map _clearErrors = {}; + final Map _clearDeadlines = {}; + final Map _clearPolls = {}; + + /// Deux cycles d'arbitrage plus une marge : au-delà, une charge toujours en défaut + /// l'est vraiment. Conclure plus tôt reprocherait à la box de n'avoir pas encore + /// exécuté le cycle suivant. + static const Duration _clearDeadline = Duration(seconds: 130); + static const Duration _clearPollInterval = Duration(seconds: 15); + + // ── Lecture ──────────────────────────────────────────────────────────────── + LoadTelemetry? get telemetry => _telemetry; + bool get loading => _loading; + String? get error => _error; + + /// Faux quand **cette box** n'expose pas `GetLoadTelemetry` (plugin antérieur au lot + /// télémétrie). Ce n'est pas une panne : c'est une version, et elle s'annonce comme + /// telle. + bool get supported => _supported; + + /// Vrai tant qu'aucun cycle d'arbitrage n'a eu lieu depuis le démarrage du service. + bool get hasNeverRun => _telemetry?.hasNeverRun ?? false; + + /// Vrai en repli L2 : compteur muet, planification suspendue, aucun budget publié. + bool get degradedMode => _telemetry?.degradedMode ?? false; + + LoadTelemetryEntry? entryFor(String loadId) => _telemetry?.entryFor(loadId); + + FaultClearState clearStateOf(String loadId) => + _clearStates[loadId] ?? FaultClearState.idle; + + String? clearErrorOf(String loadId) => _clearErrors[loadId]; + + /// Âge du dernier cycle publié, tel que la box le date. Sensible à l'écart d'horloge — + /// d'où les deux autres signaux. + Duration? get cycleAge => _telemetry?.ageAt(DateTime.now()); + + /// Temps écoulé depuis la dernière trame reçue, horloge locale. + Duration? get sinceLastFrame => + _lastFrameAt == null ? null : DateTime.now().difference(_lastFrameAt!); + + /// Temps écoulé depuis que le `timestamp` a cessé d'avancer, horloge locale. + Duration? get sinceCycleAdvanced => _cycleFirstSeenAt == null + ? null + : DateTime.now().difference(_cycleFirstSeenAt!); + + /// Âge à afficher : le plus défavorable des signaux disponibles. Annoncer le plus + /// flatteur reviendrait à choisir la version rassurante d'une donnée douteuse. + Duration? get displayedAge { + final candidates = [cycleAge, sinceCycleAdvanced].nonNulls.toList(); + if (candidates.isEmpty) return null; + return candidates.reduce((a, b) => a > b ? a : b); + } + + /// Vrai quand l'instantané ne peut plus être présenté comme l'état courant. + bool get stale { + if (_telemetry == null || hasNeverRun) return false; + final signals = [cycleAge, sinceLastFrame, sinceCycleAdvanced].nonNulls; + return signals.any((d) => d > kTelemetryStaleAfter); + } + + // ── Câblage ──────────────────────────────────────────────────────────────── + void attach(NymeaService service) { + if (identical(_service, service)) return; + _unsubscribe?.call(); + _service = service; + _unsubscribe = service.onLoadTelemetryChanged(_onFrame); + } + + /// Instantané au chargement de l'écran. La suite arrive en notification. + Future load() async { + final service = _service; + if (service == null) return; + _loading = true; + _error = null; + notifyListeners(); + try { + final payload = await service.getLoadTelemetry(); + if (payload == null) { + // Distinguer les deux « pas de données » : une box sans la méthode n'est pas + // une box en panne. + _supported = service.loadTelemetrySupported; + _telemetry = null; + } else { + _supported = true; + _adopt(payload); + } + } catch (e) { + _error = e.toString(); + } finally { + _loading = false; + _startTicker(); + notifyListeners(); + } + } + + void _onFrame(Map payload) { + _supported = true; + _adopt(payload); + _startTicker(); + notifyListeners(); + } + + void _adopt(Map payload) { + final t = LoadTelemetry.fromJson(payload); + final now = DateTime.now(); + _telemetry = t; + _lastFrameAt = now; + _error = null; + + // Le cycle a-t-il avancé ? C'est cette question, et non l'arrivée d'une trame, qui + // dit si le moteur tourne encore. + if (t.timestamp != _seenCycle) { + _seenCycle = t.timestamp; + _cycleFirstSeenAt = now; + } + + _evaluatePendingClears(); + } + + // ── Fraîcheur : faire vieillir l'affichage sans nouvelle trame ───────────── + void _startTicker() { + _ticker ??= Timer.periodic(const Duration(seconds: 5), (_) { + if (_telemetry == null) return; + notifyListeners(); + }); + } + + // ── Levée de défaut ──────────────────────────────────────────────────────── + + /// Demande la levée du verrou de défaut d'une charge. + /// + /// **Le résultat n'est jamais annoncé sur le retour du RPC.** Vérifié sur `.75` : + /// appelée sur une charge saine, `ClearLoadFault` répond `EnergyErrorNoError`. Le seul + /// constat recevable est `available` repassé à vrai dans la télémétrie relue — d'où + /// l'état [FaultClearState.pending] maintenu jusqu'à la trame qui le montre. + Future clearFault(String loadId) async { + final service = _service; + if (service == null) return; + final entry = entryFor(loadId); + // Le bouton n'est proposé que sur une charge en défaut ; cette garde couvre la + // course où la charge se relève entre l'affichage et le geste. + if (entry == null || entry.available) return; + + _clearStates[loadId] = FaultClearState.pending; + _clearErrors.remove(loadId); + notifyListeners(); + + final err = await service.clearLoadFault(loadId); + if (err != null) { + _clearStates[loadId] = FaultClearState.rejected; + _clearErrors[loadId] = err; + notifyListeners(); + return; + } + + // Relecture périodique en plus des notifications : si le poussé ne fonctionne pas, + // l'exploitant doit quand même obtenir un verdict, jamais un sablier éternel. + _clearPolls[loadId]?.cancel(); + _clearPolls[loadId] = Timer.periodic(_clearPollInterval, (_) => load()); + + _clearDeadlines[loadId]?.cancel(); + _clearDeadlines[loadId] = Timer(_clearDeadline, () { + if (_clearStates[loadId] != FaultClearState.pending) return; + _clearStates[loadId] = FaultClearState.stillFaulty; + _stopClearWatch(loadId); + notifyListeners(); + }); + } + + void _evaluatePendingClears() { + for (final loadId in _clearStates.keys.toList()) { + if (_clearStates[loadId] != FaultClearState.pending) continue; + final entry = entryFor(loadId); + // Charge disparue de `loads[]` : elle a été désactivée entre-temps. Ce n'est ni + // une levée ni un échec — on cesse d'attendre plutôt que de trancher à sa place. + if (entry == null) { + _clearStates[loadId] = FaultClearState.idle; + _stopClearWatch(loadId); + continue; + } + if (entry.available) { + _clearStates[loadId] = FaultClearState.cleared; + _stopClearWatch(loadId); + } + } + } + + void _stopClearWatch(String loadId) { + _clearDeadlines.remove(loadId)?.cancel(); + _clearPolls.remove(loadId)?.cancel(); + } + + /// Efface un verdict affiché (l'exploitant a pris connaissance du résultat). + void acknowledgeClear(String loadId) { + _clearStates.remove(loadId); + _clearErrors.remove(loadId); + notifyListeners(); + } + + /// Vide l'état au switch d'installation — cette télémétrie appartient à la box quittée. + void clearForSwitch() { + _telemetry = null; + _error = null; + _lastFrameAt = null; + _cycleFirstSeenAt = null; + _seenCycle = null; + _supported = true; + for (final t in _clearDeadlines.values) { + t.cancel(); + } + for (final t in _clearPolls.values) { + t.cancel(); + } + _clearDeadlines.clear(); + _clearPolls.clear(); + _clearStates.clear(); + _clearErrors.clear(); + _ticker?.cancel(); + _ticker = null; + notifyListeners(); + } + + @override + void dispose() { + _ticker?.cancel(); + for (final t in _clearDeadlines.values) { + t.cancel(); + } + for (final t in _clearPolls.values) { + t.cancel(); + } + _unsubscribe?.call(); + super.dispose(); + } +} diff --git a/lib/screens/energy/load_order_screen.dart b/lib/screens/energy/load_order_screen.dart index d1b7e45..4c48138 100644 --- a/lib/screens/energy/load_order_screen.dart +++ b/lib/screens/energy/load_order_screen.dart @@ -4,15 +4,29 @@ 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}); @@ -25,9 +39,15 @@ class _LoadOrderScreenState extends State { void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { - final p = context.read(); - p.attach(context.read()); - p.load(); + final service = context.read(); + final config = context.read(); + final telemetry = context.read(); + config.attach(service); + telemetry.attach(service); + config.load(); + // Instantané au chargement ; la suite arrive en `LoadTelemetryChanged`, sans + // rechargement manuel. + telemetry.load(); }); } @@ -35,6 +55,7 @@ class _LoadOrderScreenState extends State { Widget build(BuildContext context) { final t = context.l10n; final p = context.watch(); + final tp = context.watch(); return Scaffold( backgroundColor: const Color(0xFFF0F2F5), @@ -46,7 +67,12 @@ class _LoadOrderScreenState extends State { actions: [ IconButton( icon: const Icon(Icons.refresh_rounded), - onPressed: p.loading ? null : () => p.load(), + onPressed: p.loading + ? null + : () { + p.load(); + tp.load(); + }, ), ], ), @@ -57,14 +83,15 @@ class _LoadOrderScreenState extends State { ? _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), + : _Body(provider: p, telemetry: tp), ); } } class _Body extends StatelessWidget { final LoadConfigProvider provider; - const _Body({required this.provider}); + final LoadTelemetryProvider telemetry; + const _Body({required this.provider, required this.telemetry}); @override Widget build(BuildContext context) { @@ -83,8 +110,11 @@ class _Body extends StatelessWidget { // ── Ordre non groupé : signaler, montrer le réel, ne rien écrire ──────── if (provider.isNotGrouped) _NotGroupedBanner(provider: provider), - // ── La télémétrie n'existe pas sur cette box ─────────────────────────── - const _TelemetryUnavailableCard(), + // ── État de l'arbitrage ──────────────────────────────────────────────── + if (!telemetry.supported) + const _TelemetryUnavailableCard() + else + _TelemetryHeader(telemetry: telemetry), for (var gi = 0; gi < groups.length; gi++) _DomainSection( @@ -93,6 +123,7 @@ class _Body extends StatelessWidget { index: gi, total: groups.length, provider: provider, + telemetry: telemetry, ), ], ); @@ -194,12 +225,141 @@ class _TelemetryUnavailableCard extends StatelessWidget { } } +// ── 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, @@ -207,19 +367,9 @@ class _DomainSection extends StatelessWidget { required this.index, required this.total, required this.provider, + required this.telemetry, }); - String _domainLabel(L10n t) => switch (group.domain) { - 'ev' => t.domainEv, - 'ecs' => t.domainEcs, - 'hvac' => t.domainHvac, - 'heating' => t.domainHeating, - kUnclassifiedDomain => t.sectionUnclassified, - // Domaine que cette version d'app ne connaît pas (box plus récente) : - // repli lisible, jamais une phrase vide. - _ => t.domainUnknown(group.domain), - }; - @override Widget build(BuildContext context) { final t = context.l10n; @@ -234,7 +384,7 @@ class _DomainSection extends StatelessWidget { child: Row(children: [ Expanded( child: Text( - _domainLabel(t), + domainLabel(t, group.domain), style: TextStyle( fontWeight: FontWeight.w700, fontSize: 14, @@ -270,6 +420,8 @@ class _DomainSection extends StatelessWidget { key: ValueKey('load-${group.loads[i].id}'), entry: group.loads[i], handleIndex: i, + provider: provider, + telemetry: telemetry, ), ), const SizedBox(height: 6), @@ -279,12 +431,32 @@ class _DomainSection extends StatelessWidget { } } +/// 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}); + 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, @@ -304,15 +476,23 @@ class _LoadTile extends StatelessWidget { 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: [ - ReorderableDragStartListener( - index: handleIndex, - child: const Padding( - padding: EdgeInsets.symmetric(horizontal: 8), - child: Icon(Icons.drag_handle_rounded, size: 22, color: Color(0xFF9CA3AF)), + 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( @@ -347,18 +527,250 @@ class _LoadTile extends StatelessWidget { // 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, + ), + ], ], ), ), ), - _Chip(text: t.loadPriority(entry.priority), color: const Color(0xFF2E75B6)), - const SizedBox(width: 8), + 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( + 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( + enabled: false, + child: Text(t.domainAssignTitle(entry.label), + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w700)), + ), + const PopupMenuDivider(), + for (final d in kAssignableDomains) + CheckedPopupMenuItem( + value: d, + checked: current == d, + child: Text(domainLabel(t, d)), + ), + const PopupMenuDivider(), + PopupMenuItem( + 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; diff --git a/lib/services/load_priority.dart b/lib/services/load_priority.dart index bcbcf8f..d9d8fe5 100644 --- a/lib/services/load_priority.dart +++ b/lib/services/load_priority.dart @@ -39,6 +39,19 @@ import '../models/load_config_entry.dart'; /// l'ordre de priorité**, pas un asservissement. const List kDefaultDomainOrder = ['ev', 'ecs', 'hvac', 'heating']; +/// Domaines que l'app a le droit de **proposer à l'affectation**. +/// +/// L'énumération est **fermée côté plugin** (`ecs` · `ev` · `heating` · `hvac` · +/// `battery`) et `SetLoadConfig` rejette en bloc toute valeur hors liste +/// (`EnergyErrorInvalidParameter`) : proposer un domaine inventé ferait échouer +/// l'enregistrement de **toutes** les charges, pas seulement de celle qu'on classe. +/// +/// Distinct de [kDefaultDomainOrder], qui n'est qu'un ordre de mise en service : la +/// batterie est classable, mais sa place dans l'ordre de service ne s'exprime pas en +/// watts. Un domaine lu depuis une box plus récente et absent d'ici s'affiche quand même +/// (repli `domainUnknown`) — il n'est simplement pas proposé au choix. +const List kAssignableDomains = ['ev', 'ecs', 'hvac', 'heating', 'battery']; + /// Clé du bucket des charges sans domaine. `null` dans les données ; cette sentinelle /// n'existe que pour l'affichage et le tri. const String kUnclassifiedDomain = ''; diff --git a/lib/services/nymea_service.dart b/lib/services/nymea_service.dart index b76e817..edf5f4a 100644 --- a/lib/services/nymea_service.dart +++ b/lib/services/nymea_service.dart @@ -651,6 +651,14 @@ class NymeaService extends ChangeNotifier { _things.removeWhere((t) => t.id == params['thingId']); notifyListeners(); break; + case 'NymeaEnergy.LoadTelemetryChanged': + // Émise à chaque changement SIGNIFICATIF **et** toutes les 60 s même sans + // changement (battement de cœur). C'est ce battement qui rend un arbitre figé + // détectable : les trames continuent d'arriver tandis que leur `timestamp` + // cesse d'avancer. Sans lui, « installation stable » et « moteur arrêté » + // seraient le même silence. + _onLoadTelemetryChanged(params); + break; case 'NymeaEnergy.LoadConfigChanged': // Émise après TOUTE persistance réussie (SetLoadConfig ou relecture du // fichier édité hors app). Porte la liste complète à jour : c'est LA @@ -1143,6 +1151,79 @@ class NymeaService extends ChangeNotifier { } } + /// `NymeaEnergy.GetLoadTelemetry` — instantané de l'état runtime de l'arbitrage. + /// + /// Le `Get` **ne recalcule rien** : il rend l'objet qui a été publié au dernier cycle. + /// La charge utile est identique à celle de la notification `LoadTelemetryChanged`. + /// + /// \return la charge utile brute, ou `null` si **cette box n'expose pas la méthode** + /// (plugin antérieur au lot télémétrie). Les deux cas se distinguent : une box sans la + /// méthode répond `status: error` / `"No such method"`, ce qui n'est pas une panne à + /// signaler mais une version à annoncer. Une erreur de transport, elle, remonte. + Future?> getLoadTelemetry() async { + if (_isSimulation || !_connected) return null; + final r = await _sendRequest('NymeaEnergy.GetLoadTelemetry', {}); + if (r['status'] != 'success') { + _loadTelemetrySupported = false; + _log('[Télémétrie] GetLoadTelemetry ← ${r['error'] ?? r['status']} ' + '— cette box n\'expose pas la méthode', force: true); + return null; + } + _loadTelemetrySupported = true; + final params = r['params']; + if (params is! Map) return null; + final out = Map.from(params); + _log('[Télémétrie] GetLoadTelemetry → ${(out['loads'] as List?)?.length ?? 0} ' + 'charge(s), cycle ${out['timestamp'] ?? 'AUCUN'}', force: true); + return out; + } + + /// Faux dès qu'un `GetLoadTelemetry` a été refusé par cette box. + bool _loadTelemetrySupported = true; + bool get loadTelemetrySupported => _loadTelemetrySupported; + + /// `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 + /// charge parfaitement saine, la méthode répond `EnergyErrorNoError`. Le seul constat + /// recevable est `available` repassé à vrai dans la télémétrie **relue**. + /// + /// \return `null` si la box a accepté, sinon le code d'erreur (`loadId` inconnu → + /// `EnergyErrorInvalidParameter`). + Future clearLoadFault(String loadId) async { + if (_isSimulation || !_connected) return 'Disconnected'; + try { + final r = await _sendRequest( + 'NymeaEnergy.ClearLoadFault', {'loadId': loadId}); + final err = r['params']?['energyError'] as String?; + _log('[Télémétrie] ClearLoadFault($loadId) ← ${err ?? r['status']} ' + '— ne vaut PAS levée : à constater sur la télémétrie', force: true); + if (err == null || err == 'EnergyErrorNoError') return null; + return err; + } catch (e) { + _log('[Télémétrie] ClearLoadFault: $e', force: true); + return e.toString(); + } + } + + /// Abonnés à `NymeaEnergy.LoadTelemetryChanged`. + final List)> _loadTelemetryListeners = []; + + /// S'abonne à la télémétrie poussée. Renvoie la fonction de désabonnement. + void Function() onLoadTelemetryChanged(void Function(Map) cb) { + _loadTelemetryListeners.add(cb); + return () => _loadTelemetryListeners.remove(cb); + } + + void _onLoadTelemetryChanged(Map payload) { + _log('[Télémétrie] ← LoadTelemetryChanged : cycle ' + '${payload['timestamp'] ?? 'AUCUN'}, ' + '${(payload['loads'] as List?)?.length ?? 0} charge(s)'); + for (final cb in [..._loadTelemetryListeners]) { + cb(payload); + } + } + /// Trace de routage (charges hors etmvariableload : PAC SG-Ready, batterie). void logInfo(String msg) => _log(msg, force: true); diff --git a/lib/services/telemetry_text.dart b/lib/services/telemetry_text.dart new file mode 100644 index 0000000..a922231 --- /dev/null +++ b/lib/services/telemetry_text.dart @@ -0,0 +1,178 @@ +/// Rendu en français des **codes** de la télémétrie d'arbitrage. +/// +/// La box transporte un code et des paramètres numériques ; la phrase est fabriquée ici. +/// Ce n'est pas une préférence de style : la locale nymea est **par connexion** (deux +/// utilisateurs d'une même box peuvent avoir deux langues), et le handler qui émet ces +/// codes est un `JsonHandler` sans `pluginId` — structurellement hors du catalogue de +/// traduction nymea. Une phrase composée en C++ ne pourrait servir personne correctement. +/// +/// ## La règle qui gouverne ce fichier +/// +/// **Un code que cette version d'app ne connaît pas s'affiche en repli lisible — jamais +/// une phrase vide, jamais un plantage.** Une box en avance sur l'app est un cas normal +/// en parc déployé : le paquet du plugin se met à jour par `apt`, l'app par le magasin. +/// +/// Le repli vaut aussi pour un code **connu dont un paramètre manque** : composer +/// « Consigne de W servie par le surplus » serait pire qu'afficher le code brut, parce +/// que la phrase trouée a l'air d'une donnée alors que le repli a l'air de ce qu'il est. +library; + +import '../l10n/app_localizations.dart'; +import '../models/load_telemetry.dart'; + +/// Phrase de décision, ou repli explicite. +String decisionText(L10n t, LoadDecision d) { + final p = d.params; + + switch (d.code) { + case 'EV_SURPLUS': + return t.decisionEvSurplus; + case 'EV_SPOT_MARKET': + return t.decisionEvSpotMarket; + case 'EV_DEADLINE': + return t.decisionEvDeadline; + case 'EV_ECO_MIN': + return t.decisionEvEcoMin; + case 'EV_IDLE': + return t.decisionEvIdle; + case 'DEGRADED_L2': + return t.decisionDegradedL2; + case 'SAFE_STATE_RELAY': + return t.decisionSafeStateRelay; + case 'SAFE_STATE_SETPOINT': + return t.decisionSafeStateSetpoint; + case 'SAFE_STATE_SG_READY': + return t.decisionSafeStateSgReady; + + case 'LOAD_UNAVAILABLE': + final frozen = _w(d, 'frozenW'); + if (frozen == null) break; + return t.decisionLoadUnavailable(frozen); + + case 'LOCK_MIN_ON': + final applied = _w(d, 'appliedW'); + final budget = _w(d, 'budgetW'); + if (applied == null || budget == null) break; + return t.decisionLockMinOn(applied, budget); + + case 'LOCK_MIN_OFF': + final requested = _w(d, 'requestedW'); + final budget = _w(d, 'budgetW'); + if (requested == null || budget == null) break; + return t.decisionLockMinOff(requested, budget); + + case 'LOCK_MIN_OFF_CAPPED': + final applied = _w(d, 'appliedW'); + final requested = _w(d, 'requestedW'); + final budget = _w(d, 'budgetW'); + if (applied == null || requested == null || budget == null) break; + return t.decisionLockMinOffCapped(applied, requested, budget); + + case 'SURPLUS_SETPOINT': + final setpoint = _w(d, 'setpointW'); + final budget = _w(d, 'budgetW'); + if (setpoint == null || budget == null) break; + // `stepped` distingue une consigne servie telle quelle d'une consigne arrondie sur + // un palier déclaré. Absent, on ne prétend ni l'un ni l'autre : la variante neutre. + return d.boolParam('stepped') == true + ? t.decisionSurplusSetpointStepped(setpoint, budget) + : t.decisionSurplusSetpoint(setpoint, budget); + + case 'SURPLUS_INSUFFICIENT': + final budget = _w(d, 'budgetW'); + if (budget == null) break; + return t.decisionSurplusInsufficient(budget); + + case 'LOCK_MIN_STATE_HOLD': + final state = _i(d, 'state'); + if (state == null) break; + return t.decisionLockMinStateHold(state); + + case 'SG_FORCED': + final estimated = _w(d, 'estimatedW'); + final budget = _w(d, 'budgetW'); + if (estimated == null || budget == null) break; + return t.decisionSgForced(estimated, budget); + + case 'SG_RECOMMENDED': + final estimated = _w(d, 'estimatedW'); + final budget = _w(d, 'budgetW'); + if (estimated == null || budget == null) break; + return t.decisionSgRecommended(estimated, budget); + + case 'SG_NORMAL': + final budget = _w(d, 'budgetW'); + if (budget == null) break; + return t.decisionSgNormal(budget); + } + + // Code inconnu, ou code connu dont un paramètre attendu manque. + return t.decisionUnknown(d.code.isEmpty ? '—' : d.code, formatParams(p)); +} + +/// Rendu déterministe des paramètres bruts, pour le repli. +/// +/// Trié par clé : deux affichages du même motif doivent être identiques, sinon l'écran +/// « bouge » d'une trame à l'autre sans qu'il se soit rien passé. +String formatParams(Map params) { + if (params.isEmpty) return ''; + final keys = params.keys.toList()..sort(); + final body = [for (final k in keys) '$k = ${params[k]}'].join(', '); + return ' ($body)'; +} + +/// Phrase de défaut, ou repli sur le code brut. +String faultText(L10n t, String code) => switch (code) { + 'WRITE_FAILED' => t.faultWriteFailed, + 'THING_MISSING' => t.faultThingMissing, + 'UNUSABLE_ENCODING' => t.faultUnusableEncoding, + _ => t.faultUnknown(code), + }; + +/// Verrou actif. Les secondes affichées sont celles **du cycle publié** : l'app ne les +/// décompte pas localement, parce que le plugin exclut volontairement ce champ de sa +/// détection de changement — un décompte local serait une extrapolation présentée comme +/// une mesure. +String lockText(L10n t, LoadLock lock) => switch (lock.kind) { + 'minOn' => t.lockMinOn(lock.remainingS), + 'minOff' => t.lockMinOff(lock.remainingS), + 'minStateHold' => t.lockMinStateHold(lock.remainingS), + _ => t.lockUnknown(lock.kind, lock.remainingS), + }; + +/// État du mécanisme. `null` quand la variante ne porte pas de quoi composer une phrase — +/// l'appelant n'affiche alors rien plutôt qu'une ligne creuse. +String? mechanismText(L10n t, LoadMechanism m) { + switch (m.kind) { + case 'relay': + final stage = m.stageW; + final max = m.maxStageW; + if (stage == null || max == null) break; + return t.mechanismRelayStage(stage.round(), max.round()); + + case 'variable': + final setpoint = m.setpointW; + final percent = m.percent; + if (setpoint == null || percent == null) break; + return t.mechanismVariableSetpoint(setpoint.round(), percent.round()); + + case 'sgReady': + final state = m.state; + final estimated = m.estimatedPowerW; + if (state == null || estimated == null) break; + return t.mechanismSgReadyState(state, estimated.round()); + + case 'evcharger': + final current = m.currentA; + final phases = m.phaseCount; + if (current == null || phases == null) break; + return t.mechanismEvCharger(current, phases); + } + return m.kind.isEmpty ? null : t.mechanismUnknown(m.kind); +} + +/// Paramètre en watts, arrondi. `null` si absent ou non numérique — c'est ce qui +/// déclenche le repli plutôt qu'une phrase trouée. +int? _w(LoadDecision d, String key) => d.numParam(key)?.round(); + +int? _i(LoadDecision d, String key) => d.numParam(key)?.toInt(); diff --git a/test/fixtures/loadconfig_hems75_v3.json b/test/fixtures/loadconfig_hems75_v3.json new file mode 100644 index 0000000..bfe69bb --- /dev/null +++ b/test/fixtures/loadconfig_hems75_v3.json @@ -0,0 +1,88 @@ +{ + "_source": "dump réel HEMS 192.168.1.75, NymeaEnergy.GetLoadConfig, 2026-08-25 — plugin 1.15.2+etm15 (lot B-bis : la PAC entre en configuration).", + "_note": "Deux charges : 'chauffe-eau' (relay-router) et 'pac-terrain' (sg-ready), cette dernière n'était PAS configurable avant etm15. Les deux ont domain:'' = non classé. Conserve les charges utiles VIDES des mécanismes inutilisés (sgReady:{}, powerLevels:[], relays:[]) — sérialisation par méta-objet. Ne PAS nettoyer : c'est ce qui rend l'aller-retour Get→Set neutre.", + "loadConfigs": [ + { + "adapter": "relay-router", + "domain": "", + "enabled": true, + "id": "chauffe-eau", + "label": "chauffe-eau", + "maxPowerW": 0, + "minOffS": 60, + "minOnS": 60, + "mode": "fixed", + "needs": { + "dailyDeadline": "", + "minEnergyWhPerDay": 0 + }, + "powerLevels": [], + "priority": 1, + "relays": [ + { + "powerW": 500, + "thingId": "{8538782f-2c8c-4a30-bfce-c8140f791c9b}" + }, + { + "powerW": 1000, + "thingId": "{2ebe6bef-829b-4695-9d11-ddb4c16c5448}" + }, + { + "powerW": 2000, + "thingId": "{b033b212-1adb-4df0-ba2b-8fa477de52a2}" + } + ], + "sgReady": {} + }, + { + "adapter": "sg-ready", + "domain": "", + "enabled": true, + "id": "pac-terrain", + "label": "PAC banc", + "maxPowerW": 0, + "minOffS": 0, + "minOnS": 0, + "mode": "fixed", + "needs": { + "dailyDeadline": "", + "minEnergyWhPerDay": 0 + }, + "powerLevels": [], + "priority": 2, + "relays": [], + "sgReady": { + "minStateHoldS": 300, + "states": [ + { + "estimatedPowerW": 0, + "relays": [ + "{beaf92e1-aedc-4b84-9ce4-e423648638cc}" + ], + "state": 1 + }, + { + "estimatedPowerW": 0, + "relays": [], + "state": 2 + }, + { + "estimatedPowerW": 1500, + "relays": [ + "{bf236e64-5ae7-4bf1-82a6-4ede03de75a6}" + ], + "state": 3 + }, + { + "estimatedPowerW": 3000, + "relays": [ + "{beaf92e1-aedc-4b84-9ce4-e423648638cc}", + "{bf236e64-5ae7-4bf1-82a6-4ede03de75a6}" + ], + "state": 4 + } + ] + } + } + ] +} \ No newline at end of file diff --git a/test/fixtures/loadtelemetry_hems75_v3.json b/test/fixtures/loadtelemetry_hems75_v3.json new file mode 100644 index 0000000..112e03b --- /dev/null +++ b/test/fixtures/loadtelemetry_hems75_v3.json @@ -0,0 +1,52 @@ +{ + "_source": "dump réel HEMS 192.168.1.75, NymeaEnergy.GetLoadTelemetry, 2026-08-25 — plugin 1.15.2+etm15.", + "_note": "Charge utile IDENTIQUE à la notification LoadTelemetryChanged. loads[] ⊆ GetLoadConfig depuis etm15 : plus aucune charge arbitrée hors configuration. decision.code / faultCode sont des CODES à params numériques, jamais des phrases. 'timestamp' absent = aucun cycle depuis le démarrage ; 'budget' absent = mode dégradé L2. Identité vérifiable : surplusW + recreditedW - allocatedW == remainingW.", + "telemetry": { + "budget": { + "allocatedW": 6500, + "evReservedW": 0, + "recreditedW": 6500, + "remainingW": 4735.7001953125, + "surplusW": 4735.7001953125 + }, + "degradedMode": false, + "loads": [ + { + "allocatedW": 3500, + "available": true, + "decision": { + "code": "SURPLUS_SETPOINT", + "params": { + "budgetW": 8236, + "setpointW": 3500, + "stepped": true + } + }, + "loadId": "chauffe-eau", + "mechanism": { + "kind": "relay", + "maxStageW": 3500, + "stageW": 3500 + } + }, + { + "allocatedW": 3000, + "available": true, + "decision": { + "code": "SG_FORCED", + "params": { + "budgetW": 7736, + "estimatedW": 3000 + } + }, + "loadId": "pac-terrain", + "mechanism": { + "estimatedPowerW": 3000, + "kind": "sgReady", + "state": 4 + } + } + ], + "timestamp": "2026-08-25T16:50:00Z" + } +} \ No newline at end of file diff --git a/test/fixtures/loadtelemetry_hems75_v3_locked.json b/test/fixtures/loadtelemetry_hems75_v3_locked.json new file mode 100644 index 0000000..d2e0baa --- /dev/null +++ b/test/fixtures/loadtelemetry_hems75_v3_locked.json @@ -0,0 +1,38 @@ +{ + "_source": "dump réel HEMS 192.168.1.75, NymeaEnergy.GetLoadTelemetry, cycle 2026-08-25T16:47:00Z — plugin 1.15.2+etm15.", + "_note": "Même banc que loadtelemetry_hems75_v3.json, trois cycles plus tôt : la PAC y porte un verrou minStateHold actif (60 s restantes). Conservé parce que 'lock' est OPTIONNEL — un dump pris au mauvais moment n'en contient aucun, et la lecture du verrou n'y serait pas éprouvée. Les secondes restantes datent du cycle : elles ne se décomptent pas côté app.", + "telemetry": { + "budget": { + "allocatedW": 6500, + "evReservedW": 0, + "recreditedW": 6500, + "remainingW": 8396.900390625, + "surplusW": 8396.900390625 + }, + "degradedMode": false, + "loads": [ + { + "allocatedW": 3500, + "available": true, + "decision": { + "code": "SURPLUS_SETPOINT", + "params": { "budgetW": 11897, "setpointW": 3500, "stepped": true } + }, + "loadId": "chauffe-eau", + "mechanism": { "kind": "relay", "maxStageW": 3500, "stageW": 3500 } + }, + { + "allocatedW": 3000, + "available": true, + "decision": { + "code": "SG_FORCED", + "params": { "budgetW": 11397, "estimatedW": 3000 } + }, + "loadId": "pac-terrain", + "lock": { "kind": "minStateHold", "remainingS": 60 }, + "mechanism": { "estimatedPowerW": 3000, "kind": "sgReady", "state": 4 } + } + ], + "timestamp": "2026-08-25T16:47:00Z" + } +} diff --git a/test/fixtures/schema_hems75_v3.json b/test/fixtures/schema_hems75_v3.json new file mode 100644 index 0000000..181270b --- /dev/null +++ b/test/fixtures/schema_hems75_v3.json @@ -0,0 +1,153 @@ +{ + "_source": "JSONRPC.Introspect sur 192.168.1.75, 2026-08-25 — plugin 1.15.2+etm15 (194 méthodes / 101 notifications au total, dont 20/14 pour NymeaEnergy.*).", + "_note": "nymea rejette toute clé inconnue AVANT le handler (jsonvalidator.cpp:143). Ce que la box déclare fait donc autorité sur ce que l'app a le droit d'envoyer. mechanism, lock, faultCode, budget et timestamp sont OPTIONNELS (o:) — omis, jamais nuls.", + "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" + } + }, + "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" + } + }, + "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" + } + }, + "SetLoadConfig_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" + } + ] + }, + "LoadConfigChanged": { + "description": "Emitted whenever the controllable load configuration changed.", + "params": { + "loadConfigs": [ + "$ref:LoadConfig" + ] + } + } +} \ No newline at end of file diff --git a/test/load_domain_test.dart b/test/load_domain_test.dart new file mode 100644 index 0000000..605c8ac --- /dev/null +++ b/test/load_domain_test.dart @@ -0,0 +1,99 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:etm_powersync_app/providers/load_config_provider.dart'; +import 'package:etm_powersync_app/services/load_priority.dart'; +import 'package:flutter_test/flutter_test.dart'; + +List> _configs(String fixture) { + final raw = File('test/fixtures/$fixture').readAsStringSync(); + final map = jsonDecode(raw) as Map; + return (map['loadConfigs'] as List).cast>(); +} + +void main() { + // ─────────────────────────────────────────────────────────────────────────── + group('§7-4 — domaine', () { + test('les deux charges de .75 arrivent « non classées », jamais rangées d\'office', + () { + final p = LoadConfigProvider()..adoptForTest(_configs('loadconfig_hems75_v3.json')); + + // `domain: ""` sort toujours de GetLoadConfig (sérialisation par méta-objet) ; + // vide ET absent se lisent tous deux « non classée ». + expect(p.groups.length, 1); + expect(p.groups.single.isUnclassified, isTrue); + expect(p.groups.single.loads.map((e) => e.id), + ['chauffe-eau', 'pac-terrain']); + expect(p.hasPendingChanges, isFalse, + reason: 'une simple lecture ne doit jamais produire de brouillon'); + }); + + test('classer une charge la déplace dans son domaine — dans le brouillon seulement', + () { + final p = LoadConfigProvider()..adoptForTest(_configs('loadconfig_hems75_v3.json')); + p.assignDomain('chauffe-eau', 'ecs'); + + expect(p.hasPendingChanges, isTrue); + expect(p.groups.map((g) => g.domain), [kUnclassifiedDomain, 'ecs']); + expect(p.groups.first.loads.single.id, 'pac-terrain'); + expect(p.groups.last.loads.single.domain, 'ecs'); + }); + + test('la charge utile du mécanisme traverse l\'affectation intacte', () { + final source = _configs('loadconfig_hems75_v3.json'); + final p = LoadConfigProvider()..adoptForTest(source); + p.assignDomain('pac-terrain', 'hvac'); + + final payload = flattenToPayload(p.groups); + final pac = payload.firstWhere((m) => m['id'] == 'pac-terrain'); + final origin = source.firstWhere((m) => m['id'] == 'pac-terrain'); + + // Seuls `domain` et `priority` changent : la table d'états SG-Ready, les relais + // vides, `needs` et `powerLevels` sont transportés tels quels. Une + // re-sérialisation depuis un modèle typé les amputerait en silence. + expect(pac['domain'], 'hvac'); + for (final key in origin.keys) { + if (key == 'domain' || key == 'priority') continue; + expect(pac[key], equals(origin[key]), + reason: 'la clé $key a été altérée par l\'affectation de domaine'); + } + }); + + test('déclasser une charge la ramène au bucket « non classé »', () { + final p = LoadConfigProvider()..adoptForTest(_configs('loadconfig_hems75_v3.json')); + p.assignDomain('chauffe-eau', 'ecs'); + p.assignDomain('chauffe-eau', kUnclassifiedDomain); + + final ecs = p.groups.where((g) => g.domain == 'ecs'); + expect(ecs, isEmpty, reason: 'un groupe vidé ne doit pas subsister'); + expect( + p.groups + .firstWhere((g) => g.isUnclassified) + .loads + .map((e) => e.id) + .toSet(), + {'chauffe-eau', 'pac-terrain'}); + }); + + test('l\'aplatissement renumérote 1..N sans jamais réordonner les groupes', () { + final p = LoadConfigProvider()..adoptForTest(_configs('loadconfig_hems75_v3.json')); + p.assignDomain('chauffe-eau', 'ecs'); + + final payload = flattenToPayload(p.groups); + expect(payload.map((m) => m['id']), ['pac-terrain', 'chauffe-eau']); + expect(payload.map((m) => m['priority']), [1, 2]); + }); + + test('les domaines proposés sont exactement l\'énumération fermée du plugin', () { + // `SetLoadConfig` rejette EN BLOC une valeur hors énumération : proposer un + // domaine inventé ferait échouer l'enregistrement de toutes les charges. + expect(kAssignableDomains.toSet(), {'ev', 'ecs', 'hvac', 'heating', 'battery'}); + }); + + test('l\'ordre de mise en service ne contient pas la batterie', () { + // Classable, mais sa place ne s'exprime pas en watts : elle s'exprime en cible + // de SOC, hors périmètre de l'ordre de service. + expect(kDefaultDomainOrder, isNot(contains('battery'))); + }); + }); +} diff --git a/test/load_telemetry_test.dart b/test/load_telemetry_test.dart new file mode 100644 index 0000000..082854c --- /dev/null +++ b/test/load_telemetry_test.dart @@ -0,0 +1,277 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:etm_powersync_app/l10n/app_localizations.dart'; +import 'package:etm_powersync_app/models/load_telemetry.dart'; +import 'package:etm_powersync_app/services/telemetry_text.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Map _fixture(String name, String key) { + final raw = File('test/fixtures/$name').readAsStringSync(); + return Map.from( + (jsonDecode(raw) as Map)[key] as Map); +} + +void main() { + late L10n t; + + setUpAll(() async { + t = await L10n.delegate.load(const Locale('fr')); + }); + + // ─────────────────────────────────────────────────────────────────────────── + group('§7-5 — lecture de la charge utile réelle de .75', () { + test('le dump du banc se lit entièrement, budget et mécanismes compris', () { + final tel = + LoadTelemetry.fromJson(_fixture('loadtelemetry_hems75_v3.json', 'telemetry')); + + expect(tel.loads.map((l) => l.loadId), ['chauffe-eau', 'pac-terrain']); + expect(tel.degradedMode, isFalse); + expect(tel.hasNeverRun, isFalse); + expect(tel.timestamp, isNotNull); + + final ecs = tel.entryFor('chauffe-eau')!; + expect(ecs.available, isTrue); + expect(ecs.faultCode, isNull); + expect(ecs.mechanism!.kind, 'relay'); + expect(ecs.mechanism!.stageW, 3500); + expect(ecs.decision.code, 'SURPLUS_SETPOINT'); + + final pac = tel.entryFor('pac-terrain')!; + expect(pac.mechanism!.kind, 'sgReady'); + expect(pac.mechanism!.state, 4); + expect(pac.decision.code, 'SG_FORCED'); + // Aucun verrou sur ce cycle-là : `lock` est optionnel, et son absence se lit + // « aucun verrou ne mord », jamais « verrou de zéro seconde ». + expect(pac.lock, isNull); + }); + + test('un cycle antérieur du même banc porte un verrou minStateHold', () { + // Même installation, trois cycles plus tôt. Sans ce second dump, la lecture du + // verrou ne serait éprouvée que sur une charge utile fabriquée. + final tel = LoadTelemetry.fromJson( + _fixture('loadtelemetry_hems75_v3_locked.json', 'telemetry')); + final pac = tel.entryFor('pac-terrain')!; + expect(pac.lock!.kind, 'minStateHold'); + expect(pac.lock!.remainingS, 60); + expect(tel.budget!.identityHolds, isTrue); + }); + + test('l\'identité du budget annoncée par le plugin se vérifie sur le dump réel', () { + // surplusW + recreditedW − allocatedW == remainingW. Sans le terme de recrédit, + // l'égalité serait fausse dès qu'une charge consomme déjà — le cas normal. + final tel = + LoadTelemetry.fromJson(_fixture('loadtelemetry_hems75_v3.json', 'telemetry')); + expect(tel.budget!.identityHolds, isTrue); + }); + }); + + // ─────────────────────────────────────────────────────────────────────────── + group('champs omis, jamais nuls', () { + test('timestamp absent = aucun cycle, pas un cycle daté de l\'époque zéro', () { + final tel = LoadTelemetry.fromJson({'degradedMode': false, 'loads': []}); + expect(tel.hasNeverRun, isTrue); + expect(tel.timestamp, isNull); + expect(tel.ageAt(DateTime.now()), isNull); + }); + + test('budget absent = mode dégradé, pas un budget de zéro watt', () { + final tel = LoadTelemetry.fromJson({ + 'degradedMode': true, + 'timestamp': '2026-08-25T16:47:00Z', + 'loads': [], + }); + expect(tel.degradedMode, isTrue); + expect(tel.budget, isNull); + }); + + test('lock, mechanism et faultCode absents ne fabriquent aucune valeur', () { + final e = LoadTelemetryEntry.fromJson({ + 'loadId': 'x', + 'allocatedW': 0, + 'available': true, + 'decision': {'code': 'SURPLUS_INSUFFICIENT', 'params': {'budgetW': 12}}, + }); + expect(e.lock, isNull); + expect(e.mechanism, isNull); + expect(e.faultCode, isNull); + expect(e.inFault, isFalse); + }); + }); + + // ─────────────────────────────────────────────────────────────────────────── + group('§7-6 — fraîcheur', () { + LoadTelemetry at(String iso) => LoadTelemetry.fromJson({ + 'degradedMode': false, + 'timestamp': iso, + 'loads': [], + }); + + test('le seuil est supérieur au battement de cœur du plugin', () { + // Le plugin émet une notification au moins toutes les 60 s et le timestamp avance + // d'un cycle par minute (vérifié sur .75 : 12:37 → 12:38 → 12:39). Un seuil ≤ 60 s + // crierait « données anciennes » sur une installation parfaitement vivante. + expect(kTelemetryStaleAfter.inSeconds, greaterThan(60)); + }); + + test('un cycle d\'il y a deux minutes n\'est pas encore ancien', () { + final now = DateTime.utc(2026, 8, 25, 16, 49); + expect(at('2026-08-25T16:47:00Z').ageAt(now), const Duration(minutes: 2)); + expect(at('2026-08-25T16:47:00Z').ageAt(now)! > kTelemetryStaleAfter, isFalse); + }); + + test('un arbitre figé depuis dix minutes dépasse le seuil', () { + final now = DateTime.utc(2026, 8, 25, 16, 57); + expect(at('2026-08-25T16:47:00Z').ageAt(now)! > kTelemetryStaleAfter, isTrue); + }); + + test('un âge négatif (horloges désynchronisées) est ramené à zéro, pas au futur', () { + final now = DateTime.utc(2026, 8, 25, 16, 40); + expect(at('2026-08-25T16:47:00Z').ageAt(now), Duration.zero); + }); + }); + + // ─────────────────────────────────────────────────────────────────────────── + group('§7-7 — charge en défaut', () { + // Charge utile SYNTHÉTIQUE, mais recopiée de ce que `.75` a réellement publié le + // 2026-08-25 pendant le passage de `tools/rpc/fault_probe.dart` : une charge + // etmvariableload dont le ThingId n'existe pas. Le dump brut n'a pas été conservé + // (le défaut n'a pas été reprovoqué pour ça) — `--dump` le capturera au prochain + // passage. + LoadTelemetryEntry faulty() => LoadTelemetryEntry.fromJson({ + 'loadId': '{00000000-dead-4bee-8000-000000000042}', + 'allocatedW': 0, + 'available': false, + 'faultCode': 'WRITE_FAILED', + 'decision': { + 'code': 'LOAD_UNAVAILABLE', + 'params': {'frozenW': 0}, + }, + }); + + test('une charge indisponible se lit comme en défaut', () { + final e = faulty(); + expect(e.inFault, isTrue); + expect(e.faultCode, 'WRITE_FAILED'); + expect(e.allocatedW, 0); + }); + + test('le défaut et son motif se rendent en français, sans le code brut', () { + final e = faulty(); + expect(faultText(t, e.faultCode!), contains('écriture')); + expect(decisionText(t, e.decision), isNot(contains('LOAD_UNAVAILABLE'))); + }); + + test('le code de défaut prime sur l\'absence de mécanisme', () { + // Observé sur `.75` : le Thing manquant fait échouer l'écriture, l'échelle + // s'épuise, et `m_faulted` masque THING_MISSING. L'app affiche ce que la box dit, + // sans corriger le diagnostic à sa place. + final e = faulty(); + expect(e.mechanism, isNull); + expect(faultText(t, e.faultCode!), isNot(faultText(t, 'THING_MISSING'))); + }); + }); + + // ─────────────────────────────────────────────────────────────────────────── + group('§7-8 — rendu des codes, et repli sur l\'inconnu', () { + test('les motifs du catalogue se rendent en français avec leurs paramètres', () { + final phrase = decisionText( + t, + const LoadDecision( + code: 'SURPLUS_SETPOINT', + params: {'budgetW': 11897, 'setpointW': 3500, 'stepped': true}), + ); + expect(phrase, contains('palier')); + expect(phrase, isNot(contains('SURPLUS_SETPOINT'))); + }); + + test('`stepped` distingue deux phrases, et son absence ne prétend ni l\'une ni l\'autre', + () { + String render(Map p) => + decisionText(t, LoadDecision(code: 'SURPLUS_SETPOINT', params: p)); + + final stepped = render({'budgetW': 100, 'setpointW': 50, 'stepped': true}); + final plain = render({'budgetW': 100, 'setpointW': 50, 'stepped': false}); + final absent = render({'budgetW': 100, 'setpointW': 50}); + + expect(stepped, isNot(plain)); + expect(absent, plain); + }); + + test('un code absent de l\'ARB s\'affiche en repli lisible, sans plantage', () { + final phrase = decisionText( + t, + const LoadDecision(code: 'FUTURE_CODE_2027', params: {'budgetW': 42, 'a': true}), + ); + // Une box en avance sur l'app est un cas normal en parc déployé. + expect(phrase, contains('FUTURE_CODE_2027')); + expect(phrase, contains('budgetW = 42')); + expect(phrase, isNot(isEmpty)); + }); + + test('les paramètres du repli sont triés : deux rendus du même motif sont identiques', + () { + final a = formatParams({'zW': 1, 'aW': 2}); + final b = formatParams({'aW': 2, 'zW': 1}); + expect(a, b); + expect(a, ' (aW = 2, zW = 1)'); + }); + + test('un code connu privé d\'un paramètre attendu bascule en repli, pas en phrase trouée', + () { + // « Consigne de W servie par le surplus » aurait l'air d'une donnée ; le code brut + // a l'air de ce qu'il est. + final phrase = decisionText( + t, const LoadDecision(code: 'SG_FORCED', params: {'budgetW': 10})); + expect(phrase, contains('SG_FORCED')); + }); + + test('tous les codes du catalogue fermé ont une phrase, aucun ne tombe en repli', () { + const catalogue = >{ + 'EV_SURPLUS': {}, + 'EV_SPOT_MARKET': {}, + 'EV_DEADLINE': {}, + 'EV_ECO_MIN': {}, + 'EV_IDLE': {}, + 'LOAD_UNAVAILABLE': {'frozenW': 500}, + 'LOCK_MIN_ON': {'appliedW': 500, 'budgetW': 100}, + 'LOCK_MIN_OFF': {'budgetW': 100, 'requestedW': 500}, + 'LOCK_MIN_OFF_CAPPED': {'appliedW': 1, 'budgetW': 2, 'requestedW': 3}, + 'SURPLUS_SETPOINT': {'budgetW': 1, 'setpointW': 2, 'stepped': false}, + 'SURPLUS_INSUFFICIENT': {'budgetW': 229}, + 'LOCK_MIN_STATE_HOLD': {'state': 4}, + 'SG_FORCED': {'budgetW': 1, 'estimatedW': 3000}, + 'SG_RECOMMENDED': {'budgetW': 1, 'estimatedW': 1500}, + 'SG_NORMAL': {'budgetW': 1}, + 'DEGRADED_L2': {}, + 'SAFE_STATE_RELAY': {}, + 'SAFE_STATE_SETPOINT': {}, + 'SAFE_STATE_SG_READY': {}, + }; + for (final entry in catalogue.entries) { + final phrase = + decisionText(t, LoadDecision(code: entry.key, params: entry.value)); + expect(phrase, isNot(contains(entry.key)), + reason: '${entry.key} tombe en repli alors qu\'il est au catalogue'); + } + }); + + test('défauts, verrous et mécanismes ont eux aussi leur repli', () { + expect(faultText(t, 'WRITE_FAILED'), isNot(contains('WRITE_FAILED'))); + expect(faultText(t, 'THING_MISSING'), isNot(contains('THING_MISSING'))); + expect(faultText(t, 'UNUSABLE_ENCODING'), isNot(contains('UNUSABLE_ENCODING'))); + expect(faultText(t, 'FUTURE_FAULT'), contains('FUTURE_FAULT')); + + expect(lockText(t, const LoadLock(kind: 'minOff', remainingS: 60)), contains('60')); + expect(lockText(t, const LoadLock(kind: 'futureLock', remainingS: 5)), + contains('futureLock')); + + expect( + mechanismText(t, LoadMechanism(const {'stageW': 3500, 'maxStageW': 3500}, 'relay')), + contains('3')); + expect(mechanismText(t, LoadMechanism(const {}, 'futureMechanism')), + contains('futureMechanism')); + }); + }); +}