Client reported crashes, wrong/frozen data and that the app floods the adapter MCU. See docs/FIX_2026-10-12.md for the full analysis. - Poller: strictly one request outstanding; next request only after a valid 128-byte reply or a 500 ms timeout, then a quiet period + buffer flush. Default 100 ms (10 Hz), minimum 80 ms. DTC clear/probe share the same lock. - BtNotifier: filter the adapter's default (no-ECU-data) frame instead of holding last values; EcuDataStatus + status banner; auto-reconnect on link loss, stalls or long default-only runs (rate-limited). A reconnect still in flight when the user disconnects is discarded. - S300 offsets corrected by column counting (trims, PA, ERR00-03, Eth, SW5, AF); INJ/AIN little-endian; fan = SW5 FANC only. Default-frame detection requires bytes 0-16 and 32-36. - KPro RPM/MAP2/AIN little-endian (RPM still needs a bench check). - Crash hardening: zone/Flutter/platform error handlers to an in-app log (copyable from Debug); permissions requested before listing devices. - Remove temporary debug prints; graph redraws live; polling options 100-300 ms, persisted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
419 lines
14 KiB
Dart
419 lines
14 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../../../core/bluetooth/bt_poller.dart';
|
|
import '../../../core/logging/app_log.dart';
|
|
import '../../../core/providers/bt_provider.dart';
|
|
import '../../../core/providers/sensor_provider.dart';
|
|
import '../../../core/providers/settings_provider.dart';
|
|
import '../../../core/protocol/neg8.dart';
|
|
import '../../../core/protocol/sensor_state.dart';
|
|
|
|
class RawFrameDebugScreen extends ConsumerStatefulWidget {
|
|
const RawFrameDebugScreen({super.key});
|
|
|
|
@override
|
|
ConsumerState<RawFrameDebugScreen> createState() =>
|
|
_RawFrameDebugScreenState();
|
|
}
|
|
|
|
class _RawFrameDebugScreenState extends ConsumerState<RawFrameDebugScreen> {
|
|
Uint8List? _probeResult;
|
|
bool _probing = false;
|
|
|
|
Future<void> _runProbe() async {
|
|
setState(() {
|
|
_probing = true;
|
|
_probeResult = null;
|
|
});
|
|
final result =
|
|
await ref.read(btProvider.notifier).probeOfficialProtocol();
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_probing = false;
|
|
_probeResult = result;
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final bt = ref.watch(btProvider);
|
|
final settings = ref.watch(settingsProvider);
|
|
final sensorAsync = ref.watch(sensorStateProvider);
|
|
final state = ref.watch(latestSensorProvider);
|
|
final decodeStats = ref.watch(decodeStatsProvider);
|
|
final frame = bt.lastFrame;
|
|
final hex = frame == null ? '' : _hex(frame);
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: const Text('ECU Frame Debug'),
|
|
actions: [
|
|
IconButton(
|
|
tooltip: 'Copy app log',
|
|
icon: const Icon(Icons.description_outlined),
|
|
onPressed: () async {
|
|
await Clipboard.setData(ClipboardData(text: AppLog.dump()));
|
|
if (context.mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
'App log copied (${AppLog.length} lines) — paste it into an email/message')),
|
|
);
|
|
}
|
|
},
|
|
),
|
|
IconButton(
|
|
tooltip: 'Copy raw frame',
|
|
icon: const Icon(Icons.copy),
|
|
onPressed: frame == null
|
|
? null
|
|
: () async {
|
|
await Clipboard.setData(ClipboardData(text: hex));
|
|
if (context.mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Raw frame copied')),
|
|
);
|
|
}
|
|
},
|
|
),
|
|
],
|
|
),
|
|
body: ListView(
|
|
padding: const EdgeInsets.all(12),
|
|
children: [
|
|
_Section(
|
|
title: 'Connection',
|
|
rows: [
|
|
_RowData('Protocol', settings.ecuType.name.toUpperCase()),
|
|
_RowData('Link status', bt.status.name),
|
|
_RowData('ECU data', bt.dataStatus.name),
|
|
_RowData('Poll interval', '${settings.pollingInterval.inMilliseconds} ms'),
|
|
_RowData('Requests sent', bt.requestsSent.toString()),
|
|
_RowData('Valid replies', bt.frameCount.toString()),
|
|
_RowData(' live ECU frames', bt.liveFrames.toString()),
|
|
_RowData(' adapter default frames', bt.defaultFrames.toString()),
|
|
_RowData('Timeouts (no reply)', bt.timeouts.toString()),
|
|
_RowData('Dropped (bad checksum)', bt.droppedFrames.toString()),
|
|
_RowData('Last reply time', bt.lastResponseMs == null ? '-' : '${bt.lastResponseMs} ms'),
|
|
_RowData('Auto-reconnects', bt.reconnects.toString()),
|
|
_RowData('Last frame bytes', frame?.length.toString() ?? 'none'),
|
|
if (frame != null)
|
|
_RowData(
|
|
'Checksum',
|
|
validateFrame(frame) ? 'valid' : 'bad',
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
_ProtocolProbeCard(
|
|
connected: bt.isConnected,
|
|
probing: _probing,
|
|
result: _probeResult,
|
|
onProbe: _runProbe,
|
|
),
|
|
const SizedBox(height: 12),
|
|
_Section(
|
|
title: 'Decode Pipeline',
|
|
rows: [
|
|
_RowData('Decoded -> UI', decodeStats.decoded.toString()),
|
|
_RowData('Skipped (decode error)', decodeStats.decodeErrors.toString()),
|
|
_RowData(
|
|
'sensorStateProvider raw',
|
|
sensorAsync.when(
|
|
data: (s) =>
|
|
'data(rpm=${s.rpm.toStringAsFixed(0)}, age=${DateTime.now().difference(s.timestamp).inMilliseconds}ms)',
|
|
loading: () => 'loading (no frame yet)',
|
|
error: (e, st) => 'error: $e',
|
|
),
|
|
),
|
|
_RowData(
|
|
'latestSensorProvider age',
|
|
'${DateTime.now().difference(state.timestamp).inMilliseconds}ms since decode',
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
_Section(
|
|
title: 'Current App Decode',
|
|
rows: _decodedRows(state),
|
|
),
|
|
const SizedBox(height: 12),
|
|
if (frame == null)
|
|
const Center(
|
|
child: Padding(
|
|
padding: EdgeInsets.all(24),
|
|
child: Text(
|
|
'No ECU frame received yet.',
|
|
style: TextStyle(color: Colors.grey),
|
|
),
|
|
),
|
|
)
|
|
else ...[
|
|
if (settings.ecuType == EcuType.s300) ...[
|
|
_Section(
|
|
title: 'S300 Raw Candidates',
|
|
rows: _s300CandidateRows(frame),
|
|
),
|
|
const SizedBox(height: 12),
|
|
],
|
|
_RawHexBlock(hex: hex),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
List<_RowData> _decodedRows(SensorState s) => [
|
|
_RowData('RPM', s.rpm.toStringAsFixed(0)),
|
|
_RowData('Speed km/h', s.vss.toStringAsFixed(2)),
|
|
_RowData('MAP kPa', s.map.toStringAsFixed(2)),
|
|
_RowData('TPS', s.tps.toStringAsFixed(2)),
|
|
_RowData('ECT', s.ect.toStringAsFixed(2)),
|
|
_RowData('IAT', s.iat.toStringAsFixed(2)),
|
|
_RowData('Battery', s.bat.toStringAsFixed(2)),
|
|
_RowData('O2', s.o2.toStringAsFixed(2)),
|
|
_RowData('AFR', s.afr.toStringAsFixed(2)),
|
|
_RowData('MIL (unverified)', s.mil.toString()),
|
|
_RowData('Fuel Pump', s.fuelPump.toString()),
|
|
_RowData('Fan Out (unverified)', s.fanOut.toString()),
|
|
_RowData('VTEC (unverified)', s.vtec.toString()),
|
|
_RowData('Knock', s.knock.toString()),
|
|
_RowData('Rev Limit (unverified)', s.revLimit.toString()),
|
|
_RowData('Launch (unverified)', s.launch.toString()),
|
|
];
|
|
|
|
List<_RowData> _s300CandidateRows(Uint8List f) {
|
|
final rpmLe = _u16le(f, 3);
|
|
final vssLe = _u16le(f, 5);
|
|
final mapLe = _u16le(f, 7);
|
|
|
|
return [
|
|
_RowData('Header bytes', '${_b(f, 0)} ${_b(f, 1)} ${_b(f, 2)}'),
|
|
_RowData('RPM raw @3 LE', rpmLe.toString()),
|
|
_RowData('VSS raw @5 LE', vssLe.toString()),
|
|
_RowData('VSS current km/h', _s300Vss(vssLe).toStringAsFixed(2)),
|
|
_RowData('MAP raw @7 LE', mapLe.toString()),
|
|
_RowData('MAP current kPa', _s300Map(mapLe).toStringAsFixed(2)),
|
|
_RowData('TPS raw @9', f[9].toString()),
|
|
_RowData('TPS current', _s300Tps(f[9]).toStringAsFixed(2)),
|
|
_RowData('Gear raw @27 (decimal)', f[27].toString()),
|
|
_RowData('INJ raw @10 LE', _u16le(f, 10).toString()),
|
|
_RowData('IGN raw @12', f[12].toString()),
|
|
_RowData('O2 raw @16', f[16].toString()),
|
|
_RowData('Strim raw @29 / Ltrim raw @31', '${f[29]} / ${f[31]}'),
|
|
_RowData('PA raw @32', f[32].toString()),
|
|
_RowData('ECT raw @33', f[33].toString()),
|
|
_RowData('IAT raw @34', f[34].toString()),
|
|
_RowData('BAT raw @36', f[36].toString()),
|
|
_RowData('ERR00-03 @37-40',
|
|
'${_b(f, 37)} ${_b(f, 38)} ${_b(f, 39)} ${_b(f, 40)}'),
|
|
_RowData('Eth raw @41', f[41].toString()),
|
|
_RowData('AF raw @55', f[55].toString()),
|
|
_RowData('SW1 @17 (bit7..0)', '${_b(f, 17)} ${_bin(f, 17)}'),
|
|
_RowData('SW2 @18 (bit7..0)', '${_b(f, 18)} ${_bin(f, 18)}'),
|
|
_RowData('SW3 @19 (bit7..0)', '${_b(f, 19)} ${_bin(f, 19)}'),
|
|
_RowData('SW5 @49 (bit7..0)', '${_b(f, 49)} ${_bin(f, 49)}'),
|
|
_RowData('SW6 @50 (bit7..0)', '${_b(f, 50)} ${_bin(f, 50)}'),
|
|
];
|
|
}
|
|
|
|
static int _u16le(Uint8List f, int o) => f[o] | (f[o + 1] << 8);
|
|
static String _b(Uint8List f, int o) =>
|
|
f[o].toRadixString(16).padLeft(2, '0');
|
|
static String _bin(Uint8List f, int o) =>
|
|
f[o].toRadixString(2).padLeft(8, '0');
|
|
|
|
static double _s300Vss(int raw) {
|
|
return (raw < 893 || raw == 0xFFFF) ? 0.0 : 228480.0 / raw;
|
|
}
|
|
|
|
static double _s300Map(int raw) {
|
|
return raw / 10.0;
|
|
}
|
|
|
|
static double _s300Tps(int raw) {
|
|
return (raw * 100.0 / 255.0).clamp(0.0, 100.0);
|
|
}
|
|
|
|
static String _hex(Uint8List frame) {
|
|
final parts = <String>[];
|
|
for (var i = 0; i < frame.length; i++) {
|
|
if (i > 0 && i % 16 == 0) parts.add('\n');
|
|
parts.add(frame[i].toRadixString(16).padLeft(2, '0'));
|
|
}
|
|
return parts.join(' ');
|
|
}
|
|
}
|
|
|
|
/// Step 1 of the data-layer review: checks whether this device/firmware
|
|
/// understands the official, self-describing Hondata Bluetooth protocol
|
|
/// (Bluetooth.pdf) rather than only the legacy fixed-offset frame format
|
|
/// this app currently speaks. A real reply here (Protocol=0x00, ID=0x02,
|
|
/// with a recognizable DeviceType byte) means migrating to that protocol is
|
|
/// on the table; silence means this hardware is legacy-only.
|
|
class _ProtocolProbeCard extends StatelessWidget {
|
|
final bool connected;
|
|
final bool probing;
|
|
final Uint8List? result;
|
|
final VoidCallback onProbe;
|
|
|
|
const _ProtocolProbeCard({
|
|
required this.connected,
|
|
required this.probing,
|
|
required this.result,
|
|
required this.onProbe,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Text(
|
|
'Official Protocol Probe',
|
|
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
|
|
),
|
|
const SizedBox(height: 4),
|
|
const Text(
|
|
'Sends DL_DeviceInfo (00 02 04 00). Pauses normal polling for '
|
|
'the duration of the probe.',
|
|
style: TextStyle(color: Colors.grey, fontSize: 11),
|
|
),
|
|
const SizedBox(height: 8),
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: ElevatedButton(
|
|
onPressed: (connected && !probing) ? onProbe : null,
|
|
child: Text(probing ? 'Probing…' : 'Send Probe'),
|
|
),
|
|
),
|
|
if (result != null) ...[
|
|
const SizedBox(height: 8),
|
|
for (final row in _interpret(result!)) _InfoRow(row),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
List<_RowData> _interpret(Uint8List r) {
|
|
if (r.isEmpty) {
|
|
return const [
|
|
_RowData('Result', 'No response — likely legacy protocol only'),
|
|
];
|
|
}
|
|
|
|
final hex = r.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ');
|
|
final rows = [_RowData('Raw bytes', hex)];
|
|
if (r.length >= 4) {
|
|
final size = r[2] | (r[3] << 8); // little-endian per TDLHeader.Size
|
|
rows.add(_RowData('Protocol / ID / Size', '${r[0]} / ${r[1]} / $size'));
|
|
}
|
|
if (r.length >= 6) {
|
|
final deviceType = switch (r[4]) {
|
|
0xC0 => 'S300',
|
|
0xC1 => 'KPro',
|
|
0xC2 => 'FlashPro',
|
|
_ => 'unknown (0x${r[4].toRadixString(16)})',
|
|
};
|
|
rows.add(_RowData('Device type', deviceType));
|
|
rows.add(_RowData('Ignition', r[5] != 0 ? 'On' : 'Off'));
|
|
}
|
|
return rows;
|
|
}
|
|
}
|
|
|
|
class _Section extends StatelessWidget {
|
|
final String title;
|
|
final List<_RowData> rows;
|
|
|
|
const _Section({required this.title, required this.rows});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
title,
|
|
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
|
|
),
|
|
const SizedBox(height: 8),
|
|
for (final row in rows) _InfoRow(row),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _InfoRow extends StatelessWidget {
|
|
final _RowData row;
|
|
|
|
const _InfoRow(this.row);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 3),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
row.label,
|
|
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Flexible(
|
|
child: Text(
|
|
row.value,
|
|
textAlign: TextAlign.right,
|
|
style: const TextStyle(
|
|
fontSize: 12,
|
|
fontFamily: 'monospace',
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _RawHexBlock extends StatelessWidget {
|
|
final String hex;
|
|
|
|
const _RawHexBlock({required this.hex});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(12),
|
|
child: SelectableText(
|
|
hex,
|
|
style: const TextStyle(fontFamily: 'monospace', fontSize: 11),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _RowData {
|
|
final String label;
|
|
final String value;
|
|
|
|
const _RowData(this.label, this.value);
|
|
}
|