KPro, checked against KPro Manager on the bench (7267 rpm capture; a test replays that frame): - TPS (raw - 25) / 2.04 (68 %), IGN (raw - 128) / 2 (5 deg), short trim (raw - 128) / 1.28, injector 16-bit LE at 11-12. - BAT is byte 51 / 10 (11.40 V) and PA byte 53 kPa (95.0); the PDF table had them at 52 / 51. - SW1/SW2 bits are listed lowest-first: FLR bit6, FANC bit7, VTP bit0, VTS bit1, MIL bit3 (VTEC Out no longer shows VTP). S300: the same TPS formula matches the USB tool (31 % at raw ~88). RPM glitch filter: frames whose RPM jumps more than an engine can are dropped (KPro bench crank fault P0336 sent ~300 rpm frames between real ones); a real change is accepted once 6 of the last 10 readings agree. Dropped count shown on the Debug screen. Connecting from Settings now replaces the whole navigation stack, so no stale dashboard is left underneath. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
543 lines
19 KiB
Dart
543 lines
19 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/rpm_glitch_filter.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 rpmGlitches; // frames dropped for an implausible RPM jump
|
|
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.rpmGlitches = 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? rpmGlitches,
|
|
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,
|
|
rpmGlitches: rpmGlitches ?? this.rpmGlitches,
|
|
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() {
|
|
_rpmFilter.reset();
|
|
_rpmFilter.dropped = 0;
|
|
_glitchesLogged = 0;
|
|
_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 if (!_rpmFilter.accept(frame, now)) {
|
|
// Implausible RPM jump (e.g. crank-signal dropout, P0336 on the KPro
|
|
// bench): drop the whole frame and keep showing the last good one,
|
|
// as the USB tool does. Counted for the Debug screen.
|
|
final n = _rpmFilter.dropped;
|
|
if (n == 1 || n % 100 == 0) {
|
|
AppLog.w('RPM glitch frame dropped '
|
|
'(${RpmGlitchFilter.rpmOf(frame).round()} rpm, count=$n)');
|
|
}
|
|
if (_glitchesLogged < 2) {
|
|
_glitchesLogged++;
|
|
AppLog.w('Glitch frame: ${[
|
|
for (final v in frame) v.toRadixString(16).padLeft(2, '0')
|
|
].join(' ')}');
|
|
}
|
|
} else {
|
|
_liveFrames++;
|
|
_lastLiveFrameAt = now;
|
|
_maybeLogFrame(frame, now);
|
|
_defaultsSince = null;
|
|
_frameController.add(frame);
|
|
}
|
|
_publishStats(frame);
|
|
}
|
|
|
|
DateTime? _lastFrameLogAt;
|
|
late final _rpmFilter = RpmGlitchFilter(
|
|
onConfirmed: (from, to) => AppLog.i(
|
|
'RPM change ${from.round()} -> ${to.round()} accepted (majority of recent frames)'),
|
|
);
|
|
int _glitchesLogged = 0;
|
|
|
|
/// Raw hex of one live frame every 5 s, so bench readings from the USB
|
|
/// tool can be matched to exact bytes (Debug → Copy app log, or logcat).
|
|
void _maybeLogFrame(Uint8List frame, DateTime now) {
|
|
final last = _lastFrameLogAt;
|
|
if (last != null && now.difference(last) < const Duration(seconds: 5)) {
|
|
return;
|
|
}
|
|
_lastFrameLogAt = now;
|
|
final hex = StringBuffer();
|
|
for (var i = 0; i < 64; i++) {
|
|
if (i > 0) hex.write(i % 16 == 0 ? ' | ' : ' ');
|
|
hex.write(frame[i].toRadixString(16).padLeft(2, '0'));
|
|
}
|
|
AppLog.i('FRAME[0..63] $hex');
|
|
}
|
|
|
|
void _publishStats([Uint8List? frame]) {
|
|
final p = _poller;
|
|
state = state.copyWith(
|
|
requestsSent: p?.requestsSent,
|
|
frameCount: p?.validFrames,
|
|
liveFrames: _liveFrames,
|
|
rpmGlitches: _rpmFilter.dropped,
|
|
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();
|
|
});
|