openbikecontrol via dircon

This commit is contained in:
Jonas Bark
2026-02-08 11:28:48 +01:00
parent c4a8d1ef9c
commit 81f14f16fd
8 changed files with 130 additions and 36 deletions

View File

@@ -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<int> message);
}
class ObcDircon extends DirCon {
final OnMessage onMessageCallback;
ObcDircon({required super.socket, required this.onMessageCallback});
@override
List<BleCharacteristic> 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<int> characteristicData) {
if (characteristicUUID.toLowerCase() == OpenBikeControlConstants.APPINFO_CHARACTERISTIC_UUID) {
onMessageCallback.onMessage(characteristicData);
}
}
@override
List<String> get serviceUUIDs => [OpenBikeControlConstants.SERVICE_UUID];
}

View File

@@ -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<AppInfo?> 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<void> 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<void> _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<int> 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<int> 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<int> 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');
}
}
}

View File

@@ -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<OnboardingPage> {
_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 {

View File

@@ -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 &&

View File

@@ -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: [],
),

View File

@@ -12,6 +12,7 @@ import 'my_whoosh.dart';
enum OpenBikeProtocolSupport {
ble,
network,
dircon,
}
abstract class SupportedApp {

View File

@@ -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: [

View File

@@ -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