feat(installateur): ordre de service des charges + locale + ossature i18n

Lot B côté app, partie NON bloquée par le lot plugin (voir §Limites).

§1 Locale (indépendant) — JSONRPC.Hello portait {} : l'app était en en_US de
fait alors que nymea gère la locale PAR CONNEXION (o:locale). Envoi de la
locale à chaque Hello, donc aussi après reconnexion subie (m_clientLocales
repart au défaut de la box à chaque socket). Locale demandée ET retenue
journalisées : un .qm introuvable rend exactement comme une absence de
traduction, sans cette trace un catalogue mal packagé passe inaperçu.

§2 i18n — flutter_localizations + intl + ARB (français seul). Aucune chaîne
en dur dans le nouvel écran. Les codes reçus de la box (domaine, mécanisme)
sont des clés ARB avec repli explicite sur code brut si l'app ne les connaît
pas : une box en avance sur l'app est un cas normal en parc déployé.

§3 Modèle — LoadConfigEntry est une VUE en lecture au-dessus de la map brute ;
l'écriture repart de la map d'origine (patched()). Pas de couche de filtrage
préventive : l'aller-retour neutre passe contre .75, donc l'écho verbatim est
sûr par construction (§7-1).

§4 Priorité à deux niveaux — groupByDomain / flattenToPayload. Le rang de
domaine se déduit de l'ordre d'apparition à plat ; la box ne sait rien des
groupes. Config non contiguë : signalée, ordre réel affiché, normalisation
seulement sur confirmation explicite. Miroir client de validateSet().

§5 Écritures asynchrones — LoadConfigProvider ne conclut jamais sur
l'acquittement du RPC : pending → LoadConfigChanged → confirmed|diverged.

NymeaEnergy.GetLoadConfig / SetLoadConfig deviennent RÉELS (ils étaient des
stubs journalisés). Conséquence traitée : EnergySetupProvider.persist()
n'émet plus setLoadConfig(buildLoadDescriptors()) — SetLoadConfig remplace
TOUT l'ensemble, et _commit() l'appelait à chaque changement d'UI : une
reconstruction depuis le modèle typé aurait écrasé la config réelle de la
box sans geste explicite.

Écran /energy/loads, gaté installateur (drawer + garde de routeur).

Limites — le lot plugin n'est PAS déployé sur .75 : GetLoadTelemetry,
LoadTelemetryChanged et o:domain sont absents (19 méthodes / 13 notifications
inchangées). Toute la moitié télémétrie du brief est donc hors d'atteinte, et
o:domain n'étant pas au schéma, la box rejetterait la clé (jsonvalidator.cpp:143).
Fixtures multi-domaines synthétiques : .75 n'a qu'une charge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-08-25 11:34:47 +02:00
parent 40ac3b2c8e
commit 7ca42973f2
23 changed files with 2646 additions and 51 deletions

3
.gitignore vendored
View File

@ -49,3 +49,6 @@ app.*.map.json
# Captures d'écran de debug (artefacts locaux)
/screenshots/
# Artefact de génération l10n (gen-l10n) — pas du code source
l10n_untranslated.json

71
Héos_UI/hems_icon.dart Normal file
View File

@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
/// Icône du cœur HEMS (« Héos ») : étincelle de marque au centre,
/// 4 nœuds pilotés alignés sur les pointes. Dessinée en natif (pas
/// de dépendance SVG), hérite de la couleur d'`IconTheme` par défaut.
///
/// Exemple :
/// const HemsIcon(size: 24) // couleur du thème
/// HemsIcon(size: 28, color: EtmColors.eco) // couleur explicite
class HemsIcon extends StatelessWidget {
final double size;
final Color? color;
const HemsIcon({super.key, this.size = 24, this.color});
@override
Widget build(BuildContext context) {
final c = color ??
IconTheme.of(context).color ??
const Color(0xFF28A06A); // eco par défaut
return SizedBox(
width: size,
height: size,
child: CustomPaint(painter: _HemsPainter(c)),
);
}
}
class _HemsPainter extends CustomPainter {
final Color color;
_HemsPainter(this.color);
// nœuds cardinaux (repère viewBox 24x24)
static const _nodes = <Offset>[
Offset(12, 3.4), // haut
Offset(20.6, 12), // droite
Offset(12, 20.6), // bas
Offset(3.4, 12), // gauche
];
static const _nodeR = 1.7;
@override
void paint(Canvas canvas, Size size) {
final s = size.width / 24.0; // échelle depuis le viewBox
final paint = Paint()
..color = color
..style = PaintingStyle.fill
..isAntiAlias = true;
// étincelle Héos
final spark = Path()
..moveTo(12 * s, 7.4 * s)
..lineTo(13.25 * s, 10.75 * s)
..lineTo(16.6 * s, 12 * s)
..lineTo(13.25 * s, 13.25 * s)
..lineTo(12 * s, 16.6 * s)
..lineTo(10.75 * s, 13.25 * s)
..lineTo(7.4 * s, 12 * s)
..lineTo(10.75 * s, 10.75 * s)
..close();
canvas.drawPath(spark, paint);
// nœuds pilotés
for (final n in _nodes) {
canvas.drawCircle(Offset(n.dx * s, n.dy * s), _nodeR * s, paint);
}
}
@override
bool shouldRepaint(_HemsPainter old) => old.color != color;
}

9
Héos_UI/hems_icon.svg Normal file
View File

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" style="color:#28a06a" role="img" aria-label="HEMS Héos">
<!-- cœur : étincelle Héos (pointes alignées sur les 4 nœuds) -->
<path d="M12 7.4 13.25 10.75 16.6 12 13.25 13.25 12 16.6 10.75 13.25 7.4 12 10.75 10.75Z"/>
<!-- nœuds pilotés -->
<circle cx="12" cy="3.4" r="1.7"/>
<circle cx="20.6" cy="12" r="1.7"/>
<circle cx="12" cy="20.6" r="1.7"/>
<circle cx="3.4" cy="12" r="1.7"/>
</svg>

After

Width:  |  Height:  |  Size: 512 B

9
l10n.yaml Normal file
View File

@ -0,0 +1,9 @@
# Génération des délégués de localisation depuis les ARB.
# `flutter gen-l10n` (implicite au build via `generate: true` dans pubspec.yaml).
arb-dir: lib/l10n
template-arb-file: app_fr.arb
output-localization-file: app_localizations.dart
output-class: L10n
# Le template EST la langue de référence : pas de repli anglais silencieux.
# Une clé manquante dans une future traduction doit être visible, pas masquée.
untranslated-messages-file: l10n_untranslated.json

125
lib/l10n/app_fr.arb Normal file
View File

@ -0,0 +1,125 @@
{
"@@locale": "fr",
"installerLoadsTitle": "Rôles & appareils",
"@installerLoadsTitle": {
"description": "Titre de l'écran installateur de configuration des charges pilotables"
},
"installerLoadsSubtitle": "Ordre de service des charges pilotables",
"loadsEmpty": "Aucune charge pilotable déclarée sur cette installation.",
"loadsLoadError": "Lecture de la configuration impossible : {error}",
"@loadsLoadError": {
"placeholders": { "error": { "type": "String" } }
},
"sectionUnclassified": "Non classé",
"@sectionUnclassified": {
"description": "Bucket des charges sans o:domain — jamais rangées d'office dans un domaine"
},
"domainEv": "Véhicule électrique",
"domainEcs": "Eau chaude sanitaire",
"domainHvac": "Climatisation",
"domainHeating": "Chauffage",
"domainUnknown": "Domaine « {code} »",
"@domainUnknown": {
"description": "Repli quand la box renvoie un domaine que cette version d'app ne connaît pas",
"placeholders": { "code": { "type": "String" } }
},
"adapterRelayRouter": "Routeur de relais",
"adapterEtmVariableLoad": "Charge modulable",
"adapterSgReady": "SG-Ready",
"adapterEvCharger": "Borne de recharge",
"adapterUnknown": "Mécanisme « {code} »",
"@adapterUnknown": {
"placeholders": { "code": { "type": "String" } }
},
"loadEnabled": "Active",
"loadDisabled": "Exclue de l'arbitrage",
"loadPriority": "Rang {rank}",
"@loadPriority": {
"placeholders": { "rank": { "type": "int" } }
},
"loadMinOn": "Maintien ON minimal : {seconds} s",
"@loadMinOn": {
"placeholders": { "seconds": { "type": "int" } }
},
"loadMinOff": "Maintien OFF minimal : {seconds} s",
"@loadMinOff": {
"placeholders": { "seconds": { "type": "int" } }
},
"loadRelayCount": "{count, plural, =1{1 relais} other{{count} relais}}",
"@loadRelayCount": {
"placeholders": { "count": { "type": "int" } }
},
"loadMeterSlot": "Compteur dédié : non rattachable",
"@loadMeterSlot": {
"description": "Emplacement visuel réservé — LoadConfig n'a aucun champ pour rattacher un compteur à une charge"
},
"orderNotGroupedTitle": "Ordre non groupé",
"orderNotGroupedBody": "L'ordre en place sur la box n'est pas groupé par domaine : les charges d'un même domaine ne se suivent pas. L'ordre réel est affiché ci-dessous, tel que la box l'applique.",
"orderNotGroupedAction": "Normaliser l'ordre",
"orderNotGroupedWarning": "Normaliser modifiera l'ordre d'arbitrage réel de l'installation. Aucune écriture ne part tant que vous n'avez pas confirmé.",
"orderNormalizeConfirm": "Normaliser et enregistrer",
"cancel": "Annuler",
"saveOrder": "Enregistrer l'ordre",
"savePending": "Envoi en cours…",
"saveWaitingBox": "En attente de confirmation de la box…",
"saveConfirmed": "Configuration confirmée par la box.",
"saveRejected": "La box a refusé : {reason}",
"@saveRejected": {
"placeholders": { "reason": { "type": "String" } }
},
"saveDiverged": "La box a enregistré un état différent de celui demandé. L'affichage montre ce que la box applique réellement.",
"validationDuplicateId": "L'identifiant de charge « {id} » apparaît deux fois.",
"@validationDuplicateId": {
"placeholders": { "id": { "type": "String" } }
},
"validationThingClaimedTwice": "L'appareil {thingId} est commandé par deux charges actives, « {first} » et « {second} » : deux commandes contradictoires et une puissance comptée deux fois.",
"@validationThingClaimedTwice": {
"placeholders": {
"thingId": { "type": "String" },
"first": { "type": "String" },
"second": { "type": "String" }
}
},
"telemetryUnavailableTitle": "Télémétrie indisponible",
"telemetryUnavailableBody": "Cette box n'expose pas GetLoadTelemetry. Puissance allouée, verrous, disponibilité et motifs de décision ne sont pas lisibles depuis l'app.",
"telemetryStale": "Données anciennes — dernier point il y a {seconds} s",
"@telemetryStale": {
"placeholders": { "seconds": { "type": "int" } }
},
"telemetryDegradedMode": "Mode dégradé L2 — compteur muet, planification suspendue",
"faultTitle": "En défaut",
"faultClearAction": "Lever le défaut",
"faultCleared": "Défaut levé — confirmé par la télémétrie relue.",
"faultClearNoop": "Aucun défaut à lever sur cette charge.",
"faultUnknown": "Défaut « {code} »",
"@faultUnknown": {
"placeholders": { "code": { "type": "String" } }
},
"decisionUnknown": "Décision « {code} »{params}",
"@decisionUnknown": {
"description": "Repli obligatoire quand la box renvoie un decision.code absent de cet ARB — box plus récente que l'app. Affiche le code brut et ses paramètres, jamais une phrase vide.",
"placeholders": {
"code": { "type": "String" },
"params": { "type": "String" }
}
},
"localeRequested": "Locale demandée : {requested} · retenue par la box : {effective}",
"@localeRequested": {
"placeholders": {
"requested": { "type": "String" },
"effective": { "type": "String" }
}
}
}

View File

