From 81f14f16fd8aa1ec97f3d1bcc9ad5057ba82782e Mon Sep 17 00:00:00 2001 From: Jonas Bark Date: Sun, 8 Feb 2026 11:28:48 +0100 Subject: [PATCH] openbikecontrol via dircon --- .../devices/openbikecontrol/obc_dircon.dart | 39 ++++++++ .../openbikecontrol/obc_mdns_emulator.dart | 95 +++++++++++++------ lib/pages/onboarding.dart | 6 +- lib/utils/core.dart | 9 +- lib/utils/keymap/apps/openbikecontrol.dart | 2 +- lib/utils/keymap/apps/supported_app.dart | 1 + lib/utils/keymap/apps/training_peaks.dart | 1 + macos/Podfile.lock | 13 +++ 8 files changed, 130 insertions(+), 36 deletions(-) create mode 100644 lib/bluetooth/devices/openbikecontrol/obc_dircon.dart diff --git a/lib/bluetooth/devices/openbikecontrol/obc_dircon.dart b/lib/bluetooth/devices/openbikecontrol/obc_dircon.dart new file mode 100644 index 0000000..59a8ec6 --- /dev/null +++ b/lib/bluetooth/devices/openbikecontrol/obc_dircon.dart @@ -0,0 +1,39 @@ +import 'package:bike_control/bluetooth/devices/openbikecontrol/openbikecontrol_device.dart'; +import 'package:prop/emulators/dircon/dircon.dart'; +import 'package:universal_ble/universal_ble.dart'; + +abstract class OnMessage { + void onMessage(List message); +} + +class ObcDircon extends DirCon { + final OnMessage onMessageCallback; + ObcDircon({required super.socket, required this.onMessageCallback}); + + @override + List getCharacteristics(String serviceUUID) { + if (serviceUUID.toLowerCase() == OpenBikeControlConstants.SERVICE_UUID) { + return [ + BleCharacteristic( + OpenBikeControlConstants.BUTTON_STATE_CHARACTERISTIC_UUID, + [CharacteristicProperty.notify], + ), + BleCharacteristic( + OpenBikeControlConstants.APPINFO_CHARACTERISTIC_UUID, + [CharacteristicProperty.writeWithoutResponse, CharacteristicProperty.write], + ), + ]; + } + return []; + } + + @override + void processWriteCallback(String characteristicUUID, List characteristicData) { + if (characteristicUUID.toLowerCase() == OpenBikeControlConstants.APPINFO_CHARACTERISTIC_UUID) { + onMessageCallback.onMessage(characteristicData); + } + } + + @override + List get serviceUUIDs => [OpenBikeControlConstants.SERVICE_UUID]; +} diff --git a/lib/bluetooth/devices/openbikecontrol/obc_mdns_emulator.dart b/lib/bluetooth/devices/openbikecontrol/obc_mdns_emulator.dart index 3251921..4b29e81 100644 --- a/lib/bluetooth/devices/openbikecontrol/obc_mdns_emulator.dart +++ b/lib/bluetooth/devices/openbikecontrol/obc_mdns_emulator.dart @@ -1,11 +1,13 @@ import 'dart:io'; +import 'package:bike_control/bluetooth/devices/openbikecontrol/obc_dircon.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/messages/notification.dart'; import 'package:bike_control/utils/actions/base_actions.dart'; import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/keymap/apps/supported_app.dart'; import 'package:bike_control/utils/keymap/buttons.dart'; import 'package:bike_control/utils/keymap/keymap.dart'; import 'package:dartx/dartx.dart'; @@ -13,7 +15,7 @@ import 'package:flutter/foundation.dart'; import 'package:nsd/nsd.dart'; import 'package:prop/prop.dart'; -class OpenBikeControlMdnsEmulator extends TrainerConnection { +class OpenBikeControlMdnsEmulator extends TrainerConnection implements OnMessage { ServerSocket? _server; Registration? _mdnsRegistration; @@ -22,6 +24,7 @@ class OpenBikeControlMdnsEmulator extends TrainerConnection { final ValueNotifier connectedApp = ValueNotifier(null); Socket? _socket; + ObcDircon? _dirCon; OpenBikeControlMdnsEmulator() : super( @@ -29,6 +32,9 @@ class OpenBikeControlMdnsEmulator extends TrainerConnection { supportedActions: InGameAction.values, ); + bool get _useDirCon => + core.settings.getTrainerApp()?.supportsOpenBikeProtocol.contains(OpenBikeProtocolSupport.dircon) ?? false; + Future startServer() async { print('Starting mDNS server...'); isStarted.value = true; @@ -64,18 +70,23 @@ class OpenBikeControlMdnsEmulator extends TrainerConnection { _mdnsRegistration = await register( Service( name: 'BikeControl', - type: '_openbikecontrol._tcp', + type: _useDirCon ? '_wahoo-fitness-tnp._tcp' : '_openbikecontrol._tcp', port: 36867, - //hostName: 'KICKR BIKE SHIFT B84D.local', addresses: [localIP], - txt: { - 'version': Uint8List.fromList([0x01]), - 'id': Uint8List.fromList('1337'.codeUnits), - 'name': Uint8List.fromList('BikeControl'.codeUnits), - 'service-uuids': Uint8List.fromList(OpenBikeControlConstants.SERVICE_UUID.codeUnits), - 'manufacturer': Uint8List.fromList('OpenBikeControl'.codeUnits), - 'model': Uint8List.fromList('BikeControl app'.codeUnits), - }, + txt: _useDirCon + ? { + 'ble-service-uuids': Uint8List.fromList(OpenBikeControlConstants.SERVICE_UUID.codeUnits), + 'mac-address': Uint8List.fromList('00:11:22:33:44:55'.codeUnits), + 'serial-number': Uint8List.fromList('1234567890'.codeUnits), + } + : { + 'version': Uint8List.fromList([0x01]), + 'id': Uint8List.fromList('1337'.codeUnits), + 'name': Uint8List.fromList('BikeControl'.codeUnits), + 'service-uuids': Uint8List.fromList(OpenBikeControlConstants.SERVICE_UUID.codeUnits), + 'manufacturer': Uint8List.fromList('OpenBikeControl'.codeUnits), + 'model': Uint8List.fromList('BikeControl app'.codeUnits), + }, ), ); print('Service: ${_mdnsRegistration!.id} at ${localIP.address}:$_mdnsRegistration'); @@ -104,7 +115,7 @@ class OpenBikeControlMdnsEmulator extends TrainerConnection { Future _createTcpServer() async { try { _server = await ServerSocket.bind( - InternetAddress.anyIPv6, + InternetAddress.anyIPv4, 36867, shared: true, v6Only: false, @@ -127,33 +138,24 @@ class OpenBikeControlMdnsEmulator extends TrainerConnection { print('Client connected: ${socket.remoteAddress.address}:${socket.remotePort}'); } + if (_useDirCon) { + _dirCon = ObcDircon(socket: socket, onMessageCallback: this); + } + // Listen for data from the client socket.listen( (List data) { if (kDebugMode) { print('Received message: ${bytesToHex(data)}'); } - final messageType = data[0]; - switch (messageType) { - case OpenBikeProtocolParser.MSG_TYPE_APP_INFO: - try { - final appInfo = OpenBikeProtocolParser.parseAppInfo(Uint8List.fromList(data)); - isConnected.value = true; - connectedApp.value = appInfo; - - supportedActions = appInfo.supportedButtons.mapNotNull((b) => b.action).toList(); - core.connection.signalNotification( - AlertNotification(LogLevel.LOGLEVEL_INFO, 'Connected to app: ${appInfo.appId}'), - ); - } catch (e) { - core.connection.signalNotification(LogNotification('Failed to parse app info: $e')); - } - break; - default: - print('Unknown message type: $messageType'); + if (_dirCon != null) { + _dirCon!.handleIncomingData(data); + return; } + onMessage(data); }, onDone: () { + _dirCon = null; SharedLogic.stopKeepAlive(); core.connection.signalNotification( AlertNotification(LogLevel.LOGLEVEL_INFO, 'Disconnected from app: ${connectedApp.value?.appId}'), @@ -207,6 +209,37 @@ class OpenBikeControlMdnsEmulator extends TrainerConnection { void _write(Socket socket, List responseData) { debugPrint('Sending response: ${bytesToHex(responseData)}'); - socket.add(responseData); + if (_dirCon != null) { + _dirCon!.sendCharacteristicNotification(OpenBikeControlConstants.BUTTON_STATE_CHARACTERISTIC_UUID, responseData); + return; + } else { + socket.add(responseData); + } + } + + @override + void onMessage(List message) { + if (kDebugMode) { + print('Received message from DirCon: ${bytesToHex(message)}'); + } + final messageType = message[0]; + switch (messageType) { + case OpenBikeProtocolParser.MSG_TYPE_APP_INFO: + try { + final appInfo = OpenBikeProtocolParser.parseAppInfo(Uint8List.fromList(message)); + isConnected.value = true; + connectedApp.value = appInfo; + + supportedActions = appInfo.supportedButtons.mapNotNull((b) => b.action).toList(); + core.connection.signalNotification( + AlertNotification(LogLevel.LOGLEVEL_INFO, 'Connected to app: ${appInfo.appId}'), + ); + } catch (e) { + core.connection.signalNotification(LogNotification('Failed to parse app info: $e')); + } + break; + default: + print('Unknown message type: $messageType'); + } } } diff --git a/lib/pages/onboarding.dart b/lib/pages/onboarding.dart index 5da6842..8186144 100644 --- a/lib/pages/onboarding.dart +++ b/lib/pages/onboarding.dart @@ -14,6 +14,7 @@ import 'package:bike_control/widgets/scan.dart'; import 'package:bike_control/widgets/title.dart'; import 'package:bike_control/widgets/ui/help_button.dart'; import 'package:bike_control/widgets/ui/permissions_list.dart'; +import 'package:dartx/dartx.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:url_launcher/url_launcher_string.dart'; @@ -108,9 +109,10 @@ class _OnboardingPageState extends State { _OnboardingStep.trainer => _TrainerOnboardingStep( onComplete: () { setState(() { - if (core.settings.getTrainerApp()?.supportsOpenBikeProtocol.contains( + if (core.settings.getTrainerApp()?.supportsOpenBikeProtocol.containsAny([ OpenBikeProtocolSupport.network, - ) ?? + OpenBikeProtocolSupport.dircon, + ]) ?? false) { _currentStep = _OnboardingStep.openbikecontrol; } else { diff --git a/lib/utils/core.dart b/lib/utils/core.dart index 514f45f..f6a3c35 100644 --- a/lib/utils/core.dart +++ b/lib/utils/core.dart @@ -170,7 +170,11 @@ class CoreLogic { } bool get showObpMdnsEmulator { - return core.settings.getTrainerApp()?.supportsOpenBikeProtocol.contains(OpenBikeProtocolSupport.network) == true; + return core.settings.getTrainerApp()?.supportsOpenBikeProtocol.containsAny([ + OpenBikeProtocolSupport.network, + OpenBikeProtocolSupport.dircon, + ]) == + true; } bool get showObpBluetoothEmulator { @@ -216,7 +220,8 @@ class CoreLogic { core.settings.getTrainerApp()?.supportsOpenBikeProtocol.isNotEmpty == true; bool get showLocalRemoteOptions => - core.actionHandler.supportedModes.isNotEmpty && (showLocalControl || isRemoteControlEnabled || isRemoteKeyboardControlEnabled); + core.actionHandler.supportedModes.isNotEmpty && + (showLocalControl || isRemoteControlEnabled || isRemoteKeyboardControlEnabled); bool get hasNoConnectionMethod => !screenshotMode && diff --git a/lib/utils/keymap/apps/openbikecontrol.dart b/lib/utils/keymap/apps/openbikecontrol.dart index abc9909..1578c7a 100644 --- a/lib/utils/keymap/apps/openbikecontrol.dart +++ b/lib/utils/keymap/apps/openbikecontrol.dart @@ -10,7 +10,7 @@ class OpenBikeControl extends SupportedApp { packageName: "org.openbikecontrol", compatibleTargets: Target.values, supportsZwiftEmulation: false, - supportsOpenBikeProtocol: OpenBikeProtocolSupport.values, + supportsOpenBikeProtocol: [OpenBikeProtocolSupport.network, OpenBikeProtocolSupport.ble], keymap: Keymap( keyPairs: [], ), diff --git a/lib/utils/keymap/apps/supported_app.dart b/lib/utils/keymap/apps/supported_app.dart index 6a7e37f..c074fa5 100644 --- a/lib/utils/keymap/apps/supported_app.dart +++ b/lib/utils/keymap/apps/supported_app.dart @@ -12,6 +12,7 @@ import 'my_whoosh.dart'; enum OpenBikeProtocolSupport { ble, network, + dircon, } abstract class SupportedApp { diff --git a/lib/utils/keymap/apps/training_peaks.dart b/lib/utils/keymap/apps/training_peaks.dart index 1cbb469..ff62190 100644 --- a/lib/utils/keymap/apps/training_peaks.dart +++ b/lib/utils/keymap/apps/training_peaks.dart @@ -18,6 +18,7 @@ class TrainingPeaks extends SupportedApp { packageName: "com.indieVelo.client", compatibleTargets: !kIsWeb && Platform.isIOS ? [Target.otherDevice] : Target.values, supportsZwiftEmulation: false, + supportsOpenBikeProtocol: [OpenBikeProtocolSupport.ble, OpenBikeProtocolSupport.dircon], star: true, keymap: Keymap( keyPairs: [ diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 1ceb87a..111bb5c 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -1,4 +1,6 @@ PODS: + - audio_session (0.0.1): + - FlutterMacOS - bluetooth_low_energy_darwin (0.0.1): - Flutter - FlutterMacOS @@ -23,6 +25,9 @@ PODS: - FlutterMacOS - ios_receipt (0.0.1): - FlutterMacOS + - just_audio (0.0.1): + - Flutter + - FlutterMacOS - keypress_simulator_macos (0.0.1): - FlutterMacOS - media_key_detector_macos (0.0.1): @@ -53,6 +58,7 @@ PODS: - FlutterMacOS DEPENDENCIES: + - audio_session (from `Flutter/ephemeral/.symlinks/plugins/audio_session/macos`) - bluetooth_low_energy_darwin (from `Flutter/ephemeral/.symlinks/plugins/bluetooth_low_energy_darwin/darwin`) - device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`) - file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`) @@ -64,6 +70,7 @@ DEPENDENCIES: - in_app_purchase_storekit (from `Flutter/ephemeral/.symlinks/plugins/in_app_purchase_storekit/darwin`) - in_app_review (from `Flutter/ephemeral/.symlinks/plugins/in_app_review/macos`) - ios_receipt (from `Flutter/ephemeral/.symlinks/plugins/ios_receipt/macos`) + - just_audio (from `Flutter/ephemeral/.symlinks/plugins/just_audio/darwin`) - keypress_simulator_macos (from `Flutter/ephemeral/.symlinks/plugins/keypress_simulator_macos/macos`) - media_key_detector_macos (from `Flutter/ephemeral/.symlinks/plugins/media_key_detector_macos/macos`) - nsd_macos (from `Flutter/ephemeral/.symlinks/plugins/nsd_macos/macos`) @@ -82,6 +89,8 @@ SPEC REPOS: - RevenueCat EXTERNAL SOURCES: + audio_session: + :path: Flutter/ephemeral/.symlinks/plugins/audio_session/macos bluetooth_low_energy_darwin: :path: Flutter/ephemeral/.symlinks/plugins/bluetooth_low_energy_darwin/darwin device_info_plus: @@ -104,6 +113,8 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/in_app_review/macos ios_receipt: :path: Flutter/ephemeral/.symlinks/plugins/ios_receipt/macos + just_audio: + :path: Flutter/ephemeral/.symlinks/plugins/just_audio/darwin keypress_simulator_macos: :path: Flutter/ephemeral/.symlinks/plugins/keypress_simulator_macos/macos media_key_detector_macos: @@ -128,6 +139,7 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos SPEC CHECKSUMS: + audio_session: 728ae3823d914f809c485d390274861a24b0904e bluetooth_low_energy_darwin: 50bc79258e60586e4c4bed5948bd31d925f37fac device_info_plus: 1b14eed9bf95428983aed283a8d51cce3d8c4215 file_selector_macos: 3e56eaea051180007b900eacb006686fd54da150 @@ -139,6 +151,7 @@ SPEC CHECKSUMS: in_app_purchase_storekit: 2342c0a5da86593124d08dd13d920f39a52b273a in_app_review: 866c9b17c87a7b46a395bda43f5d3ca02deb585a ios_receipt: 8741a75f39e6ca0866313b73c69a5b674cf5c98c + just_audio: a42c63806f16995daf5b219ae1d679deb76e6a79 keypress_simulator_macos: f8556f9101f9f2f175652e0bceddf0fe82a4c6b2 media_key_detector_macos: a93757a483b4b47283ade432b1af9e427c47329f nsd_macos: 1a38a38a33adbb396b4c6f303bc076073514cadc