import 'dart:async'; import 'dart:math' as math; import 'dart:typed_data'; import '../models/scooter_device.dart'; import '../scooters/apollo_protocol.dart'; import 'ble_client.dart'; /// Fake BLE link that behaves like the Apollo Go captured on 2026-09-21. /// /// Accepts any PIN, streams the real base frame and a monitor frame with an /// animated speed and current, and only streams while keepalives arrive, just /// like the physical scooter. Used to preview layouts without hardware. class DemoBleClient extends BleClient { static const device = ScooterDevice( id: 'demo', name: 'Demo Apollo Go', rssi: -50, advertisedServiceUuids: {apolloDataServiceUuid}, ); final _conn = StreamController.broadcast(); final _data = StreamController.broadcast(); final _at = StreamController.broadcast(); Timer? _stream; DateTime _lastKeepalive = DateTime.fromMillisecondsSinceEpoch(0); double _t = 0; static final _base = Uint8List.fromList( [0xAA, 0x01, 0x19, 0x03, 0x0A, 0x0F, 0x1E, 0xD8, 0x80, 0x00, 0x1F, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x68, 0x61]); @override Stream> scan() => Stream.value([device]); @override Future stopScan() async {} @override Stream get connectionState => _conn.stream; @override Future connect(String deviceId) async { await Future.delayed(const Duration(milliseconds: 600)); _stream = Timer.periodic(const Duration(milliseconds: 200), (_) => _tick()); } @override Future disconnect() async { _stream?.cancel(); _stream = null; _conn.add(BleConnectionState.disconnected); } @override Future>> discoverServices() async { await Future.delayed(const Duration(milliseconds: 400)); return { apolloDataServiceUuid: {apolloDataTxUuid, apolloDataRxUuid}, apolloAtServiceUuid: {apolloAtTxUuid, apolloAtRxUuid}, }; } @override Future> subscribe({ required String serviceUuid, required String characteristicUuid, }) async { await Future.delayed(const Duration(milliseconds: 250)); return characteristicUuid == apolloDataRxUuid ? _data.stream : _at.stream; } @override Future write({ required String serviceUuid, required String characteristicUuid, required Uint8List value, }) async { if (characteristicUuid == apolloAtTxUuid) { Future.delayed(const Duration(milliseconds: 150), () => _at.add(Uint8List.fromList('OK+PWD:Y'.codeUnits))); } else if (value.length == 4 && value[0] == 0xA5) { _lastKeepalive = DateTime.now(); } } void _tick() { if (DateTime.now().difference(_lastKeepalive) > const Duration(seconds: 3)) return; _t += 0.2; final speedKmh = 14 + 12 * math.sin(_t / 4); // 2..26 km/h final rawSpeed = (speedKmh * 10).round(); final currentA = 3 + 8 * math.max(0, math.cos(_t / 4)) - (math.sin(_t / 2) < -0.8 ? 6 : 0); final rawCurrent = (currentA * 64).round() & 0xFFFF; final battery = 78; final flagsA = 0x0E | (math.sin(_t / 6) > 0 ? 0x80 : 0); // headlight blinks slowly final frame = Uint8List.fromList([ 0xAA, 0x00, 0x19, 0x01, 0x02, battery, rawSpeed >> 8, rawSpeed & 0xFF, 0, 0, 0x01, 0x9F, rawCurrent >> 8, rawCurrent & 0xFF, 0x16 + (speedKmh / 10).round(), 0x19, 0x00, 0x7B, 0x00, 0x0C, 0xC5, flagsA, 0x22, 0, 0, ]); final crc = apolloCrc16(frame.sublist(0, 23)); frame[23] = crc & 0xFF; frame[24] = crc >> 8; // Mimic the real 20 + 5 byte notification split. _data.add(frame.sublist(0, 20)); _data.add(frame.sublist(20)); _data.add(_base.sublist(0, 20)); _data.add(_base.sublist(20)); } }