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
+253
View File
@@ -0,0 +1,253 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart' as fbp;
import '../models/scooter_device.dart';
import 'protocol_log.dart';
enum BleConnectionState { disconnected, connected }
/// Thin boundary between OpenScooter and the underlying BLE plugin.
///
/// OpenScooter v1 supports EXACTLY ONE active scooter connection at a time.
/// That is why [subscribe], [write] and [discoverServices] take no device
/// identifier: they always act on the device passed to the last [connect].
///
/// Protocol code never imports the BLE plugin directly, so protocol tests run
/// with `flutter test` and a fake subclass, without hardware.
abstract class BleClient {
/// Streams the current set of visible devices. Scanning starts on listen
/// and stops when the subscription is cancelled.
Stream<List<ScooterDevice>> scan();
Future<void> stopScan();
/// Connects to the device with the given platform identifier and makes it
/// the single active device.
Future<void> connect(String deviceId);
Future<void> disconnect();
/// Emits connection changes for the active device, INCLUDING unexpected
/// disconnects. Scooter implementations must reset on `disconnected`.
Stream<BleConnectionState> get connectionState;
/// Runs GATT discovery on the active device. Returns a map of lowercase
/// 128-bit service UUID to the lowercase characteristic UUIDs it contains.
Future<Map<String, Set<String>>> discoverServices();
/// Enables notifications on a characteristic and returns its value stream.
Future<Stream<Uint8List>> subscribe({
required String serviceUuid,
required String characteristicUuid,
});
Future<void> write({
required String serviceUuid,
required String characteristicUuid,
required Uint8List value,
});
/// Normalises any UUID spelling to lowercase 128-bit form for comparisons.
static String normalizeUuid(String uuid) => fbp.Guid(uuid).str128.toLowerCase();
}
/// Production [BleClient] backed by flutter_blue_plus.
class FlutterBleClient extends BleClient {
fbp.BluetoothDevice? _device;
List<fbp.BluetoothService> _services = const [];
StreamSubscription<fbp.BluetoothConnectionState>? _connSub;
final _connState = StreamController<BleConnectionState>.broadcast();
@override
Stream<BleConnectionState> get connectionState => _connState.stream;
@override
Stream<List<ScooterDevice>> scan() {
late StreamController<List<ScooterDevice>> controller;
StreamSubscription<List<fbp.ScanResult>>? sub;
Future<void> start() async {
try {
var adapter = await fbp.FlutterBluePlus.adapterState.first;
if (adapter != fbp.BluetoothAdapterState.on &&
defaultTargetPlatform == TargetPlatform.android) {
await fbp.FlutterBluePlus.turnOn();
}
adapter = await fbp.FlutterBluePlus.adapterState
.where((s) => s == fbp.BluetoothAdapterState.on)
.first
.timeout(const Duration(seconds: 10));
sub = fbp.FlutterBluePlus.scanResults.listen((results) {
controller.add(results.map(_toDevice).toList());
}, onError: controller.addError);
_log('SCAN START');
await fbp.FlutterBluePlus.startScan(
continuousUpdates: true,
continuousDivisor: 2,
removeIfGone: const Duration(seconds: 6),
);
} catch (e) {
controller.addError(e);
}
}
controller = StreamController<List<ScooterDevice>>(
onListen: start,
onCancel: () async {
await sub?.cancel();
await stopScan();
},
);
return controller.stream;
}
ScooterDevice _toDevice(fbp.ScanResult r) {
final name = r.advertisementData.advName.isNotEmpty
? r.advertisementData.advName
: r.device.platformName;
return ScooterDevice(
id: r.device.remoteId.str,
name: name,
rssi: r.rssi,
advertisedServiceUuids: r.advertisementData.serviceUuids
.map((g) => g.str128.toLowerCase())
.toSet(),
);
}
@override
Future<void> stopScan() async {
if (fbp.FlutterBluePlus.isScanningNow) {
_log('SCAN STOP');
await fbp.FlutterBluePlus.stopScan();
}
}
@override
Future<void> connect(String deviceId) async {
await disconnect();
final device = fbp.BluetoothDevice.fromId(deviceId);
_device = device;
_log('BLE CONNECT $deviceId');
_connSub = device.connectionState.skip(1).listen((s) {
_log('CONNECTION STATE $s');
if (s == fbp.BluetoothConnectionState.disconnected) {
_log('BLE DISCONNECTED $deviceId');
_services = const [];
_connState.add(BleConnectionState.disconnected);
} else if (s == fbp.BluetoothConnectionState.connected) {
_connState.add(BleConnectionState.connected);
}
});
try {
// FlutterBluePlus 2.x requires callers to declare which license they
// operate under. OpenScooter is a non-commercial open source project.
await device.connect(license: fbp.License.nonprofit);
_log('CONNECTED mtu=${device.mtuNow}');
} catch (e) {
_log('CONNECT FAILED $e');
await _connSub?.cancel();
_connSub = null;
_device = null;
rethrow;
}
}
@override
Future<void> disconnect() async {
final device = _device;
_device = null;
_services = const [];
await _connSub?.cancel();
_connSub = null;
if (device != null) {
_log('BLE DISCONNECT ${device.remoteId.str}');
try {
await device.disconnect();
} catch (_) {
// Already gone. Nothing to do.
}
_connState.add(BleConnectionState.disconnected);
}
}
@override
Future<Map<String, Set<String>>> discoverServices() async {
final device = _requireDevice();
_services = await device.discoverServices();
final map = <String, Set<String>>{};
for (final s in _services) {
map[s.uuid.str128.toLowerCase()] =
s.characteristics.map((c) => c.uuid.str128.toLowerCase()).toSet();
for (final c in s.characteristics) {
final p = c.properties;
final props = [
if (p.read) 'read',
if (p.write) 'write',
if (p.writeWithoutResponse) 'writeNoRsp',
if (p.notify) 'notify',
if (p.indicate) 'indicate',
].join(',');
_log('DISCOVERED ${_short(s.uuid.str128)}/${_short(c.uuid.str128)} [$props]');
}
}
_log('DISCOVERED ${map.length} services');
return map;
}
@override
Future<Stream<Uint8List>> subscribe({
required String serviceUuid,
required String characteristicUuid,
}) async {
final c = _characteristic(serviceUuid, characteristicUuid);
_log('SUBSCRIBE ${_short(characteristicUuid)}');
final ok = await c.setNotifyValue(true);
_log('SUBSCRIBE ${_short(characteristicUuid)} result=$ok');
return c.onValueReceived.map((v) => Uint8List.fromList(v));
}
@override
Future<void> write({
required String serviceUuid,
required String characteristicUuid,
required Uint8List value,
}) async {
final c = _characteristic(serviceUuid, characteristicUuid);
final withoutResponse =
!c.properties.write && c.properties.writeWithoutResponse;
_log('WRITE ${_short(characteristicUuid)} ${value.length} bytes withoutResponse=$withoutResponse');
await c.write(value, withoutResponse: withoutResponse);
}
fbp.BluetoothDevice _requireDevice() {
final d = _device;
if (d == null) throw StateError('No active BLE device');
return d;
}
fbp.BluetoothCharacteristic _characteristic(String service, String char) {
_requireDevice();
if (_services.isEmpty) {
throw StateError('discoverServices() must run before subscribe/write');
}
final s = BleClient.normalizeUuid(service);
final c = BleClient.normalizeUuid(char);
for (final svc in _services) {
if (svc.uuid.str128.toLowerCase() != s) continue;
for (final ch in svc.characteristics) {
if (ch.uuid.str128.toLowerCase() == c) return ch;
}
}
throw StateError('Characteristic ${_short(char)} not found');
}
static String _short(String uuid) =>
uuid.length >= 8 ? uuid.substring(4, 8).toUpperCase() : uuid;
void _log(String message) => ProtocolLog.instance.log('BLE', message);
}
+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));
}
}
+28
View File
@@ -0,0 +1,28 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// Remembers scooter PINs in platform secure storage (Android Keystore backed
/// storage, iOS Keychain), keyed by the BLE peripheral id.
///
/// The peripheral id is not a permanent scooter identity (see ScooterDevice).
/// Once the Apollo UID is read over AT+UID? this should key by that instead.
class PinStore {
PinStore([FlutterSecureStorage? storage])
: _storage = storage ?? const FlutterSecureStorage();
final FlutterSecureStorage _storage;
String _key(String deviceId) => 'pin:$deviceId';
Future<String?> read(String deviceId) async {
try {
return await _storage.read(key: _key(deviceId));
} catch (_) {
return null;
}
}
Future<void> save(String deviceId, String pin) =>
_storage.write(key: _key(deviceId), value: pin);
Future<void> forget(String deviceId) => _storage.delete(key: _key(deviceId));
}
+67
View File
@@ -0,0 +1,67 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
/// Persistent protocol log for field debugging without a USB cable.
///
/// Every line is kept in a bounded in-memory ring (viewable in-app) and, once
/// [init] has run, appended to a file that can be pulled with adb:
///
/// adb pull /sdcard/Android/data/dev.teamhydra.openscooter/files/openscooter.log
///
/// Before [init], or in tests, only the in-memory ring is used.
/// The PIN is never written here; callers mask it first.
class ProtocolLog extends ChangeNotifier {
ProtocolLog._();
static final ProtocolLog instance = ProtocolLog._();
static const maxLines = 2000;
final List<String> _lines = <String>[];
File? _file;
IOSink? _sink;
List<String> get lines => List.unmodifiable(_lines);
String? get path => _file?.path;
Future<void> init() async {
if (_file != null) return;
try {
Directory? dir;
if (Platform.isAndroid) dir = await getExternalStorageDirectory();
dir ??= await getApplicationDocumentsDirectory();
_file = File('${dir.path}/openscooter.log');
_sink = _file!.openWrite(mode: FileMode.append);
log('LOG', 'opened ${_file!.path}');
} catch (e) {
debugPrint('ProtocolLog: could not open log file: $e');
}
}
void log(String tag, String message) {
final line = '${DateTime.now().toIso8601String()} [$tag] $message';
if (kDebugMode) debugPrint(line);
_lines.add(line);
if (_lines.length > maxLines) _lines.removeRange(0, _lines.length - maxLines);
_sink?.writeln(line);
notifyListeners();
}
Future<void> clear() async {
_lines.clear();
await _sink?.flush();
await _sink?.close();
_sink = null;
final f = _file;
if (f != null) {
try {
await f.writeAsString('');
} catch (_) {}
_sink = f.openWrite(mode: FileMode.append);
}
notifyListeners();
}
Future<void> flush() async => _sink?.flush();
}