diff --git a/.gitignore b/.gitignore index 02a13c2..a26ba56 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/Héos_UI/hems_icon.dart b/Héos_UI/hems_icon.dart new file mode 100644 index 0000000..ec84c00 --- /dev/null +++ b/Héos_UI/hems_icon.dart @@ -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(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; +} diff --git a/Héos_UI/hems_icon.svg b/Héos_UI/hems_icon.svg new file mode 100644 index 0000000..5f32ab6 --- /dev/null +++ b/Héos_UI/hems_icon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/l10n.yaml b/l10n.yaml new file mode 100644 index 0000000..5f755b4 --- /dev/null +++ b/l10n.yaml @@ -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 diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb new file mode 100644 index 0000000..fd2d13f --- /dev/null +++ b/lib/l10n/app_fr.arb @@ -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" } + } + } +} diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart new file mode 100644 index 0000000..ffa6c8b --- /dev/null +++ b/lib/l10n/app_localizations.dart @@ -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(context, L10n); + } + + static const LocalizationsDelegate 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> localizationsDelegates = + >[ + delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ]; + + /// A list of this localizations delegate's supported locales. + static const List supportedLocales = [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 { + const _L10nDelegate(); + + @override + Future load(Locale locale) { + return SynchronousFuture(lookupL10n(locale)); + } + + @override + bool isSupported(Locale locale) => + ['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.', + ); +} diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart new file mode 100644 index 0000000..f6a2399 --- /dev/null +++ b/lib/l10n/app_localizations_fr.dart @@ -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'; + } +} diff --git a/lib/l10n/l10n_ext.dart b/lib/l10n/l10n_ext.dart new file mode 100644 index 0000000..846d3f0 --- /dev/null +++ b/lib/l10n/l10n_ext.dart @@ -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)!; +} diff --git a/lib/main.dart b/lib/main.dart index 2cfdfcb..b49b698 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 diff --git a/lib/models/load_config_entry.dart b/lib/models/load_config_entry.dart new file mode 100644 index 0000000..45bede1 --- /dev/null +++ b/lib/models/load_config_entry.dart @@ -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 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 get relays { + final list = raw['relays']; + if (list is! List) return const []; + return list.whereType().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 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()) + if (s['relays'] is List) + ...(s['relays'] as List).whereType().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 patched({int? priority, String? domain}) { + return { + ...raw, + 'priority': ?priority, + 'domain': ?domain, + }; + } + + @override + String toString() => 'LoadConfigEntry($id, $adapter, p=$priority, d=$domain)'; +} diff --git a/lib/providers/energy_setup_provider.dart b/lib/providers/energy_setup_provider.dart index 68f5e94..0fe3b5e 100644 --- a/lib/providers/energy_setup_provider.dart +++ b/lib/providers/energy_setup_provider.dart @@ -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) { diff --git a/lib/providers/load_config_provider.dart b/lib/providers/load_config_provider.dart new file mode 100644 index 0000000..5e0328a --- /dev/null +++ b/lib/providers/load_config_provider.dart @@ -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 _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? _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 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 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 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 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> 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 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> 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> payload) { + final rows = [ + for (final m in payload) + '${m['id']}:${m['priority']}:${m['domain'] ?? ''}', + ]..sort(); + return rows.join('|'); + } + + static String _signatureOfEntries(List 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(); + } +} diff --git a/lib/screens/drawer/main_drawer.dart b/lib/screens/drawer/main_drawer.dart index bad2ea2..02dfb3b 100644 --- a/lib/screens/drawer/main_drawer.dart +++ b/lib/screens/drawer/main_drawer.dart @@ -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 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(); + final settings = context.watch(); 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', diff --git a/lib/screens/energy/load_order_screen.dart b/lib/screens/energy/load_order_screen.dart new file mode 100644 index 0000000..d1b7e45 --- /dev/null +++ b/lib/screens/energy/load_order_screen.dart @@ -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 createState() => _LoadOrderScreenState(); +} + +class _LoadOrderScreenState extends State { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + final p = context.read(); + p.attach(context.read()); + p.load(); + }); + } + + @override + Widget build(BuildContext context) { + final t = context.l10n; + final p = context.watch(); + + 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 _confirm(BuildContext context) async { + final t = context.l10n; + final ok = await showDialog( + 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 = [ + _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))), + ]), + ), + ); + } +} diff --git a/lib/screens/settings/app_settings_screen.dart b/lib/screens/settings/app_settings_screen.dart index 54febc2..adb3cbb 100644 --- a/lib/screens/settings/app_settings_screen.dart +++ b/lib/screens/settings/app_settings_screen.dart @@ -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'), + ), ], ), ], diff --git a/lib/services/load_priority.dart b/lib/services/load_priority.dart new file mode 100644 index 0000000..bcbcf8f --- /dev/null +++ b/lib/services/load_priority.dart @@ -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 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 loads; + + const DomainGroup({required this.domain, required this.loads}); + + bool get isUnclassified => domain == kUnclassifiedDomain; + + DomainGroup copyWith({List? 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 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 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 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 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 = []; + final buckets = >{}; + 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 = []; + for (final key in order) { + final idx = []; + 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> flattenToPayload(List groups) { + final out = >[]; + 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 applyDefaultDomainOrder(List 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 entries) { + final ids = {}; + final owner = {}; // 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; +} diff --git a/lib/services/nymea_service.dart b/lib/services/nymea_service.dart index d102215..b76e817 100644 --- a/lib/services/nymea_service.dart +++ b/lib/services/nymea_service.dart @@ -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 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((m) => Map.from(m)) + .toList()); + break; + } + } + + /// Abonnés à `NymeaEnergy.LoadConfigChanged` (l'écran de configuration des charges). + final List>)> _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>) cb) { + _loadConfigListeners.add(cb); + return () => _loadConfigListeners.remove(cb); + } + + void _onLoadConfigChanged(List> 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 setLoadConfig(List> 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 setLoadConfig(List> 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>> 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((m) => Map.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). diff --git a/pubspec.lock b/pubspec.lock index 2329d8c..8bd664f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index e166014..cb05ac3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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. diff --git a/test/fixtures/loadconfig_interleaved.json b/test/fixtures/loadconfig_interleaved.json new file mode 100644 index 0000000..6f502e0 --- /dev/null +++ b/test/fixtures/loadconfig_interleaved.json @@ -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": {} + } + ] +} \ No newline at end of file diff --git a/test/fixtures/loadconfig_multidomain.json b/test/fixtures/loadconfig_multidomain.json new file mode 100644 index 0000000..ac48cc1 --- /dev/null +++ b/test/fixtures/loadconfig_multidomain.json @@ -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": {} + } + ] +} diff --git a/test/load_priority_test.dart b/test/load_priority_test.dart new file mode 100644 index 0000000..8d89c0f --- /dev/null +++ b/test/load_priority_test.dart @@ -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 _load(String fixture) { + final raw = File('test/fixtures/$fixture').readAsStringSync(); + final map = jsonDecode(raw) as Map; + return (map['loadConfigs'] as List) + .cast>() + .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); + }); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index eb49de9..9f4c279 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -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();