hondavert-dev/lib/core/providers/bt_provider.dart
2026-10-13 00:34:11 +00:00

237 lines
8.7 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 '../protocol/neg8.dart';
import 'settings_provider.dart';
// ─── Connection status enum ──────────────────────────────────────────────────
enum BtStatus { disconnected, connecting, connected, error }
// ─── State ───────────────────────────────────────────────────────────────────
class BtState {
final BtStatus status;
final String? deviceName;
final String? error;
final int frameCount;
final int droppedFrames;
final Uint8List? lastFrame;
const BtState({
required this.status,
this.deviceName,
this.error,
this.frameCount = 0,
this.droppedFrames = 0,
this.lastFrame,
});
const BtState.disconnected() : this(status: BtStatus.disconnected);
BtState connecting(String name) =>
BtState(status: BtStatus.connecting, deviceName: name);
BtState connected(String name) =>
BtState(status: BtStatus.connected, deviceName: name);
BtState withError(String msg) => BtState(
status: BtStatus.error,
deviceName: deviceName,
error: msg,
frameCount: frameCount,
droppedFrames: droppedFrames,
lastFrame: lastFrame,
);
BtState withFrameStats(int frames, int dropped, Uint8List frame) => BtState(
status: status,
deviceName: deviceName,
frameCount: frames,
droppedFrames: dropped,
lastFrame: Uint8List.fromList(frame),
);
bool get isConnected => status == BtStatus.connected;
bool get isConnecting => status == BtStatus.connecting;
}
// ─── Notifier ────────────────────────────────────────────────────────────────
class BtNotifier extends StateNotifier<BtState> {
final Ref _ref;
BtPoller? _poller;
StreamSubscription<Uint8List>? _pollSub;
final StreamController<Uint8List> _frameController =
StreamController<Uint8List>.broadcast();
BtNotifier(this._ref) : super(const BtState.disconnected());
/// Raw validated 128-byte frame stream — consumed by sensorStateProvider.
Stream<Uint8List> get frameStream => _frameController.stream;
Future<void> connect(BluetoothDevice device) async {
state = state.connecting(device.name ?? device.address);
final service = _ref.read(btServiceProvider);
final settings = _ref.read(settingsProvider);
try {
await service.connect(device);
_poller = BtPoller(
service,
ecuType: settings.ecuType,
pollingInterval: settings.pollingInterval,
);
_pollSub = _poller!.frameStream.listen(
(frame) {
_frameController.add(frame);
state = state.withFrameStats(
_poller!.validFrames,
_poller!.droppedFrames,
frame,
);
},
onError: (Object e) {
_frameController.addError(e);
state = state.withError(e.toString());
},
onDone: () => state = state.withError('BT stream closed'),
);
_poller!.start();
state = state.connected(device.name ?? device.address);
} catch (e) {
await service.disconnect();
state = state.withError(e.toString());
}
}
/// One-off diagnostic: sends the official Hondata Bluetooth protocol's
/// DL_DeviceInfo command ({ Protocol=0x00, ID=0x02, Size=0x0004 LE }) and
/// returns whatever raw bytes come back within the response window. Used
/// to check whether this device/firmware understands the newer,
/// self-describing channel-based protocol (Bluetooth.pdf) as opposed to
/// only the legacy fixed-offset frame format this app currently speaks.
/// Pauses the legacy poller for the duration so its own request/response
/// traffic doesn't interleave with the probe's reply on the same link.
Future<Uint8List> probeOfficialProtocol() async {
final service = _ref.read(btServiceProvider);
if (!service.isConnected) return Uint8List(0);
_poller?.stop();
// stop() only cancels the poller's own timer and listener — it can't
// un-send a legacy request the poller already wrote moments earlier.
// That request's 128-byte reply is very likely still in flight, so
// drain and discard whatever arrives here before sending the probe, or
// that leftover legacy frame gets mistaken for the probe's response.
final drainSub = service.rawStream.listen((_) {});
await Future.delayed(const Duration(milliseconds: 500));
await drainSub.cancel();
final collected = <int>[];
final sub = service.rawStream.listen((chunk) => collected.addAll(chunk));
try {
await service.write(Uint8List.fromList([0x00, 0x02, 0x04, 0x00]));
await Future.delayed(const Duration(milliseconds: 800));
} finally {
await sub.cancel();
_poller?.start();
}
return Uint8List.fromList(collected);
}
/// Sends the DTC-reset command from the legacy protocol notes
/// (hvbt_protocol.pdf): { 0x1B, 0x70, NEG8 }, NEG8 over the first two
/// bytes. The ECU echoes the same 3 bytes back to acknowledge.
///
/// Deliberately does NOT pause the poller first (unlike
/// probeOfficialProtocol()) — pausing-then-sending got zero response both
/// here and for the official-protocol probe, while the regular poll cycle
/// always gets answered. One untested explanation: this adapter may only
/// answer additional commands while it keeps seeing the normal poll
/// traffic, not when that traffic stops. So this injects the command
/// alongside ongoing polling and scans the stream for the 3-byte ack
/// appearing anywhere in it — it'll be interleaved with regular 128-byte
/// datalog frames rather than arriving in isolation — over several poll
/// cycles' worth of time, in case the reply is delayed by an EEPROM/flash
/// write on the ECU rather than a simple RAM read.
Future<({bool acknowledged, Uint8List raw})> clearDtcs() async {
final service = _ref.read(btServiceProvider);
if (!service.isConnected) return (acknowledged: false, raw: Uint8List(0));
final command = Uint8List.fromList([0x1B, 0x70, 0x00]);
command[2] = calculateNeg8(Uint8List.fromList([0x1B, 0x70]));
final collected = <int>[];
final sub = service.rawStream.listen((chunk) => collected.addAll(chunk));
bool acknowledged = false;
try {
await service.write(command);
final deadline = DateTime.now().add(const Duration(seconds: 3));
while (DateTime.now().isBefore(deadline) && !acknowledged) {
await Future.delayed(const Duration(milliseconds: 150));
acknowledged = _containsAck(collected);
}
} finally {
await sub.cancel();
}
return (acknowledged: acknowledged, raw: Uint8List.fromList(collected));
}
static bool _containsAck(List<int> bytes) {
for (var i = 0; i + 2 < bytes.length; i++) {
if (bytes[i] == 0x1B && bytes[i + 1] == 0x70 && bytes[i + 2] == 0x75) {
return true;
}
}
return false;
}
Future<void> disconnect() async {
await _pollSub?.cancel();
_poller?.stop();
_poller?.dispose();
_poller = null;
await _ref.read(btServiceProvider).disconnect();
state = const BtState.disconnected();
}
@override
void dispose() {
_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).
final pairedDevicesProvider = FutureProvider<List<BluetoothDevice>>((ref) {
return FlutterBluetoothSerial.instance.getBondedDevices();
});