feat(§10): l'avertissement de saisie — une consigne, pas une fatalité affichée

Lot configuration. La section « Obligation quotidienne » porte l'énergie due, son
échéance, et l'avertissement qui rend la déclaration honnête.

Pourquoi à la saisie : au résultat l'heure est passée, l'achat a eu lieu. Et sous
`unmeasurable` l'obligation ne peut JAMAIS être close — ni annoncée tenue (R6
interdit `ECO_FLOOR_MET`), ni chiffrée dans son manque (`missingWh` absent).
`ECO_FLOOR_MISSED` sort tous les jours par construction. Un mécanisme dont le
succès est inobservable et l'échec quotidien ne doit pas, en plus, dépenser en
silence.

Et c'est une consigne : les trois causes portent trois gestes — rattacher un
compteur (le plus fréquent, réparable), en mettre un qui totalise l'énergie, ou
constater que c'est le matériel. Une cause inconnue se dit sans inventer de
geste.

Le coût est CHIFFRÉ, pas estimé : le taux est constant depuis +etm55, donc le
total acheté vaut exactement la cible.

Trois silences délibérés, chacun testé : aucune obligation déclarée (personne ne
paiera), `progressMeasurable` absent (box antérieure à +etm49 — on ne conclut pas
d'une absence), et le nom du fuseau tant que l'app ne l'a pas lu. L'échéance dit
en revanche qu'elle est à l'heure de l'INSTALLATION, ce qui est vrai sans le
fuseau.

Revérifié à la sonde avant d'écrire, plutôt que cru : `progressMeasurable: true`
et `regime: measured` sortent bien sur la même charge — la contradiction du
2026-09-03 est fermée.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
This commit is contained in:
Patrick Schurig 2026-09-05 08:40:35 +02:00
parent 58bf57e67f
commit cdffb9d804
6 changed files with 417 additions and 0 deletions

View File

@ -220,6 +220,31 @@
"description": "purchasedWh. Publié dans TOUS les régimes : c'est la commande du moteur, donc un fait connu sans mesure. Ne coïncide avec le livré que quand tout va bien — c'est sa divergence qui informe.",
"placeholders": { "wh": { "type": "String" } }
},
"obligationTitre": "Obligation quotidienne",
"obligationSousTitre": "Le plancher que la box servira quoi qu'il arrive, au réseau s'il le faut.",
"obligationEnergie": "Énergie due par jour",
"obligationEcheance": "Avant",
"obligationEcheanceAide": "Heure de l'INSTALLATION{fuseau} — pas celle de cet appareil. Une échéance est une habitude : elle suit la maison.",
"@obligationEcheanceAide": {
"description": "dailyDeadline est un « HH:MM » sans fuseau, interprété par la box dans le sien. Sur une box en Europe/London vue d'un téléphone français, « 12:00 » vaut 13:00 chez le client. Ne jamais présenter l'heure comme locale sans dire quelle horloge la box a lue.",
"placeholders": { "fuseau": { "type": "String" } }
},
"obligationNonMesurableTitre": "Cette obligation ne pourra jamais être close",
"@obligationNonMesurableTitre": {
"description": "Sous unmeasurable : ni tenue (R6 interdit ECO_FLOOR_MET), ni chiffrée dans son manque (missingWh absent). ECO_FLOOR_MISSED sortira tous les jours par construction."
},
"obligationNonMesurableCorps": "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.",
"@obligationNonMesurableCorps": {
"description": "Le coût se CHIFFRE, il ne s'estime pas : cible ÷ jour depuis +etm55, et purchasedWh le recoupe à 0,3 % une fois l'obligation en route.",
"placeholders": { "cout": { "type": "String" } }
},
"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.",
"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é.",
"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é.",
"obligationCauseInconnue": "La box ne dit pas pourquoi (cause « {cause} » inconnue de cette version).",
"@obligationCauseInconnue": {
"placeholders": { "cause": { "type": "String" } }
},
"ecoIncoherenceTenue": "Incohérence de la box : plancher annoncé atteint alors que l'avancement n'est pas mesurable",
"@ecoIncoherenceTenue": {
"description": "R6 est armée des deux côtés : ECO_FLOOR_MET ne peut pas sortir sous le régime unmeasurable. Si la combinaison arrive quand même, l'écran la DÉNONCE au lieu de la rendre — servie oui, tenue jamais."

View File

@ -577,6 +577,72 @@ abstract class L10n {
/// **'{wh} achetés pour cette obligation'**
String ecoAchete(String wh);
/// No description provided for @obligationTitre.
///
/// In fr, this message translates to:
/// **'Obligation quotidienne'**
String get obligationTitre;
/// No description provided for @obligationSousTitre.
///
/// In fr, this message translates to:
/// **'Le plancher que la box servira quoi qu\'il arrive, au réseau s\'il le faut.'**
String get obligationSousTitre;
/// No description provided for @obligationEnergie.
///
/// In fr, this message translates to:
/// **'Énergie due par jour'**
String get obligationEnergie;
/// No description provided for @obligationEcheance.
///
/// In fr, this message translates to:
/// **'Avant'**
String get obligationEcheance;
/// dailyDeadline est un « HH:MM » sans fuseau, interprété par la box dans le sien. Sur une box en Europe/London vue d'un téléphone français, « 12:00 » vaut 13:00 chez le client. Ne jamais présenter l'heure comme locale sans dire quelle horloge la box a lue.
///
/// In fr, this message translates to:
/// **'Heure de l\'INSTALLATION{fuseau} — pas celle de cet appareil. Une échéance est une habitude : elle suit la maison.'**
String obligationEcheanceAide(String fuseau);
/// Sous unmeasurable : ni tenue (R6 interdit ECO_FLOOR_MET), ni chiffrée dans son manque (missingWh absent). ECO_FLOOR_MISSED sortira tous les jours par construction.
///
/// In fr, this message translates to:
/// **'Cette obligation ne pourra jamais être close'**
String get obligationNonMesurableTitre;
/// Le coût se CHIFFRE, il ne s'estime pas : cible ÷ jour depuis +etm55, et purchasedWh le recoupe à 0,3 % une fois l'obligation en route.
///
/// In fr, this message translates to:
/// **'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.'**
String obligationNonMesurableCorps(String cout);
/// No description provided for @obligationCauseNoMeter.
///
/// In fr, this message translates to:
/// **'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.'**
String get obligationCauseNoMeter;
/// No description provided for @obligationCauseNoEnergyMeter.
///
/// In fr, this message translates to:
/// **'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é.'**
String get obligationCauseNoEnergyMeter;
/// No description provided for @obligationCauseNoSessionEnergy.
///
/// In fr, this message translates to:
/// **'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é.'**
String get obligationCauseNoSessionEnergy;
/// No description provided for @obligationCauseInconnue.
///
/// In fr, this message translates to:
/// **'La box ne dit pas pourquoi (cause « {cause} » inconnue de cette version).'**
String obligationCauseInconnue(String cause);
/// R6 est armée des deux côtés : ECO_FLOOR_MET ne peut pas sortir sous le régime unmeasurable. Si la combinaison arrive quand même, l'écran la DÉNONCE au lieu de la rendre — servie oui, tenue jamais.
///
/// In fr, this message translates to:

View File

@ -335,6 +335,50 @@ class L10nFr extends L10n {
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 obligationEcheanceAide(String fuseau) {
return 'Heure de l\'INSTALLATION$fuseau — pas celle de cet appareil. Une échéance est une habitude : elle suit la maison.';
}
@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';

View File

@ -161,6 +161,28 @@ class LoadConfigEntry {
return n is Map ? ((n['minEnergyWhPerDay'] as num?)?.toInt() ?? 0) : 0;
}
// ── Mesurabilité de l'avancement — LECTURE SEULE, publiée par le moteur ────
//
/// **Le moteur saura-t-il mesurer ce qu'il livre à cette charge ?** (`+etm49`)
///
/// Publié en `r:` avec la **configuration** et non avec la télémétrie, et c'est ce qui le
/// rend utilisable : `measurement` n'existe que pour les charges présentes dans `loads[]`,
/// or une charge qu'on est en train de déclarer est justement celle qui n'y est pas encore.
/// L'écran serait muet exactement au moment où il doit parler.
///
/// `null` = box antérieure à `+etm49` : on ne conclut rien, on n'avertit pas.
bool? get progressMeasurable => raw['progressMeasurable'] as bool?;
/// **Pourquoi** l'avancement n'est pas mesurable — absent quand il l'est.
///
/// `noMeter` · `noEnergyMeter` · `noSessionEnergy`. Trois causes, et surtout **trois
/// gestes** : deux se réparent, la troisième est le matériel. C'est ce qui fait la
/// différence entre une consigne et une fatalité affichée.
String? get progressUnmeasurableCause {
final c = raw['progressUnmeasurableCause'];
return (c is String && c.isNotEmpty) ? c : null;
}
/// Compteur dédié à CETTE charge, et sonde de la grandeur rendue. Vides = non rattachés.
///
/// Ni l'un ni l'autre n'est du **matériel de commande** : ils n'entrent pas dans

View File

@ -10,6 +10,7 @@ import '../../providers/load_config_provider.dart';
import '../../providers/load_telemetry_provider.dart';
import '../../providers/schema_provider.dart';
import '../../services/load_hardware.dart';
import '../../services/telemetry_text.dart' show energieCourte;
import '../../services/load_mechanism_switch.dart';
import '../../services/load_priority.dart';
import '../../services/nymea_service.dart';
@ -164,6 +165,8 @@ class _LoadMechanismScreenState extends State<LoadMechanismScreen> {
service: service,
telemetry: telemetry),
const SizedBox(height: 14),
_SectionObligation(entry: e, provider: provider),
const SizedBox(height: 14),
_Arbitrage(entry: e, provider: provider),
const SizedBox(height: 18),
_BarreEnregistrement(
@ -2161,3 +2164,111 @@ class _Plancher extends StatelessWidget {
);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Obligation quotidienne (§10) — et l'avertissement qui la rend honnête
// ═══════════════════════════════════════════════════════════════════════════
/// **Le plancher éco, et ce qu'il coûtera quand la box ne peut pas le vérifier.**
///
/// ## Pourquoi l'avertissement est ICI et pas au résultat
///
/// Au résultat, l'heure est passée : l'obligation a déjà acheté. Or sous le régime
/// `unmeasurable` elle ne peut **jamais** être close — ni annoncée tenue (R6 interdit
/// `ECO_FLOOR_MET`), ni chiffrée dans son manque (`missingWh` est absent, faute de savoir ce
/// qui a été livré). `ECO_FLOOR_MISSED` sortira donc **tous les jours, par construction**, et
/// l'achat se fera à taux constant sans que rien ne le corrige.
///
/// Un mécanisme dont le succès est inobservable et l'échec quotidien ne doit pas, en plus,
/// dépenser en silence. D'où : on le dit **à la saisie**.
///
/// ## Et c'est une consigne, pas une fatalité
///
/// Deux des trois causes se réparent par l'installateur — rattacher un compteur, en mettre un
/// qui totalise l'énergie. La troisième est le matériel, et le dire évite de chercher un
/// réglage qui n'existe pas. C'est pour ça que la cause est affichée, jamais le seul verdict.
class _SectionObligation extends StatelessWidget {
final LoadConfigEntry entry;
final LoadConfigProvider provider;
const _SectionObligation({required this.entry, required this.provider});
/// Le coût quotidien, **chiffré et non estimé** : le taux est constant depuis `+etm55`
/// (cible ÷ jour, LM-1013-b-v), donc le total acheté vaut exactement la cible.
String _coutQuotidien() => energieCourte(entry.minEnergyWhPerDay.toDouble());
@override
Widget build(BuildContext context) {
final t = context.l10n;
final mesurable = entry.progressMeasurable;
final cause = entry.progressUnmeasurableCause;
// `null` = box antérieure à `+etm49` : on n'avertit pas sur une absence, on se tait.
final avertir = mesurable == false && entry.minEnergyWhPerDay > 0;
return _Carte(
titre: t.obligationTitre,
badge: 'enregistré · needs',
sousTitre: t.obligationSousTitre,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextFormField(
initialValue: entry.minEnergyWhPerDay == 0
? ''
: '${entry.minEnergyWhPerDay}',
keyboardType: TextInputType.number,
decoration: InputDecoration(
isDense: true,
labelText: t.obligationEnergie,
suffixText: 'Wh'),
onChanged: (v) {
final n = v.trim().isEmpty ? 0 : int.tryParse(v.trim());
if (n == null) return;
provider.replaceEntry(
entry.id,
entry.patched(needs: {
'dailyDeadline': entry.dailyDeadline,
'minEnergyWhPerDay': n,
}));
},
),
const SizedBox(height: 10),
TextFormField(
initialValue: entry.dailyDeadline,
decoration: InputDecoration(
isDense: true,
labelText: t.obligationEcheance,
hintText: '07:00'),
onChanged: (v) => provider.replaceEntry(
entry.id,
entry.patched(needs: {
'dailyDeadline': v.trim(),
'minEnergyWhPerDay': entry.minEnergyWhPerDay,
})),
),
const SizedBox(height: 6),
// L'heure saisie est celle de la BOX, et la phrase le dit sans le fuseau — elle
// reste vraie sans lui. Le NOM du fuseau est publié par `GetEnergyRatios`
// (`timeZone`, vérifié en `+etm56` : `Europe/London`) ; il s'affichera ici quand le
// lot « bilan journalier » aura branché cet appel, et pas avant : nommer un fuseau
// qu'on n'a pas lu serait exactement la faute que cette phrase existe pour éviter.
_Aide(t.obligationEcheanceAide('')),
if (avertir) ...[
const SizedBox(height: 12),
_Aide(
'${t.obligationNonMesurableTitre}. '
'${t.obligationNonMesurableCorps(_coutQuotidien())} '
'${switch (cause) {
'noMeter' => t.obligationCauseNoMeter,
'noEnergyMeter' => t.obligationCauseNoEnergyMeter,
'noSessionEnergy' => t.obligationCauseNoSessionEnergy,
_ => t.obligationCauseInconnue(cause ?? '—'),
}}',
alerte: true,
),
],
],
),
);
}
}