@ -0,0 +1,412 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:intl/intl.dart' as intl;
import 'app_localizations_fr.dart';
// ignore_for_file: type=lint
/// Callers can lookup localized strings with an instance of L10n
/// returned by `L10n.of(context)`.
///
/// Applications need to include `L10n.delegate()` in their app's
/// `localizationDelegates` list, and the locales they support in the app's
/// `supportedLocales` list. For example:
///
/// ```dart
/// import 'l10n/app_localizations.dart';
///
/// return MaterialApp(
/// localizationsDelegates: L10n.localizationsDelegates,
/// supportedLocales: L10n.supportedLocales,
/// home: MyApplicationHome(),
/// );
/// ```
///
/// ## Update pubspec.yaml
///
/// Please make sure to update your pubspec.yaml to include the following
/// packages:
///
/// ```yaml
/// dependencies:
/// # Internationalization support.
/// flutter_localizations:
/// sdk: flutter
/// intl: any # Use the pinned version from flutter_localizations
///
/// # Rest of dependencies
/// ```
///
/// ## iOS Applications
///
/// iOS applications define key application metadata, including supported
/// locales, in an Info.plist file that is built into the application bundle.
/// To configure the locales supported by your app, you’ll need to edit this
/// file.
///
/// First, open your project’s ios/Runner.xcworkspace Xcode workspace file.
/// Then, in the Project Navigator, open the Info.plist file under the Runner
/// project’s Runner folder.
///
/// Next, select the Information Property List item, select Add Item from the
/// Editor menu, then select Localizations from the pop-up menu.
///
/// Select and expand the newly-created Localizations item then, for each
/// locale your application supports, add a new item and select the locale
/// you wish to add from the pop-up menu in the Value field. This list should
/// be consistent with the languages listed in the L10n.supportedLocales
/// property.
abstract class L10n {
L10n(String locale)
: localeName = intl.Intl.canonicalizedLocale(locale.toString());
final String localeName;
static L10n? of(BuildContext context) {
return Localizations.of<L10n>(context, L10n);
}
static const LocalizationsDelegate<L10n> delegate = _L10nDelegate();
/// A list of this localizations delegate along with the default localizations
/// delegates.
///
/// Returns a list of localizations delegates containing this delegate along with
/// GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate,
/// and GlobalWidgetsLocalizations.delegate.
///
/// Additional delegates can be added by appending to this list in
/// MaterialApp. This list does not have to be used at all if a custom list
/// of delegates is preferred or required.
static const List<LocalizationsDelegate<dynamic>> localizationsDelegates =
<LocalizationsDelegate<dynamic>>[
delegate,
GlobalMaterialLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
];
/// A list of this localizations delegate's supported locales.
static const List<Locale> supportedLocales = <Locale>[Locale('fr')];
/// Titre de l'écran installateur de configuration des charges pilotables
///
/// In fr, this message translates to:
/// **'Rôles & appareils'**
String get installerLoadsTitle;
/// No description provided for @installerLoadsSubtitle.
///
/// In fr, this message translates to:
/// **'Ordre de service des charges pilotables'**
String get installerLoadsSubtitle;
/// No description provided for @loadsEmpty.
///
/// In fr, this message translates to:
/// **'Aucune charge pilotable déclarée sur cette installation.'**
String get loadsEmpty;
/// No description provided for @loadsLoadError.
///
/// In fr, this message translates to:
/// **'Lecture de la configuration impossible : {error}'**
String loadsLoadError(String error);
/// Bucket des charges sans o:domain — jamais rangées d'office dans un domaine
///
/// In fr, this message translates to:
/// **'Non classé'**
String get sectionUnclassified;
/// No description provided for @domainEv.
///
/// In fr, this message translates to:
/// **'Véhicule électrique'**
String get domainEv;
/// No description provided for @domainEcs.
///
/// In fr, this message translates to:
/// **'Eau chaude sanitaire'**
String get domainEcs;
/// No description provided for @domainHvac.
///
/// In fr, this message translates to:
/// **'Climatisation'**
String get domainHvac;
/// No description provided for @domainHeating.
///
/// In fr, this message translates to:
/// **'Chauffage'**
String get domainHeating;
/// Repli quand la box renvoie un domaine que cette version d'app ne connaît pas
///
/// In fr, this message translates to:
/// **'Domaine « {code} »'**
String domainUnknown(String code);
/// No description provided for @adapterRelayRouter.
///
/// In fr, this message translates to:
/// **'Routeur de relais'**
String get adapterRelayRouter;
/// No description provided for @adapterEtmVariableLoad.
///
/// In fr, this message translates to:
/// **'Charge modulable'**
String get adapterEtmVariableLoad;
/// No description provided for @adapterSgReady.
///
/// In fr, this message translates to:
/// **'SG-Ready'**
String get adapterSgReady;
/// No description provided for @adapterEvCharger.
///
/// In fr, this message translates to:
/// **'Borne de recharge'**
String get adapterEvCharger;
/// No description provided for @adapterUnknown.
///
/// In fr, this message translates to:
/// **'Mécanisme « {code} »'**
String adapterUnknown(String code);
/// No description provided for @loadEnabled.
///
/// In fr, this message translates to:
/// **'Active'**
String get loadEnabled;
/// No description provided for @loadDisabled.
///
/// In fr, this message translates to:
/// **'Exclue de l\'arbitrage'**
String get loadDisabled;
/// No description provided for @loadPriority.
///
/// In fr, this message translates to:
/// **'Rang {rank}'**
String loadPriority(int rank);
/// No description provided for @loadMinOn.
///
/// In fr, this message translates to:
/// **'Maintien ON minimal : {seconds} s'**
String loadMinOn(int seconds);
/// No description provided for @loadMinOff.
///
/// In fr, this message translates to:
/// **'Maintien OFF minimal : {seconds} s'**
String loadMinOff(int seconds);
/// No description provided for @loadRelayCount.
///
/// In fr, this message translates to:
/// **'{count, plural, =1{1 relais} other{{count} relais}}'**
String loadRelayCount(int count);
/// Emplacement visuel réservé — LoadConfig n'a aucun champ pour rattacher un compteur à une charge
///
/// In fr, this message translates to:
/// **'Compteur dédié : non rattachable'**
String get loadMeterSlot;
/// No description provided for @orderNotGroupedTitle.
///
/// In fr, this message translates to:
/// **'Ordre non groupé'**
String get orderNotGroupedTitle;
/// No description provided for @orderNotGroupedBody.
///
/// In fr, this message translates to:
/// **'L\'ordre en place sur la box n\'est pas groupé par domaine : les charges d\'un même domaine ne se suivent pas. L\'ordre réel est affiché ci-dessous, tel que la box l\'applique.'**
String get orderNotGroupedBody;
/// No description provided for @orderNotGroupedAction.
///
/// In fr, this message translates to:
/// **'Normaliser l\'ordre'**
String get orderNotGroupedAction;
/// No description provided for @orderNotGroupedWarning.
///
/// In fr, this message translates to:
/// **'Normaliser modifiera l\'ordre d\'arbitrage réel de l\'installation. Aucune écriture ne part tant que vous n\'avez pas confirmé.'**
String get orderNotGroupedWarning;
/// No description provided for @orderNormalizeConfirm.
///
/// In fr, this message translates to:
/// **'Normaliser et enregistrer'**
String get orderNormalizeConfirm;
/// No description provided for @cancel.
///
/// In fr, this message translates to:
/// **'Annuler'**
String get cancel;
/// No description provided for @saveOrder.
///
/// In fr, this message translates to:
/// **'Enregistrer l\'ordre'**
String get saveOrder;
/// No description provided for @savePending.
///
/// In fr, this message translates to:
/// **'Envoi en cours…'**
String get savePending;
/// No description provided for @saveWaitingBox.
///
/// In fr, this message translates to:
/// **'En attente de confirmation de la box…'**
String get saveWaitingBox;
/// No description provided for @saveConfirmed.
///
/// In fr, this message translates to:
/// **'Configuration confirmée par la box.'**
String get saveConfirmed;
/// No description provided for @saveRejected.
///
/// In fr, this message translates to:
/// **'La box a refusé : {reason}'**
String saveRejected(String reason);
/// No description provided for @saveDiverged.
///
/// In fr, this message translates to:
/// **'La box a enregistré un état différent de celui demandé. L\'affichage montre ce que la box applique réellement.'**
String get saveDiverged;
/// No description provided for @validationDuplicateId.
///
/// In fr, this message translates to:
/// **'L\'identifiant de charge « {id} » apparaît deux fois.'**
String validationDuplicateId(String id);
/// No description provided for @validationThingClaimedTwice.
///
/// In fr, this message translates to:
/// **'L\'appareil {thingId} est commandé par deux charges actives, « {first} » et « {second} » : deux commandes contradictoires et une puissance comptée deux fois.'**
String validationThingClaimedTwice(
String thingId,
String first,
String second,
);
/// No description provided for @telemetryUnavailableTitle.
///
/// In fr, this message translates to:
/// **'Télémétrie indisponible'**
String get telemetryUnavailableTitle;
/// No description provided for @telemetryUnavailableBody.
///
/// In fr, this message translates to:
/// **'Cette box n\'expose pas GetLoadTelemetry. Puissance allouée, verrous, disponibilité et motifs de décision ne sont pas lisibles depuis l\'app.'**
String get telemetryUnavailableBody;
/// No description provided for @telemetryStale.
///
/// In fr, this message translates to:
/// **'Données anciennes — dernier point il y a {seconds} s'**
String telemetryStale(int seconds);
/// No description provided for @telemetryDegradedMode.
///
/// In fr, this message translates to:
/// **'Mode dégradé L2 — compteur muet, planification suspendue'**
String get telemetryDegradedMode;
/// No description provided for @faultTitle.
///
/// In fr, this message translates to:
/// **'En défaut'**
String get faultTitle;
/// No description provided for @faultClearAction.
///
/// In fr, this message translates to:
/// **'Lever le défaut'**
String get faultClearAction;
/// No description provided for @faultCleared.
///
/// In fr, this message translates to:
/// **'Défaut levé — confirmé par la télémétrie relue.'**
String get faultCleared;
/// No description provided for @faultClearNoop.
///
/// In fr, this message translates to:
/// **'Aucun défaut à lever sur cette charge.'**
String get faultClearNoop;
/// No description provided for @faultUnknown.
///
/// In fr, this message translates to:
/// **'Défaut « {code} »'**
String faultUnknown(String code);
/// Repli obligatoire quand la box renvoie un decision.code absent de cet ARB — box plus récente que l'app. Affiche le code brut et ses paramètres, jamais une phrase vide.
///
/// In fr, this message translates to:
/// **'Décision « {code} »{params}'**
String decisionUnknown(String code, String params);
/// No description provided for @localeRequested.
///
/// In fr, this message translates to:
/// **'Locale demandée : {requested} · retenue par la box : {effective}'**
String localeRequested(String requested, String effective);
}
class _L10nDelegate extends LocalizationsDelegate<L10n> {
const _L10nDelegate();
@override
Future<L10n> load(Locale locale) {
return SynchronousFuture<L10n>(lookupL10n(locale));
}
@override
bool isSupported(Locale locale) =>
<String>['fr'].contains(locale.languageCode);
@override
bool shouldReload(_L10nDelegate old) => false;
}
L10n lookupL10n(Locale locale) {
// Lookup logic when only language code is specified.
switch (locale.languageCode) {
case 'fr':
return L10nFr();
}
throw FlutterError(
'L10n.delegate failed to load unsupported locale "$locale". This is likely '
'an issue with the localizations generation tool. Please file an issue '
'on GitHub with a reproducible sample app and the gen-l10n configuration '
'that was used.',
);
}

View File

@ -0,0 +1,196 @@
// ignore: unused_import
import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
// ignore_for_file: type=lint
/// The translations for French (`fr`).
class L10nFr extends L10n {
L10nFr([String locale = 'fr']) : super(locale);
@override
String get installerLoadsTitle => 'Rôles & appareils';
@override
String get installerLoadsSubtitle =>
'Ordre de service des charges pilotables';
@override
String get loadsEmpty =>
'Aucune charge pilotable déclarée sur cette installation.';
@override
String loadsLoadError(String error) {
return 'Lecture de la configuration impossible : $error';
}
@override
String get sectionUnclassified => 'Non classé';
@override
String get domainEv => 'Véhicule électrique';
@override
String get domainEcs => 'Eau chaude sanitaire';
@override
String get domainHvac => 'Climatisation';
@override
String get domainHeating => 'Chauffage';
@override
String domainUnknown(String code) {
return 'Domaine « $code »';
}
@override
String get adapterRelayRouter => 'Routeur de relais';
@override
String get adapterEtmVariableLoad => 'Charge modulable';
@override
String get adapterSgReady => 'SG-Ready';
@override
String get adapterEvCharger => 'Borne de recharge';
@override
String adapterUnknown(String code) {
return 'Mécanisme « $code »';
}
@override
String get loadEnabled => 'Active';
@override
String get loadDisabled => 'Exclue de l\'arbitrage';
@override
String loadPriority(int rank) {
return 'Rang $rank';
}
@override
String loadMinOn(int seconds) {
return 'Maintien ON minimal : $seconds s';
}
@override
String loadMinOff(int seconds) {
return 'Maintien OFF minimal : $seconds s';
}
@override
String loadRelayCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count relais',
one: '1 relais',
);
return '$_temp0';
}
@override
String get loadMeterSlot => 'Compteur dédié : non rattachable';
@override
String get orderNotGroupedTitle => 'Ordre non groupé';
@override
String get orderNotGroupedBody =>
'L\'ordre en place sur la box n\'est pas groupé par domaine : les charges d\'un même domaine ne se suivent pas. L\'ordre réel est affiché ci-dessous, tel que la box l\'applique.';
@override
String get orderNotGroupedAction => 'Normaliser l\'ordre';
@override
String get orderNotGroupedWarning =>
'Normaliser modifiera l\'ordre d\'arbitrage réel de l\'installation. Aucune écriture ne part tant que vous n\'avez pas confirmé.';
@override
String get orderNormalizeConfirm => 'Normaliser et enregistrer';
@override
String get cancel => 'Annuler';
@override
String get saveOrder => 'Enregistrer l\'ordre';
@override
String get savePending => 'Envoi en cours…';
@override
String get saveWaitingBox => 'En attente de confirmation de la box…';
@override
String get saveConfirmed => 'Configuration confirmée par la box.';
@override
String saveRejected(String reason) {
return 'La box a refusé : $reason';
}
@override
String get saveDiverged =>
'La box a enregistré un état différent de celui demandé. L\'affichage montre ce que la box applique réellement.';
@override
String validationDuplicateId(String id) {
return 'L\'identifiant de charge « $id » apparaît deux fois.';
}
@override
String validationThingClaimedTwice(
String thingId,
String first,
String second,
) {
return 'L\'appareil $thingId est commandé par deux charges actives, « $first » et « $second » : deux commandes contradictoires et une puissance comptée deux fois.';
}
@override
String get telemetryUnavailableTitle => 'Télémétrie indisponible';
@override
String get telemetryUnavailableBody =>
'Cette box n\'expose pas GetLoadTelemetry. Puissance allouée, verrous, disponibilité et motifs de décision ne sont pas lisibles depuis l\'app.';
@override
String telemetryStale(int seconds) {
return 'Données anciennes — dernier point il y a $seconds s';
}
@override
String get telemetryDegradedMode =>
'Mode dégradé L2 — compteur muet, planification suspendue';
@override
String get faultTitle => 'En défaut';
@override
String get faultClearAction => 'Lever le défaut';
@override
String get faultCleared => 'Défaut levé — confirmé par la télémétrie relue.';
@override
String get faultClearNoop => 'Aucun défaut à lever sur cette charge.';
@override
String faultUnknown(String code) {
return 'Défaut « $code »';
}
@override
String decisionUnknown(String code, String params) {
return 'Décision « $code »$params';
}
@override
String localeRequested(String requested, String effective) {
return 'Locale demandée : $requested · retenue par la box : $effective';
}
}

