hondavert-dev/lib/core/providers/bt_provider.dart
metatroncubeswdev 9a56baef61 Rework BT poller and S300 decoding after client feedback
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>
2026-10-12 21:02:54 -04:00

413 lines
14 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;
_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 echoes
/// the same 3 bytes as acknowledgement.
Future<({bool acknowledged, Uint8List raw})> clearDtcs() async {
final p = _poller;
if (p == null) return (acknowledged: false, raw: Uint8List(0));
final command = Uint8List.fromList([0x1B, 0x70, 0x00]);
command[2] = calculateNeg8(Uint8List.fromList([0x1B, 0x70]));
final result = await p.sendCommandAwaitReply(command, command);
AppLog.i('Clear DTC: ack=${result.acknowledged} '
'rxBytes=${result.raw.length}');
return result;
}
@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();
});