Work that was previously only in the local working tree: - Rename fuelCut -> fuelPump (SW2 bit2, active-low, live-confirmed) - ECT/IAT/BAT moved to live-confirmed offsets 33/34/36 - S300 template-frame detection with hold-last-good logic - Persist ECU type in SharedPreferences; drop per-connect protocol dialog - Official-protocol probe and DTC clear on the debug/DTC screens - Sanity clamp for multi-byte fields; scrollable vertical dashboard - docs/HANDOFF.md Includes temporary [DBG]/[KDBG] prints, removed in the next commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
237 lines
8.4 KiB
Dart
237 lines
8.4 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();
|
|
});
|