`+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
304 lines
12 KiB
Dart
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)),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|