Initial version

This commit is contained in:
2026-09-21 18:03:21 -04:00
commit e8e7dc4a81
83 changed files with 7177 additions and 0 deletions
+109
View File
@@ -0,0 +1,109 @@
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));
}
}