12
lib/l10n/l10n_ext.dart Normal file
View File

@ -0,0 +1,12 @@
import 'package:flutter/widgets.dart';
import 'app_localizations.dart';
/// Accès non-nullable aux chaînes localisées.
///
/// `L10n.of(context)` est nullable par génération, mais le délégué est installé dans
/// `MaterialApp.router` : sous cet arbre, l'absence est un bug de câblage, pas un cas
/// à gérer widget par widget. Un `!` répété à chaque appel bruiterait tout le code UI.
extension L10nContext on BuildContext {
L10n get l10n => L10n.of(this)!;
}

View File

@ -7,12 +7,14 @@ import 'package:flutter/foundation.dart' show kIsWeb;
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/navigation_provider.dart';
import 'providers/scheduler_provider.dart';
import 'providers/tariff_provider.dart';
import 'screens/ac_screen.dart';
import 'screens/dashboard_screen.dart';
import 'screens/drawer/main_drawer.dart';
import 'screens/energy/load_order_screen.dart';
import 'screens/energy/roles_devices_screen.dart';
import 'screens/energy/scheduler_screen.dart';
import 'screens/energy/tariff_screen.dart';
@ -30,6 +32,7 @@ import 'screens/things_screen.dart';
import 'services/connection_manager.dart';
import 'services/installation_store.dart';
import 'services/nymea_service.dart';
import 'l10n/app_localizations.dart';
import 'theme/app_theme.dart';
import 'theme/etm_tokens.dart';
@ -37,15 +40,26 @@ import 'theme/etm_tokens.dart';
// Router GoRouter — gate de connexion (Étape 6)
// ─────────────────────────────────────────────────────────────────────────────
/// Routes qui exigent le mode installateur déverrouillé.
bool _isInstallerRoute(String loc) =>
loc.startsWith('/settings/system') ||
loc == '/settings/app/developer' ||
loc == '/energy/setup' ||
loc == '/energy/loads';
/// Construit le routeur avec le **gate de connexion**.
///
/// Condition = « pas de connexion active » ([ConnectionManager.hasActiveConnection],
/// qui inclut la démo). `refreshListenable` écoute le manager **et** le service :
/// une déconnexion subie (socket qui tombe → notif du service, pas du manager)
/// doit ré-évaluer le gate et renvoyer vers Installations.
GoRouter buildAppRouter(ConnectionManager cm, NymeaService svc) => GoRouter(
GoRouter buildAppRouter(
ConnectionManager cm,
NymeaService svc,
InstallerModeProvider installer,
) => GoRouter(
initialLocation: '/',
refreshListenable: Listenable.merge([cm, svc]),
refreshListenable: Listenable.merge([cm, svc, installer]),
redirect: (context, state) {
final loc = state.matchedLocation;
final onInstall =
@ -54,6 +68,9 @@ GoRouter buildAppRouter(ConnectionManager cm, NymeaService svc) => GoRouter(
// Pas de session (ni box réelle, ni démo) → Installations en racine.
return onInstall ? null : '/installations';
}
// Écrans réservés à l'installateur : masqués dans le drawer ET bloqués ici
// (accès direct par URL, deep-link, ou auto-lock pendant la navigation).
if (!installer.isUnlocked && _isInstallerRoute(loc)) return '/';
// Actif (ou démo) → on laisse tout ; Installations reste accessible (drawer).
return null;
},
@ -92,6 +109,10 @@ GoRouter buildAppRouter(ConnectionManager cm, NymeaService svc) => GoRouter(
path: '/energy/setup',
builder: (c, s) => const RolesDevicesScreen(),
),
GoRoute(
path: '/energy/loads',
builder: (c, s) => const LoadOrderScreen(),
),
GoRoute(
path: '/energy/scheduler',
builder: (c, s) => const SchedulerScreen(),
@ -164,6 +185,8 @@ void main() async {
final energySetup = EnergySetupProvider();
final scheduler = SchedulerProvider();
final tariff = TariffProvider();
final loadConfig = LoadConfigProvider();
connectionManager.registerCacheClear(loadConfig.clearForSwitch);
connectionManager.registerCacheClear(energySetup.clearForSwitch);
connectionManager.registerCacheClear(scheduler.clearForSwitch);
connectionManager.registerCacheClear(tariff.clearForSwitch);
@ -184,9 +207,10 @@ void main() async {
ChangeNotifierProvider.value(value: energySetup),
ChangeNotifierProvider.value(value: scheduler),
ChangeNotifierProvider.value(value: tariff),
ChangeNotifierProvider.value(value: loadConfig),
],
child: NymeaEnergyApp(
router: buildAppRouter(connectionManager, nymeaService),
router: buildAppRouter(connectionManager, nymeaService, installerMode),
),
),
);
@ -210,6 +234,12 @@ class NymeaEnergyApp extends StatelessWidget {
theme: AppTheme.theme,
darkTheme: AppTheme.darkTheme,
themeMode: settings.themeMode,
// i18n — français seul pour l'instant. L'ossature ARB est en place pour que
// l'ajout d'une langue soit un fichier à remplir, pas une extraction
// rétroactive de chaînes noyées dans les widgets.
localizationsDelegates: L10n.localizationsDelegates,
supportedLocales: L10n.supportedLocales,
locale: const Locale('fr'),
routerConfig: router,
builder: (context, child) {
// Sur mobile natif, on rend tel quel

View File

@ -0,0 +1,125 @@
/// Vue **en lecture** au-dessus de la charge utile brute de `NymeaEnergy.GetLoadConfig`.
///
/// ## Pourquoi une vue et non un modèle
///
/// La `Map` reçue est la **source de vérité en mémoire**. Ce type ne la remplace pas :
/// il l'interprète. L'écriture repart toujours de [raw] avec les seuls champs modifiés
/// patchés ([patched]), jamais d'une re-sérialisation.
///
/// La raison est concrète : le plugin sérialise `LoadConfig` **via le méta-objet**, donc
/// `GetLoadConfig` émet TOUTES les propriétés déclarées — y compris les charges utiles
/// vides des mécanismes inutilisés (`sgReady: {}`, `powerLevels: []`, `needs` à défaut).
/// Un modèle typé qui reconstruirait l'objet amputerait la config des mécanismes qu'il
/// ne modélise pas, en silence, au premier `SetLoadConfig`.
///
/// ## Clés inconnues : pas de filtrage préventif
///
/// nymea **rejette** toute clé hors schéma avant d'atteindre le handler
/// (`jsonvalidator.cpp:143`, `"Invalid key"`), donc transporter une clé inconnue ferait
/// rejeter tout l'appel plutôt que de préserver la config. Mais renvoyer verbatim ce que
/// *cette box* vient de donner ne peut échouer que si les schémas de `Get` et de `Set`
/// divergent. Cet aller-retour a été vérifié neutre contre `.75` le 2026-08-25
/// (`test/fixtures/loadconfig_hems75.json`) : l'écho verbatim est donc sûr par
/// construction, et aucune couche de filtrage n'est justifiée ici. Une telle couche aurait
/// son propre risque — jeter en silence une clé que la box aurait acceptée.
library;
/// Un relais d'une charge `relay-router` : `{thingId, powerW}`.
class LoadRelay {
final String thingId;
final int powerW;
const LoadRelay({required this.thingId, required this.powerW});
}
/// Normalise un ThingId à la forme `{uuid}` en minuscules.
///
/// La configuration mélange les formes avec et sans accolades — le plugin normalise via
/// `QUuid` (`loadconfig.cpp:150-166`). Une comparaison textuelle brute laisserait passer
/// un conflit de revendication entre deux charges.
String normalizeThingId(String raw) {
final bare = raw.replaceAll(RegExp(r'[{}]'), '').toLowerCase().trim();
return bare.isEmpty ? '' : '{$bare}';
}
class LoadConfigEntry {
/// Charge utile brute, **intacte**. Source de vérité.
final Map<String, dynamic> raw;
const LoadConfigEntry(this.raw);
// ── Champs communs (requis par le schéma SET) ──────────────────────────────
String get id => raw['id'] as String? ?? '';
String get label => raw['label'] as String? ?? id;
String get adapter => raw['adapter'] as String? ?? 'etmvariableload';
String get mode => raw['mode'] as String? ?? '';
bool get enabled => raw['enabled'] as bool? ?? true;
int get priority => (raw['priority'] as num?)?.toInt() ?? 0;
// ── Temporisations (lecture seule dans ce lot) ─────────────────────────────
int get minOnS => (raw['minOnS'] as num?)?.toInt() ?? 0;
int get minOffS => (raw['minOffS'] as num?)?.toInt() ?? 0;
/// Domaine d'intention saisi par l'installateur (`ev`, `ecs`, `hvac`, `heating`).
///
/// `null` tant que la box n'expose pas la clé — la charge tombe alors dans le bucket
/// « non classé ». **Ne jamais déduire le domaine du mécanisme** : un relais peut
/// piloter un chauffe-eau comme une prise commandée de recharge.
String? get domain {
final d = raw['domain'];
if (d is! String || d.trim().isEmpty) return null;
return d.trim();
}
bool get isRelayRouter => adapter == 'relay-router';
bool get isSgReady => adapter == 'sg-ready';
List<LoadRelay> get relays {
final list = raw['relays'];
if (list is! List) return const [];
return list.whereType<Map>().map((m) => LoadRelay(
thingId: m['thingId'] as String? ?? '',
powerW: (m['powerW'] as num?)?.toInt() ?? 0,
)).toList();
}
/// Things revendiqués par cette charge, forme normalisée `{uuid}`.
///
/// Miroir exact de `LoadConfig::claimedThingIds()` (`loadconfig.cpp:150-166`) : la liste
/// dépend du mécanisme. `etmvariableload` pilote le Thing qui porte son propre `id` ;
/// les deux autres nomment leurs relais.
Set<String> get claimedThingIds {
if (isSgReady) {
final sg = raw['sgReady'];
if (sg is! Map) return const {};
final states = sg['states'];
if (states is! List) return const {};
return {
for (final s in states.whereType<Map>())
if (s['relays'] is List)
...(s['relays'] as List).whereType<String>().map(normalizeThingId),
}..removeWhere((e) => e.isEmpty);
}
if (isRelayRouter) {
return relays.map((r) => normalizeThingId(r.thingId)).toSet()
..removeWhere((e) => e.isEmpty);
}
// etmvariableload : l'id de la charge EST le ThingId piloté.
final n = normalizeThingId(id);
return n.isEmpty ? const {} : {n};
}
/// Copie patchée : repart de [raw] et ne remplace **que** les clés fournies.
///
/// Toute clé absente des paramètres est transportée telle quelle — c'est ce qui
/// préserve les charges utiles des mécanismes non modélisés par l'app.
Map<String, dynamic> patched({int? priority, String? domain}) {
return {
...raw,
'priority': ?priority,
'domain': ?domain,
};
}
@override
String toString() => 'LoadConfigEntry($id, $adapter, p=$priority, d=$domain)';
}

View File

@ -390,8 +390,24 @@ class EnergySetupProvider extends ChangeNotifier {
/// Pousse l'état courant vers le plugin :
/// • gridMeter → `Energy.SetRootMeter` (**réel**, seulement si changé) ;
/// • EV / ECS → `SetLoadConfig` (**stub** : logge le JSON) ;
/// • PAC / batterie → chemins distincts (rév. 2), tracés explicitement.
///
/// ## Pourquoi `SetLoadConfig` n'est PLUS appelé ici
///
/// [buildLoadDescriptors] **reconstruit** des charges utiles depuis le modèle typé
/// de cet écran. Tant que `NymeaEnergy.SetLoadConfig` était un stub qui journalisait,
/// c'était sans conséquence. Ce n'est plus le cas : la méthode écrit réellement, et
/// `SetLoadConfig` **remplace l'ensemble** des charges.
///
/// Envoyer une reconstruction écraserait donc la configuration réelle de la box par
/// une projection partielle — sur `.75`, la charge `chauffe-eau` (relay-router, trois
/// relais) disparaîtrait au profit de descripteurs dérivés des `EmsRole`. Et
/// `_commit()` appelle `persist()` à **chaque** changement d'UI : la destruction
/// partirait sans geste explicite de l'utilisateur.
///
/// Le chemin sanctionné est *lire → patcher → réécrire* : `getLoadConfig()` puis
/// `LoadConfigEntry.patched()` puis `setLoadConfig()`, où les charges utiles des
/// mécanismes non modélisés sont transportées telles quelles.
void persist() {
final service = _service;
if (service == null) return;
@ -402,7 +418,9 @@ class EnergySetupProvider extends ChangeNotifier {
service.setRootMeter(gridId);
}
service.setLoadConfig(buildLoadDescriptors());
service.logInfo('[LoadConfig] ${buildLoadDescriptors().length} descripteur(s) '
'construits mais NON émis : SetLoadConfig remplace tout l\'ensemble, une '
'reconstruction écraserait la config réelle de la box.');
for (final role in controllableRoles) {
if (role == EmsRole.heatPump) {

View File

@ -0,0 +1,256 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../models/load_config_entry.dart';
import '../services/load_priority.dart';
import '../services/nymea_service.dart';
/// État de l'écriture en cours — l'acquittement d'un RPC ne vaut PAS application.
enum LoadSaveState {
/// Rien en cours.
idle,
/// `SetLoadConfig` envoyé, pas encore confirmé par `LoadConfigChanged`.
pending,
/// La box a confirmé, et ce qu'elle a confirmé correspond à ce qui a été demandé.
confirmed,
/// La box a confirmé, mais l'état reçu diffère de celui demandé.
/// L'affichage montre alors ce que la box dit, pas ce que l'utilisateur voulait.
diverged,
/// La box a refusé (ou le transport a échoué).
rejected,
}
/// Configuration des charges pilotables — lecture, réordonnancement, écriture.
///
/// **Aucune persistance côté app.** La source de vérité est
/// `/var/lib/nymea/energy-load-configuration.json` sur la box ; une copie côté téléphone
/// créerait deux configurations divergentes.
class LoadConfigProvider extends ChangeNotifier {
NymeaService? _service;
void Function()? _unsubscribe;
List<LoadConfigEntry> _entries = const [];
GroupedOrder? _grouped;
/// Ordre en cours d'édition. `null` tant que l'installateur n'a rien déplacé —
/// c'est ce qui garantit qu'aucune écriture ne peut partir sans geste explicite.
List<DomainGroup>? _draft;
bool _loading = false;
String? _error;
LoadSaveState _saveState = LoadSaveState.idle;
String? _saveError;
LoadSetConflict? _conflict;
/// Signature de ce qui a été envoyé, pour comparer à ce que la box confirme.
String? _expectedSignature;
Timer? _confirmTimeout;
// ── Lecture ────────────────────────────────────────────────────────────────
List<LoadConfigEntry> get entries => _entries;
GroupedOrder? get grouped => _grouped;
bool get loading => _loading;
String? get error => _error;
LoadSaveState get saveState => _saveState;
String? get saveError => _saveError;
LoadSetConflict? get conflict => _conflict;
/// Groupes tels qu'affichés : le brouillon s'il existe, sinon l'état de la box.
List<DomainGroup> get groups => _draft ?? _grouped?.groups ?? const [];
/// Vrai si l'ordre en place sur la box n'est pas groupé par domaine.
bool get isNotGrouped => _grouped != null && !_grouped!.isContiguous;
List<String> get interleavedDomains => _grouped?.interleavedDomains ?? const [];
/// Vrai s'il y a un changement non enregistré.
bool get hasPendingChanges => _draft != null;
void attach(NymeaService service) {
if (identical(_service, service)) return;
_unsubscribe?.call();
_service = service;
_unsubscribe = service.onLoadConfigChanged(_onBoxConfirmed);
}
Future<void> load() async {
final service = _service;
if (service == null) return;
_loading = true;
_error = null;
notifyListeners();
try {
final raw = await service.getLoadConfig();
_adopt(raw);
} catch (e) {
_error = e.toString();
} finally {
_loading = false;
notifyListeners();
}
}
void _adopt(List<Map<String, dynamic>> raw) {
_entries = raw.map(LoadConfigEntry.new).toList();
_grouped = groupByDomain(_entries);
_draft = null;
_conflict = null;
}
// ── Édition (brouillon, aucune écriture) ───────────────────────────────────
/// Déplace une charge à l'intérieur de son domaine.
void reorderWithinDomain(String domain, int oldIndex, int newIndex) {
final current = [...groups];
final gi = current.indexWhere((g) => g.domain == domain);
if (gi < 0) return;
final loads = [...current[gi].loads];
if (oldIndex < 0 || oldIndex >= loads.length) return;
if (newIndex > oldIndex) newIndex--;
final item = loads.removeAt(oldIndex);
loads.insert(newIndex.clamp(0, loads.length), item);
current[gi] = current[gi].copyWith(loads: loads);
_draft = current;
notifyListeners();
}
/// Déplace un domaine entier dans l'ordre des domaines.
void reorderDomains(int oldIndex, int newIndex) {
final current = [...groups];
if (oldIndex < 0 || oldIndex >= current.length) return;
if (newIndex > oldIndex) newIndex--;
final item = current.removeAt(oldIndex);
current.insert(newIndex.clamp(0, current.length), item);
_draft = current;
notifyListeners();
}
/// Prépare une normalisation — **ne l'écrit pas**. L'installateur doit confirmer.
void prepareNormalization() {
final g = _grouped;
if (g == null) return;
_draft = applyDefaultDomainOrder(g.groups);
notifyListeners();
}
/// Abandonne le brouillon et revient à l'état de la box.
void discardDraft() {
_draft = null;
_conflict = null;
notifyListeners();
}
// ── Écriture ───────────────────────────────────────────────────────────────
/// Envoie l'ordre en brouillon. Ne fait rien s'il n'y a pas de brouillon —
/// aucune écriture ne peut donc partir d'un simple affichage.
Future<void> save() async {
final service = _service;
final draft = _draft;
if (service == null || draft == null) return;
// Miroir client de validateSet() : produire un message utile plutôt qu'un rejet sec.
// La box reste l'autorité — ceci n'est qu'un confort d'affichage.
final flat = [for (final g in draft) ...g.loads];
final conflict = validateSet(flat);
if (conflict != null) {
_conflict = conflict;
_saveState = LoadSaveState.rejected;
notifyListeners();
return;
}
final payload = flattenToPayload(draft);
_expectedSignature = _signatureOfPayload(payload);
_conflict = null;
_saveError = null;
_saveState = LoadSaveState.pending;
notifyListeners();
final err = await service.setLoadConfig(payload);
if (err != null) {
_saveState = LoadSaveState.rejected;
_saveError = err;
_expectedSignature = null;
notifyListeners();
return;
}
// Acquittement reçu — on reste en « en cours » jusqu'à LoadConfigChanged.
// Si la notification n'arrive pas, on relit plutôt que d'annoncer un succès.
_confirmTimeout?.cancel();
_confirmTimeout = Timer(const Duration(seconds: 6), () async {
if (_saveState != LoadSaveState.pending) return;
await load();
_reconcile();
});
}
void _onBoxConfirmed(List<Map<String, dynamic>> configs) {
_adopt(configs);
_reconcile();
notifyListeners();
}
void _reconcile() {
_confirmTimeout?.cancel();
if (_expectedSignature == null) {
// Changement venu d'ailleurs (fichier édité, autre client) : on adopte, sans
// prétendre qu'il s'agit du résultat d'une action locale.
notifyListeners();
return;
}
final actual = _signatureOfEntries(_entries);
_saveState = actual == _expectedSignature
? LoadSaveState.confirmed
: LoadSaveState.diverged;
_expectedSignature = null;
notifyListeners();
}
/// Signature = suite `id:priority:domain` dans l'ordre de service. C'est exactement
/// ce que ce lot est autorisé à modifier ; comparer plus large ferait crier à la
/// divergence sur un champ que l'app n'a pas touché.
static String _signatureOfPayload(List<Map<String, dynamic>> payload) {
final rows = [
for (final m in payload)
'${m['id']}:${m['priority']}:${m['domain'] ?? ''}',
]..sort();
return rows.join('|');
}
static String _signatureOfEntries(List<LoadConfigEntry> entries) {
final rows = [
for (final e in entries) '${e.id}:${e.priority}:${e.domain ?? ''}',
]..sort();
return rows.join('|');
}
/// Vide l'état au switch d'installation — la config appartient à la box quittée.
void clearForSwitch() {
_entries = const [];
_grouped = null;
_draft = null;
_error = null;
_conflict = null;
_saveError = null;
_saveState = LoadSaveState.idle;
_expectedSignature = null;
_confirmTimeout?.cancel();
notifyListeners();
}
@override
void dispose() {
_confirmTimeout?.cancel();
_unsubscribe?.call();
super.dispose();
}
}

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../providers/app_settings_provider.dart';
import '../../providers/installer_mode_provider.dart';
import '../../providers/navigation_provider.dart';
import '../../services/connection_manager.dart';
@ -17,6 +18,20 @@ import 'installer_pin_dialog.dart';
// 2. DrawerPanel animé (Transform.translate)
// ─────────────────────────────────────────────────────────────────────────────
/// Route associée à chaque `AppScreen.id` de [AppSettingsProvider].
/// Un id absent de cette table n'est jamais affiché dans le drawer.
const Map<String, String> kScreenRoutes = {
'dashboard': '/',
'energy': '/energy',
'things': '/things',
'favorites': '/favorites',
'groups': '/groups',
'scenes': '/scenes',
'media': '/media',
'garages': '/garages',
'ac': '/ac',
};
/// Couche sombre cliquable qui ferme le drawer.
class DrawerScrim extends StatelessWidget {
const DrawerScrim({super.key});
@ -223,21 +238,24 @@ class _DrawerMenu extends StatelessWidget {
@override
Widget build(BuildContext context) {
final installer = context.watch<InstallerModeProvider>();
final settings = context.watch<AppSettingsProvider>();
return ListView(
padding: const EdgeInsets.symmetric(vertical: 8),
children: [
// ── VUE PRINCIPALE ────────────────────────────────────────────────
// Liste pilotée par « App Settings → Écrans actifs » : ordre et
// visibilité viennent de AppSettingsProvider. Les écrans smarthome
// (Groupes/Scènes/Médias/Garages) sont masqués par défaut — ce HEMS
// est un gestionnaire d'énergie.
_SectionLabel('VUE PRINCIPALE'),
_NavItem(icon: Icons.home_rounded, label: 'Dashboard', route: '/'),
_NavItem(icon: Icons.bar_chart_rounded, label: 'Énergie', route: '/energy'),
_NavItem(icon: Icons.device_hub_rounded, label: 'Things', route: '/things'),
_NavItem(icon: Icons.star_rounded, label: 'Favoris', route: '/favorites'),
_NavItem(icon: Icons.group_work_rounded, label: 'Groupes', route: '/groups'),
_NavItem(icon: Icons.auto_awesome_rounded, label: 'Scènes', route: '/scenes'),
_NavItem(icon: Icons.music_note_rounded, label: 'Médias', route: '/media'),
_NavItem(icon: Icons.garage_rounded, label: 'Garages', route: '/garages'),
_NavItem(icon: Icons.ac_unit_rounded, label: 'AC / Climatisation', route: '/ac'),
for (final screen in settings.visibleScreens)
if (kScreenRoutes[screen.id] != null)
_NavItem(
icon: screen.icon,
label: screen.label,
route: kScreenRoutes[screen.id]!,
),
const SizedBox(height: 4),
_Divider(),
@ -266,18 +284,16 @@ class _DrawerMenu extends StatelessWidget {
_LinkItem(
icon: Icons.menu_book_rounded,
label: 'Documentation',
url: 'https://etm.at/powersync/docs',
url: 'https://docs.etm-powersync.fr/',
),
_LinkItem(
icon: Icons.telegram_rounded,
label: 'Telegram',
url: 'https://t.me/etm_support',
),
_LinkItem(
icon: Icons.discord_rounded,
label: 'Discord',
url: 'https://discord.gg/etm',
url: 'https://t.me/etm_powersync_support',
),
// Discord désactivé — pas de serveur ETM-PowerSync pour l'instant.
// Réactiver ici dès que l'invite permanente existe :
// _LinkItem(icon: Icons.discord_rounded, label: 'Discord', url: '...'),
_NavItem(
icon: Icons.bug_report_rounded,
label: 'Rapport de bug',
@ -431,7 +447,8 @@ class _EnergyManagerExpansion extends StatelessWidget {
icon: Icons.energy_savings_leaf_rounded,
label: 'Gestionnaire d\'énergie',
children: [
_SubItem('Rôles & appareils', '/energy/setup'),
// 'Rôles & appareils' a migré dans la section MODE INSTALLATEUR :
// c'est de la configuration d'installation, pas un réglage utilisateur.
_SubItem('Scheduler & stratégie', '/energy/scheduler'),
_SubItem('Tarifs & providers', '/energy/tariffs'),
_SubItem('Timeline & décisions', '/energy/timeline'),
@ -450,7 +467,7 @@ class _AppSettingsExpansion extends StatelessWidget {
children: [
_SubItem('Apparence', '/settings/app/appearance'),
_SubItem('Écrans actifs', '/settings/app/screens'),
_SubItem('Options développeur', '/settings/app/developer'),
// 'Options développeur' a migré dans la section MODE INSTALLATEUR.
_SubItem('À propos PowerSync', '/settings/app/about'),
],
);
@ -549,6 +566,18 @@ class _InstallerSection extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SectionLabel('MODE INSTALLATEUR'),
_NavItem(
icon: Icons.account_tree_rounded,
label: 'Rôles & appareils',
route: '/energy/setup',
push: true,
),
_NavItem(
icon: Icons.low_priority_rounded,
label: 'Ordre de service des charges',
route: '/energy/loads',
push: true,
),
_NavItem(
icon: Icons.tune_rounded,
label: 'Configuration Things',
@ -591,6 +620,12 @@ class _InstallerSection extends StatelessWidget {
route: '/settings/system/devtools',
push: true,
),
_NavItem(
icon: Icons.developer_mode_rounded,
label: 'Options développeur',
route: '/settings/app/developer',
push: true,
),
_NavItem(
icon: Icons.info_outline_rounded,
label: 'À propos ETM',

View File

@ -0,0 +1,493 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../l10n/app_localizations.dart';
import '../../l10n/l10n_ext.dart';
import '../../models/load_config_entry.dart';
import '../../providers/load_config_provider.dart';
import '../../services/load_priority.dart';
import '../../services/nymea_service.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.
class LoadOrderScreen extends StatefulWidget {
const LoadOrderScreen({super.key});
@override
State<LoadOrderScreen> createState() => _LoadOrderScreenState();
}
class _LoadOrderScreenState extends State<LoadOrderScreen> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
final p = context.read<LoadConfigProvider>();
p.attach(context.read<NymeaService>());
p.load();
});
}
@override
Widget build(BuildContext context) {
final t = context.l10n;
final p = context.watch<LoadConfigProvider>();
return Scaffold(
backgroundColor: const Color(0xFFF0F2F5),
appBar: AppBar(
title: Text(t.installerLoadsTitle),
backgroundColor: Colors.white,
foregroundColor: const Color(0xFF1A1A2E),
elevation: 0,
actions: [
IconButton(
icon: const Icon(Icons.refresh_rounded),
onPressed: p.loading ? null : () => p.load(),
),
],
),
bottomNavigationBar: _SaveBar(provider: p),
body: p.loading && p.entries.isEmpty
? const Center(child: CircularProgressIndicator())
: p.error != null
? _Message(icon: Icons.error_outline_rounded, text: t.loadsLoadError(p.error!))
: p.entries.isEmpty
? _Message(icon: Icons.inbox_rounded, text: t.loadsEmpty)
: _Body(provider: p),
);
}
}
class _Body extends StatelessWidget {
final LoadConfigProvider provider;
const _Body({required this.provider});
@override
Widget build(BuildContext context) {
final t = context.l10n;
final groups = provider.groups;
return ListView(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 24),
children: [
Padding(
padding: const EdgeInsets.fromLTRB(4, 0, 4, 12),
child: Text(t.installerLoadsSubtitle,
style: const TextStyle(color: Color(0xFF6B7280), fontSize: 13)),
),
// ── Ordre non groupé : signaler, montrer le réel, ne rien écrire ────────
if (provider.isNotGrouped) _NotGroupedBanner(provider: provider),
// ── La télémétrie n'existe pas sur cette box ───────────────────────────
const _TelemetryUnavailableCard(),
for (var gi = 0; gi < groups.length; gi++)
_DomainSection(
key: ValueKey('domain-${groups[gi].domain}'),
group: groups[gi],
index: gi,
total: groups.length,
provider: provider,
),
],
);
}
}
// ── Bandeau « ordre non groupé » ─────────────────────────────────────────────
class _NotGroupedBanner extends StatelessWidget {
final LoadConfigProvider provider;
const _NotGroupedBanner({required this.provider});
@override
Widget build(BuildContext context) {
final t = context.l10n;
return Card(
color: const Color(0xFFFFF4E5),
margin: const EdgeInsets.only(bottom: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
const Icon(Icons.warning_amber_rounded, color: Color(0xFFB26A00), size: 20),
const SizedBox(width: 8),
Text(t.orderNotGroupedTitle,
style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 14)),
]),
const SizedBox(height: 8),
Text(t.orderNotGroupedBody, style: const TextStyle(fontSize: 13)),
const SizedBox(height: 8),
Text(t.orderNotGroupedWarning,
style: const TextStyle(fontSize: 12, color: Color(0xFF8A5300))),
const SizedBox(height: 10),
Align(
alignment: Alignment.centerRight,
child: OutlinedButton.icon(
icon: const Icon(Icons.low_priority_rounded, size: 18),
label: Text(t.orderNotGroupedAction),
onPressed: () => _confirm(context),
),
),
],
),
),
);
}
Future<void> _confirm(BuildContext context) async {
final t = context.l10n;
final ok = await showDialog<bool>(
context: context,
builder: (c) => AlertDialog(
title: Text(t.orderNotGroupedAction),
content: Text(t.orderNotGroupedWarning),
actions: [
TextButton(onPressed: () => Navigator.pop(c, false), child: Text(t.cancel)),
FilledButton(
onPressed: () => Navigator.pop(c, true),
child: Text(t.orderNormalizeConfirm)),
],
),
);
// La normalisation ne fait que préparer un brouillon : rien ne part tant que
// l'installateur n'a pas enregistré.
if (ok == true) provider.prepareNormalization();
}
}
// ── Télémétrie absente de cette box ──────────────────────────────────────────
class _TelemetryUnavailableCard extends StatelessWidget {
const _TelemetryUnavailableCard();
@override
Widget build(BuildContext context) {
final t = context.l10n;
return Card(
color: const Color(0xFFEFF3F7),
margin: const EdgeInsets.only(bottom: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(14),
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Icon(Icons.sensors_off_rounded, size: 20, color: Color(0xFF5B6B7B)),
const SizedBox(width: 10),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(t.telemetryUnavailableTitle,
style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 13)),
const SizedBox(height: 4),
Text(t.telemetryUnavailableBody,
style: const TextStyle(fontSize: 12, color: Color(0xFF5B6B7B))),
]),
),
]),
),
);
}
}
// ── Un domaine et ses charges ────────────────────────────────────────────────
class _DomainSection extends StatelessWidget {
final DomainGroup group;
final int index;
final int total;
final LoadConfigProvider provider;
const _DomainSection({
super.key,
required this.group,
required this.index,
required this.total,
required this.provider,
});
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;
return Card(
margin: const EdgeInsets.only(bottom: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(14, 12, 6, 4),
child: Row(children: [
Expanded(
child: Text(
_domainLabel(t),
style: TextStyle(
fontWeight: FontWeight.w700,
fontSize: 14,
color: group.isUnclassified
? const Color(0xFF9CA3AF)
: const Color(0xFF1A1A2E),
),
),
),
IconButton(
icon: const Icon(Icons.keyboard_arrow_up_rounded),
tooltip: 'Monter',
onPressed: index == 0
? null
: () => provider.reorderDomains(index, index - 1),
),
IconButton(
icon: const Icon(Icons.keyboard_arrow_down_rounded),
tooltip: 'Descendre',
onPressed: index >= total - 1
? null
: () => provider.reorderDomains(index, index + 2),
),
]),
),
ReorderableListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
buildDefaultDragHandles: false,
itemCount: group.loads.length,
onReorder: (o, n) => provider.reorderWithinDomain(group.domain, o, n),
itemBuilder: (context, i) => _LoadTile(
key: ValueKey('load-${group.loads[i].id}'),
entry: group.loads[i],
handleIndex: i,
),
),
const SizedBox(height: 6),
],
),
);
}
}
// ── Une charge ───────────────────────────────────────────────────────────────
class _LoadTile extends StatelessWidget {
final LoadConfigEntry entry;
final int handleIndex;
const _LoadTile({super.key, required this.entry, required this.handleIndex});
String _adapterLabel(L10n t) => switch (entry.adapter) {
'relay-router' => t.adapterRelayRouter,
'etmvariableload' => t.adapterEtmVariableLoad,
'sg-ready' => t.adapterSgReady,
'evcharger' => t.adapterEvCharger,
_ => t.adapterUnknown(entry.adapter),
};
@override
Widget build(BuildContext context) {
final t = context.l10n;
final details = <String>[
_adapterLabel(t),
if (entry.isRelayRouter) t.loadRelayCount(entry.relays.length),
if (entry.minOnS > 0) t.loadMinOn(entry.minOnS),
if (entry.minOffS > 0) t.loadMinOff(entry.minOffS),
];
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
child: Row(
children: [
ReorderableDragStartListener(
index: handleIndex,
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: Icon(Icons.drag_handle_rounded, size: 22, color: Color(0xFF9CA3AF)),
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Flexible(
child: Text(
entry.label,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: entry.enabled
? const Color(0xFF1A1A2E)
: const Color(0xFF9CA3AF),
),
),
),
const SizedBox(width: 8),
if (!entry.enabled)
_Chip(text: t.loadDisabled, color: const Color(0xFF9CA3AF)),
]),
const SizedBox(height: 2),
Text(details.join(' · '),
style: const TextStyle(fontSize: 12, color: Color(0xFF6B7280))),
const SizedBox(height: 2),
// Emplacement réservé : LoadConfig n'a AUCUN champ pour rattacher un
// compteur à une charge. Rien n'est inventé côté app.
Text(t.loadMeterSlot,
style: const TextStyle(fontSize: 11, color: Color(0xFFB0B7BF))),
],
),
),
),
_Chip(text: t.loadPriority(entry.priority), color: const Color(0xFF2E75B6)),
const SizedBox(width: 8),
],
),
);
}
}
class _Chip extends StatelessWidget {
final String text;
final Color color;
const _Chip({required this.text, required this.color});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(6),
),
child: Text(text,
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: color)),
);
}
}
// ── Barre d'enregistrement ───────────────────────────────────────────────────
class _SaveBar extends StatelessWidget {
final LoadConfigProvider provider;
const _SaveBar({required this.provider});
@override
Widget build(BuildContext context) {
final t = context.l10n;
final (String? message, Color color) = switch (provider.saveState) {
LoadSaveState.pending => (t.saveWaitingBox, const Color(0xFF6B7280)),
LoadSaveState.confirmed => (t.saveConfirmed, const Color(0xFF2E7D32)),
LoadSaveState.diverged => (t.saveDiverged, const Color(0xFFB26A00)),
LoadSaveState.rejected => (
_rejectionText(t),
const Color(0xFFC62828),
),
LoadSaveState.idle => (null, Colors.transparent),
};
return SafeArea(
child: Container(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 10),
decoration: const BoxDecoration(
color: Colors.white,
border: Border(top: BorderSide(color: Color(0xFFE5E7EB))),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (message != null)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(children: [
Icon(
provider.saveState == LoadSaveState.confirmed
? Icons.check_circle_outline_rounded
: Icons.info_outline_rounded,
size: 16,
color: color,
),
const SizedBox(width: 6),
Expanded(
child: Text(message,
style: TextStyle(fontSize: 12, color: color)),
),
]),
),
Row(children: [
if (provider.hasPendingChanges)
TextButton(
onPressed: provider.saveState == LoadSaveState.pending
? null
: provider.discardDraft,
child: Text(t.cancel),
),
const Spacer(),
FilledButton.icon(
icon: provider.saveState == LoadSaveState.pending
? const SizedBox(
width: 14, height: 14,
child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.save_rounded, size: 18),
label: Text(provider.saveState == LoadSaveState.pending
? t.savePending
: t.saveOrder),
// Aucune écriture possible sans geste explicite : le bouton n'est
// actif que s'il existe un brouillon.
onPressed: (!provider.hasPendingChanges ||
provider.saveState == LoadSaveState.pending)
? null
: provider.save,
),
]),
],
),
),
);
}
String _rejectionText(L10n t) {
final c = provider.conflict;
if (c == null) return t.saveRejected(provider.saveError ?? '?');
return switch (c.kind) {
'duplicateId' => t.validationDuplicateId(c.id ?? '?'),
'thingClaimedTwice' => t.validationThingClaimedTwice(
c.thingId ?? '?', c.firstLabel ?? '?', c.secondLabel ?? '?'),
_ => t.saveRejected(provider.saveError ?? '?'),
};
}
}
class _Message extends StatelessWidget {
final IconData icon;
final String text;
const _Message({required this.icon, required this.text});
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(mainAxisSize: MainAxisSize.min, children: [
Icon(icon, size: 44, color: Colors.grey.shade400),
const SizedBox(height: 14),
Text(text,
textAlign: TextAlign.center,
style: const TextStyle(color: Color(0xFF6B7280))),
]),
),
);
}
}

