import 'dart:async'; import 'package:flutter/foundation.dart'; import '../models/scooter_device.dart'; import '../models/scooter_state.dart'; import '../services/ble_client.dart'; import '../services/protocol_log.dart'; import 'apollo_protocol.dart'; import 'scooter.dart'; /// HARD WRITE GATE. /// /// Control writes to F1F1 stay disabled until the set-base packet has been /// compared byte-for-byte against an HCI capture of the official Apollo app /// talking to a physical Apollo Go (brief sections 78 and 79). Static analysis /// alone is not sufficient to flip this. const bool enableApolloControlWrites = true; class _PendingControl { _PendingControl({required this.minRevision, required this.isSatisfied}); final int minRevision; final bool Function(ApolloMonitorData) isSatisfied; final completer = Completer(); } /// Apollo Go (and protocol-compatible Apollo models) over BLE. /// /// Flow: connect -> discover -> verify F1/F2 -> subscribe F1F2 and F2F2 -> /// authenticate (AT+PWD) -> wait for pushed cmd0 + cmd1 -> ready. class ApolloScooter extends Scooter { ApolloScooter( this._ble, this.device, { this.controlWritesEnabled = enableApolloControlWrites, Duration? keepaliveInterval = apolloDefaultKeepaliveInterval, this.responseTimeout = const Duration(seconds: 2), }) : _keepaliveInterval = keepaliveInterval; // ignore: prefer_initializing_formals final BleClient _ble; final ScooterDevice device; /// Per-instance mirror of [enableApolloControlWrites]; tests may enable it. final bool controlWritesEnabled; /// Keepalive cadence. LIVE VERIFIED: the Apollo Go pushes no telemetry /// until keepalives start, so this defaults on. Null disables it. Duration? get keepaliveInterval => _keepaliveInterval; Duration? _keepaliveInterval; /// Changes the keepalive cadence at runtime. Null stops it. void setKeepaliveInterval(Duration? interval) { _keepaliveInterval = interval; _keepaliveTimer?.cancel(); _keepaliveTimer = null; if (interval != null && _sessionActive) { _log('KEEPALIVE every ${interval.inMilliseconds} ms'); _sendKeepalive(); _keepaliveTimer = Timer.periodic(interval, (_) => _sendKeepalive()); } else { _log('KEEPALIVE off'); } if (!_disposed) notifyListeners(); } final Duration responseTimeout; /// Primary Apollo detection uses advertised service UUIDs because owners can /// rename the scooter with AT+NAME. Either Apollo service is enough for v1. static bool matches(ScooterDevice d) => d.advertisedServiceUuids.contains(apolloDataServiceUuid) || d.advertisedServiceUuids.contains(apolloAtServiceUuid); /// Weak fallback hint only. Never sufficient on its own to send anything. static bool nameHint(ScooterDevice d) => d.name.toLowerCase().contains('apollo'); // ---- observable state ---------------------------------------------------- ScooterState _state = const ScooterState(); bool _disposed = false; @override ScooterState get state => _state; @override bool get canWrite => controlWritesEnabled && _sessionActive && _state.authenticated && _monitor != null && _base != null; // ---- per-connection session state (all reset on ANY disconnect) ---------- bool _sessionActive = false; StreamSubscription? _connSub; StreamSubscription? _dataSub; StreamSubscription? _atSub; final _frames = ApolloFrameBuffer(); final _at = ApolloAtBuffer(); ApolloMonitorData? _monitor; Uint8List? _lastMonitorFrame; ApolloBaseData? _base; /// Incremented for every CRC-valid cmd0 frame. Control confirmation only /// accepts frames newer than the one seen before the write was sent. int _monitorRevision = 0; Completer? _pendingAuth; _PendingControl? _pendingControl; Future _writeQueue = Future.value(); Timer? _keepaliveTimer; // ---- connection ---------------------------------------------------------- @override Future connect() async { if (_sessionActive || _state.connectionStatus == ScooterConnectionStatus.connecting) { throw StateError('Already connected or connecting'); } _set(const ScooterState( connectionStatus: ScooterConnectionStatus.connecting, connectionStep: 0, )); try { await _ble.connect(device.id); _connSub = _ble.connectionState.listen((s) { if (s == BleConnectionState.disconnected) _onConnectionLost(); }); _set(_state.copyWith(connectionStep: 1)); final services = await _ble.discoverServices(); _set(_state.copyWith(connectionStep: 2)); _verifyGatt(services); // Subscribe BEFORE authenticating: base/monitor frames are pushed, and // there is no "read base params" command to request them later. _set(_state.copyWith(connectionStep: 3)); _dataSub = (await _ble.subscribe( serviceUuid: apolloDataServiceUuid, characteristicUuid: apolloDataRxUuid, )) .listen(_onData); _set(_state.copyWith(connectionStep: 4)); _atSub = (await _ble.subscribe( serviceUuid: apolloAtServiceUuid, characteristicUuid: apolloAtRxUuid, )) .listen(_onAt); _sessionActive = true; _set(_state.copyWith( connectionStatus: ScooterConnectionStatus.connected, connectionStep: null, )); final interval = _keepaliveInterval; if (interval != null) { _keepaliveTimer = Timer.periodic(interval, (_) => _sendKeepalive()); } } catch (e) { _resetSession(e); try { await _ble.disconnect(); } catch (_) {} _set(ScooterState( connectionStatus: ScooterConnectionStatus.error, errorMessage: 'Could not connect: $e', )); rethrow; } } void _verifyGatt(Map> services) { final data = services[apolloDataServiceUuid]; final at = services[apolloAtServiceUuid]; final missing = [ if (data == null) 'F1F0 service', if (data != null && !data.contains(apolloDataTxUuid)) 'F1F1', if (data != null && !data.contains(apolloDataRxUuid)) 'F1F2', if (at == null) 'F2F0 service', if (at != null && !at.contains(apolloAtTxUuid)) 'F2F1', if (at != null && !at.contains(apolloAtRxUuid)) 'F2F2', ]; if (missing.isNotEmpty) { throw StateError('Not an Apollo scooter: missing ${missing.join(', ')}'); } } @override Future disconnect() async { _resetSession(const ScooterConnectionLostException('Disconnected by user')); try { await _ble.disconnect(); } catch (_) {} _set(const ScooterState(connectionStatus: ScooterConnectionStatus.disconnected)); } void _onConnectionLost() { if (!_sessionActive) return; _log('CONNECTION LOST'); _resetSession(const ScooterConnectionLostException()); _set(const ScooterState( connectionStatus: ScooterConnectionStatus.error, errorMessage: 'Connection to the scooter was lost.', )); } /// Clears EVERYTHING tied to the physical BLE link. Data from an earlier /// connection is never reused. void _resetSession(Object error) { _sessionActive = false; _keepaliveTimer?.cancel(); _keepaliveTimer = null; _connSub?.cancel(); _dataSub?.cancel(); _atSub?.cancel(); _connSub = _dataSub = _atSub = null; _frames.clear(); _at.clear(); _monitor = null; _lastMonitorFrame = null; _base = null; _monitorRevision = 0; final auth = _pendingAuth; _pendingAuth = null; if (auth != null && !auth.isCompleted) auth.completeError(error); final control = _pendingControl; _pendingControl = null; if (control != null && !control.completer.isCompleted) { control.completer.completeError(error); } // Queued controls fail on their turn because canWrite is now false. } @override Future disposeScooter() async { await disconnect(); _disposed = true; dispose(); } // ---- authentication ------------------------------------------------------ @override Future authenticate(String credential) async { if (!_sessionActive) throw StateError('Not connected'); if (_pendingAuth != null) throw StateError('Authentication already in progress'); final command = buildApolloPinCommand(credential); // validates format final completer = Completer(); _pendingAuth = completer; _at.clear(); _set(_state.copyWith( connectionStatus: ScooterConnectionStatus.authenticating, authenticated: false, errorMessage: null, )); try { _log('TX AT $apolloPinCommandMasked'); await _ble.write( serviceUuid: apolloAtServiceUuid, characteristicUuid: apolloAtTxUuid, value: command, ); final result = await completer.future.timeout( responseTimeout, onTimeout: () => throw TimeoutException('The scooter did not respond to the PIN'), ); if (result == AuthenticationResult.success) { _set(_state.copyWith( connectionStatus: ScooterConnectionStatus.authenticated, authenticated: true, )); _evaluateReady(); } else { _set(_state.copyWith( connectionStatus: ScooterConnectionStatus.connected, authenticated: false, )); } return result; } catch (e) { if (_sessionActive) { _set(_state.copyWith( connectionStatus: ScooterConnectionStatus.connected, authenticated: false, )); } rethrow; } finally { if (identical(_pendingAuth, completer)) _pendingAuth = null; } } void _onAt(Uint8List data) { _log('RX AT raw ${apolloHex(data)} "${String.fromCharCodes(data.where((b) => b >= 0x20 && b < 0x7F))}"'); _at.add(data); final result = parseApolloPinResponse(_at.bytes); if (result == null) return; _at.clear(); final pending = _pendingAuth; if (pending != null && !pending.isCompleted) pending.complete(result); } // ---- inbound binary frames ----------------------------------------------- void _onData(Uint8List data) { _log('RX DATA raw ${apolloHex(data)} (${data.length} bytes)'); final frames = _frames.add(data); if (frames.isEmpty) { _log('RX DATA no complete frame yet, ${_frames.length} bytes buffered'); } for (final frame in frames) { if (frame[0] == 0xA5) { _log('RX DATA ${apolloHex(frame)} (keepalive)'); continue; } switch (frame[1]) { case 0x00: _log('RX DATA ${apolloHex(frame)} cmd0 monitor crc=ok'); _handleMonitor(frame); case 0x01: _log('RX DATA ${apolloHex(frame)} cmd1 base crc=ok'); _handleBase(frame); } } } void _handleMonitor(Uint8List frame) { _lastMonitorFrame = frame; final m = parseApolloMonitorFrame( frame, internalSpeedScalingFlag: _base?.internalSpeedScalingFlag ?? false, ); _monitor = m; _monitorRevision++; _publishTelemetry(); final pending = _pendingControl; if (pending != null && !pending.completer.isCompleted && _monitorRevision > pending.minRevision && pending.isSatisfied(m)) { pending.completer.complete(); } } void _handleBase(Uint8List frame) { _base = parseApolloBaseFrame(frame); // Speed scaling depends on the base frame, so re-derive the last monitor // snapshot. This is not a new monitor frame: revision is unchanged. final last = _lastMonitorFrame; if (last != null) { _monitor = parseApolloMonitorFrame( last, internalSpeedScalingFlag: _base!.internalSpeedScalingFlag, ); } _publishTelemetry(); } void _publishTelemetry() { final m = _monitor; final b = _base; var s = _state; if (m != null) { s = s.copyWith( gear: m.gear, batteryLevel: m.batteryLevel, speed: m.speed, voltage: m.voltage, current: m.current, power: m.power, motorTemperature: m.motorTemperature, controllerTemperature: m.controllerTemperature, tripDistance: m.tripDistance, odometer: m.odometer, locked: !m.unlocked, headlight: m.headlight, atmosphereLight: m.atmosphereLight, cruiseControl: m.cruiseControl, leftTurnSignal: m.leftTurnSignal, rightTurnSignal: m.rightTurnSignal, imperial: m.imperial, ); } if (b != null) { final limits = [b.limitMode1, b.limitMode2, b.limitMode3]; final gear = m?.gear; s = s.copyWith( // INFERRED: gear byte 1..3 selects mode 1..3 limits. speedLimit: gear != null && gear >= 1 && gear <= 3 ? limits[gear - 1] : null, maxSpeedLimit: limits.reduce((a, c) => a > c ? a : c), batteryTemperature: b.batteryTemperature, batteryCycles: b.batteryCycles, displayId: b.displayId, displayVersion: b.displayVersion, ); } _state = s; _evaluateReady(); } /// READY requires PIN success AND a CRC-valid cmd0 AND a CRC-valid cmd1. void _evaluateReady() { var s = _state; if (s.authenticated && _monitor != null && _base != null) { s = s.copyWith(connectionStatus: ScooterConnectionStatus.ready); } _set(s.copyWith(canWrite: canWrite)); } // ---- control writes (gated, serialized, confirmed) ----------------------- @override Future unlock() => _control('unlock', (m) => m.unlocked, unlocked: true); @override Future lock() => _control('lock', (m) => !m.unlocked, unlocked: false); @override Future setHeadlight(bool enabled) => _control('headlight=$enabled', (m) => m.headlight == enabled, headlight: enabled); /// Queues a control change. Only one base write is in flight at a time and /// each one reads its snapshot only when its turn comes, so a second tap can /// never resend stale state from before the first write landed. Future _control( String name, bool Function(ApolloMonitorData) isSatisfied, { bool? unlocked, bool? headlight, }) { final run = _writeQueue.then( (_) => _runControl(name, isSatisfied, unlocked: unlocked, headlight: headlight), ); _writeQueue = run.catchError((_) {}); return run; } Future _runControl( String name, bool Function(ApolloMonitorData) isSatisfied, { bool? unlocked, bool? headlight, }) async { if (!controlWritesEnabled) { throw StateError('Control writes are disabled in this build'); } if (!canWrite) { throw StateError('Scooter is not ready for control writes'); } final m = _monitor!; final b = _base!; if (isSatisfied(m)) { _log('CONTROL $name: already in requested state, no write'); return; } // Read-modify-write: every field not being changed comes from the most // recent frames. gearPosition <- monitor gear byte is INFERRED. final packet = buildApolloSetBasePacket( gearPosition: m.gear, headlight: headlight ?? m.headlight, atmosphereLight: m.atmosphereLight, cruiseControl: m.cruiseControl, bootMode: m.bootMode, imperial: m.imperial, unlocked: unlocked ?? m.unlocked, limitCruise: b.limitCruise, limitMode1: b.limitMode1, limitMode2: b.limitMode2, limitMode3: b.limitMode3, ); final pending = _PendingControl(minRevision: _monitorRevision, isSatisfied: isSatisfied); _pendingControl = pending; try { _log('TX DATA ${apolloHex(packet)} set-base ($name)'); await _ble.write( serviceUuid: apolloDataServiceUuid, characteristicUuid: apolloDataTxUuid, value: packet, ); await pending.completer.future.timeout( responseTimeout, onTimeout: () => throw TimeoutException('Scooter did not confirm state change'), ); } finally { if (identical(_pendingControl, pending)) _pendingControl = null; } } Future _sendKeepalive() async { if (!_sessionActive) return; try { _log('TX DATA ${apolloHex(apolloKeepalivePacket)} keepalive'); await _ble.write( serviceUuid: apolloDataServiceUuid, characteristicUuid: apolloDataTxUuid, value: apolloKeepalivePacket, ); } catch (e) { _log('keepalive failed: $e'); } } // ---- helpers ------------------------------------------------------------- void _set(ScooterState s) { _state = s; if (!_disposed) notifyListeners(); } void _log(String message) => ProtocolLog.instance.log('Apollo', message); }