Create base classes MdnsEmulator and BluetoothEmulator with shared logic

Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2025-12-24 08:48:04 +00:00
parent 8c689cbdb1
commit d53a1905b1
7 changed files with 460 additions and 329 deletions

View File

@@ -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<bool> 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<bool> 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<void> addService(GATTService service) async {
await _peripheralManager.addService(service);
}
/// Starts advertising with the given advertisement configuration.
@protected
Future<void> startAdvertising(Advertisement advertisement) async {
await _peripheralManager.startAdvertising(advertisement);
}
/// Stops advertising and resets state.
@protected
Future<void> 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<void> 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;
}
}

View File

@@ -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<InternetAddress?> 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<ServerSocket> 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<Registration> 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<int> 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<int> 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<int> bytes) {
return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(' ');
}
/// Converts a hexadecimal string to a list of bytes.
List<int> hexToBytes(String hex) {
final bytes = <int>[];
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;
}

View File

@@ -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<AppInfo?> connectedApp = ValueNotifier<AppInfo?>(null);
bool _isServiceAdded = false;
bool _isSubscribedToEvents = false;
Central? _central;
late GATTCharacteristic _buttonCharacteristic;
@@ -35,13 +31,13 @@ class OpenBikeControlBluetoothEmulator extends TrainerConnection {
Future<void> 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<void> 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');

View File

@@ -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<AppInfo?> 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<void> _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<int> responseData) {
debugPrint('Sending response: ${bytesToHex(responseData)}');
socket.add(responseData);
}
}

View File

@@ -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<void> _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<int> responseData) {
if (kDebugMode) {
print('Sending response: ${bytesToHex(responseData)}');
}
socket.add(responseData);
}
int _propertyVal(List<String> 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<void> _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<int> {
}
}
String bytesToHex(List<int> bytes, {bool spaced = false}) {
return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(spaced ? ' ' : '');
}
String bytesToReadableHex(List<int> bytes) {
return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(' ');
}
List<int> hexToBytes(String hex) {
final bytes = <int>[];
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

View File

@@ -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<void> reconnect() async {
await _peripheralManager.stopAdvertising();
await _peripheralManager.removeAllServices();
_isServiceAdded = false;
await peripheralManager.stopAdvertising();
await peripheralManager.removeAllServices();
isServiceAdded = false;
startAdvertising(() {});
}
Future<void> 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<void> stopAdvertising() async {
await _peripheralManager.stopAdvertising();
isStarted.value = false;
isConnected.value = false;
_isLoading = false;
await super.stopAdvertising();
}
Future<void> _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();
}
}

View File

@@ -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<void> 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<void> 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<void> stopAdvertising() async {
await _peripheralManager.stopAdvertising();
isStarted.value = false;
isConnected.value = false;
_isLoading = false;
await super.stopAdvertising();
}
Future<void> 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);
}
}