feat(accueil): carte « En ce moment » sur la télémétrie, plus de marque ni de démo

Consigne app §G (décision Patrick du 2026-10-16) et motifs etm70 (§H).

G — la carte « Héos pilote en ce moment » affichait des décisions
inventées (HeosDecision.demo : voiture 3,6 kW, batterie en charge sur un
banc sans batterie). Remplacée par EnCeMomentCard : une ligne par charge
de GetLoadTelemetry — libellé de la configuration, decision.code + params
rendus par decisionText, puissance MESURÉE seulement (jamais l'allocation
à sa place). Titre en clé l10n. Rien sur « optimiseur actif » tant que
l'objet de capacités n'existe pas.
Marque retirée des identifiants (HeosDecisionCard, HeosDecision,
HeosAction, _HeosChip, _HeosBadgeSmall, heosActive → pilotageActif…), des
fichiers (heos_decision_card.dart, dashboard_models.dart supprimés), des
textes affichés (hub du flux, écran A/C) et des commentaires :
grep -ri héos/heos vide sur lib/, test/, assets/ et les .arb.

H — contre INTERFACE.md local de la branche moteur, etm70 pas encore sur
le banc :
- motifs éco publiés à la racine (R7, part majoritaire) rendus en clair ;
  ventilation des niveaux affichée (« 83 W éco + 0 W confort », vu sur
  la trame réelle etm69) ;
- ECO_FLOOR_MISSED retenu par charge (publié un seul cycle), y compris
  pour une charge sortie de loads[] ; « manque non chiffrable » quand
  missingWh est absent, jamais 0 Wh ;
- PRICE_SLOT et OPTIMIZER_DECISION en FR / DE / EN (app_en.arb et
  app_de.arb créés pour ces seules clés ; le reste retombe sur le
  français, listé dans l10n_untranslated.json). Code inconnu : repli
  générique avec le code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-10-16 21:25:51 +02:00
parent 044fcbd0f8
commit fb5b0e7165
19 changed files with 2524 additions and 382 deletions

View File

@ -1,69 +0,0 @@
/// Modèles du tableau de bord.
///
/// 🔴 `HourlyFlow`, `WeatherPoint` et `DayPlan` ont été **supprimés** le 2026-09-08 avec la
/// carte « Plan de la journée » qu'ils alimentaient. `DayPlan.demo()` fabriquait vingt-quatre
/// heures de flux à partir d'une gaussienne et de coefficients écrits en dur, présentées sans
/// mention comme la journée en cours.
///
/// Le code part **avec** la carte, délibérément : un générateur de données plausibles laissé
/// dans le dépôt se rebranche — c'est ce qui s'était passé pour les deux MPPT, les 38 % de
/// PAC, les 82 % de part solaire et le SOC à 62 %. Un plan reviendra quand il y aura une
/// prévision PV et une source tarifaire ; il se réécrira sur des données, pas sur celles-ci.
library;
class HeosAction {
final String iconKey;
final String title;
final String why;
final double powerKw;
const HeosAction({
required this.iconKey,
required this.title,
required this.why,
required this.powerKw,
});
}
class HeosDecision {
final String summary;
final String highlight;
final List<HeosAction> actions;
final DateTime updatedAt;
const HeosDecision({
required this.summary,
required this.highlight,
required this.actions,
required this.updatedAt,
});
static HeosDecision demo() => HeosDecision(
summary:
'Le surplus solaire part dans l\'eau chaude et la voiture '
'plutôt que d\'être revendu à bas prix.',
highlight: 'plutôt que d\'être revendu à bas prix',
actions: [
const HeosAction(
iconKey: 'water',
title: 'Chauffe-eau',
why: 'Surplus PV disponible · coût réseau ce soir 0,22 €/kWh',
powerKw: 2.0,
),
const HeosAction(
iconKey: 'ev',
title: 'Voiture — mode Éco',
why: 'Charge à partir du surplus · objectif 80 % pour 18h',
powerKw: 3.6,
),
const HeosAction(
iconKey: 'battery',
title: 'Batterie en charge',
why: 'Stockage pour couvrir le pic 18h–21h',
powerKw: 0.8,
),
],
updatedAt: DateTime.now(),
);
}

View File

@ -0,0 +1,218 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../l10n/app_localizations.dart';
import '../../../l10n/l10n_ext.dart';
import '../../../models/load_telemetry.dart';
import '../../../providers/load_config_provider.dart';
import '../../../providers/load_telemetry_provider.dart';
import '../../../services/telemetry_text.dart';
import '../../../theme/etm_tokens.dart';
/// **Carte « En ce moment »** — ce que le moteur décide pour chaque charge, ce cycle-ci.
///
/// Remplace la carte de démonstration du 2026-09 (décision G de la consigne, 2026-10-16) :
/// elle affichait des décisions INVENTÉES (« voiture 3,6 kW », « batterie en charge » sur
/// un banc sans batterie) sous un nom de marque. Celle-ci lit `GetLoadTelemetry` —
/// `decision.code` et `params` par charge, rendus par `decisionText` — et rien d'autre.
///
/// Rien n'est dit d'un « optimiseur actif » : l'objet de capacités qui le dirait n'existe
/// pas encore côté box.
class EnCeMomentCard extends StatelessWidget {
const EnCeMomentCard({super.key});
@override
Widget build(BuildContext context) {
final tel = context.watch<LoadTelemetryProvider>();
final cfg = context.watch<LoadConfigProvider>();
String nu(String x) => x.replaceAll(RegExp(r'[{}]'), '').toLowerCase();
final config = {for (final c in cfg.entries) nu(c.id): c};
return EnCeMomentView(
telemetry: tel.telemetry,
chargement: tel.loading && tel.telemetry == null,
supportee: tel.supported,
erreur: tel.error,
derniersManques: tel.derniersManques,
libelle: (id) => config[nu(id)]?.label ?? id,
domaine: (id) => config[nu(id)]?.domain,
);
}
}
/// Rendu pur — tout ce qu'il affiche lui est donné.
class EnCeMomentView extends StatelessWidget {
final LoadTelemetry? telemetry;
final bool chargement;
final bool supportee;
final String? erreur;
final Map<String, ({LoadDecision decision, DateTime at})> derniersManques;
final String Function(String loadId) libelle;
final String? Function(String loadId) domaine;
const EnCeMomentView({
super.key,
required this.telemetry,
this.chargement = false,
this.supportee = true,
this.erreur,
this.derniersManques = const {},
required this.libelle,
required this.domaine,
});
static String _hm(DateTime t) =>
'${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}';
@override
Widget build(BuildContext context) {
final t = context.l10n;
final ink = EtmTokens.inkOf(context);
final muted = EtmTokens.mutedOf(context);
final tel = telemetry;
final cycle = tel?.timestamp?.toLocal();
return Container(
decoration: BoxDecoration(
color: EtmTokens.surfaceOf(context),
borderRadius: BorderRadius.circular(EtmTokens.radiusCard),
boxShadow: EtmTokens.cardShadowOf(context),
),
padding: const EdgeInsets.all(18),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Expanded(
child: Text(t.enCeMomentTitre,
style: EtmTokens.sans(size: 16, weight: FontWeight.w600, color: ink)),
),
if (cycle != null)
Text(t.enCeMomentMaj(_hm(cycle)),
style: EtmTokens.mono(size: 10, weight: FontWeight.w400, color: muted)),
]),
const SizedBox(height: 8),
..._corps(context, t),
]),
);
}
List<Widget> _corps(BuildContext context, L10n t) {
final muted = EtmTokens.mutedOf(context);
Text note(String s) => Text(s, style: EtmTokens.sans(size: 12.5, color: muted));
final tel = telemetry;
if (chargement) {
return const [
SizedBox(
height: 40,
child: Center(
child: SizedBox(
width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)))),
];
}
if (tel == null) {
// Deux « pas de données » distincts : une box sans la méthode n'est pas en panne.
return [note(!supportee ? t.enCeMomentNonPublie : t.enCeMomentNonLu)];
}
String nu(String x) => x.replaceAll(RegExp(r'[{}]'), '').toLowerCase();
final presentes = {for (final e in tel.loads) nu(e.loadId)};
final lignes = <Widget>[
if (tel.degradedMode) note(t.enCeMomentDegrade),
if (tel.loads.isEmpty && !tel.degradedMode) note(t.enCeMomentAucune),
for (final e in tel.loads) _ligne(context, t, e),
// Un manque retenu pour une charge qui a quitté `loads[]` (borne débranchée) reste
// visible : c'est précisément le cas où on voudrait savoir.
for (final m in derniersManques.entries)
if (!presentes.contains(m.key))
_manque(context, t, m.value, titre: libelle(m.key)),
];
final out = <Widget>[];
for (var i = 0; i < lignes.length; i++) {
if (i > 0) out.add(Divider(height: 18, color: EtmTokens.lineOf(context)));
out.add(lignes[i]);
}
return out;
}
Widget _ligne(BuildContext context, L10n t, LoadTelemetryEntry e) {
final ink = EtmTokens.inkOf(context);
final muted = EtmTokens.mutedOf(context);
final dom = domaine(e.loadId);
final couleur = EtmTokens.domaineOf(context, dom) ?? muted;
final m = e.measurement;
// Ventilation éco / confort quand plusieurs passes se cumulent (R7, etm70) : le motif
// publié est celui de la part majoritaire, les deux parts se lisent ici.
String? ventilation;
if (e.levels.length > 1) {
ventilation = e.levels
.map((l) => t.ventilationNiveau(
l.targetW.round(),
switch (l.level) {
'eco' => t.ventilationEco,
'comfort' => t.ventilationConfort,
final autre => autre,
}))
.join(' + ');
}
final manque = derniersManques[e.loadId.replaceAll(RegExp(r'[{}]'), '').toLowerCase()];
return Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
Container(
width: 34,
height: 34,
decoration: BoxDecoration(
color: couleur.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl),
),
child: Icon(_icone(dom, e), color: couleur, size: 18),
),
const SizedBox(width: 12),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(libelle(e.loadId),
style: EtmTokens.sans(size: 13.5, weight: FontWeight.w600, color: ink)),
const SizedBox(height: 2),
Text(decisionText(t, e.decision),
style: EtmTokens.sans(size: 11.5, color: muted).copyWith(height: 1.35)),
if (ventilation != null)
Text(ventilation,
style: EtmTokens.mono(size: 10.5, weight: FontWeight.w400, color: muted)),
if (manque != null) ...[
const SizedBox(height: 4),
_manque(context, t, manque),
],
]),
),
// La puissance MESURÉE, quand la charge se mesure. Jamais l'allocation à sa place :
// décidé ≠ tiré (CLAUDE.md, « trois nombres »).
if (m.mesuree) ...[
const SizedBox(width: 8),
Text('${(m.powerW! / 1000).toStringAsFixed(1).replaceAll('.', ',')} kW',
style: EtmTokens.mono(size: 13, color: EtmTokens.ecoOf(context))),
],
]);
}
Widget _manque(BuildContext context, L10n t,
({LoadDecision decision, DateTime at}) m, {String? titre}) {
final texte = t.enCeMomentDernierManque(_hm(m.at), decisionText(t, m.decision));
return Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
Icon(Icons.warning_amber_rounded, size: 14, color: EtmTokens.orange),
const SizedBox(width: 6),
Expanded(
child: Text(titre == null ? texte : '$titre — $texte',
style: EtmTokens.sans(size: 11.5, color: EtmTokens.inkOf(context))
.copyWith(height: 1.35)),
),
]);
}
static IconData _icone(String? domaine, LoadTelemetryEntry e) {
if (e.mechanism?.isEvCharger ?? false) return Icons.ev_station_rounded;
return switch (domaine) {
'ev' => Icons.ev_station_rounded,
'ecs' => Icons.water_drop_rounded,
'heating' || 'hvac' => Icons.heat_pump_rounded,
_ => Icons.bolt_rounded,
};
}
}

