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>
413 lines
14 KiB
Dart
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();
|
|
});
|