hondavert-dev/lib/core/providers/bt_provider.dart
metatroncubeswdev ba79775fbc Fix DTC tables and Clear DTC result; auto-set ECU type from adapter name
- Rewrite S300 and KPro DTC tables from hvbt_protocol.pdf (the previous
  tables did not match the PDF; e.g. ERR01.6 is P0350 IGM, not P1361).
- Clear DTC: the adapter never echoes the documented ack, but the ECU does
  clear (bench: 3 of 4 codes cleared, P0350 re-set at once as an active
  fault). Judge success from the live ERR bytes; wait only 0.6 s for the
  ack so polling resumes, and report "N of M cleared".
- Set the ECU protocol from the adapter name (S300/KPro) on connect; a
  stale KPro setting made an S300 adapter look unresponsive.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 23:08:29 -04:00

495 lines
17 KiB
Dart

import 'dart:async';
import 'dart:typed_data';
import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../bluetooth/bt_poller.dart';
import '../bluetooth/bt_service.dart';
import '../logging/app_log.dart';
import '../protocol/neg8.dart';
import '../protocol/s300_parser.dart';
import 'settings_provider.dart';
// ─── Enums ───────────────────────────────────────────────────────────────────
enum BtStatus { disconnected, connecting, connected, reconnecting, error }
/// What the adapter is actually giving us, independent of the BT link.
enum EcuDataStatus {
/// Not connected / nothing received yet.
none,
/// Real ECU readings are arriving.
live,
/// The adapter answers, but only with its built-in default frame
/// (hvbt_protocol.pdf example values) — it has no fresh ECU data.
adapterDefaults,
/// The adapter is not answering requests.
noResponse,
}
// ─── State ───────────────────────────────────────────────────────────────────
class BtState {
final BtStatus status;
final String? deviceName;
final String? error;
final EcuDataStatus dataStatus;
final int requestsSent;
final int frameCount; // valid frames (live + default)
final int liveFrames;
final int defaultFrames;
final int droppedFrames; // bad checksum
final int timeouts;
final int reconnects;
final int? lastResponseMs;
final Uint8List? lastFrame;
const BtState({
required this.status,
this.deviceName,
this.error,
this.dataStatus = EcuDataStatus.none,
this.requestsSent = 0,
this.frameCount = 0,
this.liveFrames = 0,
this.defaultFrames = 0,
this.droppedFrames = 0,
this.timeouts = 0,
this.reconnects = 0,
this.lastResponseMs,
this.lastFrame,
});
const BtState.disconnected() : this(status: BtStatus.disconnected);
BtState copyWith({
BtStatus? status,
String? deviceName,
String? error,
bool clearError = false,
EcuDataStatus? dataStatus,
int? requestsSent,
int? frameCount,
int? liveFrames,
int? defaultFrames,
int? droppedFrames,
int? timeouts,
int? reconnects,
int? lastResponseMs,
Uint8List? lastFrame,
}) =>
BtState(
status: status ?? this.status,
deviceName: deviceName ?? this.deviceName,
error: clearError ? null : (error ?? this.error),
dataStatus: dataStatus ?? this.dataStatus,
requestsSent: requestsSent ?? this.requestsSent,
frameCount: frameCount ?? this.frameCount,
liveFrames: liveFrames ?? this.liveFrames,
defaultFrames: defaultFrames ?? this.defaultFrames,
droppedFrames: droppedFrames ?? this.droppedFrames,
timeouts: timeouts ?? this.timeouts,
reconnects: reconnects ?? this.reconnects,
lastResponseMs: lastResponseMs ?? this.lastResponseMs,
lastFrame: lastFrame ?? this.lastFrame,
);
bool get isConnected => status == BtStatus.connected;
bool get isConnecting =>
status == BtStatus.connecting || status == BtStatus.reconnecting;
}
// ─── Notifier ────────────────────────────────────────────────────────────────
class BtNotifier extends StateNotifier<BtState> {
BtNotifier(this._ref) : super(const BtState.disconnected()) {
// Apply polling-interval changes from Settings to a running poller.
_ref.listen<AppSettings>(settingsProvider, (prev, next) {
_poller?.pollingInterval = next.pollingInterval;
_poller?.ecuType = next.ecuType;
});
}
final Ref _ref;
BtPoller? _poller;
StreamSubscription<Uint8List>? _pollSub;
Timer? _statusTimer;
final StreamController<Uint8List> _frameController =
StreamController<Uint8List>.broadcast();
BluetoothDevice? _device;
bool _userDisconnected = false;
bool _reconnecting = false;
// Bumped on every user connect/disconnect. A link opened by an older
// session (e.g. an auto-reconnect still in flight when the user tapped
// Disconnect) is closed instead of being adopted.
int _session = 0;
DateTime? _lastAnyFrameAt;
DateTime? _lastLiveFrameAt;
DateTime? _defaultsSince;
DateTime? _lastAutoReconnectAt;
int _liveFrames = 0;
int _defaultFrames = 0;
int _reconnects = 0;
static const _staleAfter = Duration(milliseconds: 1500);
static const _defaultsReconnectAfter = Duration(seconds: 8);
static const _minTimeBetweenAutoReconnects = Duration(seconds: 45);
/// Validated 128-byte frames carrying REAL ECU data (adapter default frames
/// are filtered out). Consumed by sensorStateProvider and the datalog.
Stream<Uint8List> get frameStream => _frameController.stream;
BtPoller? get poller => _poller;
Future<void> connect(BluetoothDevice device) async {
if (state.isConnecting) return; // ignore double taps
final session = ++_session;
// The adapter's name says which ECU it is wired to; a stale saved
// setting for the other ECU makes every request go unanswered.
final inferred = ecuTypeFromDeviceName(device.name);
if (inferred != null && inferred != _ref.read(settingsProvider).ecuType) {
AppLog.i('ECU protocol set to ${inferred.name} from adapter name '
'"${device.name}"');
_ref.read(settingsProvider.notifier).setEcuType(inferred);
}
_device = device;
_userDisconnected = false;
_resetCounters();
state = BtState(
status: BtStatus.connecting,
deviceName: device.name ?? device.address,
);
try {
await _openLink(device);
if (session != _session || !mounted) {
AppLog.i('Connect finished after disconnect — closing link');
await _closeLink();
return;
}
state = state.copyWith(status: BtStatus.connected, clearError: true);
} catch (e, st) {
AppLog.e('Connect failed', e, st);
if (session != _session || !mounted) return;
await _closeLink();
state = state.copyWith(status: BtStatus.error, error: _friendly(e));
}
}
Future<void> disconnect() async {
_session++;
_userDisconnected = true;
await _closeLink();
state = const BtState.disconnected();
}
// ── Link management ───────────────────────────────────────────────────────
Future<void> _openLink(BluetoothDevice device) async {
final service = _ref.read(btServiceProvider);
final settings = _ref.read(settingsProvider);
await service.connect(device);
final poller = BtPoller(
service,
ecuType: settings.ecuType,
pollingInterval: settings.pollingInterval,
onLinkStalled: () => _onLinkProblem('adapter stopped answering'),
);
_poller = poller;
_pollSub = poller.frameStream.listen(
_onFrame,
onError: (Object e) => _onLinkProblem('$e'),
);
poller.start();
_statusTimer?.cancel();
_statusTimer =
Timer.periodic(const Duration(milliseconds: 500), (_) => _tick());
}
Future<void> _closeLink() async {
_statusTimer?.cancel();
_statusTimer = null;
await _pollSub?.cancel();
_pollSub = null;
_poller?.dispose();
_poller = null;
await _ref.read(btServiceProvider).disconnect();
}
void _resetCounters() {
_lastAnyFrameAt = null;
_lastLiveFrameAt = null;
_defaultsSince = null;
_liveFrames = 0;
_defaultFrames = 0;
}
void _onFrame(Uint8List frame) {
if (!mounted) return;
final now = DateTime.now();
_lastAnyFrameAt = now;
final ecuType = _ref.read(settingsProvider).ecuType;
final isDefault = ecuType == EcuType.s300 && isS300DefaultFrame(frame);
if (isDefault) {
_defaultFrames++;
if (_defaultFrames == 1 || _defaultFrames % 50 == 0) {
AppLog.w('Adapter returned its default (no-ECU-data) frame '
'(count=$_defaultFrames)');
}
} else {
_liveFrames++;
_lastLiveFrameAt = now;
_defaultsSince = null;
_frameController.add(frame);
}
_publishStats(frame);
}
void _publishStats([Uint8List? frame]) {
final p = _poller;
state = state.copyWith(
requestsSent: p?.requestsSent,
frameCount: p?.validFrames,
liveFrames: _liveFrames,
defaultFrames: _defaultFrames,
droppedFrames: p?.droppedFrames,
timeouts: p?.timeouts,
reconnects: _reconnects,
lastResponseMs: p?.lastResponseTime?.inMilliseconds,
lastFrame: frame,
dataStatus: _computeDataStatus(),
);
}
EcuDataStatus _computeDataStatus() {
final now = DateTime.now();
bool recent(DateTime? t) => t != null && now.difference(t) < _staleAfter;
if (recent(_lastLiveFrameAt)) return EcuDataStatus.live;
if (recent(_lastAnyFrameAt)) return EcuDataStatus.adapterDefaults;
if (_poller?.isRunning ?? false) return EcuDataStatus.noResponse;
return EcuDataStatus.none;
}
void _tick() {
if (!mounted || _poller == null) return;
final status = _computeDataStatus();
// Adapter answering only with defaults for a long stretch: one reconnect
// has been seen to clear this (see docs/HANDOFF.md), but never loop on it.
if (status == EcuDataStatus.adapterDefaults) {
_defaultsSince ??= DateTime.now();
if (DateTime.now().difference(_defaultsSince!) > _defaultsReconnectAfter &&
_autoReconnectAllowed()) {
_onLinkProblem('only default frames for '
'${_defaultsReconnectAfter.inSeconds}s');
}
} else {
_defaultsSince = null;
}
_publishStats();
}
bool _autoReconnectAllowed() {
final last = _lastAutoReconnectAt;
return last == null ||
DateTime.now().difference(last) > _minTimeBetweenAutoReconnects;
}
Future<void> _onLinkProblem(String reason) async {
if (_reconnecting || _userDisconnected || _device == null || !mounted) {
return;
}
final service = _ref.read(btServiceProvider);
// A stall on a link that is still up: only reconnect occasionally; the
// poller keeps trying at its timeout pace in between.
if (service.isConnected && !_autoReconnectAllowed()) {
AppLog.w('Link problem ($reason) — keeping link, recent reconnect');
return;
}
final session = _session;
bool stillWanted() => session == _session && !_userDisconnected && mounted;
_reconnecting = true;
_lastAutoReconnectAt = DateTime.now();
AppLog.w('Link problem: $reason — reconnecting');
state = state.copyWith(status: BtStatus.reconnecting);
await _closeLink();
for (var attempt = 1; attempt <= 5; attempt++) {
await Future<void>.delayed(Duration(seconds: attempt));
if (!stillWanted()) break;
try {
_resetCounters();
await _openLink(_device!);
if (!stillWanted()) {
// User disconnected while this attempt was in flight.
AppLog.i('Reconnect finished after disconnect — closing link');
await _closeLink();
break;
}
_reconnects++;
AppLog.i('Reconnected (attempt $attempt)');
_reconnecting = false;
state = state.copyWith(status: BtStatus.connected, clearError: true);
return;
} catch (e) {
AppLog.w('Reconnect attempt $attempt failed: $e');
// Don't tear down a link that a newer session may own.
if (!stillWanted()) break;
await _closeLink();
}
}
_reconnecting = false;
if (stillWanted()) {
state = state.copyWith(
status: BtStatus.error,
error: 'Connection lost. Check the adapter and connect again.',
dataStatus: EcuDataStatus.none,
);
}
}
static String _friendly(Object e) {
final s = e.toString();
if (s.contains('timed out')) return s.replaceFirst('Exception: ', '');
if (s.contains('read failed') || s.contains('socket')) {
return 'Could not open the Bluetooth link. Make sure the adapter is '
'powered and not connected to another phone, then try again.';
}
return s;
}
// ── One-off commands ──────────────────────────────────────────────────────
/// Diagnostic: Hondata official-protocol DL_DeviceInfo probe. Runs between
/// polls via the poller's link lock, so it never overlaps a request.
Future<Uint8List> probeOfficialProtocol() async {
final p = _poller;
if (p == null) return Uint8List(0);
return p.sendCommandCollect(
Uint8List.fromList([0x00, 0x02, 0x04, 0x00]),
const Duration(milliseconds: 800),
);
}
/// DTC reset per hvbt_protocol.pdf: { 0x1B, 0x70, NEG8 }; the adapter should
/// echo the same 3 bytes as acknowledgement.
///
/// The adapter never sends that ack (confirmed on the bench), so success is
/// judged from the data: after sending, polling resumes and the ERR bytes
/// of the live frames are watched for [_dtcWatch]. If any code that was
/// stored before disappears, the ECU cleared. Codes for faults that are
/// still present come straight back (often before the first frame), just
/// as with the USB tool, so "all zero" is not required. The ack wait is
/// kept short because the adapter stops reading the ECU after ~1 s without
/// datalog requests (Bluetooth.pdf §2.4).
///
/// [codesBefore]/[codesCleared] count stored code bits before the command
/// and how many of them were seen cleared.
Future<
({
bool acknowledged,
int codesBefore,
int codesCleared,
Uint8List raw,
})> clearDtcs() async {
final p = _poller;
if (p == null) {
return (
acknowledged: false,
codesBefore: 0,
codesCleared: 0,
raw: Uint8List(0),
);
}
final ecuType = _ref.read(settingsProvider).ecuType;
// ERR00.. bytes: S300 37-40, KPro 62-79 (hvbt_protocol.pdf).
List<int> errOf(Uint8List f) =>
ecuType == EcuType.s300 ? f.sublist(37, 41) : f.sublist(62, 80);
String hex(List<int> b) =>
b.map((v) => v.toRadixString(16).padLeft(2, '0')).join(' ');
int bits(int v) => v.toRadixString(2).replaceAll('0', '').length;
final beforeFrame = state.lastFrame;
final before = beforeFrame == null ? null : errOf(beforeFrame);
final codesBefore = before == null ? 0 : before.fold(0, (n, b) => n + bits(b));
AppLog.i('Clear DTC: ERR bytes before = ${before == null ? "?" : hex(before)}');
// Bits stored before that have been seen clear at least once.
final clearedMask = List<int>.filled(before?.length ?? 0, 0);
String? lastErr = before == null ? null : hex(before);
final watchStart = DateTime.now();
final sub = frameStream.listen((f) {
final err = errOf(f);
final h = hex(err);
if (h != lastErr) {
final ms = DateTime.now().difference(watchStart).inMilliseconds;
AppLog.i('Clear DTC: +$ms ms ERR bytes = $h');
lastErr = h;
}
if (before != null) {
for (var i = 0; i < before.length; i++) {
clearedMask[i] |= before[i] & ~err[i];
}
}
});
final command = Uint8List.fromList([0x1B, 0x70, 0x00]);
command[2] = calculateNeg8(Uint8List.fromList([0x1B, 0x70]));
final result = await p.sendCommandAwaitReply(
command,
command,
timeout: const Duration(milliseconds: 600),
);
await Future<void>.delayed(_dtcWatch);
await sub.cancel();
final codesCleared = clearedMask.fold(0, (n, b) => n + bits(b));
AppLog.i('Clear DTC: ack=${result.acknowledged} '
'rxBytes=${result.raw.length} '
'codesCleared=$codesCleared/$codesBefore '
'ERR bytes after = ${lastErr ?? "?"}');
return (
acknowledged: result.acknowledged,
codesBefore: codesBefore,
codesCleared: codesCleared,
raw: result.raw,
);
}
static const _dtcWatch = Duration(seconds: 5);
@override
void dispose() {
_statusTimer?.cancel();
_pollSub?.cancel();
_poller?.dispose();
_frameController.close();
super.dispose();
}
}
// ─── Providers ───────────────────────────────────────────────────────────────
/// Singleton BtService — lives as long as the app.
final btServiceProvider = Provider<BtService>((ref) {
final service = BtService();
ref.onDispose(service.dispose);
return service;
});
/// BT connection state notifier.
final btProvider =
StateNotifierProvider<BtNotifier, BtState>((ref) => BtNotifier(ref));
/// Paired BT devices list (re-fetchable via ref.refresh). Only watched after
/// Bluetooth permissions are granted.
final pairedDevicesProvider = FutureProvider<List<BluetoothDevice>>((ref) {
return FlutterBluetoothSerial.instance.getBondedDevices();
});