View File

@ -363,7 +363,7 @@ class _NodeLayerState extends State<_NodeLayer>
final d = widget.data;
final eco = EtmTokens.ecoOf(context);
return Stack(children: [
// Hub — Héos with pulsing ring
// Hub — pilotage, with pulsing ring
_at(Offset(0.5, 0.5), _buildHub(context, eco)),
// Solar (top) — tappable → prod
_at(Offset(0.5, 0.12),
@ -546,7 +546,7 @@ class _NodeLayerState extends State<_NodeLayer>
);
}
// Hub with Héos pulsing ring
// Hub with pulsing ring
Widget _buildHub(BuildContext context, Color eco) {
final surf = EtmTokens.surfaceOf(context);
final muted = EtmTokens.mutedOf(context);
@ -556,7 +556,7 @@ class _NodeLayerState extends State<_NodeLayer>
child: Stack(
alignment: Alignment.center,
children: [
// Pulsing Héos ring
// Pulsing ring
if (widget.pulseAnim != null)
AnimatedBuilder(
animation: widget.pulseAnim!,
@ -571,7 +571,7 @@ class _NodeLayerState extends State<_NodeLayer>
),
),
),
// Hub circle — Héos
// Hub circle — pilotage
Container(
width: 92, height: 92,
decoration: BoxDecoration(
@ -584,7 +584,7 @@ class _NodeLayerState extends State<_NodeLayer>
children: [
HemsIcon(size: 24, color: eco),
const SizedBox(height: 3),
Text('Héos', style: EtmTokens.sans(size: 9, color: muted)),
Text('Pilotage', style: EtmTokens.sans(size: 9, color: muted)),
],
),
),

View File

@ -1,280 +0,0 @@
import 'package:flutter/material.dart';
import '../../../theme/etm_tokens.dart';
import '../models/dashboard_models.dart';
class HeosDecisionCard extends StatefulWidget {
const HeosDecisionCard({super.key});
@override
State<HeosDecisionCard> createState() => _HeosDecisionCardState();
}
class _HeosDecisionCardState extends State<HeosDecisionCard>
with SingleTickerProviderStateMixin {
late final AnimationController _pulseCtrl;
late final Animation<double> _pulseAnim;
final _decision = HeosDecision.demo();
@override
void initState() {
super.initState();
_pulseCtrl = AnimationController(
vsync: this,
duration: const Duration(seconds: 2),
)..repeat(reverse: true);
_pulseAnim = Tween<double>(begin: 0.25, end: 0.7)
.animate(CurvedAnimation(parent: _pulseCtrl, curve: Curves.easeInOut));
}
@override
void dispose() {
_pulseCtrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final eco = EtmTokens.ecoOf(context);
final ink = EtmTokens.inkOf(context);
return Container(
decoration: BoxDecoration(
color: EtmTokens.surfaceOf(context),
borderRadius: BorderRadius.circular(EtmTokens.radiusCard),
boxShadow: EtmTokens.cardShadowOf(context),
border: Border.all(color: eco.withValues(alpha: 0.25), width: 1),
),
padding: const EdgeInsets.all(18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── En-tête
Row(
children: [
Container(
width: 34, height: 34,
decoration: BoxDecoration(
color: eco.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl),
),
child: Icon(Icons.auto_awesome_rounded, color: eco, size: 18),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Héos pilote en ce moment',
style: EtmTokens.sans(
size: 14,
weight: FontWeight.w600,
color: ink,
)),
Text(
'optimise sur 24 h · maj ${_fmtTime(_decision.updatedAt)}',
style: EtmTokens.mono(
size: 10,
color: EtmTokens.mutedOf(context),
weight: FontWeight.w400,
),
),
],
),
),
AnimatedBuilder(
animation: _pulseAnim,
builder: (context, _) => Container(
width: 10, height: 10,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: eco.withValues(alpha: _pulseAnim.value),
boxShadow: [
BoxShadow(
color: eco.withValues(alpha: _pulseAnim.value * 0.6),
blurRadius: 6,
spreadRadius: 2,
),
],
),
),
),
],
),
const SizedBox(height: 14),
// ── Résumé avec highlight
_HighlightedSummary(
text: _decision.summary,
highlight: _decision.highlight,
highlightColor: eco,
textColor: EtmTokens.mutedOf(context),
),
const SizedBox(height: 14),
// ── Hairline
Divider(height: 1, color: EtmTokens.lineOf(context)),
// ── Actions
...List.generate(_decision.actions.length, (i) {
final action = _decision.actions[i];
final isLast = i == _decision.actions.length - 1;
return Column(
children: [
_ActionRow(action: action, eco: eco, context: context),
if (!isLast)
Divider(height: 1, color: EtmTokens.lineOf(context)),
],
);
}),
const SizedBox(height: 12),
// ── Pied
GestureDetector(
onTap: () {},
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Voir le plan de la journée',
style: EtmTokens.sans(
size: 13,
weight: FontWeight.w600,
color: EtmTokens.brand),
),
const SizedBox(width: 4),
Icon(Icons.arrow_forward_ios_rounded,
size: 12, color: EtmTokens.brand),
],
),
),
],
),
);
}
String _fmtTime(DateTime dt) =>
'${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
}
// ── Highlighted summary ───────────────────────────────────────────────────────
class _HighlightedSummary extends StatelessWidget {
final String text;
final String highlight;
final Color highlightColor;
final Color textColor;
const _HighlightedSummary({
required this.text,
required this.highlight,
required this.highlightColor,
required this.textColor,
});
@override
Widget build(BuildContext context) {
final idx = text.indexOf(highlight);
if (idx < 0) {
return Text(text,
style: EtmTokens.sans(size: 13, color: textColor, height: 1.5));
}
return Text.rich(
TextSpan(children: [
TextSpan(
text: text.substring(0, idx),
style: EtmTokens.sans(size: 13, color: textColor, height: 1.5),
),
WidgetSpan(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 1),
decoration: BoxDecoration(
color: highlightColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(4),
),
child: Text(
highlight,
style: EtmTokens.sans(
size: 13,
color: highlightColor,
weight: FontWeight.w600,
height: 1.5,
),
),
),
),
TextSpan(
text: text.substring(idx + highlight.length),
style: EtmTokens.sans(size: 13, color: textColor, height: 1.5),
),
]),
);
}
}
// ── Action row ────────────────────────────────────────────────────────────────
class _ActionRow extends StatelessWidget {
final HeosAction action;
final Color eco;
final BuildContext context;
const _ActionRow({
required this.action,
required this.eco,
required this.context,
});
@override
Widget build(BuildContext _) {
final icon = _iconFor(action.iconKey);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 32, height: 32,
decoration: BoxDecoration(
color: eco.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl),
),
child: Icon(icon, color: eco, size: 17),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(action.title,
style: EtmTokens.sans(
size: 13,
weight: FontWeight.w600,
color: EtmTokens.inkOf(context))),
const SizedBox(height: 2),
Text(action.why,
style: EtmTokens.sans(
size: 11, color: EtmTokens.mutedOf(context))),
],
),
),
const SizedBox(width: 8),
Text(
'${action.powerKw.toStringAsFixed(1)} kW',
style: EtmTokens.mono(size: 13, color: eco),
),
],
),
);
}
IconData _iconFor(String key) => switch (key) {
'water' => Icons.water_drop_outlined,
'ev' => Icons.ev_station_rounded,
'battery' => Icons.battery_charging_full_rounded,
'heat' => Icons.heat_pump_outlined,
_ => Icons.bolt_rounded,
};
}

7
lib/l10n/app_de.arb Normal file
View File

@ -0,0 +1,7 @@
{
"@@locale": "de",
"enCeMomentTitre": "Gerade jetzt",
"decisionPriceSlot": "Günstiger Preis ({prix} ct/kWh).",
"decisionPriceSlotSetpoint": "Günstiger Preis ({prix} ct/kWh) — Sollwert {setpoint} W.",
"decisionOptimizer": "Entscheidung des Optimierers."
}

7
lib/l10n/app_en.arb Normal file
View File

@ -0,0 +1,7 @@
{
"@@locale": "en",
"enCeMomentTitre": "Right now",
"decisionPriceSlot": "Favourable price ({prix} ct/kWh).",
"decisionPriceSlotSetpoint": "Favourable price ({prix} ct/kWh) — setpoint {setpoint} W.",
"decisionOptimizer": "Optimizer decision."
}

View File

@ -479,5 +479,85 @@
"requested": { "type": "String" },
"effective": { "type": "String" }
}
}
},
"enCeMomentTitre": "En ce moment",
"@enCeMomentTitre": {
"description": "Titre de la carte d'accueil des décisions par charge. Décision G du 2026-10-16 : aucun nom de marque."
},
"enCeMomentMaj": "cycle de {heure}",
"@enCeMomentMaj": {
"placeholders": {
"heure": {
"type": "String"
}
}
},
"enCeMomentAucune": "Aucune charge pilotée en ce moment.",
"enCeMomentNonPublie": "Cette box ne publie pas l'arbitrage des charges.",
"enCeMomentNonLu": "Arbitrage non lu : la box n'a pas répondu.",
"enCeMomentDegrade": "Mode dégradé : le moteur n'a pas de budget ce cycle.",
"enCeMomentDernierManque": "Objectif manqué à {heure} : {texte}",
"@enCeMomentDernierManque": {
"description": "ECO_FLOOR_MISSED n'est publié qu'un cycle : l'app retient le dernier par charge.",
"placeholders": {
"heure": {
"type": "String"
},
"texte": {
"type": "String"
}
}
},
"ventilationNiveau": "{w} W {niveau}",
"@ventilationNiveau": {
"placeholders": {
"w": {
"type": "int",
"format": "decimalPattern"
},
"niveau": {
"type": "String"
}
}
},
"ecoManqueNonChiffrable": " · manque non chiffrable",
"@ecoManqueNonChiffrable": {
"description": "missingWh ABSENT (cause unmeasurable) : jamais « 0 Wh »."
},
"decisionPriceSlot": "Prix favorable ({prix} ct/kWh).",
"@decisionPriceSlot": {
"description": "PRICE_SLOT (etm70). Prix complet du créneau.",
"placeholders": {
"prix": {
"type": "double",
"format": "decimalPatternDigits",
"optionalParameters": {
"decimalDigits": 2
}
}
}
},
"decisionPriceSlotSetpoint": "Prix favorable ({prix} ct/kWh) — consigne {setpoint} W.",
"@decisionPriceSlotSetpoint": {
"placeholders": {
"prix": {
"type": "double",
"format": "decimalPatternDigits",
"optionalParameters": {
"decimalDigits": 2
}
},
"setpoint": {
"type": "int",
"format": "decimalPattern"
}
}
},
"decisionOptimizer": "Décision de l'optimiseur.",
"@decisionOptimizer": {
"description": "OPTIMIZER_DECISION (etm70) : motif hors catalogue, sans paramètre. Aucun nom de marque."
},
"ventilationEco": "éco",
"ventilationConfort": "confort"
}

