import 'dart:async'; import 'dart:io'; import 'dart:typed_data'; import 'package:flutter_classic_bluetooth/flutter_classic_bluetooth.dart'; import 'package:permission_handler/permission_handler.dart'; import '../link_transport.dart'; import 'bluetooth_device_info.dart'; import 'bluetooth_device_selector.dart'; /// Bluetooth-Classic-(SPP)-Transport (Doku Kommunikationsschicht Abschnitt 3): /// verbindet sich mit dem RadioMaster-Pocket-Backpack (mLRS Tx) ueber RFCOMM. /// Setzt den in Abschnitt 3 beschriebenen Verbindungsablauf um: /// /// 1. Bluetooth-Adapter an? (kann ab Android 12 nicht mehr programmatisch /// eingeschaltet werden - meldet stattdessen [LinkErrorReason.bluetoothOff]). /// 2. Geraet bestimmen, ohne zu scannen: gemerkte MAC-Adresse, sonst erstes /// gekoppeltes Geraet mit "mLRS"-Praefix, sonst /// [LinkErrorReason.deviceSelectionRequired] (Oberflaeche zeigt /// Auswahlliste, siehe [listBondedDevices]). /// 3. Laufende Discovery abbrechen, falls eine laeuft. /// 4. Socket oeffnen; danach uebernimmt der eingebaute Reconnect-Mechanismus /// des Plugins (exponentielles Backoff 1s -> 30s) das Wiederverbinden bei /// Verbindungsabbruch. class BluetoothClassicTransport implements LinkTransport { BluetoothClassicTransport({ required Future Function() getRememberedAddress, required Future Function(String address) rememberAddress, FlutterClassicBluetooth? bluetooth, }) : _getRememberedAddress = getRememberedAddress, _rememberAddress = rememberAddress, _bluetooth = bluetooth ?? FlutterClassicBluetooth(); final Future Function() _getRememberedAddress; final Future Function(String address) _rememberAddress; final FlutterClassicBluetooth _bluetooth; final _incomingController = StreamController.broadcast(); final _stateController = StreamController.broadcast(); BtcReconnectingConnection? _link; StreamSubscription? _linkDataSub; StreamSubscription? _linkStateSub; BluetoothDeviceInfo? _connectedDevice; LinkState _state = LinkState.disconnected; LinkErrorReason? _lastErrorReason; String? _lastErrorMessage; @override Stream get incoming => _incomingController.stream; @override Stream get state => _stateController.stream; @override LinkErrorReason? get lastErrorReason => _lastErrorReason; @override String? get lastErrorMessage => _lastErrorMessage; /// Das aktuell verbundene (oder zuletzt verbundene) Geraet, falls bekannt. BluetoothDeviceInfo? get connectedDevice => _connectedDevice; /// Gekoppelte Geraete fuer die manuelle Auswahlliste (Doku 3.2c). Scannt /// nicht - liefert nur bereits gekoppelte Geraete, das ist sofort verfuegbar. Future> listBondedDevices() async { final devices = await _bluetooth.getPairedDevices(); return [ for (final d in devices) BluetoothDeviceInfo(address: d.address, name: d.name ?? d.alias), ]; } @override Future connect() async { _lastErrorReason = null; _lastErrorMessage = null; _setState(LinkState.connecting); try { if (!await _bluetooth.isEnabled()) { _fail( LinkErrorReason.bluetoothOff, 'Bluetooth-Adapter ist aus. Kann ab Android 12 nicht mehr aus der ' 'App heraus eingeschaltet werden.', ); } final permissionOutcome = await _ensureConnectPermission(); if (permissionOutcome == _PermissionOutcome.deniedPermanently) { _fail( LinkErrorReason.permissionDeniedPermanently, '"Geraete in der Naehe"-Berechtigung dauerhaft abgelehnt.', ); } if (permissionOutcome == _PermissionOutcome.denied) { _fail( LinkErrorReason.permissionDenied, '"Geraete in der Naehe"-Berechtigung abgelehnt.', ); } final bonded = await listBondedDevices(); final remembered = await _getRememberedAddress(); final selection = selectBluetoothDevice( bondedDevices: bonded, rememberedAddress: remembered, ); if (selection is BluetoothDeviceSelectionRequired) { _fail( LinkErrorReason.deviceSelectionRequired, 'Kein gekoppeltes mLRS-Geraet automatisch bestimmbar ' '(${bonded.length} gekoppelte Geraete zur Auswahl).', ); } final device = (selection as BluetoothDeviceResolved).device; if (device.address != remembered) { await _rememberAddress(device.address); } try { if (await _bluetooth.isDiscovering()) { await _bluetooth.stopDiscovery(); } } catch (_) { // Discovery laeuft ohnehin nicht (mehr) - unkritisch (Doku 3.3). } _connectedDevice = device; final link = _bluetooth.connectWithReconnect( address: device.address, uuid: BtcUuid.spp, ); _link = link; _linkStateSub = link.state.listen(_onReconnectStateChanged); _linkDataSub = link.input.listen(_incomingController.add); } on BtcException catch (e) { _fail(_reasonForException(e), e.message); } } @override Future disconnect() async { await _linkDataSub?.cancel(); _linkDataSub = null; await _linkStateSub?.cancel(); _linkStateSub = null; await _link?.close(); _link = null; _setState(LinkState.disconnected); } @override Future send(Uint8List data) async { final link = _link; if (link == null) { throw const LinkConnectException( LinkErrorReason.connectionFailed, 'send() ohne aktive Verbindung aufgerufen.', ); } await link.send(data); } @override void dispose() { _linkDataSub?.cancel(); _linkStateSub?.cancel(); unawaited(_link?.close()); _link = null; _incomingController.close(); _stateController.close(); } void _onReconnectStateChanged(BtcReconnectState s) { switch (s) { case BtcReconnectState.connecting: case BtcReconnectState.reconnecting: _setState(LinkState.connecting); case BtcReconnectState.connected: _lastErrorReason = null; _lastErrorMessage = null; _setState(LinkState.connected); case BtcReconnectState.closed: _setState(LinkState.disconnected); case BtcReconnectState.failed: // Nur erreichbar, wenn BtcReconnectPolicy.maxAttempts gesetzt waere; // die Standardpolicy (Doku 3, Robustheit: 1s->30s, unbegrenzt) haelt // stattdessen einfach LinkState.connecting, bis es klappt oder // disconnect() gerufen wird. Trotzdem hier behandelt, falls die // Policy kuenftig eine Obergrenze bekommt. _setError( LinkErrorReason.connectionFailed, _link?.lastError?.toString() ?? 'Verbindung fehlgeschlagen.', ); } } /// Setzt Fehlergrund/-nachricht und meldet [LinkState.error] ueber den /// Zustands-Stream, ohne selbst zu werfen - fuer asynchrone Beobachter /// (z.B. den Reconnect-Zustands-Listener). void _setError(LinkErrorReason reason, String message) { _lastErrorReason = reason; _lastErrorMessage = message; _setState(LinkState.error); } /// Wie [_setError], wirft danach zusaetzlich eine [LinkConnectException] /// mit demselben Grund - siehe dortige Doku. Nur fuer Fehler, die /// synchron innerhalb von [connect] erkannt werden. Never _fail(LinkErrorReason reason, String message) { _setError(reason, message); throw LinkConnectException(reason, message); } void _setState(LinkState next) { _state = next; if (!_stateController.isClosed) _stateController.add(next); } LinkState get currentState => _state; Future<_PermissionOutcome> _ensureConnectPermission() async { // MVP ist auf Android beschraenkt (Doku CLAUDE.md); auf anderen // Plattformen (z.B. Windows-Desktop waehrend der UI-Entwicklung) gibt es // keine "Geraete in der Naehe"-Laufzeitberechtigung zu pruefen. if (!Platform.isAndroid) return _PermissionOutcome.granted; var status = await Permission.bluetoothConnect.status; if (status.isGranted) return _PermissionOutcome.granted; if (status.isPermanentlyDenied) { return _PermissionOutcome.deniedPermanently; } status = await Permission.bluetoothConnect.request(); if (status.isGranted) return _PermissionOutcome.granted; if (status.isPermanentlyDenied) { return _PermissionOutcome.deniedPermanently; } return _PermissionOutcome.denied; } LinkErrorReason _reasonForException(BtcException e) { if (e is BtcPermissionException) return LinkErrorReason.permissionDenied; if (e is BtcDisabledException) return LinkErrorReason.bluetoothOff; if (e is BtcConnectionException || e is BtcTimeoutException) { return LinkErrorReason.connectionFailed; } return LinkErrorReason.unknown; } } enum _PermissionOutcome { granted, denied, deniedPermanently }