From d53a1905b1a2cc2c8059de3032876edb093e0831 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 24 Dec 2025 08:48:04 +0000 Subject: [PATCH] Create base classes MdnsEmulator and BluetoothEmulator with shared logic Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com> --- lib/bluetooth/devices/bluetooth_emulator.dart | 174 ++++++++++++++++++ lib/bluetooth/devices/mdns_emulator.dart | 153 +++++++++++++++ .../openbikecontrol/obc_ble_emulator.dart | 67 +++---- .../openbikecontrol/obc_mdns_emulator.dart | 77 ++------ .../devices/zwift/ftms_mdns_emulator.dart | 111 ++--------- .../devices/zwift/zwift_emulator.dart | 119 +++++------- lib/bluetooth/remote_pairing.dart | 88 +++------ 7 files changed, 460 insertions(+), 329 deletions(-) create mode 100644 lib/bluetooth/devices/bluetooth_emulator.dart create mode 100644 lib/bluetooth/devices/mdns_emulator.dart diff --git a/lib/bluetooth/devices/bluetooth_emulator.dart b/lib/bluetooth/devices/bluetooth_emulator.dart new file mode 100644 index 0000000..70faa89 --- /dev/null +++ b/lib/bluetooth/devices/bluetooth_emulator.dart @@ -0,0 +1,174 @@ +import 'dart:io'; + +import 'package:bike_control/bluetooth/devices/trainer_connection.dart'; +import 'package:bike_control/bluetooth/messages/notification.dart'; +import 'package:bike_control/gen/l10n.dart'; +import 'package:bike_control/utils/actions/base_actions.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/keymap/buttons.dart'; +import 'package:bluetooth_low_energy/bluetooth_low_energy.dart'; +import 'package:flutter/foundation.dart'; +import 'package:permission_handler/permission_handler.dart'; + +/// Base class for Bluetooth Low Energy (BLE) peripheral emulators. +/// Provides common functionality for peripheral management, service advertising, +/// and connection state handling. +abstract class BluetoothEmulator extends TrainerConnection { + final _peripheralManager = PeripheralManager(); + bool _isLoading = false; + bool _isServiceAdded = false; + bool _isSubscribedToEvents = false; + Central? _central; + + BluetoothEmulator({ + required super.title, + required super.supportedActions, + }); + + /// Gets the peripheral manager instance. + @protected + PeripheralManager get peripheralManager => _peripheralManager; + + /// Gets whether the emulator is currently loading/initializing. + bool get isLoading => _isLoading; + + /// Sets the loading state. + @protected + set isLoading(bool value) => _isLoading = value; + + /// Gets whether services have been added to the peripheral manager. + @protected + bool get isServiceAdded => _isServiceAdded; + + /// Sets whether services have been added. + @protected + set isServiceAdded(bool value) => _isServiceAdded = value; + + /// Gets whether event subscriptions are active. + @protected + bool get isSubscribedToEvents => _isSubscribedToEvents; + + /// Sets whether event subscriptions are active. + @protected + set isSubscribedToEvents(bool value) => _isSubscribedToEvents = value; + + /// Gets the current connected central device. + @protected + Central? get central => _central; + + /// Sets the connected central device. + @protected + set central(Central? value) => _central = value; + + /// Subscribes to peripheral manager state changes. + @protected + void subscribeToStateChanges() { + _peripheralManager.stateChanged.forEach((state) { + if (kDebugMode) { + print('Peripheral manager state: ${state.state}'); + } + }); + } + + /// Subscribes to connection state changes on Android. + /// Handles connection and disconnection events. + @protected + void subscribeToConnectionStateChanges(VoidCallback? onUpdate) { + if (!kIsWeb && Platform.isAndroid) { + _peripheralManager.connectionStateChanged.forEach((state) { + if (kDebugMode) { + print('Peripheral connection state: ${state.state} of ${state.central.uuid}'); + } + if (state.state == ConnectionState.connected) { + // Override in subclass if needed + } else if (state.state == ConnectionState.disconnected) { + handleDisconnection(); + onUpdate?.call(); + } + }); + } + } + + /// Handles disconnection events. Can be overridden by subclasses. + @protected + void handleDisconnection() { + _central = null; + isConnected.value = false; + core.connection.signalNotification( + AlertNotification(LogLevel.LOGLEVEL_INFO, AppLocalizations.current.disconnected), + ); + } + + /// Requests Bluetooth advertise permission on Android. + /// Returns true if permission is granted, false otherwise. + @protected + Future requestBluetoothAdvertisePermission() async { + if (!kIsWeb && Platform.isAndroid) { + final status = await Permission.bluetoothAdvertise.request(); + if (!status.isGranted) { + if (kDebugMode) { + print('Bluetooth advertise permission not granted'); + } + return false; + } + } + return true; + } + + /// Waits for the peripheral manager to be powered on. + /// Returns true if powered on, false if cancelled (e.g., by user action). + @protected + Future waitForPoweredOn(bool Function() shouldContinue) async { + while (_peripheralManager.state != BluetoothLowEnergyState.poweredOn && shouldContinue()) { + if (kDebugMode) { + print('Waiting for peripheral manager to be powered on...'); + } + await Future.delayed(const Duration(seconds: 1)); + } + return _peripheralManager.state == BluetoothLowEnergyState.poweredOn; + } + + /// Adds a GATT service to the peripheral manager. + @protected + Future addService(GATTService service) async { + await _peripheralManager.addService(service); + } + + /// Starts advertising with the given advertisement configuration. + @protected + Future startAdvertising(Advertisement advertisement) async { + await _peripheralManager.startAdvertising(advertisement); + } + + /// Stops advertising and resets state. + @protected + Future stopAdvertising() async { + await _peripheralManager.stopAdvertising(); + isStarted.value = false; + isConnected.value = false; + _isLoading = false; + } + + /// Notifies a characteristic with the given value to the connected central. + @protected + Future notifyCharacteristic( + Central central, + GATTCharacteristic characteristic, { + required Uint8List value, + }) async { + await _peripheralManager.notifyCharacteristic(central, characteristic, value: value); + } + + /// Cleans up resources by stopping advertising and removing services. + @protected + void cleanup() { + _peripheralManager.stopAdvertising(); + _peripheralManager.removeAllServices(); + _isServiceAdded = false; + _isSubscribedToEvents = false; + _central = null; + isConnected.value = false; + isStarted.value = false; + _isLoading = false; + } +} diff --git a/lib/bluetooth/devices/mdns_emulator.dart b/lib/bluetooth/devices/mdns_emulator.dart new file mode 100644 index 0000000..51a7f6b --- /dev/null +++ b/lib/bluetooth/devices/mdns_emulator.dart @@ -0,0 +1,153 @@ +import 'dart:io'; + +import 'package:bike_control/bluetooth/devices/trainer_connection.dart'; +import 'package:bike_control/utils/keymap/buttons.dart'; +import 'package:flutter/foundation.dart'; +import 'package:nsd/nsd.dart'; + +/// Base class for mDNS-based emulators that advertise services over the network. +/// Provides common functionality for TCP server management, mDNS registration, +/// and network interface discovery. +abstract class MdnsEmulator extends TrainerConnection { + ServerSocket? _tcpServer; + Registration? _mdnsRegistration; + Socket? _socket; + + MdnsEmulator({ + required super.title, + required super.supportedActions, + }); + + /// Gets the TCP server instance, if available. + @protected + ServerSocket? get tcpServer => _tcpServer; + + /// Gets the current client socket connection, if available. + @protected + Socket? get socket => _socket; + + /// Sets the client socket connection. + @protected + set socket(Socket? value) => _socket = value; + + /// Gets the mDNS registration, if available. + @protected + Registration? get mdnsRegistration => _mdnsRegistration; + + /// Finds and returns a local IPv4 address from available network interfaces. + /// Returns null if no suitable address is found. + @protected + Future findLocalIP() async { + final interfaces = await NetworkInterface.list(); + InternetAddress? localIP; + + for (final interface in interfaces) { + for (final addr in interface.addresses) { + if (addr.type == InternetAddressType.IPv4 && !addr.isLoopback) { + localIP = addr; + break; + } + } + if (localIP != null) break; + } + + return localIP; + } + + /// Creates a TCP server on the specified port. + /// The server listens on all IPv6 addresses (with v6Only: false to also accept IPv4). + @protected + Future createTcpServer(int port) async { + try { + _tcpServer = await ServerSocket.bind( + InternetAddress.anyIPv6, + port, + shared: true, + v6Only: false, + ); + if (kDebugMode) { + print('TCP Server started on port ${_tcpServer!.port}'); + } + return _tcpServer!; + } catch (e) { + if (kDebugMode) { + print('Failed to start server: $e'); + } + rethrow; + } + } + + /// Registers an mDNS service with the given configuration. + @protected + Future registerMdnsService(Service service) async { + if (kDebugMode) { + enableLogging(LogTopic.calls); + enableLogging(LogTopic.errors); + } + disableServiceTypeValidation(true); + + _mdnsRegistration = await register(service); + if (kDebugMode) { + print('mDNS service registered: ${service.name}'); + } + return _mdnsRegistration!; + } + + /// Unregisters the mDNS service if one is registered. + @protected + void unregisterMdnsService() { + if (_mdnsRegistration != null) { + unregister(_mdnsRegistration!); + _mdnsRegistration = null; + } + } + + /// Closes the TCP server and client socket. + @protected + void closeTcpServer() { + _socket?.destroy(); + _socket = null; + _tcpServer?.close(); + _tcpServer = null; + } + + /// Writes data to the client socket. + @protected + void writeToSocket(Socket socket, List data) { + if (kDebugMode) { + print('Sending response: ${bytesToHex(data)}'); + } + socket.add(data); + } + + /// Stops the emulator by closing connections and unregistering services. + void stop() { + isStarted.value = false; + isConnected.value = false; + closeTcpServer(); + unregisterMdnsService(); + if (kDebugMode) { + print('Stopped ${runtimeType}'); + } + } +} + +/// Converts a list of bytes to a hexadecimal string representation. +String bytesToHex(List bytes, {bool spaced = false}) { + return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(spaced ? ' ' : ''); +} + +/// Converts a list of bytes to a readable hexadecimal string with spaces. +String bytesToReadableHex(List bytes) { + return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(' '); +} + +/// Converts a hexadecimal string to a list of bytes. +List hexToBytes(String hex) { + final bytes = []; + for (var i = 0; i < hex.length; i += 2) { + final byte = hex.substring(i, i + 2); + bytes.add(int.parse(byte, radix: 16)); + } + return bytes; +} diff --git a/lib/bluetooth/devices/openbikecontrol/obc_ble_emulator.dart b/lib/bluetooth/devices/openbikecontrol/obc_ble_emulator.dart index b141723..8067e22 100644 --- a/lib/bluetooth/devices/openbikecontrol/obc_ble_emulator.dart +++ b/lib/bluetooth/devices/openbikecontrol/obc_ble_emulator.dart @@ -1,8 +1,8 @@ import 'dart:io'; +import 'package:bike_control/bluetooth/devices/bluetooth_emulator.dart'; import 'package:bike_control/bluetooth/devices/openbikecontrol/openbikecontrol_device.dart'; import 'package:bike_control/bluetooth/devices/openbikecontrol/protocol_parser.dart'; -import 'package:bike_control/bluetooth/devices/trainer_connection.dart'; import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pbenum.dart'; import 'package:bike_control/utils/actions/base_actions.dart'; import 'package:bike_control/utils/core.dart'; @@ -15,12 +15,8 @@ import 'package:flutter/foundation.dart'; import '../../messages/notification.dart' show AlertNotification; -class OpenBikeControlBluetoothEmulator extends TrainerConnection { - late final _peripheralManager = PeripheralManager(); +class OpenBikeControlBluetoothEmulator extends BluetoothEmulator { final ValueNotifier connectedApp = ValueNotifier(null); - bool _isServiceAdded = false; - bool _isSubscribedToEvents = false; - Central? _central; late GATTCharacteristic _buttonCharacteristic; @@ -35,13 +31,13 @@ class OpenBikeControlBluetoothEmulator extends TrainerConnection { Future startServer() async { isStarted.value = true; - _peripheralManager.stateChanged.forEach((state) { - print('Peripheral manager state: ${state.state}'); - }); + subscribeToStateChanges(); if (!kIsWeb && Platform.isAndroid) { - _peripheralManager.connectionStateChanged.forEach((state) { - print('Peripheral connection state: ${state.state} of ${state.central.uuid}'); + peripheralManager.connectionStateChanged.forEach((state) { + if (kDebugMode) { + print('Peripheral connection state: ${state.state} of ${state.central.uuid}'); + } if (state.state == ConnectionState.connected) { } else if (state.state == ConnectionState.disconnected) { if (connectedApp.value != null) { @@ -51,14 +47,13 @@ class OpenBikeControlBluetoothEmulator extends TrainerConnection { } isConnected.value = false; connectedApp.value = null; - _central = null; + central = null; } }); } - while (_peripheralManager.state != BluetoothLowEnergyState.poweredOn && core.settings.getObpBleEnabled()) { - print('Waiting for peripheral manager to be powered on...'); - await Future.delayed(Duration(seconds: 1)); + if (!await waitForPoweredOn(() => core.settings.getObpBleEnabled())) { + return; } _buttonCharacteristic = GATTCharacteristic.mutable( @@ -70,12 +65,12 @@ class OpenBikeControlBluetoothEmulator extends TrainerConnection { permissions: [], ); - if (!_isServiceAdded) { - await Future.delayed(Duration(seconds: 1)); + if (!isServiceAdded) { + await Future.delayed(const Duration(seconds: 1)); - if (!_isSubscribedToEvents) { - _isSubscribedToEvents = true; - _peripheralManager.characteristicReadRequested.forEach((eventArgs) async { + if (!isSubscribedToEvents) { + isSubscribedToEvents = true; + peripheralManager.characteristicReadRequested.forEach((eventArgs) async { print('Read request for characteristic: ${eventArgs.characteristic.uuid}'); switch (eventArgs.characteristic.uuid.toString().toUpperCase()) { @@ -85,20 +80,20 @@ class OpenBikeControlBluetoothEmulator extends TrainerConnection { final request = eventArgs.request; final trimmedValue = Uint8List.fromList([]); - await _peripheralManager.respondReadRequestWithValue( + await peripheralManager.respondReadRequestWithValue( request, value: trimmedValue, ); // You can respond to read requests here if needed }); - _peripheralManager.characteristicNotifyStateChanged.forEach((char) { - _central = char.central; + peripheralManager.characteristicNotifyStateChanged.forEach((char) { + central = char.central; print( 'Notify state changed for characteristic: ${char.characteristic.uuid}: ${char.state}', ); }); - _peripheralManager.characteristicWriteRequested.forEach((eventArgs) async { + peripheralManager.characteristicWriteRequested.forEach((eventArgs) async { final characteristic = eventArgs.characteristic; final request = eventArgs.request; final value = request.value; @@ -125,13 +120,13 @@ class OpenBikeControlBluetoothEmulator extends TrainerConnection { print('Unhandled write request for characteristic: ${eventArgs.characteristic.uuid}'); } - await _peripheralManager.respondWriteRequest(request); + await peripheralManager.respondWriteRequest(request); }); } if (!Platform.isWindows) { // Device Information - await _peripheralManager.addService( + await addService( GATTService( uuid: UUID.fromString('180A'), isPrimary: true, @@ -162,7 +157,7 @@ class OpenBikeControlBluetoothEmulator extends TrainerConnection { ); } // Battery Service - await _peripheralManager.addService( + await addService( GATTService( uuid: UUID.fromString('180F'), isPrimary: true, @@ -184,7 +179,7 @@ class OpenBikeControlBluetoothEmulator extends TrainerConnection { ); // Unknown Service - await _peripheralManager.addService( + await addService( GATTService( uuid: UUID.fromString(OpenBikeControlConstants.SERVICE_UUID), isPrimary: true, @@ -206,7 +201,7 @@ class OpenBikeControlBluetoothEmulator extends TrainerConnection { includedServices: [], ), ); - _isServiceAdded = true; + isServiceAdded = true; } final advertisement = Advertisement( @@ -215,16 +210,14 @@ class OpenBikeControlBluetoothEmulator extends TrainerConnection { ); print('Starting advertising with OpenBikeControl service...'); - await _peripheralManager.startAdvertising(advertisement); + await startAdvertising(advertisement); } Future stopServer() async { if (kDebugMode) { print('Stopping OpenBikeControl BLE server...'); } - await _peripheralManager.stopAdvertising(); - isStarted.value = false; - isConnected.value = false; + await stopAdvertising(); connectedApp.value = null; } @@ -238,7 +231,7 @@ class OpenBikeControlBluetoothEmulator extends TrainerConnection { if (inGameAction == null) { return Error('Invalid in-game action for key pair: $keyPair'); - } else if (_central == null) { + } else if (central == null) { return Error('No central connected'); } else if (connectedApp.value == null) { return Error('No app info received from central'); @@ -250,16 +243,16 @@ class OpenBikeControlBluetoothEmulator extends TrainerConnection { final responseDataDown = OpenBikeProtocolParser.encodeButtonState( mappedButtons.map((b) => ButtonState(b, 1)).toList(), ); - await _peripheralManager.notifyCharacteristic(_central!, _buttonCharacteristic, value: responseDataDown); + await notifyCharacteristic(central!, _buttonCharacteristic, value: responseDataDown); final responseDataUp = OpenBikeProtocolParser.encodeButtonState( mappedButtons.map((b) => ButtonState(b, 0)).toList(), ); - await _peripheralManager.notifyCharacteristic(_central!, _buttonCharacteristic, value: responseDataUp); + await notifyCharacteristic(central!, _buttonCharacteristic, value: responseDataUp); } else { final responseData = OpenBikeProtocolParser.encodeButtonState( mappedButtons.map((b) => ButtonState(b, isKeyDown ? 1 : 0)).toList(), ); - await _peripheralManager.notifyCharacteristic(_central!, _buttonCharacteristic, value: responseData); + await notifyCharacteristic(central!, _buttonCharacteristic, value: responseData); } return Success('Buttons ${inGameAction.title} sent'); diff --git a/lib/bluetooth/devices/openbikecontrol/obc_mdns_emulator.dart b/lib/bluetooth/devices/openbikecontrol/obc_mdns_emulator.dart index bfd47a2..c68899d 100644 --- a/lib/bluetooth/devices/openbikecontrol/obc_mdns_emulator.dart +++ b/lib/bluetooth/devices/openbikecontrol/obc_mdns_emulator.dart @@ -1,10 +1,8 @@ import 'dart:io'; +import 'package:bike_control/bluetooth/devices/mdns_emulator.dart'; import 'package:bike_control/bluetooth/devices/openbikecontrol/openbikecontrol_device.dart'; import 'package:bike_control/bluetooth/devices/openbikecontrol/protocol_parser.dart'; -import 'package:bike_control/bluetooth/devices/trainer_connection.dart'; -import 'package:bike_control/bluetooth/devices/zwift/ftms_mdns_emulator.dart'; -import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pb.dart'; import 'package:bike_control/bluetooth/messages/notification.dart'; import 'package:bike_control/utils/actions/base_actions.dart'; import 'package:bike_control/utils/core.dart'; @@ -14,16 +12,11 @@ import 'package:dartx/dartx.dart'; import 'package:flutter/foundation.dart'; import 'package:nsd/nsd.dart'; -class OpenBikeControlMdnsEmulator extends TrainerConnection { - ServerSocket? _server; - Registration? _mdnsRegistration; - +class OpenBikeControlMdnsEmulator extends MdnsEmulator { static const String connectionTitle = 'OpenBikeControl mDNS Emulator'; final ValueNotifier connectedApp = ValueNotifier(null); - Socket? _socket; - OpenBikeControlMdnsEmulator() : super( title: connectionTitle, @@ -35,18 +28,7 @@ class OpenBikeControlMdnsEmulator extends TrainerConnection { isStarted.value = true; // Get local IP - final interfaces = await NetworkInterface.list(); - InternetAddress? localIP; - - for (final interface in interfaces) { - for (final addr in interface.addresses) { - if (addr.type == InternetAddressType.IPv4 && !addr.isLoopback) { - localIP = addr; - break; - } - } - if (localIP != null) break; - } + final localIP = await findLocalIP(); if (localIP == null) { throw 'Could not find network interface'; @@ -54,15 +36,9 @@ class OpenBikeControlMdnsEmulator extends TrainerConnection { await _createTcpServer(); - if (kDebugMode) { - enableLogging(LogTopic.calls); - enableLogging(LogTopic.errors); - } - disableServiceTypeValidation(true); - try { // Create service - _mdnsRegistration = await register( + await registerMdnsService( Service( name: 'BikeControl', type: '_openbikecontrol._tcp', @@ -79,7 +55,7 @@ class OpenBikeControlMdnsEmulator extends TrainerConnection { }, ), ); - print('Service: ${_mdnsRegistration!.id} at ${localIP.address}:$_mdnsRegistration'); + print('Service: ${mdnsRegistration!.id} at ${localIP.address}:$mdnsRegistration'); print('Server started - advertising service!'); } catch (e, s) { core.connection.signalNotification(AlertNotification(LogLevel.LOGLEVEL_ERROR, 'Failed to start mDNS server: $e')); @@ -91,37 +67,17 @@ class OpenBikeControlMdnsEmulator extends TrainerConnection { if (kDebugMode) { print('Stopping OpenBikeControl mDNS server...'); } - if (_mdnsRegistration != null) { - unregister(_mdnsRegistration!); - _mdnsRegistration = null; - } - isStarted.value = false; - isConnected.value = false; + stop(); connectedApp.value = null; - _socket?.destroy(); - _socket = null; } Future _createTcpServer() async { - try { - _server = await ServerSocket.bind( - InternetAddress.anyIPv6, - 36867, - shared: true, - v6Only: false, - ); - } catch (e) { - core.connection.signalNotification(AlertNotification(LogLevel.LOGLEVEL_ERROR, 'Failed to start server: $e')); - rethrow; - } - if (kDebugMode) { - print('Server started on port ${_server!.port}'); - } + await createTcpServer(36867); // Accept connection - _server!.listen( + tcpServer!.listen( (Socket socket) { - _socket = socket; + this.socket = socket; if (kDebugMode) { print('Client connected: ${socket.remoteAddress.address}:${socket.remotePort}'); @@ -155,7 +111,7 @@ class OpenBikeControlMdnsEmulator extends TrainerConnection { ); isConnected.value = false; connectedApp.value = null; - _socket = null; + this.socket = null; }, ); }, @@ -172,7 +128,7 @@ class OpenBikeControlMdnsEmulator extends TrainerConnection { if (inGameAction == null) { return Error('Invalid in-game action for key pair: $keyPair'); - } else if (_socket == null) { + } else if (socket == null) { print('No client connected, cannot send button press'); return Error('No client connected'); } else if (connectedApp.value == null) { @@ -185,23 +141,18 @@ class OpenBikeControlMdnsEmulator extends TrainerConnection { final responseDataDown = OpenBikeProtocolParser.encodeButtonState( mappedButtons.map((b) => ButtonState(b, 1)).toList(), ); - _write(_socket!, responseDataDown); + writeToSocket(socket!, responseDataDown); final responseDataUp = OpenBikeProtocolParser.encodeButtonState( mappedButtons.map((b) => ButtonState(b, 0)).toList(), ); - _write(_socket!, responseDataUp); + writeToSocket(socket!, responseDataUp); } else { final responseData = OpenBikeProtocolParser.encodeButtonState( mappedButtons.map((b) => ButtonState(b, isKeyDown ? 1 : 0)).toList(), ); - _write(_socket!, responseData); + writeToSocket(socket!, responseData); } return Success('Sent ${inGameAction.title} button press'); } - - void _write(Socket socket, List responseData) { - debugPrint('Sending response: ${bytesToHex(responseData)}'); - socket.add(responseData); - } } diff --git a/lib/bluetooth/devices/zwift/ftms_mdns_emulator.dart b/lib/bluetooth/devices/zwift/ftms_mdns_emulator.dart index 14cbbb2..c171c02 100644 --- a/lib/bluetooth/devices/zwift/ftms_mdns_emulator.dart +++ b/lib/bluetooth/devices/zwift/ftms_mdns_emulator.dart @@ -1,6 +1,6 @@ import 'dart:io'; -import 'package:bike_control/bluetooth/devices/trainer_connection.dart'; +import 'package:bike_control/bluetooth/devices/mdns_emulator.dart'; import 'package:bike_control/bluetooth/devices/zwift/constants.dart'; import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pbenum.dart'; import 'package:bike_control/bluetooth/devices/zwift/protocol/zwift.pb.dart' show RideKeyPadStatus; @@ -15,13 +15,9 @@ import 'package:dartx/dartx.dart'; import 'package:flutter/foundation.dart'; import 'package:nsd/nsd.dart'; -class FtmsMdnsEmulator extends TrainerConnection { - ServerSocket? _tcpServer; - Registration? _mdnsRegistration; - +class FtmsMdnsEmulator extends MdnsEmulator { static const String connectionTitle = 'Zwift Network Emulator'; - Socket? _socket; var lastMessageId = 0; FtmsMdnsEmulator() @@ -46,18 +42,7 @@ class FtmsMdnsEmulator extends TrainerConnection { print('Starting mDNS server...'); // Get local IP - final interfaces = await NetworkInterface.list(); - InternetAddress? localIP; - - for (final interface in interfaces) { - for (final addr in interface.addresses) { - if (addr.type == InternetAddressType.IPv4 && !addr.isLoopback) { - localIP = addr; - break; - } - } - if (localIP != null) break; - } + final localIP = await findLocalIP(); if (localIP == null) { throw 'Could not find network interface'; @@ -65,13 +50,7 @@ class FtmsMdnsEmulator extends TrainerConnection { await _createTcpServer(); - if (kDebugMode) { - enableLogging(LogTopic.calls); - enableLogging(LogTopic.errors); - } - disableServiceTypeValidation(true); - - _mdnsRegistration = await register( + await registerMdnsService( Service( name: 'KICKR BIKE PRO 1337', addresses: [localIP], @@ -87,41 +66,13 @@ class FtmsMdnsEmulator extends TrainerConnection { print('Server started - advertising service!'); } - void stop() { - isStarted.value = false; - isConnected.value = false; - _tcpServer?.close(); - if (_mdnsRegistration != null) { - unregister(_mdnsRegistration!); - } - _tcpServer = null; - _mdnsRegistration = null; - _socket = null; - print('Stopped FtmsMdnsEmulator'); - } - Future _createTcpServer() async { - try { - _tcpServer = await ServerSocket.bind( - InternetAddress.anyIPv6, - 36867, - shared: true, - v6Only: false, - ); - } catch (e) { - if (kDebugMode) { - print('Failed to start server: $e'); - } - rethrow; - } - if (kDebugMode) { - print('Server started on port ${_tcpServer!.port}'); - } + await createTcpServer(36867); // Accept connection - _tcpServer!.listen( + tcpServer!.listen( (Socket socket) { - _socket = socket; + this.socket = socket; isConnected.value = true; if (kDebugMode) { print('Client connected: ${socket.remoteAddress.address}:${socket.remotePort}'); @@ -171,7 +122,7 @@ class FtmsMdnsEmulator extends TrainerConnection { // Expected 0101000000100000fc8200001000800000805f9b34fb // Got 0101000000100000fc8200001000800000805f9b34fb - _write(socket, bytes); + writeToSocket(socket, bytes); case FtmsMdnsConstants.DC_MESSAGE_DISCOVER_CHARACTERISTICS: final rawUUID = body.takeBytes(16); final serviceUUID = bytesToHex(rawUUID).toUUID(); @@ -201,7 +152,7 @@ class FtmsMdnsEmulator extends TrainerConnection { ]; // OK: 0102010000430000fc8200001000800000805f9b34fb0000000319ca465186e5fa29dcdd09d1020000000219ca465186e5fa29dcdd09d1040000000419ca465186e5fa29dcdd09d104 - _write(socket, responseData); + writeToSocket(socket, responseData); } case FtmsMdnsConstants.DC_MESSAGE_READ_CHARACTERISTIC: final rawUUID = body.takeBytes(16); @@ -220,7 +171,7 @@ class FtmsMdnsEmulator extends TrainerConnection { ...responseBody, ]; - _write(socket, responseData); + writeToSocket(socket, responseData); case FtmsMdnsConstants.DC_MESSAGE_WRITE_CHARACTERISTIC: final rawUUID = body.takeBytes(16); final characteristicUUID = bytesToHex(rawUUID).toUUID(); @@ -239,7 +190,7 @@ class FtmsMdnsEmulator extends TrainerConnection { ...responseBody, ]; - _write(socket, responseData); + writeToSocket(socket, responseData); final response = core.zwiftEmulator.handleWriteRequest( characteristicUUID, @@ -269,7 +220,7 @@ class FtmsMdnsEmulator extends TrainerConnection { ]; // 0106050000180000000419ca465186e5fa29dcdd09d1526964654f6e0203 - _write(socket, responseData); + writeToSocket(socket, responseData); if (response.contentEquals(ZwiftConstants.RIDE_ON)) { _sendKeepAlive(); @@ -293,7 +244,7 @@ class FtmsMdnsEmulator extends TrainerConnection { ...responseBody, ]; - _write(socket, responseData); + writeToSocket(socket, responseData); case FtmsMdnsConstants.DC_MESSAGE_CHARACTERISTIC_NOTIFICATION: print('Hamlo'); default: @@ -308,20 +259,13 @@ class FtmsMdnsEmulator extends TrainerConnection { core.connection.signalNotification( AlertNotification(LogLevel.LOGLEVEL_INFO, AppLocalizations.current.disconnected), ); - _socket = null; + this.socket = null; }, ); }, ); } - void _write(Socket socket, List responseData) { - if (kDebugMode) { - print('Sending response: ${bytesToHex(responseData)}'); - } - socket.add(responseData); - } - int _propertyVal(List properties) { int res = 0; @@ -368,7 +312,7 @@ class FtmsMdnsEmulator extends TrainerConnection { ]), ); - _write(_socket!, commandProto); + writeToSocket(socket!, commandProto); } if (isKeyUp) { @@ -377,7 +321,7 @@ class FtmsMdnsEmulator extends TrainerConnection { Uint8List.fromList([Opcode.CONTROLLER_NOTIFICATION.value, 0x08, 0xFF, 0xFF, 0xFF, 0xFF, 0x0F]), ); - _write(_socket!, zero); + writeToSocket(socket!, zero); } if (kDebugMode) { print('Sent action $isKeyUp vs $isKeyDown ${keyPair.inGameAction!.title} to Zwift Emulator'); @@ -411,9 +355,9 @@ class FtmsMdnsEmulator extends TrainerConnection { Future _sendKeepAlive() async { await Future.delayed(const Duration(seconds: 5)); - if (_socket != null) { - _write( - _socket!, + if (socket != null) { + writeToSocket( + socket!, _buildNotify( ZwiftConstants.ZWIFT_SYNC_TX_CHARACTERISTIC_UUID, hexToBytes('B70100002041201C00180004001B4F00B701000020798EC5BDEFCBE4563418269E4926FBE1'), @@ -464,23 +408,6 @@ extension on List { } } -String bytesToHex(List bytes, {bool spaced = false}) { - return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(spaced ? ' ' : ''); -} - -String bytesToReadableHex(List bytes) { - return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(' '); -} - -List hexToBytes(String hex) { - final bytes = []; - for (var i = 0; i < hex.length; i += 2) { - final byte = hex.substring(i, i + 2); - bytes.add(int.parse(byte, radix: 16)); - } - return bytes; -} - class FtmsMdnsConstants { static const DC_RC_REQUEST_COMPLETED_SUCCESSFULLY = 0; // Request completed successfully static const DC_RC_UNKNOWN_MESSAGE_TYPE = 1; // Unknown Message Type diff --git a/lib/bluetooth/devices/zwift/zwift_emulator.dart b/lib/bluetooth/devices/zwift/zwift_emulator.dart index 736984c..7c15f68 100644 --- a/lib/bluetooth/devices/zwift/zwift_emulator.dart +++ b/lib/bluetooth/devices/zwift/zwift_emulator.dart @@ -1,7 +1,7 @@ import 'dart:io'; import 'package:bike_control/bluetooth/ble.dart'; -import 'package:bike_control/bluetooth/devices/trainer_connection.dart'; +import 'package:bike_control/bluetooth/devices/bluetooth_emulator.dart'; import 'package:bike_control/bluetooth/devices/zwift/constants.dart'; import 'package:bike_control/bluetooth/devices/zwift/ftms_mdns_emulator.dart'; import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pb.dart'; @@ -17,18 +17,10 @@ import 'package:bike_control/utils/requirements/multi.dart'; import 'package:bike_control/widgets/title.dart'; import 'package:bluetooth_low_energy/bluetooth_low_energy.dart'; import 'package:flutter/foundation.dart'; -import 'package:permission_handler/permission_handler.dart'; - -class ZwiftEmulator extends TrainerConnection { - bool get isLoading => _isLoading; +class ZwiftEmulator extends BluetoothEmulator { static const String connectionTitle = 'Zwift BLE Emulator'; - late final _peripheralManager = PeripheralManager(); - bool _isLoading = false; - bool _isServiceAdded = false; - bool _isSubscribedToEvents = false; - Central? _central; GATTCharacteristic? _asyncCharacteristic; GATTCharacteristic? _syncTxCharacteristic; @@ -50,51 +42,30 @@ class ZwiftEmulator extends TrainerConnection { ); Future reconnect() async { - await _peripheralManager.stopAdvertising(); - await _peripheralManager.removeAllServices(); - _isServiceAdded = false; + await peripheralManager.stopAdvertising(); + await peripheralManager.removeAllServices(); + isServiceAdded = false; startAdvertising(() {}); } Future startAdvertising(VoidCallback onUpdate) async { - _isLoading = true; + isLoading = true; isStarted.value = true; onUpdate(); - _peripheralManager.stateChanged.forEach((state) { - print('Peripheral manager state: ${state.state}'); - }); + subscribeToStateChanges(); + subscribeToConnectionStateChanges(onUpdate); - if (!kIsWeb && Platform.isAndroid) { - _peripheralManager.connectionStateChanged.forEach((state) { - print('Peripheral connection state: ${state.state} of ${state.central.uuid}'); - if (state.state == ConnectionState.connected) { - } else if (state.state == ConnectionState.disconnected) { - _central = null; - isConnected.value = false; - core.connection.signalNotification( - AlertNotification(LogLevel.LOGLEVEL_INFO, AppLocalizations.current.disconnected), - ); - onUpdate(); - } - }); - - final status = await Permission.bluetoothAdvertise.request(); - if (!status.isGranted) { - print('Bluetooth advertise permission not granted'); - isStarted.value = false; - onUpdate(); - return; - } + if (!await requestBluetoothAdvertisePermission()) { + isStarted.value = false; + onUpdate(); + return; } - while (_peripheralManager.state != BluetoothLowEnergyState.poweredOn && - core.settings.getZwiftBleEmulatorEnabled()) { - print('Waiting for peripheral manager to be powered on...'); + if (!await waitForPoweredOn(() => core.settings.getZwiftBleEmulatorEnabled())) { if (core.settings.getLastTarget() == Target.thisDevice) { return; } - await Future.delayed(Duration(seconds: 1)); } _syncTxCharacteristic = GATTCharacteristic.mutable( @@ -118,12 +89,12 @@ class ZwiftEmulator extends TrainerConnection { permissions: [], ); - if (!_isServiceAdded) { - await Future.delayed(Duration(seconds: 1)); + if (!isServiceAdded) { + await Future.delayed(const Duration(seconds: 1)); - if (!_isSubscribedToEvents) { - _isSubscribedToEvents = true; - _peripheralManager.characteristicReadRequested.forEach((eventArgs) async { + if (!isSubscribedToEvents) { + isSubscribedToEvents = true; + peripheralManager.characteristicReadRequested.forEach((eventArgs) async { print('Read request for characteristic: ${eventArgs.characteristic.uuid}'); switch (eventArgs.characteristic.uuid.toString().toUpperCase()) { @@ -131,7 +102,7 @@ class ZwiftEmulator extends TrainerConnection { print('Handling read request for SYNC TX characteristic'); break; case BleUuid.DEVICE_INFORMATION_CHARACTERISTIC_BATTERY_LEVEL: - await _peripheralManager.respondReadRequestWithValue( + await peripheralManager.respondReadRequestWithValue( eventArgs.request, value: Uint8List.fromList([100]), ); @@ -142,20 +113,20 @@ class ZwiftEmulator extends TrainerConnection { final request = eventArgs.request; final trimmedValue = Uint8List.fromList([]); - await _peripheralManager.respondReadRequestWithValue( + await peripheralManager.respondReadRequestWithValue( request, value: trimmedValue, ); // You can respond to read requests here if needed }); - _peripheralManager.characteristicNotifyStateChanged.forEach((char) { + peripheralManager.characteristicNotifyStateChanged.forEach((char) { print( 'Notify state changed for characteristic: ${char.characteristic.uuid}: ${char.state}', ); }); - _peripheralManager.characteristicWriteRequested.forEach((eventArgs) async { - _central = eventArgs.central; + peripheralManager.characteristicWriteRequested.forEach((eventArgs) async { + central = eventArgs.central; isConnected.value = true; core.connection.signalNotification( @@ -165,8 +136,8 @@ class ZwiftEmulator extends TrainerConnection { final request = eventArgs.request; final response = handleWriteRequest(eventArgs.characteristic.uuid.toString(), request.value); if (response != null) { - await _peripheralManager.notifyCharacteristic( - _central!, + await notifyCharacteristic( + central!, _syncTxCharacteristic!, value: response, ); @@ -176,13 +147,13 @@ class ZwiftEmulator extends TrainerConnection { } } - await _peripheralManager.respondWriteRequest(request); + await peripheralManager.respondWriteRequest(request); }); } if (!Platform.isWindows) { // Device Information - await _peripheralManager.addService( + await addService( GATTService( uuid: UUID.fromString('180A'), isPrimary: true, @@ -213,7 +184,7 @@ class ZwiftEmulator extends TrainerConnection { ); } // Battery Service - await _peripheralManager.addService( + await addService( GATTService( uuid: UUID.fromString('180F'), isPrimary: true, @@ -235,7 +206,7 @@ class ZwiftEmulator extends TrainerConnection { ); // Unknown Service - await _peripheralManager.addService( + await addService( GATTService( uuid: UUID.fromString(ZwiftConstants.ZWIFT_CUSTOM_SERVICE_UUID), isPrimary: true, @@ -276,7 +247,7 @@ class ZwiftEmulator extends TrainerConnection { includedServices: [], ), ); - _isServiceAdded = true; + isServiceAdded = true; } final advertisement = Advertisement( @@ -294,23 +265,21 @@ class ZwiftEmulator extends TrainerConnection { ); print('Starting advertising with Zwift service...'); - await _peripheralManager.startAdvertising(advertisement); - _isLoading = false; + await startAdvertising(advertisement); + isLoading = false; onUpdate(); } + @override Future stopAdvertising() async { - await _peripheralManager.stopAdvertising(); - isStarted.value = false; - isConnected.value = false; - _isLoading = false; + await super.stopAdvertising(); } Future _sendKeepAlive() async { await Future.delayed(const Duration(seconds: 5)); - if (isConnected.value && _central != null) { + if (isConnected.value && central != null) { final zero = Uint8List.fromList([Opcode.CONTROLLER_NOTIFICATION.value, 0x08, 0xFF, 0xFF, 0xFF, 0xFF, 0x0F]); - _peripheralManager.notifyCharacteristic(_central!, _syncTxCharacteristic!, value: zero); + await notifyCharacteristic(central!, _syncTxCharacteristic!, value: zero); _sendKeepAlive(); } } @@ -347,8 +316,8 @@ class ZwiftEmulator extends TrainerConnection { ...bytes, ]); - _peripheralManager.notifyCharacteristic( - _central!, + await notifyCharacteristic( + central!, _asyncCharacteristic!, value: commandProto, ); @@ -356,7 +325,7 @@ class ZwiftEmulator extends TrainerConnection { if (isKeyUp) { final zero = Uint8List.fromList([Opcode.CONTROLLER_NOTIFICATION.value, 0x08, 0xFF, 0xFF, 0xFF, 0xFF, 0x0F]); - _peripheralManager.notifyCharacteristic(_central!, _asyncCharacteristic!, value: zero); + await notifyCharacteristic(central!, _asyncCharacteristic!, value: zero); } return Success('Sent action: ${keyPair.inGameAction!.name}'); @@ -462,14 +431,8 @@ class ZwiftEmulator extends TrainerConnection { return null; } + @override void cleanup() { - _peripheralManager.stopAdvertising(); - _peripheralManager.removeAllServices(); - _isServiceAdded = false; - _isSubscribedToEvents = false; - _central = null; - isConnected.value = false; - isStarted.value = false; - _isLoading = false; + super.cleanup(); } } diff --git a/lib/bluetooth/remote_pairing.dart b/lib/bluetooth/remote_pairing.dart index 286f69d..deea522 100644 --- a/lib/bluetooth/remote_pairing.dart +++ b/lib/bluetooth/remote_pairing.dart @@ -1,6 +1,6 @@ import 'dart:io'; -import 'package:bike_control/bluetooth/devices/trainer_connection.dart'; +import 'package:bike_control/bluetooth/devices/bluetooth_emulator.dart'; import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pb.dart'; import 'package:bike_control/bluetooth/messages/notification.dart'; import 'package:bike_control/gen/l10n.dart'; @@ -11,19 +11,10 @@ import 'package:bike_control/utils/keymap/buttons.dart'; import 'package:bike_control/utils/requirements/multi.dart'; import 'package:bluetooth_low_energy/bluetooth_low_energy.dart'; import 'package:flutter/foundation.dart'; -import 'package:permission_handler/permission_handler.dart'; import '../utils/keymap/keymap.dart'; -class RemotePairing extends TrainerConnection { - bool get isLoading => _isLoading; - - late final _peripheralManager = PeripheralManager(); - bool _isLoading = false; - bool _isServiceAdded = false; - bool _isSubscribedToEvents = false; - - Central? _central; +class RemotePairing extends BluetoothEmulator { GATTCharacteristic? _inputReport; static const String connectionTitle = 'Remote Control'; @@ -35,9 +26,9 @@ class RemotePairing extends TrainerConnection { ); Future reconnect() async { - await _peripheralManager.stopAdvertising(); - await _peripheralManager.removeAllServices(); - _isServiceAdded = false; + await peripheralManager.stopAdvertising(); + await peripheralManager.removeAllServices(); + isServiceAdded = false; startAdvertising().catchError((e) { core.settings.setRemoteControlEnabled(false); core.connection.signalNotification( @@ -47,40 +38,21 @@ class RemotePairing extends TrainerConnection { } Future startAdvertising() async { - _isLoading = true; + isLoading = true; isStarted.value = true; - _peripheralManager.stateChanged.forEach((state) { - print('Peripheral manager state: ${state.state}'); - }); + subscribeToStateChanges(); + subscribeToConnectionStateChanges(null); - if (!kIsWeb && Platform.isAndroid) { - _peripheralManager.connectionStateChanged.forEach((state) { - print('Peripheral connection state: ${state.state} of ${state.central.uuid}'); - if (state.state == ConnectionState.connected) { - } else if (state.state == ConnectionState.disconnected) { - _central = null; - isConnected.value = false; - core.connection.signalNotification( - AlertNotification(LogLevel.LOGLEVEL_INFO, AppLocalizations.current.disconnected), - ); - } - }); - - final status = await Permission.bluetoothAdvertise.request(); - if (!status.isGranted) { - print('Bluetooth advertise permission not granted'); - isStarted.value = false; - return; - } + if (!await requestBluetoothAdvertisePermission()) { + isStarted.value = false; + return; } - while (_peripheralManager.state != BluetoothLowEnergyState.poweredOn && core.settings.getRemoteControlEnabled()) { - print('Waiting for peripheral manager to be powered on...'); + if (!await waitForPoweredOn(() => core.settings.getRemoteControlEnabled())) { if (core.settings.getLastTarget() == Target.thisDevice) { return; } - await Future.delayed(Duration(seconds: 1)); } final inputReport = GATTCharacteristic.mutable( uuid: UUID.fromString('2A4D'), @@ -95,8 +67,8 @@ class RemotePairing extends TrainerConnection { ], ); - if (!_isServiceAdded) { - await Future.delayed(Duration(seconds: 1)); + if (!isServiceAdded) { + await Future.delayed(const Duration(seconds: 1)); final reportMapDataAbsolute = Uint8List.fromList([ 0x05, 0x01, // Usage Page (Generic Desktop) @@ -206,21 +178,21 @@ class RemotePairing extends TrainerConnection { includedServices: [], ); - if (!_isSubscribedToEvents) { - _isSubscribedToEvents = true; - _peripheralManager.characteristicReadRequested.forEach((char) { + if (!isSubscribedToEvents) { + isSubscribedToEvents = true; + peripheralManager.characteristicReadRequested.forEach((char) { print('Read request for characteristic: ${char}'); // You can respond to read requests here if needed }); - _peripheralManager.characteristicNotifyStateChanged.forEach((char) { + peripheralManager.characteristicNotifyStateChanged.forEach((char) { if (char.characteristic.uuid == inputReport.uuid) { if (char.state) { _inputReport = char.characteristic; - _central = char.central; + central = char.central; } else { _inputReport = null; - _central = null; + central = null; } } print( @@ -228,10 +200,10 @@ class RemotePairing extends TrainerConnection { ); }); } - await _peripheralManager.addService(hidService); + await addService(hidService); // 3) Optional Battery service - await _peripheralManager.addService( + await addService( GATTService( uuid: UUID.fromString('180F'), isPrimary: true, @@ -245,7 +217,7 @@ class RemotePairing extends TrainerConnection { includedServices: [], ), ); - _isServiceAdded = true; + isServiceAdded = true; } final advertisement = Advertisement( @@ -259,20 +231,18 @@ class RemotePairing extends TrainerConnection { ); print('Starting advertising with Zwift service...'); - await _peripheralManager.startAdvertising(advertisement); - _isLoading = false; + await startAdvertising(advertisement); + isLoading = false; } + @override Future stopAdvertising() async { - await _peripheralManager.stopAdvertising(); - isStarted.value = false; - isConnected.value = false; - _isLoading = false; + await super.stopAdvertising(); } Future notifyCharacteristic(Uint8List value) async { - if (_inputReport != null && _central != null) { - await _peripheralManager.notifyCharacteristic(_central!, _inputReport!, value: value); + if (_inputReport != null && central != null) { + await peripheralManager.notifyCharacteristic(central!, _inputReport!, value: value); } }