View File

@ -5,6 +5,8 @@ import 'package:flutter/widgets.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:intl/intl.dart' as intl;
import 'app_localizations_de.dart';
import 'app_localizations_en.dart';
import 'app_localizations_fr.dart';
// ignore_for_file: type=lint
@ -91,7 +93,11 @@ abstract class L10n {
];
/// A list of this localizations delegate's supported locales.
static const List<Locale> supportedLocales = <Locale>[Locale('fr')];
static const List<Locale> supportedLocales = <Locale>[
Locale('de'),
Locale('en'),
Locale('fr'),
];
/// Titre de l'écran installateur de configuration des charges pilotables
///
@ -968,6 +974,90 @@ abstract class L10n {
/// In fr, this message translates to:
/// **'Locale demandée : {requested} · retenue par la box : {effective}'**
String localeRequested(String requested, String effective);
/// Titre de la carte d'accueil des décisions par charge. Décision G du 2026-10-16 : aucun nom de marque.
///
/// In fr, this message translates to:
/// **'En ce moment'**
String get enCeMomentTitre;
/// No description provided for @enCeMomentMaj.
///
/// In fr, this message translates to:
/// **'cycle de {heure}'**
String enCeMomentMaj(String heure);
/// No description provided for @enCeMomentAucune.
///
/// In fr, this message translates to:
/// **'Aucune charge pilotée en ce moment.'**
String get enCeMomentAucune;
/// No description provided for @enCeMomentNonPublie.
///
/// In fr, this message translates to:
/// **'Cette box ne publie pas l\'arbitrage des charges.'**
String get enCeMomentNonPublie;
/// No description provided for @enCeMomentNonLu.
///
/// In fr, this message translates to:
/// **'Arbitrage non lu : la box n\'a pas répondu.'**
String get enCeMomentNonLu;
/// No description provided for @enCeMomentDegrade.
///
/// In fr, this message translates to:
/// **'Mode dégradé : le moteur n\'a pas de budget ce cycle.'**
String get enCeMomentDegrade;
/// ECO_FLOOR_MISSED n'est publié qu'un cycle : l'app retient le dernier par charge.
///
/// In fr, this message translates to:
/// **'Objectif manqué à {heure} : {texte}'**
String enCeMomentDernierManque(String heure, String texte);
/// No description provided for @ventilationNiveau.
///
/// In fr, this message translates to:
/// **'{w} W {niveau}'**
String ventilationNiveau(int w, String niveau);
/// missingWh ABSENT (cause unmeasurable) : jamais « 0 Wh ».
///
/// In fr, this message translates to:
/// **' · manque non chiffrable'**
String get ecoManqueNonChiffrable;
/// PRICE_SLOT (etm70). Prix complet du créneau.
///
/// In fr, this message translates to:
/// **'Prix favorable ({prix} ct/kWh).'**
String decisionPriceSlot(double prix);
/// No description provided for @decisionPriceSlotSetpoint.
///
/// In fr, this message translates to:
/// **'Prix favorable ({prix} ct/kWh) — consigne {setpoint} W.'**
String decisionPriceSlotSetpoint(double prix, int setpoint);
/// OPTIMIZER_DECISION (etm70) : motif hors catalogue, sans paramètre. Aucun nom de marque.
///
/// In fr, this message translates to:
/// **'Décision de l\'optimiseur.'**
String get decisionOptimizer;
/// No description provided for @ventilationEco.
///
/// In fr, this message translates to:
/// **'éco'**
String get ventilationEco;
/// No description provided for @ventilationConfort.
///
/// In fr, this message translates to:
/// **'confort'**
String get ventilationConfort;
}
class _L10nDelegate extends LocalizationsDelegate<L10n> {
@ -980,7 +1070,7 @@ class _L10nDelegate extends LocalizationsDelegate<L10n> {
@override
bool isSupported(Locale locale) =>
<String>['fr'].contains(locale.languageCode);
<String>['de', 'en', 'fr'].contains(locale.languageCode);
@override
bool shouldReload(_L10nDelegate old) => false;
@ -989,6 +1079,10 @@ class _L10nDelegate extends LocalizationsDelegate<L10n> {
L10n lookupL10n(Locale locale) {
// Lookup logic when only language code is specified.
switch (locale.languageCode) {
case 'de':
return L10nDe();
case 'en':
return L10nEn();
case 'fr':
return L10nFr();
}

View File

@ -0,0 +1,820 @@
// ignore: unused_import
import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
// ignore_for_file: type=lint
/// The translations for German (`de`).
class L10nDe extends L10n {
L10nDe([String locale = 'de']) : 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 get telemetryTitle => 'Arbitrage en direct';
@override
String get telemetryNeverRan =>
'Aucun cycle d\'arbitrage depuis le démarrage du service.';
@override
String telemetryLastCycle(int seconds) {
return 'Dernier cycle il y a $seconds s';
}
@override
String get telemetryBudgetTitle => 'Budget de surplus du cycle';
@override
String get telemetryBudgetAbsent =>
'Aucun budget publié pour ce cycle : la planification est suspendue.';
@override
String get budgetSurplus => 'Surplus entrant';
@override
String get budgetEvReserved => 'Commandé, pas encore mesuré';
@override
String get budgetRecredited => 'Recrédité';
@override
String get budgetAllocated => 'Alloué';
@override
String get budgetRemaining => 'Reliquat';
@override
String watts(int watts) {
final intl.NumberFormat wattsNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String wattsString = wattsNumberFormat.format(watts);
return '$wattsString W';
}
@override
String telemetryAllocated(int watts) {
final intl.NumberFormat wattsNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String wattsString = wattsNumberFormat.format(watts);
return 'Alloué $wattsString W';
}
@override
String telemetryEvCharging(double current, int phases) {
final intl.NumberFormat currentNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String currentString = currentNumberFormat.format(current);
return 'Borne : en charge, $currentString A sur $phases phase(s)';
}
@override
String get telemetryEvIdle => 'Borne : ne charge pas';
@override
String get telemetryEvUnplugged => 'Borne : aucun véhicule branché';
@override
String get telemetryEvStateUnknown => 'Borne : état non publié ce cycle';
@override
String get telemetryFundedByGrid =>
'Financée au réseau (échéance ou tarif dynamique) — hors budget de surplus';
@override
String get niveauEco => 'ÉCO';
@override
String get niveauConfort => 'CONFORT';
@override
String decisionEcoFloorGrid(int gridW) {
return 'Plancher servi — $gridW W achetés au réseau';
}
@override
String decisionEcoFloorMet(String livre, String cible) {
return 'Plancher atteint — $cible dus, $livre livrés';
}
@override
String decisionEcoFloorMissed(String raison) {
return 'Obligation abandonnée à la bascule — $raison';
}
@override
String get ecoCauseDrawCap => 'le plafond de soutirage l\'a refusée';
@override
String get ecoCauseLoadCapacity =>
'la charge ne pouvait pas absorber davantage';
@override
String get ecoCauseUnmeasurable =>
'avancement non vérifiable — ni tenue, ni chiffrable';
@override
String get ecoCauseUnavailable => 'la charge était indisponible';
@override
String ecoManque(String wh) {
return ' · il manquait $wh';
}
@override
String ecoAvancementMesure(String livre, String cible) {
return '$livre sur $cible livrés';
}
@override
String ecoAvancementNonVerifiable(String wh) {
return 'reste $wh à livrer — avancement non vérifiable';
}
@override
String ecoMesureDepuis(String heure) {
return 'mesuré depuis $heure';
}
@override
String ecoMesureIntermittente(String heure) {
return 'mesuré par intermittence depuis $heure';
}
@override
String get ecoTauxConstant =>
'taux constant (cible ÷ jour) — sans mesure, rien ne le corrige';
@override
String get ecoAuMaximum => 'au maximum de la charge';
@override
String ecoAchete(String wh) {
return '$wh achetés pour cette obligation';
}
@override
String get obligationTitre => 'Obligation quotidienne';
@override
String get obligationSousTitre =>
'Le plancher que la box servira quoi qu\'il arrive, au réseau s\'il le faut.';
@override
String get obligationEnergie => 'Énergie due par jour';
@override
String get obligationEcheance => 'Avant';
@override
String obligationEcheanceHorloge(String heure, String fuseau) {
return '$heure heure de la box ($fuseau) — pas celle de cet appareil.';
}
@override
String get obligationNonMesurableTitre =>
'Cette obligation ne pourra jamais être close';
@override
String obligationNonMesurableCorps(String cout) {
return 'La box ne saura pas mesurer ce qu\'elle livre ici : l\'obligation ne pourra être annoncée ni tenue, ni manquée d\'une quantité connue. Elle sera achetée au réseau à taux constant — $cout par jour — sans que rien ne vienne le corriger.';
}
@override
String get obligationCauseNoMeter =>
'Aucun compteur n\'est rattaché à cette charge. Rattachez-en un ci-dessus : c\'est la cause la plus fréquente, et elle se répare en une manipulation.';
@override
String get obligationCauseNoEnergyMeter =>
'Le compteur rattaché publie une puissance, pas une énergie. Il faut un compteur qui totalise les kWh — la puissance seule ne suffit pas à mesurer ce qui a été livré.';
@override
String get obligationCauseNoSessionEnergy =>
'Cette borne ne publie pas l\'énergie de session. C\'est le matériel : aucun réglage ne le changera, et il vaut mieux le savoir maintenant qu\'après avoir cherché.';
@override
String obligationCauseInconnue(String cause) {
return 'La box ne dit pas pourquoi (cause « $cause » inconnue de cette version).';
}
@override
String get ecoIncoherenceTenue =>
'Incohérence de la box : plancher annoncé atteint alors que l\'avancement n\'est pas mesurable';
@override
String confortEnAttente(String motif) {
return 'En attente — $motif';
}
@override
String get telemetryNotArbitrated =>
'Non arbitrée — absente du dernier cycle publié';
@override
String lockMinOn(int seconds) {
return 'Maintien ON — $seconds s restantes';
}
@override
String lockMinOff(int seconds) {
return 'Maintien OFF — $seconds s restantes';
}
@override
String lockMinStateHold(int seconds) {
return 'Maintien d\'état — $seconds s restantes';
}
@override
String lockUnknown(String kind, int seconds) {
return 'Verrou « $kind » — $seconds s restantes';
}
@override
String get lockAsOfCycle => 'au dernier cycle';
@override
String mechanismRelayStage(int stage, int max) {
final intl.NumberFormat stageNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String stageString = stageNumberFormat.format(stage);
final intl.NumberFormat maxNumberFormat = intl.NumberFormat.decimalPattern(
localeName,
);
final String maxString = maxNumberFormat.format(max);
return 'Palier $stageString W sur $maxString W';
}
@override
String mechanismVariableSetpoint(int setpoint, int percent) {
final intl.NumberFormat setpointNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String setpointString = setpointNumberFormat.format(setpoint);
return 'Consigne $setpointString W ($percent %)';
}
@override
String mechanismSgReadyState(int state, int estimated) {
final intl.NumberFormat estimatedNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String estimatedString = estimatedNumberFormat.format(estimated);
return 'État SG-Ready $state — estimation $estimatedString W';
}
@override
String mechanismEvCharger(double current, int phases) {
final intl.NumberFormat currentNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String currentString = currentNumberFormat.format(current);
return '$currentString A sur $phases phase(s)';
}
@override
String mechanismUnknown(String kind) {
return 'Mécanisme « $kind »';
}
@override
String get decisionEvSurplus =>
'Véhicule rechargé sur le surplus photovoltaïque.';
@override
String get decisionEvSpotMarket =>
'Véhicule rechargé sur un créneau tarifaire favorable.';
@override
String get decisionEvDeadline =>
'Véhicule prioritaire : l\'échéance approche.';
@override
String get decisionEvEcoMin =>
'Pas de surplus : le courant minimum est maintenu.';
@override
String get decisionEvIdle => 'Pas de surplus : la recharge est suspendue.';
@override
String decisionLoadUnavailable(int frozen) {
final intl.NumberFormat frozenNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String frozenString = frozenNumberFormat.format(frozen);
return 'Charge en défaut : aucune commande n\'est émise, et $frozenString W restent comptés au budget.';
}
@override
String decisionLockMinOn(int applied, int budget) {
final intl.NumberFormat appliedNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String appliedString = appliedNumberFormat.format(applied);
final intl.NumberFormat budgetNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String budgetString = budgetNumberFormat.format(budget);
return 'Le maintien ON a relevé la consigne à $appliedString W ; le budget en payait $budgetString W.';
}
@override
String decisionLockMinOff(int requested, int budget) {
final intl.NumberFormat requestedNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String requestedString = requestedNumberFormat.format(requested);
final intl.NumberFormat budgetNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String budgetString = budgetNumberFormat.format(budget);
return 'Le maintien OFF interdit l\'enclenchement ; le budget payait pourtant $requestedString W sur $budgetString W disponibles.';
}
@override
String decisionLockMinOffCapped(int applied, int requested, int budget) {
final intl.NumberFormat appliedNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String appliedString = appliedNumberFormat.format(applied);
final intl.NumberFormat requestedNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String requestedString = requestedNumberFormat.format(requested);
final intl.NumberFormat budgetNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String budgetString = budgetNumberFormat.format(budget);
return 'Le maintien OFF plafonne la consigne à $appliedString W ; $requestedString W étaient demandés sur $budgetString W de budget.';
}
@override
String decisionSurplusSetpoint(int setpoint, int budget) {
final intl.NumberFormat setpointNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String setpointString = setpointNumberFormat.format(setpoint);
final intl.NumberFormat budgetNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String budgetString = budgetNumberFormat.format(budget);
return 'Consigne de $setpointString W servie par le surplus ($budgetString W de budget).';
}
@override
String decisionSurplusSetpointStepped(int setpoint, int budget) {
final intl.NumberFormat setpointNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String setpointString = setpointNumberFormat.format(setpoint);
final intl.NumberFormat budgetNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String budgetString = budgetNumberFormat.format(budget);
return 'Consigne de $setpointString W servie par le surplus, arrondie sur un palier déclaré ($budgetString W de budget).';
}
@override
String decisionSurplusInsufficient(int budget) {
final intl.NumberFormat budgetNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String budgetString = budgetNumberFormat.format(budget);
return 'Aucun surplus à distribuer ($budgetString W) — rien n\'est commandé.';
}
@override
String decisionLockMinStateHold(int state) {
return 'État $state maintenu par protection anti court-cycle.';
}
@override
String decisionSgForced(int estimated, int budget) {
final intl.NumberFormat estimatedNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String estimatedString = estimatedNumberFormat.format(estimated);
final intl.NumberFormat budgetNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String budgetString = budgetNumberFormat.format(budget);
return 'Pompe à chaleur en état 4 (forcé) — estimation $estimatedString W sur $budgetString W de budget.';
}
@override
String decisionSgRecommended(int estimated, int budget) {
final intl.NumberFormat estimatedNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String estimatedString = estimatedNumberFormat.format(estimated);
final intl.NumberFormat budgetNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String budgetString = budgetNumberFormat.format(budget);
return 'Pompe à chaleur en état 3 (recommandation) — estimation $estimatedString W sur $budgetString W de budget.';
}
@override
String decisionSgNormal(int budget) {
final intl.NumberFormat budgetNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String budgetString = budgetNumberFormat.format(budget);
return 'Aucun surplus ($budgetString W) — pompe à chaleur en état 2 (normal, sans consigne).';
}
@override
String decisionBelowMinPower(int budget, int min) {
final intl.NumberFormat budgetNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String budgetString = budgetNumberFormat.format(budget);
final intl.NumberFormat minNumberFormat = intl.NumberFormat.decimalPattern(
localeName,
);
final String minString = minNumberFormat.format(min);
return 'Budget de $budgetString W sous le premier palier ($minString W) — rien n\'est commandé, le budget passe à la charge suivante.';
}
@override
String decisionBelowMinPowerState(int budget, int state, int min) {
final intl.NumberFormat budgetNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String budgetString = budgetNumberFormat.format(budget);
final intl.NumberFormat minNumberFormat = intl.NumberFormat.decimalPattern(
localeName,
);
final String minString = minNumberFormat.format(min);
return 'Budget de $budgetString W sous l\'état $state ($minString W estimés) — maintien en état 2, le budget passe à la charge suivante.';
}
@override
String decisionEvGridStart(int grid, int budget, int floor) {
final intl.NumberFormat gridNumberFormat = intl.NumberFormat.decimalPattern(
localeName,
);
final String gridString = gridNumberFormat.format(grid);
final intl.NumberFormat budgetNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String budgetString = budgetNumberFormat.format(budget);
final intl.NumberFormat floorNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String floorString = floorNumberFormat.format(floor);
return 'Démarrage soutiré : $gridString W achetés au réseau pour compléter les $budgetString W de surplus et atteindre le plancher de $floorString W.';
}
@override
String decisionPhaseLimit(int limit, int required) {
final intl.NumberFormat limitNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String limitString = limitNumberFormat.format(limit);
final intl.NumberFormat requiredNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String requiredString = requiredNumberFormat.format(required);
return 'Limite de phase atteinte : $limitString W disponibles pour $requiredString W exigés — ce n\'est pas le surplus qui manque.';
}
@override
String decisionBatteryReserveWithheld(int withheld, int soc, int seuil) {
final intl.NumberFormat withheldNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String withheldString = withheldNumberFormat.format(withheld);
return '$withheldString W de surplus retenus par la réserve batterie ($soc % sous le seuil de $seuil %) — il y a du surplus, mais une règle interdit de le distribuer.';
}
@override
String decisionBatteryReserve(int soc, int seuil) {
return 'Budget annulé par la réserve batterie ($soc % sous le seuil de $seuil %) — il y a du surplus, mais une règle interdit de le distribuer.';
}
@override
String get decisionBatteryReserveShort =>
'Budget annulé par la réserve batterie — il y a du surplus, mais une règle interdit de le distribuer.';
@override
String get decisionDegradedL2 =>
'Consigne de repli : le compteur est muet et le watchdog a suspendu la planification.';
@override
String get decisionSafeStateRelay =>
'État sûr avant retrait : tous les relais sont ouverts.';
@override
String get decisionSafeStateSetpoint =>
'État sûr avant retrait : consigne ramenée à 0 W.';
@override
String get decisionSafeStateSgReady =>
'État sûr avant retrait : état 2 (normal), jamais le blocage.';
@override
String get faultWriteFailed =>
'Échec d\'écriture : l\'échelle de commande est épuisée, plus aucune commande n\'est émise tant que le défaut n\'est pas levé.';
@override
String get faultThingMissing =>
'L\'appareil commandé n\'existe plus dans nymea — retiré, ou jamais réinstallé après un remplacement. La charge, elle, est toujours déclarée : c\'est pour ça qu\'elle apparaît ici.';
@override
String get faultThingMissingAction =>
'Rien à lever ici : le défaut ne partira pas tant qu\'aucun appareil ne répond. Réinstallez-le dans nymea, ou retirez cette charge de la configuration.';
@override
String get faultUnusableEncoding =>
'Encodage SG-Ready inexploitable : l\'état 2 ne peut pas être exprimé, cette pompe à chaleur n\'est pas pilotable.';
@override
String get faultClearPending =>
'Levée demandée — en attente de la télémétrie…';
@override
String get faultClearStillFaulty =>
'La charge est toujours en défaut après la levée.';
@override
String faultClearRejected(String reason) {
return 'La box a refusé la levée : $reason';
}
@override
String get faultClearHint =>
'La levée est une action délibérée : le défaut ne se lève jamais tout seul.';
@override
String get domainBattery => 'Batterie';
@override
String domainAssignTitle(String label) {
return 'Classer « $label »';
}
@override
String get domainAssignAction => 'Changer de domaine';
@override
String get domainAssignNone => 'Retirer du classement';
@override
String get domainAssignHint =>
'Le domaine est une intention déclarée par l\'installateur. L\'arbitre ne la lit pas : elle ne sert qu\'à grouper l\'ordre de service.';
@override
String localeRequested(String requested, String effective) {
return 'Locale demandée : $requested · retenue par la box : $effective';
}
@override
String get enCeMomentTitre => 'Gerade jetzt';
@override
String enCeMomentMaj(String heure) {
return 'cycle de $heure';
}
@override
String get enCeMomentAucune => 'Aucune charge pilotée en ce moment.';
@override
String get enCeMomentNonPublie =>
'Cette box ne publie pas l\'arbitrage des charges.';
@override
String get enCeMomentNonLu => 'Arbitrage non lu : la box n\'a pas répondu.';
@override
String get enCeMomentDegrade =>
'Mode dégradé : le moteur n\'a pas de budget ce cycle.';
@override
String enCeMomentDernierManque(String heure, String texte) {
return 'Objectif manqué à $heure : $texte';
}
@override
String ventilationNiveau(int w, String niveau) {
final intl.NumberFormat wNumberFormat = intl.NumberFormat.decimalPattern(
localeName,
);
final String wString = wNumberFormat.format(w);
return '$wString W $niveau';
}
@override
String get ecoManqueNonChiffrable => ' · manque non chiffrable';
@override
String decisionPriceSlot(double prix) {
final intl.NumberFormat prixNumberFormat =
intl.NumberFormat.decimalPatternDigits(
locale: localeName,
decimalDigits: 2,
);
final String prixString = prixNumberFormat.format(prix);
return 'Günstiger Preis ($prixString ct/kWh).';
}
@override
String decisionPriceSlotSetpoint(double prix, int setpoint) {
final intl.NumberFormat prixNumberFormat =
intl.NumberFormat.decimalPatternDigits(
locale: localeName,
decimalDigits: 2,
);
final String prixString = prixNumberFormat.format(prix);
final intl.NumberFormat setpointNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String setpointString = setpointNumberFormat.format(setpoint);
return 'Günstiger Preis ($prixString ct/kWh) — Sollwert $setpointString W.';
}
@override
String get decisionOptimizer => 'Entscheidung des Optimierers.';
@override
String get ventilationEco => 'éco';
@override
String get ventilationConfort => 'confort';
}

View File

@ -0,0 +1,820 @@
// ignore: unused_import
import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
// ignore_for_file: type=lint
/// The translations for English (`en`).
class L10nEn extends L10n {
L10nEn([String locale = 'en']) : 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 get telemetryTitle => 'Arbitrage en direct';
@override
String get telemetryNeverRan =>
'Aucun cycle d\'arbitrage depuis le démarrage du service.';
@override
String telemetryLastCycle(int seconds) {
return 'Dernier cycle il y a $seconds s';
}
@override
String get telemetryBudgetTitle => 'Budget de surplus du cycle';
@override
String get telemetryBudgetAbsent =>
'Aucun budget publié pour ce cycle : la planification est suspendue.';
@override
String get budgetSurplus => 'Surplus entrant';
@override
String get budgetEvReserved => 'Commandé, pas encore mesuré';
@override
String get budgetRecredited => 'Recrédité';
@override
String get budgetAllocated => 'Alloué';
@override
String get budgetRemaining => 'Reliquat';
@override
String watts(int watts) {
final intl.NumberFormat wattsNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String wattsString = wattsNumberFormat.format(watts);
return '$wattsString W';
}
@override
String telemetryAllocated(int watts) {
final intl.NumberFormat wattsNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String wattsString = wattsNumberFormat.format(watts);
return 'Alloué $wattsString W';
}
@override
String telemetryEvCharging(double current, int phases) {
final intl.NumberFormat currentNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String currentString = currentNumberFormat.format(current);
return 'Borne : en charge, $currentString A sur $phases phase(s)';
}
@override
String get telemetryEvIdle => 'Borne : ne charge pas';
@override
String get telemetryEvUnplugged => 'Borne : aucun véhicule branché';
@override
String get telemetryEvStateUnknown => 'Borne : état non publié ce cycle';
@override
String get telemetryFundedByGrid =>
'Financée au réseau (échéance ou tarif dynamique) — hors budget de surplus';
@override
String get niveauEco => 'ÉCO';
@override
String get niveauConfort => 'CONFORT';
@override
String decisionEcoFloorGrid(int gridW) {
return 'Plancher servi — $gridW W achetés au réseau';
}
@override
String decisionEcoFloorMet(String livre, String cible) {
return 'Plancher atteint — $cible dus, $livre livrés';
}
@override
String decisionEcoFloorMissed(String raison) {
return 'Obligation abandonnée à la bascule — $raison';
}
@override
String get ecoCauseDrawCap => 'le plafond de soutirage l\'a refusée';
@override
String get ecoCauseLoadCapacity =>
'la charge ne pouvait pas absorber davantage';
@override
String get ecoCauseUnmeasurable =>
'avancement non vérifiable — ni tenue, ni chiffrable';
@override
String get ecoCauseUnavailable => 'la charge était indisponible';
@override
String ecoManque(String wh) {
return ' · il manquait $wh';
}
@override
String ecoAvancementMesure(String livre, String cible) {
return '$livre sur $cible livrés';
}
@override
String ecoAvancementNonVerifiable(String wh) {
return 'reste $wh à livrer — avancement non vérifiable';
}
@override
String ecoMesureDepuis(String heure) {
return 'mesuré depuis $heure';
}
@override
String ecoMesureIntermittente(String heure) {
return 'mesuré par intermittence depuis $heure';
}
@override
String get ecoTauxConstant =>
'taux constant (cible ÷ jour) — sans mesure, rien ne le corrige';
@override
String get ecoAuMaximum => 'au maximum de la charge';
@override
String ecoAchete(String wh) {
return '$wh achetés pour cette obligation';
}
@override
String get obligationTitre => 'Obligation quotidienne';
@override
String get obligationSousTitre =>
'Le plancher que la box servira quoi qu\'il arrive, au réseau s\'il le faut.';
@override
String get obligationEnergie => 'Énergie due par jour';
@override
String get obligationEcheance => 'Avant';
@override
String obligationEcheanceHorloge(String heure, String fuseau) {
return '$heure heure de la box ($fuseau) — pas celle de cet appareil.';
}
@override
String get obligationNonMesurableTitre =>
'Cette obligation ne pourra jamais être close';
@override
String obligationNonMesurableCorps(String cout) {
return 'La box ne saura pas mesurer ce qu\'elle livre ici : l\'obligation ne pourra être annoncée ni tenue, ni manquée d\'une quantité connue. Elle sera achetée au réseau à taux constant — $cout par jour — sans que rien ne vienne le corriger.';
}
@override
String get obligationCauseNoMeter =>
'Aucun compteur n\'est rattaché à cette charge. Rattachez-en un ci-dessus : c\'est la cause la plus fréquente, et elle se répare en une manipulation.';
@override
String get obligationCauseNoEnergyMeter =>
'Le compteur rattaché publie une puissance, pas une énergie. Il faut un compteur qui totalise les kWh — la puissance seule ne suffit pas à mesurer ce qui a été livré.';
@override
String get obligationCauseNoSessionEnergy =>
'Cette borne ne publie pas l\'énergie de session. C\'est le matériel : aucun réglage ne le changera, et il vaut mieux le savoir maintenant qu\'après avoir cherché.';
@override
String obligationCauseInconnue(String cause) {
return 'La box ne dit pas pourquoi (cause « $cause » inconnue de cette version).';
}
@override
String get ecoIncoherenceTenue =>
'Incohérence de la box : plancher annoncé atteint alors que l\'avancement n\'est pas mesurable';
@override
String confortEnAttente(String motif) {
return 'En attente — $motif';
}
@override
String get telemetryNotArbitrated =>
'Non arbitrée — absente du dernier cycle publié';
@override
String lockMinOn(int seconds) {
return 'Maintien ON — $seconds s restantes';
}
@override
String lockMinOff(int seconds) {
return 'Maintien OFF — $seconds s restantes';
}
@override
String lockMinStateHold(int seconds) {
return 'Maintien d\'état — $seconds s restantes';
}
@override
String lockUnknown(String kind, int seconds) {
return 'Verrou « $kind » — $seconds s restantes';
}
@override
String get lockAsOfCycle => 'au dernier cycle';
@override
String mechanismRelayStage(int stage, int max) {
final intl.NumberFormat stageNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String stageString = stageNumberFormat.format(stage);
final intl.NumberFormat maxNumberFormat = intl.NumberFormat.decimalPattern(
localeName,
);
final String maxString = maxNumberFormat.format(max);
return 'Palier $stageString W sur $maxString W';
}
@override
String mechanismVariableSetpoint(int setpoint, int percent) {
final intl.NumberFormat setpointNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String setpointString = setpointNumberFormat.format(setpoint);
return 'Consigne $setpointString W ($percent %)';
}
@override
String mechanismSgReadyState(int state, int estimated) {
final intl.NumberFormat estimatedNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String estimatedString = estimatedNumberFormat.format(estimated);
return 'État SG-Ready $state — estimation $estimatedString W';
}
@override
String mechanismEvCharger(double current, int phases) {
final intl.NumberFormat currentNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String currentString = currentNumberFormat.format(current);
return '$currentString A sur $phases phase(s)';
}
@override
String mechanismUnknown(String kind) {
return 'Mécanisme « $kind »';
}
@override
String get decisionEvSurplus =>
'Véhicule rechargé sur le surplus photovoltaïque.';
@override
String get decisionEvSpotMarket =>
'Véhicule rechargé sur un créneau tarifaire favorable.';
@override
String get decisionEvDeadline =>
'Véhicule prioritaire : l\'échéance approche.';
@override
String get decisionEvEcoMin =>
'Pas de surplus : le courant minimum est maintenu.';
@override
String get decisionEvIdle => 'Pas de surplus : la recharge est suspendue.';
@override
String decisionLoadUnavailable(int frozen) {
final intl.NumberFormat frozenNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String frozenString = frozenNumberFormat.format(frozen);
return 'Charge en défaut : aucune commande n\'est émise, et $frozenString W restent comptés au budget.';
}
@override
String decisionLockMinOn(int applied, int budget) {
final intl.NumberFormat appliedNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String appliedString = appliedNumberFormat.format(applied);
final intl.NumberFormat budgetNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String budgetString = budgetNumberFormat.format(budget);
return 'Le maintien ON a relevé la consigne à $appliedString W ; le budget en payait $budgetString W.';
}
@override
String decisionLockMinOff(int requested, int budget) {
final intl.NumberFormat requestedNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String requestedString = requestedNumberFormat.format(requested);
final intl.NumberFormat budgetNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String budgetString = budgetNumberFormat.format(budget);
return 'Le maintien OFF interdit l\'enclenchement ; le budget payait pourtant $requestedString W sur $budgetString W disponibles.';
}
@override
String decisionLockMinOffCapped(int applied, int requested, int budget) {
final intl.NumberFormat appliedNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String appliedString = appliedNumberFormat.format(applied);
final intl.NumberFormat requestedNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String requestedString = requestedNumberFormat.format(requested);
final intl.NumberFormat budgetNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String budgetString = budgetNumberFormat.format(budget);
return 'Le maintien OFF plafonne la consigne à $appliedString W ; $requestedString W étaient demandés sur $budgetString W de budget.';
}
@override
String decisionSurplusSetpoint(int setpoint, int budget) {
final intl.NumberFormat setpointNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String setpointString = setpointNumberFormat.format(setpoint);
final intl.NumberFormat budgetNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String budgetString = budgetNumberFormat.format(budget);
return 'Consigne de $setpointString W servie par le surplus ($budgetString W de budget).';
}
@override
String decisionSurplusSetpointStepped(int setpoint, int budget) {
final intl.NumberFormat setpointNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String setpointString = setpointNumberFormat.format(setpoint);
final intl.NumberFormat budgetNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String budgetString = budgetNumberFormat.format(budget);
return 'Consigne de $setpointString W servie par le surplus, arrondie sur un palier déclaré ($budgetString W de budget).';
}
@override
String decisionSurplusInsufficient(int budget) {
final intl.NumberFormat budgetNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String budgetString = budgetNumberFormat.format(budget);
return 'Aucun surplus à distribuer ($budgetString W) — rien n\'est commandé.';
}
@override
String decisionLockMinStateHold(int state) {
return 'État $state maintenu par protection anti court-cycle.';
}
@override
String decisionSgForced(int estimated, int budget) {
final intl.NumberFormat estimatedNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String estimatedString = estimatedNumberFormat.format(estimated);
final intl.NumberFormat budgetNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String budgetString = budgetNumberFormat.format(budget);
return 'Pompe à chaleur en état 4 (forcé) — estimation $estimatedString W sur $budgetString W de budget.';
}
@override
String decisionSgRecommended(int estimated, int budget) {
final intl.NumberFormat estimatedNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String estimatedString = estimatedNumberFormat.format(estimated);
final intl.NumberFormat budgetNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String budgetString = budgetNumberFormat.format(budget);
return 'Pompe à chaleur en état 3 (recommandation) — estimation $estimatedString W sur $budgetString W de budget.';
}
@override
String decisionSgNormal(int budget) {
final intl.NumberFormat budgetNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String budgetString = budgetNumberFormat.format(budget);
return 'Aucun surplus ($budgetString W) — pompe à chaleur en état 2 (normal, sans consigne).';
}
@override
String decisionBelowMinPower(int budget, int min) {
final intl.NumberFormat budgetNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String budgetString = budgetNumberFormat.format(budget);
final intl.NumberFormat minNumberFormat = intl.NumberFormat.decimalPattern(
localeName,
);
final String minString = minNumberFormat.format(min);
return 'Budget de $budgetString W sous le premier palier ($minString W) — rien n\'est commandé, le budget passe à la charge suivante.';
}
@override
String decisionBelowMinPowerState(int budget, int state, int min) {
final intl.NumberFormat budgetNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String budgetString = budgetNumberFormat.format(budget);
final intl.NumberFormat minNumberFormat = intl.NumberFormat.decimalPattern(
localeName,
);
final String minString = minNumberFormat.format(min);
return 'Budget de $budgetString W sous l\'état $state ($minString W estimés) — maintien en état 2, le budget passe à la charge suivante.';
}
@override
String decisionEvGridStart(int grid, int budget, int floor) {
final intl.NumberFormat gridNumberFormat = intl.NumberFormat.decimalPattern(
localeName,
);
final String gridString = gridNumberFormat.format(grid);
final intl.NumberFormat budgetNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String budgetString = budgetNumberFormat.format(budget);
final intl.NumberFormat floorNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String floorString = floorNumberFormat.format(floor);
return 'Démarrage soutiré : $gridString W achetés au réseau pour compléter les $budgetString W de surplus et atteindre le plancher de $floorString W.';
}
@override
String decisionPhaseLimit(int limit, int required) {
final intl.NumberFormat limitNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String limitString = limitNumberFormat.format(limit);
final intl.NumberFormat requiredNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String requiredString = requiredNumberFormat.format(required);
return 'Limite de phase atteinte : $limitString W disponibles pour $requiredString W exigés — ce n\'est pas le surplus qui manque.';
}
@override
String decisionBatteryReserveWithheld(int withheld, int soc, int seuil) {
final intl.NumberFormat withheldNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String withheldString = withheldNumberFormat.format(withheld);
return '$withheldString W de surplus retenus par la réserve batterie ($soc % sous le seuil de $seuil %) — il y a du surplus, mais une règle interdit de le distribuer.';
}
@override
String decisionBatteryReserve(int soc, int seuil) {
return 'Budget annulé par la réserve batterie ($soc % sous le seuil de $seuil %) — il y a du surplus, mais une règle interdit de le distribuer.';
}
@override
String get decisionBatteryReserveShort =>
'Budget annulé par la réserve batterie — il y a du surplus, mais une règle interdit de le distribuer.';
@override
String get decisionDegradedL2 =>
'Consigne de repli : le compteur est muet et le watchdog a suspendu la planification.';
@override
String get decisionSafeStateRelay =>
'État sûr avant retrait : tous les relais sont ouverts.';
@override
String get decisionSafeStateSetpoint =>
'État sûr avant retrait : consigne ramenée à 0 W.';
@override
String get decisionSafeStateSgReady =>
'État sûr avant retrait : état 2 (normal), jamais le blocage.';
@override
String get faultWriteFailed =>
'Échec d\'écriture : l\'échelle de commande est épuisée, plus aucune commande n\'est émise tant que le défaut n\'est pas levé.';
@override
String get faultThingMissing =>
'L\'appareil commandé n\'existe plus dans nymea — retiré, ou jamais réinstallé après un remplacement. La charge, elle, est toujours déclarée : c\'est pour ça qu\'elle apparaît ici.';
@override
String get faultThingMissingAction =>
'Rien à lever ici : le défaut ne partira pas tant qu\'aucun appareil ne répond. Réinstallez-le dans nymea, ou retirez cette charge de la configuration.';
@override
String get faultUnusableEncoding =>
'Encodage SG-Ready inexploitable : l\'état 2 ne peut pas être exprimé, cette pompe à chaleur n\'est pas pilotable.';
@override
String get faultClearPending =>
'Levée demandée — en attente de la télémétrie…';
@override
String get faultClearStillFaulty =>
'La charge est toujours en défaut après la levée.';
@override
String faultClearRejected(String reason) {
return 'La box a refusé la levée : $reason';
}
@override
String get faultClearHint =>
'La levée est une action délibérée : le défaut ne se lève jamais tout seul.';
@override
String get domainBattery => 'Batterie';
@override
String domainAssignTitle(String label) {
return 'Classer « $label »';
}
@override
String get domainAssignAction => 'Changer de domaine';
@override
String get domainAssignNone => 'Retirer du classement';
@override
String get domainAssignHint =>
'Le domaine est une intention déclarée par l\'installateur. L\'arbitre ne la lit pas : elle ne sert qu\'à grouper l\'ordre de service.';
@override
String localeRequested(String requested, String effective) {
return 'Locale demandée : $requested · retenue par la box : $effective';
}
@override
String get enCeMomentTitre => 'Right now';
@override
String enCeMomentMaj(String heure) {
return 'cycle de $heure';
}
@override
String get enCeMomentAucune => 'Aucune charge pilotée en ce moment.';
@override
String get enCeMomentNonPublie =>
'Cette box ne publie pas l\'arbitrage des charges.';
@override
String get enCeMomentNonLu => 'Arbitrage non lu : la box n\'a pas répondu.';
@override
String get enCeMomentDegrade =>
'Mode dégradé : le moteur n\'a pas de budget ce cycle.';
@override
String enCeMomentDernierManque(String heure, String texte) {
return 'Objectif manqué à $heure : $texte';
}
@override
String ventilationNiveau(int w, String niveau) {
final intl.NumberFormat wNumberFormat = intl.NumberFormat.decimalPattern(
localeName,
);
final String wString = wNumberFormat.format(w);
return '$wString W $niveau';
}
@override
String get ecoManqueNonChiffrable => ' · manque non chiffrable';
@override
String decisionPriceSlot(double prix) {
final intl.NumberFormat prixNumberFormat =
intl.NumberFormat.decimalPatternDigits(
locale: localeName,
decimalDigits: 2,
);
final String prixString = prixNumberFormat.format(prix);
return 'Favourable price ($prixString ct/kWh).';
}
@override
String decisionPriceSlotSetpoint(double prix, int setpoint) {
final intl.NumberFormat prixNumberFormat =
intl.NumberFormat.decimalPatternDigits(
locale: localeName,
decimalDigits: 2,
);
final String prixString = prixNumberFormat.format(prix);
final intl.NumberFormat setpointNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String setpointString = setpointNumberFormat.format(setpoint);
return 'Favourable price ($prixString ct/kWh) — setpoint $setpointString W.';
}
@override
String get decisionOptimizer => 'Optimizer decision.';
@override
String get ventilationEco => 'éco';
@override
String get ventilationConfort => 'confort';
}

View File

@ -741,4 +741,80 @@ class L10nFr extends L10n {
String localeRequested(String requested, String effective) {
return 'Locale demandée : $requested · retenue par la box : $effective';
}
@override
String get enCeMomentTitre => 'En ce moment';
@override
String enCeMomentMaj(String heure) {
return 'cycle de $heure';
}
@override
String get enCeMomentAucune => 'Aucune charge pilotée en ce moment.';
@override
String get enCeMomentNonPublie =>
'Cette box ne publie pas l\'arbitrage des charges.';
@override
String get enCeMomentNonLu => 'Arbitrage non lu : la box n\'a pas répondu.';
@override
String get enCeMomentDegrade =>
'Mode dégradé : le moteur n\'a pas de budget ce cycle.';
@override
String enCeMomentDernierManque(String heure, String texte) {
return 'Objectif manqué à $heure : $texte';
}
@override
String ventilationNiveau(int w, String niveau) {
final intl.NumberFormat wNumberFormat = intl.NumberFormat.decimalPattern(
localeName,
);
final String wString = wNumberFormat.format(w);
return '$wString W $niveau';
}
@override
String get ecoManqueNonChiffrable => ' · manque non chiffrable';
@override
String decisionPriceSlot(double prix) {
final intl.NumberFormat prixNumberFormat =
intl.NumberFormat.decimalPatternDigits(
locale: localeName,
decimalDigits: 2,
);
final String prixString = prixNumberFormat.format(prix);
return 'Prix favorable ($prixString ct/kWh).';
}
@override
String decisionPriceSlotSetpoint(double prix, int setpoint) {
final intl.NumberFormat prixNumberFormat =
intl.NumberFormat.decimalPatternDigits(
locale: localeName,
decimalDigits: 2,
);
final String prixString = prixNumberFormat.format(prix);
final intl.NumberFormat setpointNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String setpointString = setpointNumberFormat.format(setpoint);
return 'Prix favorable ($prixString ct/kWh) — consigne $setpointString W.';
}
@override
String get decisionOptimizer => 'Décision de l\'optimiseur.';
@override
String get ventilationEco => 'éco';
@override
String get ventilationConfort => 'confort';
}

View File

@ -78,6 +78,16 @@ class LoadTelemetryProvider extends ChangeNotifier {
// ── Lecture ────────────────────────────────────────────────────────────────
LoadTelemetry? get telemetry => _telemetry;
/// **Le dernier objectif éco manqué, par charge** (`ECO_FLOOR_MISSED`, etm70).
///
/// Le moteur ne le publie qu'UN cycle — celui de la bascule — puis la charge reprend son
/// motif ordinaire. Lu à l'instant, il serait presque toujours invisible : l'app le
/// RETIENT. Clé = identifiant de charge normalisé (sans accolades, minuscules), y compris
/// pour une charge qui a quitté `loads[]` depuis (borne débranchée).
Map<String, ({LoadDecision decision, DateTime at})> get derniersManques =>
Map.unmodifiable(_derniersManques);
final _derniersManques = <String, ({LoadDecision decision, DateTime at})>{};
bool get loading => _loading;
String? get error => _error;
@ -182,6 +192,16 @@ class LoadTelemetryProvider extends ChangeNotifier {
void _adopt(Map<String, dynamic> payload) {
final t = LoadTelemetry.fromJson(payload);
final now = DateTime.now();
for (final e in t.loads) {
// En tête de `decision` (R7), ou dans son niveau éco : les deux se lisent.
final candidats = [e.decision, for (final l in e.levels) ?l.decision];
for (final d in candidats) {
if (d.code != 'ECO_FLOOR_MISSED') continue;
_derniersManques[e.loadId.replaceAll(RegExp(r'[{}]'), '').toLowerCase()] =
(decision: d, at: t.timestamp?.toLocal() ?? now);
break;
}
}
_telemetry = t;
_lastFrameAt = now;
_error = null;
@ -279,6 +299,7 @@ class LoadTelemetryProvider extends ChangeNotifier {
/// Vide l'état au switch d'installation — cette télémétrie appartient à la box quittée.
void clearForSwitch() {
_telemetry = null;
_derniersManques.clear();
_error = null;
_lastFrameAt = null;
_cycleFirstSeenAt = null;

View File

@ -14,7 +14,7 @@ import '../main.dart' show DrawerMenuButton;
// ① Thermostats par pièce EN HAUT (geste fréquent)
// - pièces actives : expandées avec temp + modes
// - pièces éteintes : compactes
// ② Sources pilotées par Héos EN BAS
// ② Sources pilotées EN BAS
// - PAC Atlantic (SG-Ready : 4 états)
// - Chauffe-eau thermodynamique (Surplus / Eco / Boost)
// - Climatiseur (rafraîchissement anticipé)
@ -39,7 +39,7 @@ class _Zone {
final double targetTemp;
final ACMode mode;
final bool isOn;
final bool heosActive; // Héos pilote en ce moment
final bool pilotageActif; // piloté en ce moment
const _Zone({
required this.name,
@ -47,7 +47,7 @@ class _Zone {
required this.targetTemp,
required this.mode,
required this.isOn,
this.heosActive = false,
this.pilotageActif = false,
});
_Zone copyWith({double? targetTemp, ACMode? mode, bool? isOn}) => _Zone(
@ -56,7 +56,7 @@ class _Zone {
targetTemp: targetTemp ?? this.targetTemp,
mode: mode ?? this.mode,
isOn: isOn ?? this.isOn,
heosActive: heosActive,
pilotageActif: pilotageActif,
);
}
@ -71,7 +71,7 @@ class ACScreen extends StatefulWidget {
class _ACScreenState extends State<ACScreen> {
final List<_Zone> _zones = [
const _Zone(name: 'Salon', currentTemp: 19.5, targetTemp: 21.0, mode: ACMode.heat, isOn: true, heosActive: true),
const _Zone(name: 'Salon', currentTemp: 19.5, targetTemp: 21.0, mode: ACMode.heat, isOn: true, pilotageActif: true),
const _Zone(name: 'Chambre', currentTemp: 18.0, targetTemp: 19.0, mode: ACMode.cool, isOn: false),
const _Zone(name: 'Bureau', currentTemp: 20.0, targetTemp: 22.0, mode: ACMode.heat, isOn: true),
const _Zone(name: 'Cuisine', currentTemp: 21.5, targetTemp: 21.0, mode: ACMode.auto, isOn: false),
@ -81,7 +81,7 @@ class _ACScreenState extends State<ACScreen> {
int? _zonesSurLaBox;
SGReadyState _sgReady = SGReadyState.forced;
bool _sgAuto = true; // Héos pilote SG-Ready
bool _sgAuto = true; // SG-Ready piloté
DHWMode _dhwMode = DHWMode.surplus;
double _dhwCurrent = 52;
@ -155,8 +155,8 @@ class _ACScreenState extends State<ACScreen> {
const SizedBox(height: 8),
// ── ② Sources pilotées par Héos ─────────────────────────────
_SectionLabel('SOURCES PILOTÉES PAR HÉOS'),
// ── ② Sources pilotées ─────────────────────────────
_SectionLabel('SOURCES PILOTÉES'),
const SizedBox(height: 12),
// PAC Atlantic — SG-Ready
@ -367,10 +367,10 @@ class _ActiveZoneCard extends StatelessWidget {
}).toList(),
),
// Chip Héos si actif
if (zone.heosActive) ...[
// Puce « piloté » si actif
if (zone.pilotageActif) ...[
const SizedBox(height: 12),
_HeosChip('Chauffe au solaire en ce moment'),
_PilotageChip('Chauffe au solaire en ce moment'),
],
],
),
@ -463,7 +463,7 @@ class _PACCard extends StatelessWidget {
],
),
),
_HeosBadgeSmall(),
_PilotageBadge(),
],
),
@ -624,7 +624,7 @@ class _DHWCard extends StatelessWidget {
],
),
),
_HeosBadgeSmall(),
_PilotageBadge(),
],
),
@ -827,7 +827,7 @@ class _ClimCard extends StatelessWidget {
const SizedBox(height: 14),
// Info Héos
// Info pilotage
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
@ -914,9 +914,9 @@ class _TempButton extends StatelessWidget {
}
}
class _HeosChip extends StatelessWidget {
class _PilotageChip extends StatelessWidget {
final String label;
const _HeosChip(this.label);
const _PilotageChip(this.label);
@override
Widget build(BuildContext context) {
@ -940,8 +940,8 @@ class _HeosChip extends StatelessWidget {
}
}
class _HeosBadgeSmall extends StatelessWidget {
const _HeosBadgeSmall();
class _PilotageBadge extends StatelessWidget {
const _PilotageBadge();
@override
Widget build(BuildContext context) {
@ -956,7 +956,7 @@ class _HeosBadgeSmall extends StatelessWidget {
children: [
const Icon(Icons.auto_awesome_rounded, size: 10, color: EtmTokens.greenDark),
const SizedBox(width: 4),
Text('Piloté par Héos',
Text('Piloté',
style: EtmTokens.sans(size: 10, weight: FontWeight.w600,
color: EtmTokens.greenDark)),
],

View File

@ -12,7 +12,7 @@ import '../services/nymea_service.dart';
import '../theme/etm_tokens.dart';
import '../features/dashboard/widgets/energy_flow_card.dart';
import '../features/dashboard/widgets/kpi_row.dart';
import '../features/dashboard/widgets/heos_decision_card.dart';
import '../features/dashboard/widgets/en_ce_moment_card.dart';
import '../features/dashboard/widgets/journee_card.dart';
class DashboardScreen extends StatefulWidget {
@ -181,7 +181,9 @@ class _DashboardScreenState extends State<DashboardScreen> {
const SizedBox(height: 14),
KpiRow(data: data, balance: _bilan, ratios: _ratios),
const SizedBox(height: 14),
const HeosDecisionCard(),
// Ce que le moteur décide, charge par charge — lu dans la télémétrie
// d'arbitrage (consigne G, 2026-10-16). Plus de démo, plus de marque.
const EnCeMomentCard(),
const SizedBox(height: 14),
// La journée heure par heure — APRÈS les décisions (Patrick,
// 2026-10-14). Variante E de la maquette, retenue le 2026-10-15 :

View File

@ -25,6 +25,31 @@ String decisionText(L10n t, LoadDecision d) {
final p = d.params;
switch (d.code) {
// ── Motifs de niveau éco remontés en tête de `decision` (R7, etm70) ─────────
//
// Depuis etm70, `decision.level` est le niveau dont le motif est publié : la part
// majoritaire de la consigne. Un motif éco peut donc arriver ici, à la racine — et
// `ECO_FLOOR_MISSED` y arrive en tête le cycle de la bascule. Une seule phrase pour le
// même motif, où qu'il soit lu.
case 'ECO_FLOOR_GRID' || 'ECO_FLOOR_MET' || 'ECO_FLOOR_MISSED':
return ecoDecisionText(t, d);
// ── Optimiseur (brief 4 T5, etm70) ──────────────────────────────────────────
//
// `priceCtkWh` est le prix COMPLET du créneau, vérifiable sur `price.importCtkWh`.
case 'PRICE_SLOT':
final prix = d.numParam('priceCtkWh')?.toDouble();
if (prix == null) break;
final consigne = _w(d, 'setpointW');
return consigne == null
? t.decisionPriceSlot(prix)
: t.decisionPriceSlotSetpoint(prix, consigne);
// Motif hors catalogue d'un optimiseur : transmis sans texte ni paramètre. Aucun nom
// de marque (décision G).
case 'OPTIMIZER_DECISION':
return t.decisionOptimizer;
// `EV_SURPLUS`, `EV_ECO_MIN` et `EV_IDLE` ne sont **plus émis** depuis `+etm22` : une
// borne reçoit désormais les motifs communs à tous les mécanismes
// (`SURPLUS_SETPOINT`, `BELOW_MIN_POWER`, `SURPLUS_INSUFFICIENT`,
@ -317,10 +342,10 @@ String ecoDecisionText(L10n t, LoadDecision d) {
};
if (raison == null) break;
// `missingWh` est ABSENT sous `unmeasurable`, et c'est voulu : on ne sait pas ce qui a
// été livré, donc pas ce qui manquait. Ne rien ajouter alors.
// été livré, donc pas ce qui manquait. On le DIT (consigne H) — jamais « 0 Wh ».
final manque = d.numParam('missingWh');
return t.decisionEcoFloorMissed(raison) +
(manque == null ? '' : t.ecoManque(energieCourte(manque)));
(manque == null ? t.ecoManqueNonChiffrable : t.ecoManque(energieCourte(manque)));
}
return t.decisionUnknown(d.code.isEmpty ? '—' : d.code, formatParams(d.params));
}

View File

@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
/// Icône du cœur HEMS (« Héos ») : étincelle de marque au centre,
/// Icône du cœur HEMS : étincelle au centre,
/// 4 nœuds pilotés alignés sur les pointes.
class HemsIcon extends StatelessWidget {
final double size;

View File

@ -437,7 +437,7 @@ class _OptimalBanner extends StatelessWidget {
children: [
Text('Mode optimal',
style: EtmTokens.sans(size: 14, weight: FontWeight.w600, color: EtmTokens.greenDark)),
Text('Héos optimise votre consommation',
Text('Pilotage local actif',
style: EtmTokens.sans(size: 12, color: EtmTokens.mutedOf(context))),
],
),

180
test/en_ce_moment_test.dart Normal file
View File

@ -0,0 +1,180 @@
/// Carte « En ce moment » (consigne G) et motifs du moteur etm70 (consigne H).
///
/// La trame de base est RÉELLE (banc `hems`, `+etm69`, 2026-10-16). Les trames etm70 en sont
/// dérivées d'après `INTERFACE.md` de la branche moteur (R7, catalogue) : etm70 n'est pas
/// encore sur le banc, rien ici n'a été vu sur machine.
library;
import 'dart:convert';
import 'dart:io';
import 'package:etm_powersync_app/features/dashboard/widgets/en_ce_moment_card.dart';
import 'package:etm_powersync_app/l10n/app_localizations.dart';
import 'package:etm_powersync_app/models/load_telemetry.dart';
import 'package:etm_powersync_app/providers/load_telemetry_provider.dart';
import 'package:etm_powersync_app/services/telemetry_text.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
Map<String, dynamic> _banc() => (jsonDecode(
File('test/fixtures/loadtelemetry_hems_etm69.json').readAsStringSync())
as Map<String, dynamic>)['telemetry'] as Map<String, dynamic>;
Widget _app(Widget w) => MaterialApp(
localizationsDelegates: L10n.localizationsDelegates,
supportedLocales: L10n.supportedLocales,
locale: const Locale('fr'),
home: Scaffold(body: ListView(children: [w])),
);
EnCeMomentView _vue(Map<String, dynamic> trame,
{Map<String, ({LoadDecision decision, DateTime at})> manques = const {}}) =>
EnCeMomentView(
telemetry: LoadTelemetry.fromJson(trame),
derniersManques: manques,
libelle: (id) => switch (id.replaceAll(RegExp(r'[{}]'), '').toLowerCase()) {
'chauffe-eau' => 'Chauffe-eau',
'pac-terrain' => 'PAC banc',
'4d1adafd-cafc-4813-93b1-ca8933ce71ab' => 'V2C Trydan',
_ => id,
},
domaine: (id) => switch (id) {
'chauffe-eau' => 'ecs',
'pac-terrain' => 'heating',
_ => 'ev',
},
);
LoadDecision _d(String code, Map<String, dynamic> params) =>
LoadDecision.fromJson({'code': code, 'params': params});
void main() {
final fr = lookupL10n(const Locale('fr'));
testWidgets('trame réelle : un motif par charge, rien d\'inventé, aucune marque',
(t) async {
await t.pumpWidget(_app(_vue(_banc())));
expect(find.text('En ce moment'), findsOneWidget);
expect(find.text('Chauffe-eau'), findsOneWidget);
expect(find.text('PAC banc'), findsOneWidget);
for (final e in LoadTelemetry.fromJson(_banc()).loads) {
expect(find.text(decisionText(fr, e.decision)), findsOneWidget);
}
// Les décisions inventées de l'ancienne démo ne peuvent plus sortir.
expect(find.textContaining('Batterie en charge'), findsNothing);
// Le nom de marque, écrit en échappement pour que le grep de la consigne reste vide.
expect(find.textContaining('H\u00e9os'), findsNothing);
});
testWidgets('la ventilation des niveaux se lit (83 W éco + 0 W confort)', (t) async {
await t.pumpWidget(_app(_vue(_banc())));
expect(find.text('83 W éco + 0 W confort'), findsOneWidget);
});
testWidgets('puissance MESURÉE seulement — jamais l\'allocation à sa place', (t) async {
await t.pumpWidget(_app(_vue(_banc())));
// La PAC mesure 0 W (compteur) ; le chauffe-eau est `source: none`, sans chiffre.
expect(find.text('0,0 kW'), findsOneWidget);
});
test('etm70 : un motif éco publié à la racine (part majoritaire) se rend en clair', () {
// R7 : chauffe-eau, éco 83 W > confort 0 W → `decision` = motif éco, `level: "eco"`.
final d = _d('ECO_FLOOR_GRID', {'gridW': 83});
expect(decisionText(fr, d), 'Plancher servi — 83 W achetés au réseau');
});
group('ECO_FLOOR_MISSED — publié un seul cycle, RETENU par l\'app', () {
Map<String, dynamic> trameManque({Map<String, dynamic>? params, bool avecBorne = true}) {
final b = _banc();
final loads = [
for (final l in (b['loads'] as List).cast<Map<String, dynamic>>())
if (l['loadId'] == 'chauffe-eau')
{
...l,
'decision': {
'code': 'ECO_FLOOR_MISSED',
'level': 'eco',
'params': params ?? {'targetWh': 2000, 'cause': 'unmeasurable'},
},
}
else
l,
];
return {...b, 'loads': loads};
}
test('le provider le garde après le cycle suivant', () {
final p = LoadTelemetryProvider()
..adoptForTest(trameManque())
..adoptForTest(_banc()); // cycle suivant : motif ordinaire
expect(p.derniersManques['chauffe-eau']!.decision.code, 'ECO_FLOOR_MISSED');
p.dispose();
});
testWidgets('missingWh absent : « manque non chiffrable », jamais 0 Wh', (t) async {
final p = LoadTelemetryProvider()
..adoptForTest(trameManque())
..adoptForTest(_banc());
await t.pumpWidget(_app(_vue(_banc(), manques: p.derniersManques)));
expect(find.textContaining('manque non chiffrable'), findsOneWidget);
expect(find.textContaining('0 Wh'), findsNothing);
expect(find.textContaining('Objectif manqué à'), findsOneWidget);
p.dispose();
});
test('missingWh présent : chiffré', () {
final d = _d('ECO_FLOOR_MISSED',
{'targetWh': 4000, 'cause': 'drawCap', 'missingWh': 2500});
expect(decisionText(fr, d), contains('il manquait 2,5 kWh'));
});
testWidgets('une charge partie de loads[] garde son manque visible', (t) async {
final borne = {
'4d1adafd-cafc-4813-93b1-ca8933ce71ab': (
decision: _d('ECO_FLOOR_MISSED', {'targetWh': 5000, 'cause': 'unavailable'}),
at: DateTime(2026, 10, 16, 18, 0),
)
};
await t.pumpWidget(_app(_vue(_banc(), manques: borne)));
expect(find.textContaining('V2C Trydan — Objectif manqué à 18:00'), findsOneWidget);
});
});
group('nouveaux codes, FR / DE / EN, sans marque', () {
final prix = _d('PRICE_SLOT', {'priceCtkWh': 16.96, 'setpointW': 2400});
test('PRICE_SLOT', () {
// Le français groupe les milliers avec une espace fine insécable : on la normalise.
String n(String x) => x.replaceAll(RegExp('[\u202f\u00a0]'), ' ');
expect(n(decisionText(fr, prix)), 'Prix favorable (16,96 ct/kWh) — consigne 2 400 W.');
expect(decisionText(lookupL10n(const Locale('en')), prix),
'Favourable price (16.96 ct/kWh) — setpoint 2,400 W.');
expect(n(decisionText(lookupL10n(const Locale('de')), prix)),
'Günstiger Preis (16,96 ct/kWh) — Sollwert 2.400 W.');
expect(decisionText(fr, _d('PRICE_SLOT', {'priceCtkWh': 16.96})),
'Prix favorable (16,96 ct/kWh).');
});
test('OPTIMIZER_DECISION', () {
final o = _d('OPTIMIZER_DECISION', const {});
expect(decisionText(fr, o), 'Décision de l\'optimiseur.');
expect(decisionText(lookupL10n(const Locale('de')), o), 'Entscheidung des Optimierers.');
expect(decisionText(lookupL10n(const Locale('en')), o), 'Optimizer decision.');
});
test('code inconnu : libellé générique avec le code, rien d\'inventé', () {
expect(decisionText(fr, _d('NOUVEAU_MOTIF', {'x': 1})), contains('NOUVEAU_MOTIF'));
});
test('PRICE_SLOT sans prix : repli, pas une phrase trouée', () {
expect(decisionText(fr, _d('PRICE_SLOT', const {})), contains('PRICE_SLOT'));
});
});
testWidgets('box sans télémétrie : dit « ne publie pas », pas « aucune charge »',
(t) async {
await t.pumpWidget(_app(EnCeMomentView(
telemetry: null, supportee: false, libelle: (i) => i, domaine: (_) => null)));
expect(find.text('Cette box ne publie pas l\'arbitrage des charges.'), findsOneWidget);
});
}

View File

@ -0,0 +1,141 @@
{
"_source": "dump réel banc hems 10.42.60.21, NymeaEnergy.GetLoadTelemetry, 2026-10-16, moteur 1.15.2+etm69",
"telemetry": {
"budget": {
"allocatedW": 1200,
"evReservedW": 0,
"recreditedW": 1200,
"remainingW": -949.3777465820312,
"surplusW": -949.3777465820312
},
"degradedMode": false,
"draw": {
"authorisedW": 6261.473164558411,
"binding": "connection",
"bindingPhase": "C",
"committedW": 83.33333333333333,
"perPhaseBound": true,
"remainingW": 6178.139831225078
},
"loads": [
{
"allocatedW": 83.33333333333333,
"available": true,
"decision": {
"code": "SURPLUS_INSUFFICIENT",
"level": "comfort",
"params": {
"budgetW": -949
}
},
"levels": [
{
"counts": {
"draw.committedW": 83.33333333333333
},
"decision": {
"code": "ECO_FLOOR_GRID",
"params": {
"atMaximum": false,
"gridW": 83,
"regime": "unmeasurable",
"remainingS": 53039,
"remainingWh": 2000,
"targetWh": 2000
}
},
"funding": "grid",
"level": "eco",
"progress": {
"purchasedWh": 764.9768518518601,
"regime": "unmeasurable",
"targetWh": 2000
},
"targetW": 83.33333333333333
},
{
"counts": {
"budget.allocatedW": 0
},
"decision": {
"code": "SURPLUS_INSUFFICIENT",
"params": {
"budgetW": -949
}
},
"funding": "surplus",
"level": "comfort",
"targetW": 0
}
],
"loadId": "chauffe-eau",
"measurement": {
"source": "none"
},
"mechanism": {
"kind": "relay",
"maxStageW": 3500,
"stageW": 0,
"stagesW": [
0,
500,
1000,
1500,
2000,
2500,
3000,
3500
]
}
},
{
"allocatedW": 1200,
"available": true,
"command": {
"divergent": true,
"expectedW": 1200,
"observedW": 0,
"since": "2026-10-15T06:12:00Z"
},
"decision": {
"code": "BELOW_MIN_POWER",
"level": "comfort",
"params": {
"budgetW": 251,
"minPowerW": 1500,
"state": 3
}
},
"levels": [
{
"counts": {
"budget.allocatedW": 1200
},
"decision": {
"code": "BELOW_MIN_POWER",
"params": {
"budgetW": 251,
"minPowerW": 1500,
"state": 3
}
},
"funding": "surplus",
"level": "comfort",
"targetW": 1200
}
],
"loadId": "pac-terrain",
"measurement": {
"powerW": 0,
"source": "meter"
},
"mechanism": {
"estimatedPowerW": 1200,
"kind": "sgReady",
"state": 2
}
}
],
"timestamp": "2026-10-16T19:16:00Z"
}
}