View File

@ -45,14 +45,8 @@ class AppSettingsScreen extends StatelessWidget {
const SizedBox(height: 12),
_SettingsGroup(
title: 'DÉVELOPPEUR',
title: 'INFORMATIONS',
items: [
_SettingsItem(
icon: Icons.code_rounded,
label: 'Options développeur',
subtitle: 'Logs verbeux, PIN installateur',
onTap: () => context.push('/settings/app/developer'),
),
_SettingsItem(
icon: Icons.info_outline_rounded,
label: 'À propos PowerSync',
@ -62,17 +56,31 @@ class AppSettingsScreen extends StatelessWidget {
],
),
// Configuration système + options développeur : réservés au mode
// installateur (le routeur bloque aussi l'accès direct par URL).
if (installer.isUnlocked) ...[
const SizedBox(height: 12),
_SettingsGroup(
title: 'INSTALLATEUR',
items: [
_SettingsItem(
icon: Icons.account_tree_rounded,
label: 'Rôles & appareils',
subtitle: 'Compteur racine, charges pilotables',
onTap: () => context.push('/energy/setup'),
),
_SettingsItem(
icon: Icons.settings_rounded,
label: 'Configuration système',
subtitle: 'Réseau, protocoles, plugins',
onTap: () => context.push('/settings/system'),
),
_SettingsItem(
icon: Icons.developer_mode_rounded,
label: 'Options développeur',
subtitle: 'Logs verbeux, PIN installateur',
onTap: () => context.push('/settings/app/developer'),
),
],
),
],

View File

@ -0,0 +1,232 @@
/// Priorité à deux niveaux — regroupement par domaine au-dessus d'un `priority` plat.
///
/// ## Le modèle
///
/// L'installateur manipule deux niveaux : l'ordre des **domaines** entre eux, puis
/// l'ordre des **appareils à l'intérieur** d'un domaine. L'ordre effectif est
/// lexicographique strict : `(rang de domaine, rang dans le domaine)`.
///
/// ## Pourquoi aucun champ nouveau n'est nécessaire
///
/// Un ordre lexicographique implique que les charges d'un même domaine sont **contiguës**
/// dans l'ordre à plat. Le rang de domaine est donc **déductible** de la liste à plat :
/// c'est l'ordre d'apparition des domaines.
///
/// **La box ne sait rien des groupes.** `priority` reste un entier plat, l'arbitre est
/// inchangé, et aucune clé `group` / `groupPriority` n'est ajoutée à `LoadConfig`.
library;
import '../models/load_config_entry.dart';
/// Ordre de domaines proposé à la mise en service, **avant** toute intervention.
///
/// C'est un **défaut modifiable porté par l'app**, pas une constante du plugin : le figer
/// côté box le rendrait impossible à faire évoluer sans redéployer une machine.
///
/// - `ev` en premier : échéance dure — la voiture doit être chargée pour un départ.
/// - `ecs` ensuite : échéance souple, thermostat mécanique en filet.
/// - `hvac` : granularité fine, et chauffe la pièce → fait retomber la demande du chauffage.
/// - `heating` : palier grossier, sert le reliquat.
///
/// La **batterie** n'y figure pas : sa place s'exprime en cible de SOC, pas en enveloppe
/// de watts.
///
/// Deux limites à ne pas présenter comme du pilotage :
/// - le rang de `ev` est **décoratif** tant que l'EV est décidé en proxy AVANT le
/// waterfall (transplantation 3g non faite) ;
/// - le couplage clim → baisse de demande du chauffage passe par l'inertie du bâti ; il
/// n'est ni mesuré ni vérifiable avec le modèle actuel. C'est une stratégie **posée par
/// l'ordre de priorité**, pas un asservissement.
const List<String> kDefaultDomainOrder = ['ev', 'ecs', 'hvac', 'heating'];
/// 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 = '';
/// Un domaine et ses charges, dans l'ordre de service intra-domaine.
class DomainGroup {
/// Code de domaine, ou [kUnclassifiedDomain] pour le bucket « non classé ».
final String domain;
final List<LoadConfigEntry> loads;
const DomainGroup({required this.domain, required this.loads});
bool get isUnclassified => domain == kUnclassifiedDomain;
DomainGroup copyWith({List<LoadConfigEntry>? loads}) =>
DomainGroup(domain: domain, loads: loads ?? this.loads);
}
/// Résultat de la lecture : les deux niveaux reconstitués, plus le verdict de contiguïté.
class GroupedOrder {
/// Domaines dans leur ordre d'apparition à plat.
final List<DomainGroup> groups;
/// Ordre à plat réellement en place sur la box, trié par `priority` croissant.
/// C'est **cet** ordre qu'il faut afficher quand [isContiguous] est faux.
final List<LoadConfigEntry> flatOrder;
/// Faux si au moins un domaine est entrelacé avec un autre dans l'ordre à plat.
///
/// Une config lue peut ne pas être groupée : fichier édité à la main, config
/// antérieure, charge ajoutée par un autre chemin. Dans ce cas il est **interdit** de
/// regrouper silencieusement et de réaplatir — l'app changerait l'arbitrage réel au
/// premier enregistrement sans que personne ne l'ait demandé.
final bool isContiguous;
/// Domaines effectivement entrelacés — pour nommer le problème à l'installateur.
final List<String> interleavedDomains;
const GroupedOrder({
required this.groups,
required this.flatOrder,
required this.isContiguous,
required this.interleavedDomains,
});
}
/// Reconstitue les deux niveaux depuis la liste brute de `GetLoadConfig`.
///
/// Le rang de domaine est l'**ordre d'apparition** dans l'ordre à plat trié par
/// `priority` — jamais [kDefaultDomainOrder], qui ne sert qu'à la mise en service d'une
/// installation encore non ordonnée.
GroupedOrder groupByDomain(List<LoadConfigEntry> entries) {
final flat = [...entries]..sort((a, b) {
final c = a.priority.compareTo(b.priority);
// Départage stable sur l'id : deux charges de même priority ne doivent pas
// permuter d'une lecture à l'autre, sinon l'écran « bouge » sans raison.
return c != 0 ? c : a.id.compareTo(b.id);
});
final order = <String>[];
final buckets = <String, List<LoadConfigEntry>>{};
for (final e in flat) {
final key = e.domain ?? kUnclassifiedDomain;
if (!buckets.containsKey(key)) {
buckets[key] = [];
order.add(key);
}
buckets[key]!.add(e);
}
// Contiguïté : les indices d'un même domaine doivent former une plage continue.
final interleaved = <String>[];
for (final key in order) {
final idx = <int>[];
for (var i = 0; i < flat.length; i++) {
if ((flat[i].domain ?? kUnclassifiedDomain) == key) idx.add(i);
}
if (idx.isNotEmpty && (idx.last - idx.first + 1) != idx.length) {
interleaved.add(key);
}
}
return GroupedOrder(
groups: [
for (final key in order) DomainGroup(domain: key, loads: buckets[key]!),
],
flatOrder: flat,
isContiguous: interleaved.isEmpty,
interleavedDomains: interleaved,
);
}
/// Réaplatit les deux niveaux en une suite d'entiers `1..N`, et renvoie les charges
/// utiles **patchées** prêtes pour `SetLoadConfig`.
///
/// Chaque entrée repart de sa map d'origine ([LoadConfigEntry.patched]) : seul `priority`
/// est réécrit. Les charges utiles des mécanismes non modélisés sont transportées telles
/// quelles.
List<Map<String, dynamic>> flattenToPayload(List<DomainGroup> groups) {
final out = <Map<String, dynamic>>[];
var rank = 1;
for (final g in groups) {
for (final load in g.loads) {
out.add(load.patched(priority: rank));
rank++;
}
}
return out;
}
/// Ordonne des groupes selon [kDefaultDomainOrder], en conservant en queue les domaines
/// inconnus et le bucket « non classé ».
///
/// Utilisé **uniquement** à la mise en service ou lors d'une normalisation explicitement
/// confirmée par l'installateur — jamais automatiquement à la lecture.
List<DomainGroup> applyDefaultDomainOrder(List<DomainGroup> groups) {
int rankOf(DomainGroup g) {
final i = kDefaultDomainOrder.indexOf(g.domain);
if (i >= 0) return i;
// Domaine inconnu de cette version d'app, ou bucket non classé : en queue, mais
// dans un ordre stable et prévisible.
return kDefaultDomainOrder.length + (g.isUnclassified ? 1 : 0);
}
final sorted = [...groups];
sorted.sort((a, b) {
final c = rankOf(a).compareTo(rankOf(b));
return c != 0 ? c : a.domain.compareTo(b.domain);
});
return sorted;
}
// ─────────────────────────────────────────────────────────────────────────────
// Miroir client de LoadConfigStore::validateSet()
// ─────────────────────────────────────────────────────────────────────────────
/// Conflit détecté par [validateSet], suffisamment décrit pour être rendu en français.
class LoadSetConflict {
/// `duplicateId` ou `thingClaimedTwice`.
final String kind;
final String? id;
final String? thingId;
final String? firstLabel;
final String? secondLabel;
const LoadSetConflict.duplicateId(this.id)
: kind = 'duplicateId',
thingId = null,
firstLabel = null,
secondLabel = null;
const LoadSetConflict.thingClaimedTwice({
required this.thingId,
required this.firstLabel,
required this.secondLabel,
}) : kind = 'thingClaimedTwice',
id = null;
}
/// Rejoue la validation d'ensemble du plugin **avant** l'envoi.
///
/// Miroir de `LoadConfigStore::validateSet()` (`loadconfigstore.cpp:83-112`) :
/// unicité des identifiants sur **toutes** les charges, unicité des Things revendiqués
/// **parmi les `enabled` seulement** — une charge désactivée ne construit aucun
/// adaptateur, donc ne revendique rien.
///
/// C'est un **confort d'affichage**. La box reste l'autorité : si elle refuse, c'est son
/// refus qu'il faut montrer.
LoadSetConflict? validateSet(List<LoadConfigEntry> entries) {
final ids = <String>{};
final owner = <String, String>{}; // thingId normalisé → libellé de la charge
for (final c in entries) {
if (!ids.add(c.id)) return LoadSetConflict.duplicateId(c.id);
if (!c.enabled) continue; // ne construit aucun adaptateur : ne revendique rien
for (final thing in c.claimedThingIds) {
final already = owner[thing];
if (already != null) {
return LoadSetConflict.thingClaimedTwice(
thingId: thing,
firstLabel: already,
secondLabel: c.label,
);
}
owner[thing] = c.label;
}
}
return null;
}

View File

@ -48,6 +48,33 @@ class NymeaService extends ChangeNotifier {
String _username = '';
String _password = '';
// ── Locale de connexion (JSONRPC.Hello `o:locale`) ───────────────────────────
// nymea gère la locale PAR CONNEXION (jsonrpcserverimplementation.cpp:239-241,
// QHash<QUuid, QLocale> m_clientLocales) : deux clients de la même box peuvent
// avoir des langues différentes. Sans ce paramètre, la box retombe sur son
// défaut persisté (`en_US`), quelle que soit la langue de l'app.
//
// ⚠️ Ne RIEN attendre du vocabulaire matériel : mesuré sur .75, sur 784 chaînes
// affichables, l'allemand en change une et le français zéro (aucun catalogue .qm
// fr n'est livré par les plugins installés). Les `displayName` de ThingClass,
// états et actions sont à traiter comme des identifiants anglais.
String _requestedLocale = 'fr_FR';
String _serverLocale = ''; // locale réellement retenue par la box
/// Locale demandée à la box au prochain `Hello` (format `fr_FR`).
String get requestedLocale => _requestedLocale;
set requestedLocale(String v) {
if (v.isEmpty || v == _requestedLocale) return;
_requestedLocale = v;
_log('🌐 Locale demandée changée → $v (effective au prochain Hello)',
force: true);
}
/// Locale confirmée par la box au dernier `Hello` — vide si jamais connecté.
/// Diverge de [requestedLocale] si la box n'a pas su interpréter la valeur
/// (ex. `zz_ZZ` → `C`).
String get serverLocale => _serverLocale;
// ── Identité & état d'auth issus du Hello (JSONRPC.Hello) ────────────────────
String _serverUuid = ''; // identité stable de la box (Hello `uuid`)
String _serverName = ''; // nom de la box (Hello `name`)
@ -329,9 +356,14 @@ class NymeaService extends ChangeNotifier {
_log('📡 WS listener actif, envoi Hello...', force: true);
// Sur WebSocket, c'est le CLIENT qui envoie Hello en premier
final hello = await _sendRequest('JSONRPC.Hello', {})
.timeout(const Duration(seconds: 8));
// Sur WebSocket, c'est le CLIENT qui envoie Hello en premier.
// La locale part à CHAQUE Hello, donc aussi après une reconnexion subie :
// `m_clientLocales` est indexé par clientId et repart au défaut de la box
// à chaque nouvelle socket (jsonrpcserverimplementation.cpp:1085-1086).
final hello = await _sendRequest(
'JSONRPC.Hello',
{'locale': _requestedLocale},
).timeout(const Duration(seconds: 8));
return _finishConnect(hello);
}
@ -349,6 +381,19 @@ class NymeaService extends ChangeNotifier {
// ici → clé de stockage / comparaison / affichage cohérentes en aval.
_serverUuid = ((p['uuid'] as String?) ?? '').replaceAll(RegExp(r'[{}]'), '');
_serverName = (p['name'] as String?) ?? 'nymea';
// Locale retenue par la box. Journalisée systématiquement (force) : un .qm
// introuvable côté box produit EXACTEMENT le même rendu qu'une absence de
// traduction. Sans cette trace, un catalogue mal packagé passe inaperçu.
_serverLocale = (p['locale'] as String?) ?? (p['language'] as String?) ?? '';
if (_serverLocale == _requestedLocale) {
_log('🌐 Locale : demandée "$_requestedLocale" → retenue "$_serverLocale"',
force: true);
} else {
_log('🌐 ⚠️ Locale : demandée "$_requestedLocale" mais la box a retenu '
'"$_serverLocale" — valeur non interprétée, ou Hello sans paramètre.',
force: true);
}
_authenticationRequired = p['authenticationRequired'] == true;
_initialSetupRequired =
_authenticationRequired && p['initialSetupRequired'] == true;
@ -489,6 +534,9 @@ class NymeaService extends ChangeNotifier {
_token = '';
_serverUuid = '';
_serverName = '';
// La locale DEMANDÉE est une préférence d'app : elle survit à la déconnexion.
// Seule la locale CONFIRMÉE est effacée — elle appartient à la connexion morte.
_serverLocale = '';
_initialSetupRequired = false;
_authenticationRequired = false;
_authenticated = false;
@ -603,6 +651,36 @@ class NymeaService extends ChangeNotifier {
_things.removeWhere((t) => t.id == params['thingId']);
notifyListeners();
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
// confirmation qu'une écriture a réellement été appliquée — l'acquittement
// du RPC, lui, ne vaut pas application.
final raw = (params['loadConfigs'] as List?) ?? const [];
_onLoadConfigChanged(raw
.whereType<Map>()
.map((m) => Map<String, dynamic>.from(m))
.toList());
break;
}
}
/// Abonnés à `NymeaEnergy.LoadConfigChanged` (l'écran de configuration des charges).
final List<void Function(List<Map<String, dynamic>>)> _loadConfigListeners = [];
/// S'abonne aux changements de configuration des charges poussés par la box.
/// Renvoie la fonction de désabonnement.
void Function() onLoadConfigChanged(
void Function(List<Map<String, dynamic>>) cb) {
_loadConfigListeners.add(cb);
return () => _loadConfigListeners.remove(cb);
}
void _onLoadConfigChanged(List<Map<String, dynamic>> configs) {
_log('[LoadConfig] ← LoadConfigChanged : ${configs.length} charge(s)',
force: true);
for (final cb in [..._loadConfigListeners]) {
cb(configs);
}
}
@ -1009,24 +1087,60 @@ class NymeaService extends ChangeNotifier {
}
}
/// **STUB** — `NymeaEnergy.SetLoadConfig` n'existe pas encore côté plugin
/// (l'energymanager l'exposera plus tard). En attendant on logge **le JSON
/// exact qui partira**, sans rien casser ni bloquer.
Future<void> setLoadConfig(List<Map<String, dynamic>> descriptors) async {
_log('[LoadConfig STUB] NymeaEnergy.SetLoadConfig — '
'${descriptors.length} charge(s) etmvariableload', force: true);
for (final d in descriptors) {
final pretty = const JsonEncoder.withIndent(' ').convert(d);
_log('[LoadConfig STUB] LoadDescriptor:\n$pretty', force: true);
/// `NymeaEnergy.SetLoadConfig` — **réel**, vérifié contre `.75` le 2026-08-25.
///
/// Remplace l'ENSEMBLE des charges : rejet en bloc (`energyError`) si une seule
/// entrée est invalide ; persistance et application atomiques.
///
/// [payloads] doit provenir de `LoadConfigEntry.patched()` — c'est-à-dire des maps
/// reçues de [getLoadConfig] avec les seuls champs modifiés réécrits. **Ne jamais
/// passer une re-sérialisation d'un modèle typé** : le plugin sérialise via le
/// méta-objet, donc la réponse porte les charges utiles vides des mécanismes
/// inutilisés (`sgReady: {}`, `powerLevels: []`) qu'une reconstruction amputerait.
///
/// L'acquittement ne vaut PAS application : la confirmation est la notification
/// `NymeaEnergy.LoadConfigChanged`, émise après toute persistance réussie.
///
/// \return `null` si la box a accepté, sinon le code d'erreur qu'elle a renvoyé.
Future<String?> setLoadConfig(List<Map<String, dynamic>> payloads) async {
if (_isSimulation || !_connected) {
_log('[LoadConfig] sim/déconnecté — SetLoadConfig ignoré', force: true);
return 'Disconnected';
}
try {
final r = await _sendRequest(
'NymeaEnergy.SetLoadConfig', {'loadConfigs': payloads});
final err = r['params']?['energyError'] as String?;
_log('[LoadConfig] SetLoadConfig(${payloads.length} charge(s)) ← '
'${err ?? r['status']}', force: true);
if (err == null || err == 'EnergyErrorNoError') return null;
return err;
} catch (e) {
_log('[LoadConfig] SetLoadConfig: $e', force: true);
return e.toString();
}
}
/// **STUB** — `NymeaEnergy.GetLoadConfig` absent côté plugin. Retourne vide,
/// ne bloque pas le démarrage.
/// `NymeaEnergy.GetLoadConfig` — **réel**, vérifié contre `.75`.
///
/// Renvoie les charges utiles BRUTES, telles que la box les a émises, y compris les
/// charges `enabled: false` (éditables depuis l'app) et les charges utiles vides des
/// mécanismes inutilisés. Ces maps sont la source de vérité : les conserver intactes.
Future<List<Map<String, dynamic>>> getLoadConfig() async {
_log('[LoadConfig STUB] NymeaEnergy.GetLoadConfig → [] '
'(méthode plugin absente)', force: true);
return const [];
if (_isSimulation || !_connected) return const [];
try {
final r = await _sendRequest('NymeaEnergy.GetLoadConfig', {});
final raw = (r['params']?['loadConfigs'] as List?) ?? const [];
final out = raw
.whereType<Map>()
.map((m) => Map<String, dynamic>.from(m))
.toList();
_log('[LoadConfig] GetLoadConfig → ${out.length} charge(s)', force: true);
return out;
} catch (e) {
_log('[LoadConfig] GetLoadConfig: $e', force: true);
rethrow;
}
}
/// Trace de routage (charges hors etmvariableload : PAC SG-Ready, batterie).

View File

@ -126,6 +126,11 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_localizations:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_secure_storage:
dependency: "direct main"
description:
@ -232,6 +237,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.2"
intl:
dependency: "direct main"
description:
name: intl
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
url: "https://pub.dev"
source: hosted
version: "0.20.2"
jni:
dependency: transitive
description:

View File

@ -45,6 +45,9 @@ dependencies:
google_fonts: ^6.2.1
flutter_staggered_grid_view: ^0.7.0
flutter_secure_storage: ^9.2.2
flutter_localizations:
sdk: flutter
intl: any
dev_dependencies:
flutter_test:
@ -63,6 +66,9 @@ dev_dependencies:
# The following section is specific to Flutter packages.
flutter:
# Génération des classes de localisation depuis lib/l10n/*.arb (voir l10n.yaml).
generate: true
# The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in
# the material Icons class.

View File

@ -0,0 +1,141 @@
{
"_source": "FIXTURE SYNTHÉTIQUE — PAS un dump machine.",
"_note": "Ordre volontairement ENTRELACÉ : ev(1), ecs(2), ev(3). Le domaine 'ev' n'est pas contigu. Sert le critère §7-3 : l'app doit le SIGNALER, afficher l'ordre réel, et n'écrire qu'après confirmation explicite.",
"loadConfigs": [
{
"adapter": "etmvariableload",
"enabled": true,
"id": "{11111111-1111-4111-8111-111111111111}",
"label": "Borne garage",
"domain": "ev",
"maxPowerW": 11000,
"minOffS": 0,
"minOnS": 0,
"mode": "dynamic",
"needs": {
"dailyDeadline": "07:00",
"minEnergyWhPerDay": 8000
},
"powerLevels": [],
"priority": 1,
"relays": [],
"sgReady": {}
},
{
"adapter": "etmvariableload",
"enabled": true,
"id": "{22222222-2222-4222-8222-222222222222}",
"label": "Borne extérieure",
"domain": "ev",
"maxPowerW": 7400,
"minOffS": 0,
"minOnS": 0,
"mode": "dynamic",
"needs": {
"dailyDeadline": "",
"minEnergyWhPerDay": 0
},
"powerLevels": [],
"priority": 3,
"relays": [],
"sgReady": {}
},
{
"adapter": "relay-router",
"enabled": true,
"id": "chauffe-eau",
"label": "chauffe-eau",
"domain": "ecs",
"maxPowerW": 0,
"minOffS": 60,
"minOnS": 60,
"mode": "fixed",
"needs": {
"dailyDeadline": "",
"minEnergyWhPerDay": 0
},
"powerLevels": [],
"priority": 2,
"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",
"enabled": true,
"id": "clim-salon",
"label": "Clim salon",
"domain": "hvac",
"maxPowerW": 0,
"minOffS": 0,
"minOnS": 0,
"mode": "fixed",
"needs": {
"dailyDeadline": "",
"minEnergyWhPerDay": 0
},
"powerLevels": [],
"priority": 4,
"relays": [],
"sgReady": {
"minStateHoldS": 900,
"states": [
{
"estimatedPowerW": 0.0,
"relays": [],
"state": 2
},
{
"estimatedPowerW": 1200.0,
"relays": [
"{aaaa1111-0000-4000-8000-000000000001}"
],
"state": 3
},
{
"estimatedPowerW": 2400.0,
"relays": [
"{aaaa1111-0000-4000-8000-000000000002}"
],
"state": 4
}
]
}
},
{
"adapter": "relay-router",
"enabled": true,
"id": "seche-serviettes",
"label": "Sèche-serviettes",
"maxPowerW": 0,
"minOffS": 30,
"minOnS": 30,
"mode": "fixed",
"needs": {
"dailyDeadline": "",
"minEnergyWhPerDay": 0
},
"powerLevels": [],
"priority": 5,
"relays": [
{
"powerW": 750,
"thingId": "{cccc3333-0000-4000-8000-000000000003}"
}
],
"sgReady": {}
}
]
}

View File

@ -0,0 +1,65 @@
{
"_source": "FIXTURE SYNTHÉTIQUE — PAS un dump machine.",
"_note": "hems .75 n'a QU'UNE charge (chauffe-eau, relay-router) et aucune charge SG-Ready : le regroupement par domaine n'y est pas exerçable en vrai. Cette fixture existe pour couvrir le multi-domaines en test unitaire uniquement. Elle porte 'domain', que la box .75 n'expose pas encore.",
"_shape": "Ordre à plat CONTIGU : ev(1,2) puis ecs(3) puis hvac(4) puis non classé(5).",
"loadConfigs": [
{
"adapter": "etmvariableload", "enabled": true,
"id": "{11111111-1111-4111-8111-111111111111}", "label": "Borne garage",
"domain": "ev", "maxPowerW": 11000, "minOffS": 0, "minOnS": 0,
"mode": "dynamic",
"needs": { "dailyDeadline": "07:00", "minEnergyWhPerDay": 8000 },
"powerLevels": [], "priority": 1, "relays": [], "sgReady": {}
},
{
"adapter": "etmvariableload", "enabled": true,
"id": "{22222222-2222-4222-8222-222222222222}", "label": "Borne extérieure",
"domain": "ev", "maxPowerW": 7400, "minOffS": 0, "minOnS": 0,
"mode": "dynamic",
"needs": { "dailyDeadline": "", "minEnergyWhPerDay": 0 },
"powerLevels": [], "priority": 2, "relays": [], "sgReady": {}
},
{
"adapter": "relay-router", "enabled": true,
"id": "chauffe-eau", "label": "chauffe-eau",
"domain": "ecs", "maxPowerW": 0, "minOffS": 60, "minOnS": 60,
"mode": "fixed",
"needs": { "dailyDeadline": "", "minEnergyWhPerDay": 0 },
"powerLevels": [], "priority": 3,
"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", "enabled": true,
"id": "clim-salon", "label": "Clim salon",
"domain": "hvac", "maxPowerW": 0, "minOffS": 0, "minOnS": 0,
"mode": "fixed",
"needs": { "dailyDeadline": "", "minEnergyWhPerDay": 0 },
"powerLevels": [], "priority": 4, "relays": [],
"sgReady": {
"minStateHoldS": 900,
"states": [
{ "estimatedPowerW": 0.0, "relays": [], "state": 2 },
{ "estimatedPowerW": 1200.0, "relays": ["{aaaa1111-0000-4000-8000-000000000001}"], "state": 3 },
{ "estimatedPowerW": 2400.0, "relays": ["{aaaa1111-0000-4000-8000-000000000002}"], "state": 4 }
]
}
},
{
"adapter": "relay-router", "enabled": true,
"id": "seche-serviettes", "label": "Sèche-serviettes",
"maxPowerW": 0, "minOffS": 30, "minOnS": 30,
"mode": "fixed",
"needs": { "dailyDeadline": "", "minEnergyWhPerDay": 0 },
"powerLevels": [], "priority": 5,
"relays": [
{ "powerW": 750, "thingId": "{cccc3333-0000-4000-8000-000000000003}" }
],
"sgReady": {}
}
]
}

View File

@ -0,0 +1,221 @@
import 'dart:convert';
import 'dart:io';
import 'package:etm_powersync_app/models/load_config_entry.dart';
import 'package:etm_powersync_app/services/load_priority.dart';
import 'package:flutter_test/flutter_test.dart';
List<LoadConfigEntry> _load(String fixture) {
final raw = File('test/fixtures/$fixture').readAsStringSync();
final map = jsonDecode(raw) as Map<String, dynamic>;
return (map['loadConfigs'] as List)
.cast<Map<String, dynamic>>()
.map(LoadConfigEntry.new)
.toList();
}
void main() {
// ───────────────────────────────────────────────────────────────────────────
group('§7-1 — aller-retour neutre', () {
test('le dump réel de .75 se réécrit à l\'identique, charges utiles vides comprises', () {
final entries = _load('loadconfig_hems75.json');
final payload = flattenToPayload(groupByDomain(entries).groups);
// Même nombre d'entrées, mêmes maps — clé par clé, y compris sgReady:{} et
// powerLevels:[] que la sérialisation par méta-objet du plugin produit.
expect(payload.length, entries.length);
for (var i = 0; i < payload.length; i++) {
expect(payload[i], equals(entries[i].raw),
reason: 'la charge ${entries[i].id} a été altérée par l\'aller-retour');
}
});
test('une clé inconnue de l\'app est transportée telle quelle', () {
// Simule une box plus récente qui aurait ajouté un champ que l'app ne modélise pas.
final entry = LoadConfigEntry({
'id': 'x', 'label': 'X', 'adapter': 'relay-router', 'mode': 'fixed',
'enabled': true, 'priority': 1,
'champInconnuDeLApp': {'imbriqué': [1, 2, 3]},
});
final patched = entry.patched(priority: 7);
expect(patched['champInconnuDeLApp'], equals({'imbriqué': [1, 2, 3]}));
expect(patched['priority'], 7);
});
});
// ───────────────────────────────────────────────────────────────────────────
group('§7-2 — aplatissement réversible', () {
test('config contiguë : lire → deux niveaux → réaplatir donne l\'ordre plat d\'origine', () {
final entries = _load('loadconfig_multidomain.json');
final grouped = groupByDomain(entries);
expect(grouped.isContiguous, isTrue);
final payload = flattenToPayload(grouped.groups);
final replayed = [
for (final m in payload) '${m['id']}:${m['priority']}',
];
final original = [
for (final e in grouped.flatOrder) '${e.id}:${e.priority}',
];
expect(replayed, equals(original));
});
test('les deux niveaux sont bien reconstitués dans l\'ordre d\'apparition', () {
final grouped = groupByDomain(_load('loadconfig_multidomain.json'));
expect(grouped.groups.map((g) => g.domain).toList(),
equals(['ev', 'ecs', 'hvac', kUnclassifiedDomain]));
expect(grouped.groups.first.loads.length, 2);
});
test('le rang de domaine vient de la liste à plat, PAS du défaut app', () {
// Ordre en place volontairement contraire à kDefaultDomainOrder : ecs avant ev.
final entries = [
LoadConfigEntry({'id': 'a', 'domain': 'ecs', 'priority': 1, 'adapter': 'relay-router'}),
LoadConfigEntry({'id': 'b', 'domain': 'ev', 'priority': 2, 'adapter': 'etmvariableload'}),
];
final grouped = groupByDomain(entries);
expect(grouped.isContiguous, isTrue);
// L'ordre RÉEL de la box est respecté, le défaut app ne s'impose pas à la lecture.
expect(grouped.groups.map((g) => g.domain).toList(), equals(['ecs', 'ev']));
expect(kDefaultDomainOrder.first, 'ev'); // le défaut existe mais ne s'applique pas ici
});
});
// ───────────────────────────────────────────────────────────────────────────
group('§7-3 — config non contiguë', () {
test('l\'entrelacement est détecté et nommé', () {
final grouped = groupByDomain(_load('loadconfig_interleaved.json'));
expect(grouped.isContiguous, isFalse);
expect(grouped.interleavedDomains, contains('ev'));
});
test('l\'ordre RÉEL de la box est exposé, pas une version regroupée', () {
final grouped = groupByDomain(_load('loadconfig_interleaved.json'));
expect(
grouped.flatOrder.map((e) => e.domain ?? kUnclassifiedDomain).toList(),
equals(['ev', 'ecs', 'ev', 'hvac', kUnclassifiedDomain]),
reason: 'flatOrder doit montrer l\'arbitrage tel qu\'il est, entrelacement compris',
);
});
test('la normalisation est un geste explicite, jamais un effet de la lecture', () {
final grouped = groupByDomain(_load('loadconfig_interleaved.json'));
// La lecture seule ne réordonne rien : les priorités d'origine sont intactes.
expect(grouped.flatOrder.map((e) => e.priority).toList(), equals([1, 2, 3, 4, 5]));
expect(grouped.flatOrder.map((e) => e.id).first, '{11111111-1111-4111-8111-111111111111}');
// Ce n'est qu'en appelant explicitement la normalisation que l'ordre change.
final normalized = flattenToPayload(applyDefaultDomainOrder(grouped.groups));
final evRanks = [
for (final m in normalized)
if (m['domain'] == 'ev') m['priority'] as int,
];
expect(evRanks, equals([1, 2]), reason: 'après normalisation, ev est contigu et en tête');
});
});
// ───────────────────────────────────────────────────────────────────────────
group('§4 — domaine : saisie, jamais déduction', () {
test('une charge sans domain tombe en « non classé » et n\'est jamais rangée d\'office', () {
final grouped = groupByDomain(_load('loadconfig_multidomain.json'));
final unclassified = grouped.groups.firstWhere((g) => g.isUnclassified);
expect(unclassified.loads.map((e) => e.id), contains('seche-serviettes'));
// Le sèche-serviettes est un relay-router, comme le chauffe-eau (ecs).
// Il ne doit PAS avoir été déduit comme ECS : un relais n'est pas forcément un ECS.
final ecs = grouped.groups.firstWhere((g) => g.domain == 'ecs');
expect(ecs.loads.map((e) => e.id), isNot(contains('seche-serviettes')));
});
test('un domain vide ou blanc est traité comme absent, pas comme un domaine nommé', () {
final e = LoadConfigEntry({'id': 'a', 'domain': ' ', 'priority': 1});
expect(e.domain, isNull);
});
test('un domaine inconnu de l\'app est conservé, pas écrasé', () {
final grouped = groupByDomain([
LoadConfigEntry({'id': 'a', 'domain': 'pool-heater', 'priority': 1}),
]);
expect(grouped.groups.single.domain, 'pool-heater');
// Et il part en queue à la normalisation, sans disparaître.
expect(applyDefaultDomainOrder(grouped.groups).single.domain, 'pool-heater');
});
});
// ───────────────────────────────────────────────────────────────────────────
group('claimedThingIds — miroir de loadconfig.cpp:150-166', () {
test('relay-router revendique ses relais', () {
final e = _load('loadconfig_hems75.json').single;
expect(e.claimedThingIds, {
'{8538782f-2c8c-4a30-bfce-c8140f791c9b}',
'{2ebe6bef-829b-4695-9d11-ddb4c16c5448}',
'{b033b212-1adb-4df0-ba2b-8fa477de52a2}',
});
});
test('etmvariableload revendique le Thing qui porte son propre id', () {
final e = LoadConfigEntry({
'id': '{11111111-1111-4111-8111-111111111111}',
'adapter': 'etmvariableload',
});
expect(e.claimedThingIds, {'{11111111-1111-4111-8111-111111111111}'});
});
test('sg-ready revendique les relais de tous ses états', () {
final e = _load('loadconfig_multidomain.json')
.firstWhere((c) => c.id == 'clim-salon');
expect(e.claimedThingIds, {
'{aaaa1111-0000-4000-8000-000000000001}',
'{aaaa1111-0000-4000-8000-000000000002}',
});
});
test('les formes avec et sans accolades sont normalisées', () {
final withBraces = LoadConfigEntry({
'id': 'x', 'adapter': 'relay-router',
'relays': [{'thingId': '{ABCD1234-0000-4000-8000-000000000001}', 'powerW': 1}],
});
final without = LoadConfigEntry({
'id': 'y', 'adapter': 'relay-router',
'relays': [{'thingId': 'abcd1234-0000-4000-8000-000000000001', 'powerW': 1}],
});
expect(withBraces.claimedThingIds, equals(without.claimedThingIds));
});
});
// ───────────────────────────────────────────────────────────────────────────
group('§4 — miroir de validateSet()', () {
test('identifiant en double refusé', () {
final c = validateSet([
LoadConfigEntry({'id': 'dup', 'priority': 1}),
LoadConfigEntry({'id': 'dup', 'priority': 2}),
]);
expect(c?.kind, 'duplicateId');
expect(c?.id, 'dup');
});
test('un Thing commandé par deux charges ACTIVES est refusé', () {
final relay = [{'thingId': '{8538782f-2c8c-4a30-bfce-c8140f791c9b}', 'powerW': 500}];
final c = validateSet([
LoadConfigEntry({'id': 'a', 'label': 'A', 'adapter': 'relay-router', 'enabled': true, 'relays': relay}),
LoadConfigEntry({'id': 'b', 'label': 'B', 'adapter': 'relay-router', 'enabled': true, 'relays': relay}),
]);
expect(c?.kind, 'thingClaimedTwice');
expect(c?.firstLabel, 'A');
expect(c?.secondLabel, 'B');
});
test('une charge DÉSACTIVÉE ne revendique rien — pas de conflit', () {
final relay = [{'thingId': '{8538782f-2c8c-4a30-bfce-c8140f791c9b}', 'powerW': 500}];
final c = validateSet([
LoadConfigEntry({'id': 'a', 'label': 'A', 'adapter': 'relay-router', 'enabled': true, 'relays': relay}),
LoadConfigEntry({'id': 'b', 'label': 'B', 'adapter': 'relay-router', 'enabled': false, 'relays': relay}),
]);
expect(c, isNull);
});
test('la config réelle de .75 passe la validation', () {
expect(validateSet(_load('loadconfig_hems75.json')), isNull);
});
});
}

View File

@ -10,8 +10,9 @@ import 'package:provider/provider.dart';
void main() {
testWidgets('App smoke test — gate vers Installations sans connexion active',
(WidgetTester tester) async {
final service = NymeaService();
final manager = ConnectionManager(service, InstallationStore());
final service = NymeaService();
final manager = ConnectionManager(service, InstallationStore());
final installer = InstallerModeProvider();
await tester.pumpWidget(
MultiProvider(
@ -19,9 +20,9 @@ void main() {
ChangeNotifierProvider.value(value: service),
ChangeNotifierProvider.value(value: manager),
ChangeNotifierProvider(create: (_) => AppSettingsProvider()),
ChangeNotifierProvider(create: (_) => InstallerModeProvider()),
ChangeNotifierProvider.value(value: installer),
],
child: NymeaEnergyApp(router: buildAppRouter(manager, service)),
child: NymeaEnergyApp(router: buildAppRouter(manager, service, installer)),
),
);
await tester.pump();