R2 est livrée côté moteur (1.15.2+etm30 sur .75) : `funding` ne figure plus à la racine d'une entrée de `loads[]`, il vit dans `levels[]`, un par passe. La rupture est effective, pas seulement contractuelle — `entry.funding` est désormais `null` partout, donc la mention « servie au réseau » avait disparu de la carte et une charge achetée au réseau aurait été comptée comme surplus dans l'identité. Une panne muette : rien n'échoue, tout se lit de travers. - `LoadLevel` — une passe : `level`, `targetW` (du DÉCIDÉ, comme `allocatedW`), `funding` ; - `resolvedFunding` / `isGridFunded` résolvent les DEUX formes ; `levels[]` vide = box antérieure, pas « aucune passe » ; - `'mixed'` est NOMMÉ plutôt que replié : impossible aujourd'hui (une seule passe publiée), courant au §10, et l'affirmer d'un côté serait une demi-vérité ; - `surplusShareW` somme les `targetW` financés au surplus, l'exception écrite `EV_GRID_START` (part de surplus = `params.budgetW`) passant avant. Vérifié à la sonde contre `.75`, pas en test : 3 500 + 3 000 == 6 500 == `budget.allocatedW` au cycle 06:10, puis 1 500 == 1 500 à 06:14. Le niveau publié est « comfort », et c'est déjà le bon nom. Aucun écran à deux niveaux n'est commencé : le reste de la maquette attend R1 et R3 à R8, et le §10 n'est pas implémenté. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
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.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)),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|