etm-powersync-app/lib/screens/settings/bug_report_screen.dart
Patrick Schurig a1ffd13107 feat(télémétrie): la réconciliation repose sur counts — l'exception EV_GRID_START tombe
`+etm32` publie `counts` par niveau : où chaque watt est compté, et combien.
Vérifié à la sonde sur `.75` avant de toucher au code — `counts` sur tous les
niveaux, destination publiée même à zéro, et les DEUX identités qui tiennent.

- `LoadLevel.counts`, `countsTotalW`, `countsCoherent`, `partSurplusW` ;
- `surplusShareW` LIT `counts["budget.allocatedW"]` au lieu d'interpréter ;
- `soutirageShareW` + `LoadTelemetry.soutirageIdentityHolds` : la seconde
  identité, qui n'existait pas ce matin faute de registre pour les watts achetés ;
- `LoadTelemetry.drawCommittedW` — absent = box antérieure à `+etm31`, jamais
  « rien acheté » ;
- `destinationsInconnues` : une clé née après cette app est NOMMÉE, jamais
  comptée zéro, et l'identité par niveau tient sans connaître le sens des clés.

L'exception au motif survit exactement là où la box ne sait pas dire le partage
autrement — formes sans `counts`. Ce n'est pas une version qui l'a retirée, c'est
une charge utile qui l'a rendue inutile ; sur une installation déjà déployée, la
retirer casserait la réconciliation.

Le rapport de bogue porte désormais les deux identités, les destinations
inconnues et les niveaux incohérents (Σ counts != targetW) — une incohérence de
la box, pas un arrondi.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-08-30 10:51:10 +02:00

304 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é soutirage/achats : '
'${switch (tel.soutirageIdentityHolds) {
null => "sans objet (box antérieure à +etm32)",
true => "OK",
false => "FAUSSE (somme ${tel.soutirageAllocatedW!.round()} W "
"contre ${tel.drawCommittedW!.round()} W)",
}}')
..writeln('- destinations inconnues : '
'${tel.destinationsInconnues.isEmpty ? "aucune" : tel.destinationsInconnues.join(", ")}')
..writeln('- niveaux incohérents (Σ counts != targetW) : '
'${tel.niveauxIncoherents.isEmpty ? "aucun" : tel.niveauxIncoherents.join(", ")}')
..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.resolvedFunding ?? "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)),
),
],
);
}
}