110 lines
3.7 KiB
Dart
110 lines
3.7 KiB
Dart
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<BleConnectionState>.broadcast();
|
|
final _data = StreamController<Uint8List>.broadcast();
|
|
final _at = StreamController<Uint8List>.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<List<ScooterDevice>> scan() => Stream.value([device]);
|
|
@override
|
|
Future<void> stopScan() async {}
|
|
@override
|
|
Stream<BleConnectionState> get connectionState => _conn.stream;
|
|
|
|
@override
|
|
Future<void> connect(String deviceId) async {
|
|
await Future<void>.delayed(const Duration(milliseconds: 600));
|
|
_stream = Timer.periodic(const Duration(milliseconds: 200), (_) => _tick());
|
|
}
|
|
|
|
@override
|
|
Future<void> disconnect() async {
|
|
_stream?.cancel();
|
|
_stream = null;
|
|
_conn.add(BleConnectionState.disconnected);
|
|
}
|
|
|
|
@override
|
|
Future<Map<String, Set<String>>> discoverServices() async {
|
|
await Future<void>.delayed(const Duration(milliseconds: 400));
|
|
return {
|
|
apolloDataServiceUuid: {apolloDataTxUuid, apolloDataRxUuid},
|
|
apolloAtServiceUuid: {apolloAtTxUuid, apolloAtRxUuid},
|
|
};
|
|
}
|
|
|
|
@override
|
|
Future<Stream<Uint8List>> subscribe({
|
|
required String serviceUuid,
|
|
required String characteristicUuid,
|
|
}) async {
|
|
await Future<void>.delayed(const Duration(milliseconds: 250));
|
|
return characteristicUuid == apolloDataRxUuid ? _data.stream : _at.stream;
|
|
}
|
|
|
|
@override
|
|
Future<void> write({
|
|
required String serviceUuid,
|
|
required String characteristicUuid,
|
|
required Uint8List value,
|
|
}) async {
|
|
if (characteristicUuid == apolloAtTxUuid) {
|
|
Future<void>.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));
|
|
}
|
|
}
|