Écrit AVANT le déploiement du moteur, pour qu'il n'y ait pas de trou.
`measuredW` portait trois régimes sous un seul nom et jetait la distinction : une borne
rendait une mesure, un routeur de relais une mesure OU son palier nominal selon que ses
relais mesurent, un SG-Ready une puissance toujours déclarée. C'est la condition C2 de
LM-1009. Le régime se publie désormais :
measurement: { source: "meter" | "device" | "none", powerW }
Les trois pièges d'affichage du §7, figés par test :
· `none` est un état POSITIF — « cette charge n'est pas mesurable ». Il autorise à MASQUER
le bloc au lieu d'afficher un vide, et sépare enfin « pas mesurable » de « compteur en
panne ». Aucune puissance nominale n'est allée la chercher dans mechanism.* pour combler :
elle produirait un écart identiquement nul à tous les cycles, soit un « tout concorde »
permanent qui ne signale plus rien.
· {source: "device", powerW: 0} est une MESURE. Une borne branchée qui ne charge pas mesure
zéro watt. Si la source basculait à l'extinction, le bloc apparaîtrait et disparaîtrait au
rythme du thermostat.
· L'écart n'a pas la même valeur selon la source, et l'écran le DIT : sous `device`, il
montre la désobéissance mais une concordance ne prouve rien — plusieurs wallbox renvoient
leur consigne ; sous `meter`, la mesure est indépendante de l'organe commandé et la
concordance devient une preuve.
Le compteur dédié se décide sur `measurement.source`, JAMAIS sur `adapter` :
source == device → réglage avancé, replié (l'appareil publie déjà)
sinon → proposé, visible
La règle couvre SG-Ready sans avoir à le connaître — `none` par construction, LM-1105-b : un
contact de signal ne voit pas le courant de la machine, et pour une PAC le compteur dédié est
la SEULE voie vers une mesure. Elle couvre aussi la topologie de relais mixte, `none` elle
aussi, qu'un « masqué dès que la charge mesure, sauf SG-Ready » aurait manquée. Redéduire le
régime du mécanisme remettrait ici la déduction que le moteur vient d'en retirer.
⚠️ Une distinction que le contrat n'a pas à porter mais que l'écran doit faire : une charge
ABSENTE de la télémétrie n'est pas `none`. `none` est publié ; l'absence ne dit rien. Les
confondre annoncerait « seule voie vers une mesure » sur une borne qui publiera très bien la
sienne au prochain cycle.
⏳ Alias temporaire `measuredW` en repli, avec sa date de retrait : dès que +etm26 est sur
.75 (entrée TODO, sonde nommée). `measurement` gagne quand les deux sont là.
204 tests (+16), flutter analyze inchangé à 27 remarques. Prêt pour le déploiement.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
293 lines
12 KiB
Dart
293 lines
12 KiB
Dart
/// **Rapport de bug pré-rempli** — ce que l'utilisateur ne doit pas avoir à taper.
|
|
///
|
|
/// ## Pourquoi il est pré-rempli, et pas un simple champ libre
|
|
///
|
|
/// Un rapport utile tient à des identifiants que personne ne connaît par cœur : version du
|
|
/// plugin, version d'expérience, état de l'arbitrage au moment du défaut. Un champ libre
|
|
/// seul produit « ça ne marche pas », et l'aller-retour pour obtenir le reste coûte des
|
|
/// jours. Tout ce que l'app SAIT déjà est donc collecté d'office.
|
|
///
|
|
/// ## Ce qu'il ne collecte PAS, et c'est délibéré
|
|
///
|
|
/// Aucun secret ne franchit ce rapport : ni le PIN installateur (même haché), ni le jeton
|
|
/// de connexion, ni le mot de passe. Un rapport de bug voyage — par courriel, par capture
|
|
/// d'écran, dans un fil de discussion — et ce qui y entre en sort.
|
|
///
|
|
/// Les identifiants de Things sont conservés : ce sont des UUID locaux, sans valeur hors de
|
|
/// l'installation, et ce sont eux qui permettent de recouper avec le journal de la box.
|
|
///
|
|
/// ## L'envoi n'existe pas, et le dire vaut mieux que le simuler
|
|
///
|
|
/// Il n'y a pas de service de collecte. Le rapport se **copie** dans le presse-papier :
|
|
/// c'est vérifiable par l'utilisateur, là où un bouton « Envoyer » sans destinataire
|
|
/// donnerait le sentiment que quelqu'un l'a reçu.
|
|
library;
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:package_info_plus/package_info_plus.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../../providers/load_config_provider.dart';
|
|
import '../../providers/load_telemetry_provider.dart';
|
|
import '../../providers/schema_provider.dart';
|
|
import '../../services/nymea_service.dart';
|
|
import '../../theme/etm_tokens.dart';
|
|
|
|
class BugReportScreen extends StatefulWidget {
|
|
const BugReportScreen({super.key});
|
|
|
|
@override
|
|
State<BugReportScreen> createState() => _BugReportScreenState();
|
|
}
|
|
|
|
class _BugReportScreenState extends State<BugReportScreen> {
|
|
final _quoi = TextEditingController();
|
|
final _attendu = TextEditingController();
|
|
PackageInfo? _paquet;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
// La version vient du paquet installé, jamais d'une constante recopiée à la main :
|
|
// une constante dérive de `pubspec.yaml` en silence, et un rapport qui annonce la
|
|
// mauvaise version envoie chercher un défaut dans un code qui n'est pas celui-là.
|
|
PackageInfo.fromPlatform().then((p) {
|
|
if (mounted) setState(() => _paquet = p);
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_quoi.dispose();
|
|
_attendu.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
/// Le rapport, en texte brut — le seul format qui se colle partout.
|
|
String _rapport(BuildContext context) {
|
|
final svc = context.read<NymeaService>();
|
|
final schema = context.read<SchemaProvider>();
|
|
final config = context.read<LoadConfigProvider>();
|
|
final tel = context.read<LoadTelemetryProvider>().telemetry;
|
|
|
|
final b = StringBuffer()
|
|
..writeln('## Rapport ETM-PowerSync')
|
|
..writeln()
|
|
..writeln('### Ce qui s\'est passé')
|
|
..writeln(_quoi.text.trim().isEmpty ? '(non renseigné)' : _quoi.text.trim())
|
|
..writeln()
|
|
..writeln('### Ce qui était attendu')
|
|
..writeln(
|
|
_attendu.text.trim().isEmpty ? '(non renseigné)' : _attendu.text.trim())
|
|
..writeln()
|
|
..writeln('### Application')
|
|
..writeln('- version : ${_paquet == null ? "en cours de lecture" : "${_paquet!.version}+${_paquet!.buildNumber}"}')
|
|
..writeln('- paquet : ${_paquet?.packageName ?? "—"}')
|
|
..writeln()
|
|
..writeln('### Box')
|
|
..writeln('- hôte : ${svc.host.isEmpty ? "aucun" : svc.host}')
|
|
..writeln('- connectée : ${svc.isConnected ? "oui" : "NON"}'
|
|
'${svc.isSimulation ? " (SIMULATION — aucune box réelle)" : ""}')
|
|
..writeln('- versions : ${schema.schema.loaded ? schema.versionLine() : "schéma non lu"}')
|
|
..writeln('- things : ${svc.things.length}');
|
|
|
|
b
|
|
..writeln()
|
|
..writeln('### Charges')
|
|
..writeln('- configurées : ${config.entries.length}'
|
|
'${config.error != null ? " (erreur de lecture : ${config.error})" : ""}');
|
|
for (final e in config.entries) {
|
|
b.writeln(' - ${e.id} · ${e.adapter} · rang ${e.priority} · '
|
|
'domaine ${e.domain ?? "non classé"} · ${e.enabled ? "active" : "désactivée"}');
|
|
}
|
|
|
|
b
|
|
..writeln()
|
|
..writeln('### Arbitrage (dernier cycle publié)');
|
|
if (tel == null) {
|
|
// Pas de télémétrie ≠ arbitrage à zéro. Le distinguer ici évite un diagnostic
|
|
// fondé sur des zéros que personne n'a publiés.
|
|
b.writeln('- aucune télémétrie reçue');
|
|
} else {
|
|
b
|
|
..writeln('- horodatage : ${tel.timestamp?.toIso8601String() ?? "AUCUN CYCLE depuis le démarrage du service"}')
|
|
..writeln('- mode dégradé : ${tel.degradedMode ? "OUI (watchdog L2)" : "non"}');
|
|
final bud = tel.budget;
|
|
if (bud == null) {
|
|
b.writeln('- budget : ABSENT (aucun budget pour ce cycle)');
|
|
} else {
|
|
b
|
|
..writeln('- budget : surplus ${bud.surplusW.round()} W · '
|
|
'réservé VE ${bud.evReservedW.round()} W · '
|
|
'recrédité ${bud.recreditedW.round()} W · '
|
|
'alloué ${bud.allocatedW.round()} W · '
|
|
'reliquat ${bud.remainingW.round()} W')
|
|
// Les deux identités que le plugin déclare vérifiables. Leur verdict vaut
|
|
// souvent plus que la description du symptôme : un rapport qui les porte
|
|
// fausses désigne le moteur, un rapport qui les porte vraies désigne l'écran.
|
|
..writeln('- identité du budget : ${bud.identityHolds ? "OK" : "FAUSSE"}')
|
|
..writeln('- identité surplus/allocations : '
|
|
'${switch (tel.surplusIdentityHolds) {
|
|
null => "sans objet (pas de budget)",
|
|
true => "OK",
|
|
false => "FAUSSE (somme ${tel.surplusAllocatedW.round()} W "
|
|
"contre ${bud.allocatedW.round()} W)",
|
|
}}');
|
|
}
|
|
for (final l in tel.loads) {
|
|
final m = l.mechanism;
|
|
b.writeln(' - ${l.loadId} · alloué ${l.allocatedW.round()} W'
|
|
'${l.measurement.mesuree ? " · mesuré ${l.measurement.powerW!.round()} W"
|
|
" (${l.measurement.source.name})" : ""}'
|
|
' · ${l.funding ?? "financement omis"}'
|
|
' · motif ${l.decision.code}${formatParamsBrut(l.decision.params)}'
|
|
'${l.inFault ? " · EN DÉFAUT ${l.faultCode ?? ""}" : ""}'
|
|
'${m != null ? " · mécanisme ${m.kind}" : ""}');
|
|
}
|
|
}
|
|
return b.toString();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final ink = EtmTokens.inkOf(context);
|
|
final muted = EtmTokens.mutedOf(context);
|
|
|
|
return Scaffold(
|
|
backgroundColor: EtmTokens.bgOf(context),
|
|
appBar: AppBar(
|
|
title: const Text('Rapport de bug'),
|
|
backgroundColor: EtmTokens.surfaceOf(context),
|
|
foregroundColor: ink,
|
|
elevation: 0,
|
|
),
|
|
body: ListView(
|
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
|
|
children: [
|
|
Text(
|
|
'Décrivez le problème en deux phrases. Tout le contexte technique est déjà '
|
|
'joint : versions, charges déclarées, et l\'état du dernier cycle '
|
|
'd\'arbitrage. Vous pouvez le relire avant de copier.',
|
|
style: TextStyle(fontSize: 13, height: 1.4, color: muted),
|
|
),
|
|
const SizedBox(height: 16),
|
|
_Champ(
|
|
libelle: 'Ce qui s\'est passé',
|
|
aide: 'Ce que vous avez fait, et ce que l\'écran a montré.',
|
|
controller: _quoi,
|
|
),
|
|
const SizedBox(height: 12),
|
|
_Champ(
|
|
libelle: 'Ce qui était attendu',
|
|
aide: 'Sans cette ligne, un défaut de compréhension ressemble à un défaut '
|
|
'de logiciel.',
|
|
controller: _attendu,
|
|
),
|
|
const SizedBox(height: 18),
|
|
Text('Contexte joint automatiquement',
|
|
style: TextStyle(
|
|
fontSize: 13, fontWeight: FontWeight.w700, color: ink)),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
'Aucun secret n\'y figure : ni PIN installateur, ni jeton, ni mot de passe. '
|
|
'Les identifiants d\'appareils sont conservés — ce sont eux qui permettent de '
|
|
'recouper avec le journal de la box.',
|
|
style: TextStyle(fontSize: 11.5, height: 1.35, color: muted),
|
|
),
|
|
const SizedBox(height: 10),
|
|
Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: EtmTokens.surfaceOf(context),
|
|
borderRadius: BorderRadius.circular(EtmTokens.radiusCard),
|
|
border: Border.all(color: EtmTokens.lineOf(context)),
|
|
),
|
|
child: SelectableText(
|
|
_rapport(context),
|
|
style: TextStyle(
|
|
fontSize: 11, height: 1.4, fontFamily: 'monospace', color: ink),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
FilledButton.icon(
|
|
onPressed: () async {
|
|
final texte = _rapport(context);
|
|
await Clipboard.setData(ClipboardData(text: texte));
|
|
if (!context.mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
|
content: Text('Rapport copié — collez-le dans votre message.'),
|
|
));
|
|
},
|
|
icon: const Icon(Icons.copy_all_rounded),
|
|
label: const Text('Copier le rapport'),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'Il n\'y a pas d\'envoi automatique : le rapport est copié dans le '
|
|
'presse-papier, et c\'est vous qui décidez où il va.',
|
|
style: TextStyle(fontSize: 11.5, height: 1.35, color: muted),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Rendu compact des paramètres d'un motif, trié — deux rapports du même état doivent
|
|
/// être comparables ligne à ligne.
|
|
String formatParamsBrut(Map<String, dynamic> params) {
|
|
if (params.isEmpty) return '';
|
|
final keys = params.keys.toList()..sort();
|
|
return ' {${[for (final k in keys) '$k=${params[k]}'].join(', ')}}';
|
|
}
|
|
|
|
class _Champ extends StatelessWidget {
|
|
final String libelle;
|
|
final String aide;
|
|
final TextEditingController controller;
|
|
|
|
const _Champ({
|
|
required this.libelle,
|
|
required this.aide,
|
|
required this.controller,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(libelle,
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w700,
|
|
color: EtmTokens.inkOf(context))),
|
|
const SizedBox(height: 2),
|
|
Text(aide,
|
|
style: TextStyle(
|
|
fontSize: 11.5, height: 1.3, color: EtmTokens.mutedOf(context))),
|
|
const SizedBox(height: 6),
|
|
TextField(
|
|
controller: controller,
|
|
maxLines: 3,
|
|
minLines: 2,
|
|
textInputAction: TextInputAction.newline,
|
|
keyboardType: TextInputType.multiline,
|
|
decoration: InputDecoration(
|
|
filled: true,
|
|
fillColor: EtmTokens.surfaceOf(context),
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl),
|
|
borderSide: BorderSide(color: EtmTokens.lineOf(context)),
|
|
),
|
|
contentPadding: const EdgeInsets.all(12),
|
|
),
|
|
style: TextStyle(fontSize: 13, color: EtmTokens.inkOf(context)),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|