View File

@ -0,0 +1,149 @@
/// **L'avertissement de saisie** — dire à la déclaration ce que l'obligation coûtera quand
/// la box ne peut pas la vérifier.
///
/// Pourquoi à la saisie et pas au résultat : au résultat, l'heure est passée et l'achat a eu
/// lieu. Et sous le régime `unmeasurable` l'obligation ne peut **jamais** être close — ni
/// annoncée tenue (R6 interdit `ECO_FLOOR_MET`), ni chiffrée dans son manque (`missingWh`
/// est absent, faute de savoir ce qui a été livré). `ECO_FLOOR_MISSED` sort donc tous les
/// jours par construction.
///
/// Le champ qui déclenche l'avertissement, `progressMeasurable`, a porté une contradiction
/// entre le 2026-08-30 et le 2026-09-03 : il disait ce que le MATÉRIEL permet pendant que le
/// régime disait ce que le MOTEUR fait, et l'avertissement serait resté muet exactement sur
/// les charges achetées à l'aveugle. Revérifié à la sonde le 2026-09-05 : les deux
/// s'accordent (`progressMeasurable: true` et `regime: measured` sur la même charge).
library;
import 'package:etm_powersync_app/l10n/app_localizations.dart';
import 'package:etm_powersync_app/providers/load_config_provider.dart';
import 'package:etm_powersync_app/providers/load_telemetry_provider.dart';
import 'package:etm_powersync_app/providers/schema_provider.dart';
import 'package:etm_powersync_app/screens/energy/load_mechanism_screen.dart';
import 'package:etm_powersync_app/services/nymea_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
/// Le chauffe-eau du banc, avec les champs de mesurabilité tels que `+etm49` les publie.
List<Map<String, dynamic>> _charge({
bool? mesurable = true,
String? cause,
int energie = 3000,
String echeance = '12:00',
}) =>
[
{
'id': 'chauffe-eau',
'label': 'chauffe-eau',
'adapter': 'relay-router',
'domain': 'ecs',
'mode': 'dynamic',
'priority': 1,
'enabled': true,
'relays': const [],
'needs': {'dailyDeadline': echeance, 'minEnergyWhPerDay': energie},
if (mesurable != null) 'progressMeasurable': mesurable,
if (cause != null) 'progressUnmeasurableCause': cause,
}
];
Widget _harness(List<Map<String, dynamic>> config) => MultiProvider(
providers: [
ChangeNotifierProvider.value(
value: LoadConfigProvider()..adoptForTest(config)),
ChangeNotifierProvider.value(
value: LoadTelemetryProvider()
..adoptForTest({'degradedMode': false, 'loads': const []})),
ChangeNotifierProvider.value(value: SchemaProvider()),
ChangeNotifierProvider.value(value: NymeaService()),
],
child: MaterialApp(
locale: const Locale('fr'),
localizationsDelegates: L10n.localizationsDelegates,
supportedLocales: L10n.supportedLocales,
home: const LoadMechanismScreen(loadId: 'chauffe-eau'),
),
);
/// L'écran est un `ListView` : hors viewport, rien n'est construit, et `find` rend une
/// absence qui se lit comme un succès. Grande surface obligatoire.
Future<void> _poser(WidgetTester tester, Widget w) async {
await tester.binding.setSurfaceSize(const Size(1000, 8000));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(w);
await tester.pumpAndSettle();
}
void main() {
group('l\'avertissement se déclenche sur ce que le MOTEUR fait', () {
testWidgets('mesurable : la section est là, l\'alarme non', (tester) async {
await _poser(tester, _harness(_charge()));
expect(find.text('Obligation quotidienne'), findsOneWidget);
expect(find.textContaining('ne pourra jamais être close'), findsNothing);
});
testWidgets('non mesurable : ce que ça coûte, et le geste qui le répare',
(tester) async {
await _poser(tester, _harness(_charge(mesurable: false, cause: 'noMeter')));
expect(find.textContaining('ne pourra jamais être close'), findsOneWidget);
// Le coût est CHIFFRÉ, pas estimé : taux constant depuis +etm55, donc le total
// acheté vaut exactement la cible.
expect(find.textContaining('3,0 kWh par jour'), findsOneWidget);
// Et surtout : une consigne, pas une fatalité.
expect(find.textContaining('Rattachez-en un'), findsOneWidget);
});
testWidgets('un compteur sans énergie n\'est pas « pas de compteur »',
(tester) async {
await _poser(
tester, _harness(_charge(mesurable: false, cause: 'noEnergyMeter')));
expect(find.textContaining('publie une puissance, pas une énergie'),
findsOneWidget);
expect(find.textContaining('Rattachez-en un'), findsNothing);
});
testWidgets('la borne sans énergie de session : c\'est le matériel',
(tester) async {
await _poser(
tester, _harness(_charge(mesurable: false, cause: 'noSessionEnergy')));
expect(find.textContaining('C\'est le matériel'), findsOneWidget);
// On n'envoie pas chercher un réglage qui n'existe pas.
expect(find.textContaining('Rattachez'), findsNothing);
});
});
group('quand il faut se taire', () {
testWidgets('aucune obligation déclarée : rien à avertir', (tester) async {
// Personne ne paiera : l'avertissement serait du bruit sur un écran qu'on ouvre
// pour toute autre chose.
await _poser(tester,
_harness(_charge(mesurable: false, cause: 'noMeter', energie: 0)));
expect(find.textContaining('ne pourra jamais être close'), findsNothing);
});
testWidgets('box antérieure à +etm49 : on ne conclut pas d\'une absence',
(tester) async {
// `progressMeasurable` absent ne veut pas dire « non mesurable ». Avertir ici
// serait fabriquer une alarme à partir d'un champ que la box ne publie pas.
await _poser(tester, _harness(_charge(mesurable: null)));
expect(find.textContaining('ne pourra jamais être close'), findsNothing);
});
testWidgets('cause inconnue : on le dit, on n\'invente pas de geste',
(tester) async {
await _poser(
tester, _harness(_charge(mesurable: false, cause: 'futureCause')));
expect(find.textContaining('futureCause'), findsOneWidget);
});
});
group('l\'échéance est à l\'heure de l\'installation', () {
testWidgets('et l\'écran le dit, plutôt que de choisir une horloge',
(tester) async {
await _poser(tester, _harness(_charge()));
expect(find.textContaining('Heure de l\'INSTALLATION'), findsOneWidget);
expect(find.textContaining('pas celle de cet appareil'), findsOneWidget);
});
});
}