Work that was previously only in the local working tree: - Rename fuelCut -> fuelPump (SW2 bit2, active-low, live-confirmed) - ECT/IAT/BAT moved to live-confirmed offsets 33/34/36 - S300 template-frame detection with hold-last-good logic - Persist ECU type in SharedPreferences; drop per-connect protocol dialog - Official-protocol probe and DTC clear on the debug/DTC screens - Sanity clamp for multi-byte fields; scrollable vertical dashboard - docs/HANDOFF.md Includes temporary [DBG]/[KDBG] prints, removed in the next commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
233 lines
7.6 KiB
Dart
233 lines
7.6 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import '../../../core/protocol/dtc_map.dart';
|
|
import '../../../core/providers/bt_provider.dart';
|
|
import '../../../core/providers/sensor_provider.dart';
|
|
import '../../../core/providers/settings_provider.dart';
|
|
import '../../../core/bluetooth/bt_poller.dart';
|
|
import 'widgets/dtc_row.dart';
|
|
|
|
class DtcScreen extends ConsumerStatefulWidget {
|
|
const DtcScreen({super.key});
|
|
|
|
@override
|
|
ConsumerState<DtcScreen> createState() => _DtcScreenState();
|
|
}
|
|
|
|
class _DtcScreenState extends ConsumerState<DtcScreen> {
|
|
bool _clearing = false;
|
|
Uint8List? _lastRaw;
|
|
|
|
String _hex(Uint8List bytes) {
|
|
final parts = <String>[];
|
|
for (var i = 0; i < bytes.length; i++) {
|
|
if (i > 0 && i % 16 == 0) parts.add('\n');
|
|
parts.add(bytes[i].toRadixString(16).padLeft(2, '0'));
|
|
}
|
|
return parts.join(' ');
|
|
}
|
|
|
|
void _showRawCapture() {
|
|
final raw = _lastRaw;
|
|
if (raw == null) return;
|
|
final hex = _hex(raw);
|
|
showDialog<void>(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: Text('Raw capture (${raw.length} bytes)'),
|
|
content: SizedBox(
|
|
width: double.maxFinite,
|
|
child: SingleChildScrollView(
|
|
child: SelectableText(
|
|
hex,
|
|
style: const TextStyle(fontFamily: 'monospace', fontSize: 11),
|
|
),
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () async {
|
|
await Clipboard.setData(ClipboardData(text: hex));
|
|
if (context.mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Copied')),
|
|
);
|
|
}
|
|
},
|
|
child: const Text('Copy'),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text('Close'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _confirmAndClear() async {
|
|
final confirmed = await showDialog<bool>(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: const Text('Clear Trouble Codes'),
|
|
content: const Text(
|
|
'This sends a reset command to the ECU and erases all stored '
|
|
'fault codes. This cannot be undone.',
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context, false),
|
|
child: const Text('Cancel'),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context, true),
|
|
child: const Text('Clear',
|
|
style: TextStyle(color: Colors.red)),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
if (confirmed != true) return;
|
|
|
|
setState(() => _clearing = true);
|
|
final result = await ref.read(btProvider.notifier).clearDtcs();
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_clearing = false;
|
|
_lastRaw = result.raw;
|
|
});
|
|
|
|
final String message;
|
|
if (result.acknowledged) {
|
|
message = 'Trouble codes cleared';
|
|
} else if (result.raw.isEmpty) {
|
|
message = 'No response from ECU — codes may not have cleared';
|
|
} else {
|
|
// The scan runs alongside normal polling now, so "not acknowledged"
|
|
// can mean several seconds' worth of regular datalog traffic with no
|
|
// ack byte pattern found in it — dumping all of that raw would be
|
|
// unusable in a snackbar, so just report how much was seen. "View"
|
|
// opens the full hex to check by hand for anything our narrow 3-byte
|
|
// pattern match might have missed.
|
|
message =
|
|
'No ack seen — ${result.raw.length} bytes of other traffic during the window';
|
|
}
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(message),
|
|
action: (!result.acknowledged && result.raw.isNotEmpty)
|
|
? SnackBarAction(label: 'View', onPressed: _showRawCapture)
|
|
: null,
|
|
duration: const Duration(seconds: 6),
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final state = ref.watch(latestSensorProvider);
|
|
final bt = ref.watch(btProvider);
|
|
final ecuType = ref.watch(settingsProvider).ecuType;
|
|
|
|
final dtcMap =
|
|
ecuType == EcuType.s300 ? s300DtcMap : kproDtcMap;
|
|
final active = decodeDtcs(state.errBytes, dtcMap);
|
|
|
|
return Column(
|
|
children: [
|
|
// Header bar
|
|
Container(
|
|
color: const Color(0xFF1A1A1A),
|
|
padding:
|
|
const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
active.isEmpty
|
|
? Icons.check_circle_outline
|
|
: Icons.warning_amber_rounded,
|
|
color: active.isEmpty ? Colors.green : Colors.amber,
|
|
size: 18,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
active.isEmpty
|
|
? 'No active fault codes'
|
|
: '${active.length} active DTC${active.length > 1 ? "s" : ""}',
|
|
style: TextStyle(
|
|
color: active.isEmpty ? Colors.green : Colors.amber,
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
const Spacer(),
|
|
Text(
|
|
ecuType == EcuType.s300 ? 'S300' : 'KPro',
|
|
style:
|
|
const TextStyle(color: Colors.grey, fontSize: 12),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const Divider(height: 1, color: Colors.white10),
|
|
|
|
// DTC list
|
|
Expanded(
|
|
child: active.isEmpty
|
|
? const Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(Icons.check_circle_outline,
|
|
size: 56, color: Colors.green),
|
|
SizedBox(height: 12),
|
|
Text('All clear — no fault codes',
|
|
style: TextStyle(
|
|
color: Colors.grey, fontSize: 15)),
|
|
],
|
|
),
|
|
)
|
|
: ListView.separated(
|
|
itemCount: active.length,
|
|
separatorBuilder: (_, __) =>
|
|
const Divider(height: 1, color: Colors.white10),
|
|
itemBuilder: (context, i) {
|
|
final full = active[i]; // "P0130 — O2 Sensor (front)"
|
|
final parts = full.split(' — ');
|
|
final code = parts.first.trim();
|
|
final desc =
|
|
parts.length > 1 ? parts.sublist(1).join(' — ') : '';
|
|
return DtcRow(code: code, description: desc);
|
|
},
|
|
),
|
|
),
|
|
SafeArea(
|
|
top: false,
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: SizedBox(
|
|
width: double.infinity,
|
|
child: ElevatedButton(
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: Colors.red,
|
|
foregroundColor: Colors.white,
|
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
|
),
|
|
onPressed: (bt.isConnected && !_clearing)
|
|
? _confirmAndClear
|
|
: null,
|
|
child: Text(
|
|
_clearing ? 'Clearing…' : 'CLEAR TROUBLE CODES',
|
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|