Initial version
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user