diff --git a/lib/bluetooth/devices/zwift/constants.dart b/lib/bluetooth/devices/zwift/constants.dart index 55ef554..d487c70 100644 --- a/lib/bluetooth/devices/zwift/constants.dart +++ b/lib/bluetooth/devices/zwift/constants.dart @@ -59,7 +59,7 @@ class ZwiftButtons { // left controller static const ControllerButton navigationUp = ControllerButton( 'navigationUp', - action: null, + action: InGameAction.toggleUi, icon: Icons.keyboard_arrow_up, color: Colors.black, ); diff --git a/lib/bluetooth/devices/zwift/zwift_emulator.dart b/lib/bluetooth/devices/zwift/zwift_emulator.dart new file mode 100644 index 0000000..c90c6dc --- /dev/null +++ b/lib/bluetooth/devices/zwift/zwift_emulator.dart @@ -0,0 +1,356 @@ +import 'dart:io'; + +import 'package:bluetooth_low_energy/bluetooth_low_energy.dart'; +import 'package:dartx/dartx.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart' hide ConnectionState; +import 'package:permission_handler/permission_handler.dart'; +import 'package:swift_control/bluetooth/ble.dart'; +import 'package:swift_control/bluetooth/devices/zwift/constants.dart'; +import 'package:swift_control/bluetooth/devices/zwift/protocol/zp.pbenum.dart'; +import 'package:swift_control/bluetooth/devices/zwift/zwift_ride.dart'; +import 'package:swift_control/main.dart'; +import 'package:swift_control/utils/keymap/buttons.dart'; +import 'package:swift_control/utils/requirements/multi.dart'; + +import 'protocol/zwift.pb.dart' show RideKeyPadStatus; + +final zwiftEmulator = ZwiftEmulator(); + +class ZwiftEmulator { + static final List supportedActions = InGameAction.values; + + ValueNotifier isConnected = ValueNotifier(false); + bool get isAdvertising => _isAdvertising; + bool get isLoading => _isLoading; + + final peripheralManager = PeripheralManager(); + bool _isAdvertising = false; + bool _isLoading = false; + bool _isServiceAdded = false; + bool _isSubscribedToEvents = false; + Central? _central; + GATTCharacteristic? _asyncCharacteristic; + + Future reconnect() async { + await peripheralManager.stopAdvertising(); + await peripheralManager.removeAllServices(); + _isServiceAdded = false; + _isAdvertising = false; + startAdvertising(() {}); + } + + Future startAdvertising(VoidCallback onUpdate) async { + _isLoading = true; + onUpdate(); + + peripheralManager.stateChanged.forEach((state) { + print('Peripheral manager state: ${state.state}'); + }); + + if (!kIsWeb && Platform.isAndroid) { + if (Platform.isAndroid) { + peripheralManager.connectionStateChanged.forEach((state) { + print('Peripheral connection state: ${state.state} of ${state.central.uuid}'); + if (state.state == ConnectionState.connected) { + /*(actionHandler as RemoteActions).setConnectedCentral(state.central, inputReport); + //peripheralManager.stopAdvertising(); + onUpdate();*/ + } else if (state.state == ConnectionState.disconnected) { + //(actionHandler as RemoteActions).setConnectedCentral(null, null); + onUpdate(); + } + }); + } + + final status = await Permission.bluetoothAdvertise.request(); + if (!status.isGranted) { + print('Bluetooth advertise permission not granted'); + _isAdvertising = false; + onUpdate(); + return; + } + } + + while (peripheralManager.state != BluetoothLowEnergyState.poweredOn) { + print('Waiting for peripheral manager to be powered on...'); + if (settings.getLastTarget() == Target.thisDevice) { + return; + } + await Future.delayed(Duration(seconds: 1)); + } + + final syncTxCharacteristic = GATTCharacteristic.mutable( + uuid: UUID.fromString(ZwiftConstants.ZWIFT_SYNC_TX_CHARACTERISTIC_UUID), + descriptors: [], + properties: [ + GATTCharacteristicProperty.read, + GATTCharacteristicProperty.indicate, + ], + permissions: [ + GATTCharacteristicPermission.read, + ], + ); + + _asyncCharacteristic = GATTCharacteristic.mutable( + uuid: UUID.fromString(ZwiftConstants.ZWIFT_ASYNC_CHARACTERISTIC_UUID), + descriptors: [], + properties: [ + GATTCharacteristicProperty.notify, + ], + permissions: [], + ); + + if (!_isServiceAdded) { + await Future.delayed(Duration(seconds: 1)); + + if (!_isSubscribedToEvents) { + _isSubscribedToEvents = true; + peripheralManager.characteristicReadRequested.forEach((eventArgs) async { + print('Read request for characteristic: ${eventArgs.characteristic.uuid}'); + + switch (eventArgs.characteristic.uuid.toString().toUpperCase()) { + case ZwiftConstants.ZWIFT_SYNC_TX_CHARACTERISTIC_UUID: + print('Handling read request for SYNC TX characteristic'); + break; + case BleUuid.DEVICE_INFORMATION_CHARACTERISTIC_BATTERY_LEVEL: + await peripheralManager.respondReadRequestWithValue( + eventArgs.request, + value: Uint8List.fromList([100]), + ); + break; + default: + print('Unhandled read request for characteristic: ${eventArgs.characteristic.uuid}'); + } + + final request = eventArgs.request; + final trimmedValue = Uint8List.fromList([]); + await peripheralManager.respondReadRequestWithValue( + request, + value: trimmedValue, + ); + // You can respond to read requests here if needed + }); + + peripheralManager.characteristicNotifyStateChanged.forEach((char) { + print( + 'Notify state changed for characteristic: ${char.characteristic.uuid}: ${char.state}', + ); + }); + peripheralManager.characteristicWriteRequested.forEach((eventArgs) async { + _central = eventArgs.central; + isConnected.value = true; + + final characteristic = eventArgs.characteristic; + final request = eventArgs.request; + final value = request.value; + print( + 'Write request for characteristic: ${characteristic.uuid}', + ); + + switch (eventArgs.characteristic.uuid.toString().toUpperCase()) { + case ZwiftConstants.ZWIFT_SYNC_RX_CHARACTERISTIC_UUID: + print( + 'Handling write request for SYNC RX characteristic, value: ${value.map((e) => e.toRadixString(16).padLeft(2, '0')).join(' ')}\n${String.fromCharCodes(value)}', + ); + + final handshake = [...ZwiftConstants.RIDE_ON, ...ZwiftConstants.RESPONSE_START_CLICK_V2]; + + if (value.contentEquals(handshake)) { + await peripheralManager.notifyCharacteristic( + _central!, + syncTxCharacteristic, + value: ZwiftConstants.RIDE_ON, + ); + onUpdate(); + } + break; + default: + print('Unhandled write request for characteristic: ${eventArgs.characteristic.uuid}'); + } + + await peripheralManager.respondWriteRequest(request); + }); + } + + // Device Information + await peripheralManager.addService( + GATTService( + uuid: UUID.fromString('180A'), + isPrimary: true, + characteristics: [ + GATTCharacteristic.immutable( + uuid: UUID.fromString('2A29'), + value: Uint8List.fromList('Zwift Inc'.codeUnits), + descriptors: [], + ), + GATTCharacteristic.immutable( + uuid: UUID.fromString('2A25'), + value: Uint8List.fromList('09-B48123283828F1337'.codeUnits), + descriptors: [], + ), + GATTCharacteristic.immutable( + uuid: UUID.fromString('2A27'), + value: Uint8List.fromList('A.0'.codeUnits), + descriptors: [], + ), + GATTCharacteristic.immutable( + uuid: UUID.fromString('2A26'), + value: Uint8List.fromList('1.1.0'.codeUnits), + descriptors: [], + ), + ], + includedServices: [], + ), + ); + + // Battery Service + await peripheralManager.addService( + GATTService( + uuid: UUID.fromString('180F'), + isPrimary: true, + characteristics: [ + GATTCharacteristic.mutable( + uuid: UUID.fromString('2A19'), + descriptors: [], + properties: [ + GATTCharacteristicProperty.read, + GATTCharacteristicProperty.notify, + ], + permissions: [ + GATTCharacteristicPermission.read, + ], + ), + ], + includedServices: [], + ), + ); + + // Unknown Service + await peripheralManager.addService( + GATTService( + uuid: UUID.fromString(ZwiftConstants.ZWIFT_RIDE_CUSTOM_SERVICE_UUID_SHORT), + isPrimary: true, + characteristics: [ + _asyncCharacteristic!, + GATTCharacteristic.mutable( + uuid: UUID.fromString(ZwiftConstants.ZWIFT_SYNC_RX_CHARACTERISTIC_UUID), + descriptors: [], + properties: [ + GATTCharacteristicProperty.writeWithoutResponse, + ], + permissions: [], + ), + syncTxCharacteristic, + GATTCharacteristic.mutable( + uuid: UUID.fromString('00000005-19CA-4651-86E5-FA29DCDD09D1'), + descriptors: [], + properties: [ + GATTCharacteristicProperty.notify, + ], + permissions: [], + ), + GATTCharacteristic.mutable( + uuid: UUID.fromString('00000006-19CA-4651-86E5-FA29DCDD09D1'), + descriptors: [], + properties: [ + GATTCharacteristicProperty.indicate, + GATTCharacteristicProperty.read, + GATTCharacteristicProperty.writeWithoutResponse, + GATTCharacteristicProperty.write, + ], + permissions: [ + GATTCharacteristicPermission.read, + GATTCharacteristicPermission.write, + ], + ), + ], + includedServices: [], + ), + ); + _isServiceAdded = true; + } + + final advertisement = Advertisement( + name: 'SwiftControl', + serviceUUIDs: [UUID.fromString(ZwiftConstants.ZWIFT_RIDE_CUSTOM_SERVICE_UUID_SHORT)], + serviceData: { + UUID.fromString(ZwiftConstants.ZWIFT_RIDE_CUSTOM_SERVICE_UUID_SHORT): Uint8List.fromList([0x02]), + }, + manufacturerSpecificData: [ + ManufacturerSpecificData( + id: 0x094A, + data: Uint8List.fromList([ZwiftConstants.CLICK_V2_LEFT_SIDE, 0x13, 0x37]), + ), + ], + ); + print('Starting advertising with HID service...'); + + await peripheralManager.startAdvertising(advertisement); + _isAdvertising = true; + _isLoading = false; + onUpdate(); + } + + Future stopAdvertising() async { + await peripheralManager.stopAdvertising(); + _isAdvertising = false; + _isLoading = false; + } + + Future sendAction(InGameAction inGameAction, int? inGameActionValue) async { + final button = switch (inGameAction) { + InGameAction.shiftUp => RideButtonMask.SHFT_UP_R_BTN, + InGameAction.shiftDown => RideButtonMask.SHFT_UP_L_BTN, + InGameAction.navigateLeft => RideButtonMask.LEFT_BTN, + InGameAction.navigateRight => RideButtonMask.RIGHT_BTN, + InGameAction.increaseResistance => RideButtonMask.SHFT_UP_R_BTN, + InGameAction.decreaseResistance => RideButtonMask.SHFT_UP_L_BTN, + InGameAction.toggleUi => RideButtonMask.UP_BTN, + InGameAction.cameraAngle => RideButtonMask.Z_BTN, + InGameAction.emote => RideButtonMask.A_BTN, + InGameAction.uturn => RideButtonMask.DOWN_BTN, + InGameAction.steerLeft => RideButtonMask.LEFT_BTN, + InGameAction.steerRight => RideButtonMask.RIGHT_BTN, + }; + + final status = RideKeyPadStatus() + ..buttonMap = (~button.mask) & 0xFFFFFFFF + ..analogPaddles.clear(); + + // Serialize to bytes if you need to send it + final bytes = status.writeToBuffer(); + + //..buttonMinus = !down ? PlayButtonStatus.ON : PlayButtonStatus.OFF; + final commandProto = Uint8List.fromList([ + Opcode.CONTROLLER_NOTIFICATION.value, + ...bytes, + ]); + + print('Constructed proto : ${commandProto.map((e) => e.toRadixString(16).padLeft(2, '0')).join(' ')}'); + + peripheralManager.notifyCharacteristic(_central!, _asyncCharacteristic!, value: commandProto); + + final zero = Uint8List.fromList([0x23, 0x08, 0xFF, 0xFF, 0xFF, 0xFF, 0x0F]); + peripheralManager.notifyCharacteristic(_central!, _asyncCharacteristic!, value: zero); + return 'Sent action: ${inGameAction.name}'; + } +} + +class ZwiftEmulatorInformation extends StatelessWidget { + const ZwiftEmulatorInformation({super.key}); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: zwiftEmulator.isConnected, + builder: (context, isConnected, _) { + return StatefulBuilder( + builder: (context, setState) { + return Text('Zwift is ${isConnected ? 'connected' : 'not connected'}'); + }, + ); + }, + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index bd63486..92bfd3a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -34,6 +34,7 @@ enum ConnectionType { unknown, local, remote, + zwift, } Future initializeActions(ConnectionType connectionType) async { @@ -42,18 +43,21 @@ Future initializeActions(ConnectionType connectionType) async { } else if (Platform.isAndroid) { actionHandler = switch (connectionType) { ConnectionType.local => AndroidActions(), + ConnectionType.zwift => AndroidActions(), ConnectionType.remote => RemoteActions(), ConnectionType.unknown => StubActions(), }; } else if (Platform.isIOS) { actionHandler = switch (connectionType) { ConnectionType.local => StubActions(), + ConnectionType.zwift => StubActions(), ConnectionType.remote => RemoteActions(), ConnectionType.unknown => StubActions(), }; } else { actionHandler = switch (connectionType) { ConnectionType.local => DesktopActions(), + ConnectionType.zwift => DesktopActions(), ConnectionType.remote => RemoteActions(), ConnectionType.unknown => StubActions(), }; diff --git a/lib/pages/device.dart b/lib/pages/device.dart index 3a0a638..6543212 100644 --- a/lib/pages/device.dart +++ b/lib/pages/device.dart @@ -6,9 +6,11 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:swift_control/bluetooth/devices/link/link_device.dart'; +import 'package:swift_control/bluetooth/devices/zwift/zwift_emulator.dart'; import 'package:swift_control/main.dart'; import 'package:swift_control/utils/actions/desktop.dart'; import 'package:swift_control/utils/keymap/apps/my_whoosh.dart'; +import 'package:swift_control/utils/keymap/apps/zwift.dart'; import 'package:swift_control/utils/keymap/manager.dart'; import 'package:swift_control/widgets/beta_pill.dart'; import 'package:swift_control/widgets/keymap_explanation.dart'; @@ -229,7 +231,8 @@ class _DevicePageState extends State with WidgetsBindingObserver { if (connection.remoteDevices.isNotEmpty || actionHandler is RemoteActions || - settings.getTrainerApp() is MyWhoosh) + settings.getTrainerApp() is MyWhoosh || + settings.getTrainerApp() is Zwift) Container( margin: const EdgeInsets.only(bottom: 8.0), width: double.infinity, @@ -254,6 +257,7 @@ class _DevicePageState extends State with WidgetsBindingObserver { if (settings.getTrainerApp() is MyWhoosh && !whooshLink.isConnected.value) LinkDevice('').showInformation(context), + if (settings.getTrainerApp() is Zwift) ZwiftEmulatorInformation(), if (actionHandler is RemoteActions) Row( diff --git a/lib/pages/requirements.dart b/lib/pages/requirements.dart index ad03556..3d207a2 100644 --- a/lib/pages/requirements.dart +++ b/lib/pages/requirements.dart @@ -175,7 +175,9 @@ class _RequirementsPageState extends State with WidgetsBinding } void _reloadRequirements() { - getRequirements(settings.getLastTarget()?.connectionType ?? ConnectionType.unknown).then((req) { + getRequirements( + settings.getTrainerApp()?.connectionType ?? settings.getLastTarget()?.connectionType ?? ConnectionType.unknown, + ).then((req) { _requirements = req; final unresolvedIndex = req.indexWhere((req) => !req.status); if (unresolvedIndex != -1) { diff --git a/lib/pages/touch_area.dart b/lib/pages/touch_area.dart index e68bfed..6ddb770 100644 --- a/lib/pages/touch_area.dart +++ b/lib/pages/touch_area.dart @@ -9,6 +9,7 @@ import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; import 'package:image_picker/image_picker.dart'; import 'package:path_provider/path_provider.dart'; +import 'package:swift_control/bluetooth/devices/zwift/zwift_emulator.dart'; import 'package:swift_control/main.dart'; import 'package:swift_control/widgets/button_widget.dart'; import 'package:swift_control/widgets/keymap_explanation.dart'; @@ -400,7 +401,7 @@ class KeypairExplanation extends StatelessWidget { ) else Icon(keyPair.icon), - if (keyPair.inGameAction != null && whooshLink.isConnected.value) + if (keyPair.inGameAction != null && (whooshLink.isConnected.value || zwiftEmulator.isConnected.value)) _KeyWidget( label: [ keyPair.inGameAction.toString().split('.').last, diff --git a/lib/utils/actions/android.dart b/lib/utils/actions/android.dart index bfba135..5791f13 100644 --- a/lib/utils/actions/android.dart +++ b/lib/utils/actions/android.dart @@ -1,5 +1,6 @@ import 'package:accessibility/accessibility.dart'; import 'package:flutter/services.dart'; +import 'package:swift_control/bluetooth/devices/zwift/zwift_emulator.dart'; import 'package:swift_control/main.dart'; import 'package:swift_control/utils/actions/base_actions.dart'; import 'package:swift_control/utils/keymap/buttons.dart'; @@ -37,6 +38,8 @@ class AndroidActions extends BaseActions { if (keyPair.inGameAction != null && whooshLink.isConnected.value) { return whooshLink.sendAction(keyPair.inGameAction!, keyPair.inGameActionValue); + } else if (keyPair.inGameAction != null && zwiftEmulator.isConnected.value) { + return zwiftEmulator.sendAction(keyPair.inGameAction!, keyPair.inGameActionValue); } else if (keyPair.isSpecialKey) { await accessibilityHandler.controlMedia(switch (keyPair.physicalKey) { PhysicalKeyboardKey.mediaTrackNext => MediaAction.next, diff --git a/lib/utils/actions/desktop.dart b/lib/utils/actions/desktop.dart index cb866c7..444bff8 100644 --- a/lib/utils/actions/desktop.dart +++ b/lib/utils/actions/desktop.dart @@ -1,6 +1,7 @@ import 'dart:ui'; import 'package:keypress_simulator/keypress_simulator.dart'; +import 'package:swift_control/bluetooth/devices/zwift/zwift_emulator.dart'; import 'package:swift_control/main.dart'; import 'package:swift_control/utils/actions/base_actions.dart'; import 'package:swift_control/utils/keymap/buttons.dart'; @@ -25,6 +26,8 @@ class DesktopActions extends BaseActions { // Handle regular key press mode (existing behavior) if (keyPair.inGameAction != null && whooshLink.isConnected.value) { return whooshLink.sendAction(keyPair.inGameAction!, keyPair.inGameActionValue); + } else if (keyPair.inGameAction != null && zwiftEmulator.isConnected.value) { + return zwiftEmulator.sendAction(keyPair.inGameAction!, keyPair.inGameActionValue); } else if (keyPair.physicalKey != null) { if (isKeyDown && isKeyUp) { await keyPressSimulator.simulateKeyDown(keyPair.physicalKey); diff --git a/lib/utils/actions/remote.dart b/lib/utils/actions/remote.dart index 9e1d4b6..d3457ff 100644 --- a/lib/utils/actions/remote.dart +++ b/lib/utils/actions/remote.dart @@ -4,6 +4,7 @@ import 'package:accessibility/accessibility.dart'; import 'package:bluetooth_low_energy/bluetooth_low_energy.dart'; import 'package:flutter/foundation.dart'; import 'package:swift_control/bluetooth/devices/zwift/zwift_click.dart'; +import 'package:swift_control/bluetooth/devices/zwift/zwift_emulator.dart'; import 'package:swift_control/main.dart'; import 'package:swift_control/utils/actions/base_actions.dart'; import 'package:swift_control/utils/keymap/buttons.dart'; @@ -29,6 +30,8 @@ class RemoteActions extends BaseActions { if (keyPair.inGameAction != null && whooshLink.isConnected.value) { return whooshLink.sendAction(keyPair.inGameAction!, keyPair.inGameActionValue); + } else if (keyPair.inGameAction != null && zwiftEmulator.isConnected.value) { + return zwiftEmulator.sendAction(keyPair.inGameAction!, keyPair.inGameActionValue); } else if (!(actionHandler as RemoteActions).isConnected) { return 'Not connected to a ${settings.getLastTarget()?.name ?? 'remote'} device'; } diff --git a/lib/utils/keymap/apps/biketerra.dart b/lib/utils/keymap/apps/biketerra.dart index df8ac79..4a2e86f 100644 --- a/lib/utils/keymap/apps/biketerra.dart +++ b/lib/utils/keymap/apps/biketerra.dart @@ -1,6 +1,7 @@ import 'package:dartx/dartx.dart'; import 'package:flutter/services.dart'; import 'package:swift_control/utils/keymap/apps/supported_app.dart'; +import 'package:swift_control/utils/requirements/multi.dart'; import '../buttons.dart'; import '../keymap.dart'; @@ -10,6 +11,7 @@ class Biketerra extends SupportedApp { : super( name: 'Biketerra', packageName: "biketerra", + compatibleTargets: Target.values, keymap: Keymap( keyPairs: [ KeyPair( diff --git a/lib/utils/keymap/apps/custom_app.dart b/lib/utils/keymap/apps/custom_app.dart index 204f15d..9bce6d1 100644 --- a/lib/utils/keymap/apps/custom_app.dart +++ b/lib/utils/keymap/apps/custom_app.dart @@ -1,6 +1,7 @@ import 'package:dartx/dartx.dart'; import 'package:flutter/services.dart'; import 'package:swift_control/utils/keymap/apps/supported_app.dart'; +import 'package:swift_control/utils/requirements/multi.dart'; import '../buttons.dart'; import '../keymap.dart'; @@ -11,6 +12,7 @@ class CustomApp extends SupportedApp { CustomApp({this.profileName = 'Other'}) : super( name: profileName, + compatibleTargets: Target.values, packageName: "custom_$profileName", keymap: Keymap(keyPairs: []), ); diff --git a/lib/utils/keymap/apps/my_whoosh.dart b/lib/utils/keymap/apps/my_whoosh.dart index dc9a1ad..52b819c 100644 --- a/lib/utils/keymap/apps/my_whoosh.dart +++ b/lib/utils/keymap/apps/my_whoosh.dart @@ -1,6 +1,7 @@ import 'package:dartx/dartx.dart'; import 'package:flutter/services.dart'; import 'package:swift_control/utils/keymap/apps/supported_app.dart'; +import 'package:swift_control/utils/requirements/multi.dart'; import '../buttons.dart'; import '../keymap.dart'; @@ -10,6 +11,7 @@ class MyWhoosh extends SupportedApp { : super( name: 'MyWhoosh', packageName: "com.mywhoosh.whooshgame", + compatibleTargets: Target.values, keymap: Keymap( keyPairs: [ KeyPair( diff --git a/lib/utils/keymap/apps/supported_app.dart b/lib/utils/keymap/apps/supported_app.dart index 3ac074e..7765517 100644 --- a/lib/utils/keymap/apps/supported_app.dart +++ b/lib/utils/keymap/apps/supported_app.dart @@ -1,18 +1,29 @@ +import 'package:swift_control/main.dart'; import 'package:swift_control/utils/keymap/apps/biketerra.dart'; import 'package:swift_control/utils/keymap/apps/training_peaks.dart'; +import 'package:swift_control/utils/keymap/apps/zwift.dart'; +import 'package:swift_control/utils/requirements/multi.dart'; import '../keymap.dart'; import 'custom_app.dart'; import 'my_whoosh.dart'; abstract class SupportedApp { + final List compatibleTargets; final String packageName; final String name; final Keymap keymap; + final ConnectionType? connectionType; - const SupportedApp({required this.name, required this.packageName, required this.keymap}); + const SupportedApp({ + required this.name, + required this.packageName, + required this.keymap, + required this.compatibleTargets, + this.connectionType, + }); - static final List supportedApps = [MyWhoosh(), TrainingPeaks(), Biketerra(), CustomApp()]; + static final List supportedApps = [MyWhoosh(), Zwift(), TrainingPeaks(), Biketerra(), CustomApp()]; @override String toString() { diff --git a/lib/utils/keymap/apps/training_peaks.dart b/lib/utils/keymap/apps/training_peaks.dart index 26a17dd..2b1e032 100644 --- a/lib/utils/keymap/apps/training_peaks.dart +++ b/lib/utils/keymap/apps/training_peaks.dart @@ -4,6 +4,7 @@ import 'package:swift_control/bluetooth/devices/elite/elite_square.dart'; import 'package:swift_control/bluetooth/devices/zwift/constants.dart'; import 'package:swift_control/utils/keymap/apps/supported_app.dart'; import 'package:swift_control/utils/keymap/buttons.dart'; +import 'package:swift_control/utils/requirements/multi.dart'; import '../keymap.dart'; @@ -12,6 +13,7 @@ class TrainingPeaks extends SupportedApp { : super( name: 'TrainingPeaks Virtual / IndieVelo', packageName: "com.indieVelo.client", + compatibleTargets: Target.values, keymap: Keymap( keyPairs: [ // Explicit controller-button mappings with updated touch coordinates diff --git a/lib/utils/keymap/apps/zwift.dart b/lib/utils/keymap/apps/zwift.dart new file mode 100644 index 0000000..3f35d3b --- /dev/null +++ b/lib/utils/keymap/apps/zwift.dart @@ -0,0 +1,23 @@ +import 'package:dartx/dartx.dart'; +import 'package:swift_control/bluetooth/devices/zwift/zwift_clickv2.dart'; +import 'package:swift_control/main.dart'; +import 'package:swift_control/utils/keymap/apps/supported_app.dart'; +import 'package:swift_control/utils/requirements/multi.dart'; +import 'package:universal_ble/universal_ble.dart'; + +import '../keymap.dart'; + +class Zwift extends SupportedApp { + Zwift() + : super( + name: 'Zwift', + packageName: "com.zwift.zwiftgame", + connectionType: ConnectionType.zwift, + compatibleTargets: Target.values.whereNot((e) => e == Target.thisDevice).toList(), + keymap: Keymap( + keyPairs: ZwiftClickV2(BleDevice(name: '', deviceId: '')).availableButtons + .map((b) => KeyPair(buttons: [b], physicalKey: null, logicalKey: null, inGameAction: b.action)) + .toList(), + ), + ); +} diff --git a/lib/utils/requirements/multi.dart b/lib/utils/requirements/multi.dart index c6272c8..ad88847 100644 --- a/lib/utils/requirements/multi.dart +++ b/lib/utils/requirements/multi.dart @@ -7,6 +7,7 @@ import 'package:swift_control/main.dart'; import 'package:swift_control/utils/keymap/apps/custom_app.dart'; import 'package:swift_control/utils/keymap/apps/my_whoosh.dart'; import 'package:swift_control/utils/keymap/apps/supported_app.dart'; +import 'package:swift_control/utils/keymap/apps/zwift.dart'; import 'package:swift_control/utils/requirements/platform.dart'; import 'package:swift_control/utils/requirements/remote.dart'; import 'package:swift_control/widgets/beta_pill.dart'; @@ -86,49 +87,70 @@ typedef BoolFunction = bool Function(); enum Target { thisDevice( - title: 'This device', - description: 'Trainer app runs on this device', + title: 'This Device', icon: Icons.devices, ), - iPad( - title: 'iPad', - description: 'Remotely control any trainer app on an iPad by acting as a Mouse, or directly via MyWhoosh Link', + iOS( + title: 'iPhone / iPad / Apple TV', icon: Icons.settings_remote_outlined, ), android( title: 'Android Device', - description: 'Remotely control any trainer app on another Android device, or directly via MyWhoosh Link', icon: Icons.settings_remote_outlined, - isBeta: true, ), macOS( title: 'Mac', - description: 'Remotely control any trainer app on another Mac, or directly via MyWhoosh Link', icon: Icons.settings_remote_outlined, - isBeta: true, ), windows( title: 'Windows PC', - description: 'Remotely control any trainer app on another Windows PC, or directly via MyWhoosh Link', icon: Icons.settings_remote_outlined, - isBeta: true, ); final String title; - final String description; final IconData icon; - final bool isBeta; - const Target({required this.title, required this.description, required this.icon, this.isBeta = false}); + const Target({required this.title, required this.icon}); bool get isCompatible { + return actionHandler.supportedApp?.compatibleTargets.contains(this) == true; + } + + bool get isBeta { + final supportedApp = actionHandler.supportedApp; + + if (supportedApp is Zwift) { + // everything is supported, this device is not compatible anyway + return false; + } + return switch (this) { - Target.thisDevice => !Platform.isIOS, + Target.thisDevice => false, _ => true, }; } + String getDescription(SupportedApp? app) { + return switch (this) { + Target.thisDevice when !isCompatible => + 'Due to platform restrictions only controlling ${app?.name ?? 'the Trainer app'} on other devices is supported.', + Target.thisDevice => 'Run ${app?.name ?? 'the Trainer app'} on this device.', + Target.iOS => + 'Run ${app?.name ?? 'the Trainer app'} on your Apple device and control it remotely from this device${app is MyWhoosh ? ', e.g. by using MyWhoosh Link method' : ''}.', + Target.android => + 'Run ${app?.name ?? 'the Trainer app'} on your Android device and control it remotely from this device${app is MyWhoosh ? ', e.g. by using MyWhoosh Link method' : ''}.', + Target.macOS => + 'Run ${app?.name ?? 'the Trainer app'} on your Mac and control it remotely from this device${app is MyWhoosh ? ', e.g. by using MyWhoosh Link method' : ''}.', + Target.windows => + 'Run ${app?.name ?? 'the Trainer app'} on your Windows PC and control it remotely from this device${app is MyWhoosh ? ', e.g. by using MyWhoosh Link method' : ''}.', + }; + } + String? get warning { + if (actionHandler.supportedApp is Zwift) { + // no warnings for zwift + return null; + } return switch (this) { Target.android when Platform.isAndroid => "Select 'This device' unless you want to control another Android device. Are you sure?", @@ -170,86 +192,104 @@ class TargetRequirement extends PlatformRequirement { @override Widget? build(BuildContext context, VoidCallback onUpdate) { - return Column( - spacing: 8, - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Select Trainer App', style: TextStyle(fontWeight: FontWeight.bold)), - DropdownMenu( - dropdownMenuEntries: SupportedApp.supportedApps.map((app) { - return DropdownMenuEntry( - value: app, - label: app.name, - ); - }).toList(), - hintText: 'Select Trainer app', - initialSelection: settings.getTrainerApp(), - onSelected: (selectedApp) async { - if (settings.getTrainerApp() is MyWhoosh && selectedApp is! MyWhoosh && whooshLink.isStarted.value) { - whooshLink.stopServer(); - } - settings.setTrainerApp(selectedApp!); - if (actionHandler.supportedApp == null || - (actionHandler.supportedApp is! CustomApp && selectedApp is! CustomApp)) { - actionHandler.init(selectedApp); - settings.setSupportedApp(selectedApp); - } - }, - ), - SizedBox(height: 8), - Text( - 'Select Target where ${settings.getTrainerApp()?.name ?? 'the Trainer app'} runs on', - style: TextStyle(fontWeight: FontWeight.bold), - ), - DropdownMenu( - dropdownMenuEntries: Target.values.map((target) { - return DropdownMenuEntry( - value: target, - label: target.title, - enabled: target.isCompatible, - trailingIcon: Icon(target.icon), - labelWidget: Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text(target.title, style: TextStyle(fontWeight: FontWeight.bold)), - if (target.isBeta || (!Platform.isIOS && target == Target.iPad)) BetaPill(), - ], - ), - Text( - target.isCompatible - ? target.description - : 'Due to iOS restrictions only controlling trainer apps on other devices is supported.', - style: TextStyle(fontSize: 12, color: Colors.grey), - ), - ], - ), - ), - ); - }).toList(), - hintText: name, - initialSelection: settings.getLastTarget(), - onSelected: (target) async { - if (target != null) { - await settings.setLastTarget(target); - initializeActions(target.connectionType); - if (target.warning != null) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(target.warning!), - duration: Duration(seconds: 10), - ), - ); + return StatefulBuilder( + builder: (c, setState) => Column( + spacing: 8, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Select Trainer App', style: TextStyle(fontWeight: FontWeight.bold)), + DropdownMenu( + dropdownMenuEntries: SupportedApp.supportedApps.map((app) { + return DropdownMenuEntry( + value: app, + label: app.name, + ); + }).toList(), + hintText: 'Select Trainer app', + initialSelection: settings.getTrainerApp(), + onSelected: (selectedApp) async { + if (settings.getTrainerApp() is MyWhoosh && selectedApp is! MyWhoosh && whooshLink.isStarted.value) { + whooshLink.stopServer(); } - onUpdate(); - } - }, - ), - ], + settings.setTrainerApp(selectedApp!); + if (actionHandler.supportedApp == null || + (actionHandler.supportedApp is! CustomApp && selectedApp is! CustomApp)) { + actionHandler.init(selectedApp); + settings.setSupportedApp(selectedApp); + } + setState(() {}); + }, + ), + SizedBox(height: 8), + Text( + 'Select Target where ${settings.getTrainerApp()?.name ?? 'the Trainer app'} runs on', + style: TextStyle(fontWeight: FontWeight.bold), + ), + DropdownMenu( + dropdownMenuEntries: Target.values.map((target) { + return DropdownMenuEntry( + value: target, + label: target.title, + enabled: target.isCompatible, + trailingIcon: Icon(target.icon), + labelWidget: Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text(target.title, style: TextStyle(fontWeight: FontWeight.bold)), + if (target.isBeta) BetaPill(), + ], + ), + Text( + target.getDescription(actionHandler.supportedApp), + style: TextStyle(fontSize: 12, color: Colors.grey), + ), + if (target == Target.thisDevice) + Container( + margin: EdgeInsets.only(top: 12), + height: 4, + decoration: BoxDecoration( + color: Theme.of(context).dividerColor, + borderRadius: BorderRadius.circular(4), + ), + ), + ], + ), + ), + ); + }).toList(), + hintText: 'Select Target device', + initialSelection: settings.getLastTarget(), + onSelected: (target) async { + if (target != null) { + await settings.setLastTarget(target); + initializeActions(settings.getTrainerApp()?.connectionType ?? target.connectionType); + if (target.warning != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(target.warning!), + duration: Duration(seconds: 10), + ), + ); + } + setState(() {}); + } + }, + ), + ElevatedButton( + onPressed: settings.getTrainerApp() != null && settings.getLastTarget() != null + ? () { + onUpdate(); + } + : null, + child: Text('Continue'), + ), + ], + ), ); } diff --git a/lib/utils/requirements/platform.dart b/lib/utils/requirements/platform.dart index f429c1d..9f5430b 100644 --- a/lib/utils/requirements/platform.dart +++ b/lib/utils/requirements/platform.dart @@ -45,6 +45,7 @@ Future> getRequirements(ConnectionType connectionType) BluetoothTurnedOn(), switch (connectionType) { ConnectionType.local => KeyboardRequirement(), + ConnectionType.zwift => ZwiftRequirement(), ConnectionType.remote => RemoteRequirement(), ConnectionType.unknown => PlaceholderRequirement(), }, @@ -55,6 +56,7 @@ Future> getRequirements(ConnectionType connectionType) BluetoothTurnedOn(), switch (connectionType) { ConnectionType.local => RemoteRequirement(), + ConnectionType.zwift => ZwiftRequirement(), ConnectionType.remote => RemoteRequirement(), ConnectionType.unknown => PlaceholderRequirement(), }, @@ -65,6 +67,7 @@ Future> getRequirements(ConnectionType connectionType) BluetoothTurnedOn(), switch (connectionType) { ConnectionType.local => KeyboardRequirement(), + ConnectionType.zwift => ZwiftRequirement(), ConnectionType.remote => RemoteRequirement(), ConnectionType.unknown => PlaceholderRequirement(), }, @@ -84,7 +87,8 @@ Future> getRequirements(ConnectionType connectionType) ], switch (connectionType) { ConnectionType.local => AccessibilityRequirement(), - ConnectionType.remote => ZwiftRequirement(), + ConnectionType.zwift => ZwiftRequirement(), + ConnectionType.remote => RemoteRequirement(), ConnectionType.unknown => PlaceholderRequirement(), }, ]; diff --git a/lib/utils/requirements/remote.dart b/lib/utils/requirements/remote.dart index 6f180e9..a542c10 100644 --- a/lib/utils/requirements/remote.dart +++ b/lib/utils/requirements/remote.dart @@ -35,7 +35,7 @@ class RemoteRequirement extends PlatformRequirement { ? null : Text( switch (settings.getLastTarget()) { - Target.iPad => + Target.iOS => 'On your iPad go to Settings > Accessibility > Touch > AssistiveTouch > Pointer Devices > Devices and pair your device. Make sure AssistiveTouch is enabled.', _ => 'On your ${settings.getLastTarget()?.title} go into Bluetooth settings and look for SwiftControl or your machines name. Pairing is required to use the remote feature.', diff --git a/lib/utils/requirements/zwift.dart b/lib/utils/requirements/zwift.dart index 3ab36b5..cb2038a 100644 --- a/lib/utils/requirements/zwift.dart +++ b/lib/utils/requirements/zwift.dart @@ -1,36 +1,14 @@ -import 'dart:io'; - -import 'package:bluetooth_low_energy/bluetooth_low_energy.dart'; -import 'package:dartx/dartx.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart' hide ConnectionState; -import 'package:permission_handler/permission_handler.dart'; -import 'package:swift_control/bluetooth/ble.dart'; -import 'package:swift_control/bluetooth/devices/zwift/constants.dart'; -import 'package:swift_control/bluetooth/devices/zwift/protocol/zp.pb.dart'; -import 'package:swift_control/bluetooth/devices/zwift/protocol/zwift.pb.dart' hide RideButtonMask; +import 'package:swift_control/bluetooth/devices/zwift/zwift_emulator.dart'; import 'package:swift_control/main.dart'; -import 'package:swift_control/utils/actions/remote.dart'; -import 'package:swift_control/utils/keymap/apps/my_whoosh.dart'; -import 'package:swift_control/utils/requirements/multi.dart'; +import 'package:swift_control/pages/markdown.dart'; import 'package:swift_control/utils/requirements/platform.dart'; import 'package:swift_control/widgets/small_progress_indicator.dart'; -import '../../bluetooth/devices/zwift/zwift_ride.dart'; -import '../../pages/markdown.dart'; - -final peripheralManager = PeripheralManager(); -bool _isAdvertising = false; -bool _isLoading = false; -bool _isServiceAdded = false; -bool _isSubscribedToEvents = false; -Central? _central; -GATTCharacteristic? _asyncCharacteristic; - class ZwiftRequirement extends PlatformRequirement { ZwiftRequirement() : super( - 'Connect to your target device', + 'Pair SwiftControl with Zwift', ); @override @@ -41,273 +19,10 @@ class ZwiftRequirement extends PlatformRequirement { return settings.getLastTarget() == null ? null : Text( - switch (settings.getLastTarget()) { - Target.iPad => - 'On your iPad go to Settings > Accessibility > Touch > AssistiveTouch > Pointer Devices > Devices and pair your device. Make sure AssistiveTouch is enabled.', - _ => - 'On your ${settings.getLastTarget()?.title} go into Bluetooth settings and look for SwiftControl or your machines name. Pairing is required to use the remote feature.', - }, + 'In Zwift on your ${settings.getLastTarget()?.title} go into the Pairing settings and select SwiftControl from the list of available controllers.', ); } - Future reconnect() async { - await peripheralManager.stopAdvertising(); - await peripheralManager.removeAllServices(); - _isServiceAdded = false; - _isAdvertising = false; - (actionHandler as RemoteActions).setConnectedCentral(null, null); - startAdvertising(() {}); - } - - Future startAdvertising(VoidCallback onUpdate) async { - peripheralManager.stateChanged.forEach((state) { - print('Peripheral manager state: ${state.state}'); - }); - - if (!kIsWeb && Platform.isAndroid) { - if (Platform.isAndroid) { - peripheralManager.connectionStateChanged.forEach((state) { - print('Peripheral connection state: ${state.state} of ${state.central.uuid}'); - if (state.state == ConnectionState.connected) { - /*(actionHandler as RemoteActions).setConnectedCentral(state.central, inputReport); - //peripheralManager.stopAdvertising(); - onUpdate();*/ - } else if (state.state == ConnectionState.disconnected) { - //(actionHandler as RemoteActions).setConnectedCentral(null, null); - onUpdate(); - } - }); - } - - final status = await Permission.bluetoothAdvertise.request(); - if (!status.isGranted) { - print('Bluetooth advertise permission not granted'); - _isAdvertising = false; - onUpdate(); - return; - } - } - - while (peripheralManager.state != BluetoothLowEnergyState.poweredOn) { - print('Waiting for peripheral manager to be powered on...'); - if (settings.getLastTarget() == Target.thisDevice) { - return; - } - await Future.delayed(Duration(seconds: 1)); - } - - final syncTxCharacteristic = GATTCharacteristic.mutable( - uuid: UUID.fromString(ZwiftConstants.ZWIFT_SYNC_TX_CHARACTERISTIC_UUID), - descriptors: [], - properties: [ - GATTCharacteristicProperty.read, - GATTCharacteristicProperty.indicate, - ], - permissions: [ - GATTCharacteristicPermission.read, - ], - ); - - _asyncCharacteristic = GATTCharacteristic.mutable( - uuid: UUID.fromString(ZwiftConstants.ZWIFT_ASYNC_CHARACTERISTIC_UUID), - descriptors: [], - properties: [ - GATTCharacteristicProperty.notify, - ], - permissions: [], - ); - - if (!_isServiceAdded) { - await Future.delayed(Duration(seconds: 1)); - - if (!_isSubscribedToEvents) { - _isSubscribedToEvents = true; - peripheralManager.characteristicReadRequested.forEach((eventArgs) async { - print('Read request for characteristic: ${eventArgs.characteristic.uuid}'); - - switch (eventArgs.characteristic.uuid.toString().toUpperCase()) { - case ZwiftConstants.ZWIFT_SYNC_TX_CHARACTERISTIC_UUID: - print('Handling read request for SYNC TX characteristic'); - break; - case BleUuid.DEVICE_INFORMATION_CHARACTERISTIC_BATTERY_LEVEL: - await peripheralManager.respondReadRequestWithValue( - eventArgs.request, - value: Uint8List.fromList([100]), - ); - break; - default: - print('Unhandled read request for characteristic: ${eventArgs.characteristic.uuid}'); - } - - final central = eventArgs.central; - final characteristic = eventArgs.characteristic; - final request = eventArgs.request; - final offset = request.offset; - final trimmedValue = Uint8List.fromList([]); - await peripheralManager.respondReadRequestWithValue( - request, - value: trimmedValue, - ); - // You can respond to read requests here if needed - }); - - peripheralManager.characteristicNotifyStateChanged.forEach((char) { - print( - 'Notify state changed for characteristic: ${char.characteristic.uuid}: ${char.state}', - ); - }); - peripheralManager.characteristicWriteRequested.forEach((eventArgs) async { - _central = eventArgs.central; - final characteristic = eventArgs.characteristic; - final request = eventArgs.request; - final offset = request.offset; - final value = request.value; - print( - 'Write request for characteristic: ${characteristic.uuid}', - ); - - switch (eventArgs.characteristic.uuid.toString().toUpperCase()) { - case ZwiftConstants.ZWIFT_SYNC_RX_CHARACTERISTIC_UUID: - print( - 'Handling write request for SYNC RX characteristic, value: ${value.map((e) => e.toRadixString(16).padLeft(2, '0')).join(' ')}\n${String.fromCharCodes(value)}', - ); - - final handshake = [...ZwiftConstants.RIDE_ON, ...ZwiftConstants.RESPONSE_START_CLICK_V2]; - - if (value.contentEquals(handshake)) { - await peripheralManager.notifyCharacteristic( - _central!, - syncTxCharacteristic, - value: ZwiftConstants.RIDE_ON, - ); - } - break; - default: - print('Unhandled write request for characteristic: ${eventArgs.characteristic.uuid}'); - } - - await peripheralManager.respondWriteRequest(request); - }); - } - - // Device Information - await peripheralManager.addService( - GATTService( - uuid: UUID.fromString('180A'), - isPrimary: true, - characteristics: [ - GATTCharacteristic.immutable( - uuid: UUID.fromString('2A29'), - value: Uint8List.fromList('Zwift Inc'.codeUnits), - descriptors: [], - ), - GATTCharacteristic.immutable( - uuid: UUID.fromString('2A25'), - value: Uint8List.fromList('09-B48123283828F1337'.codeUnits), - descriptors: [], - ), - GATTCharacteristic.immutable( - uuid: UUID.fromString('2A27'), - value: Uint8List.fromList('A.0'.codeUnits), - descriptors: [], - ), - GATTCharacteristic.immutable( - uuid: UUID.fromString('2A26'), - value: Uint8List.fromList('1.1.0'.codeUnits), - descriptors: [], - ), - ], - includedServices: [], - ), - ); - - // Battery Service - await peripheralManager.addService( - GATTService( - uuid: UUID.fromString('180F'), - isPrimary: true, - characteristics: [ - GATTCharacteristic.mutable( - uuid: UUID.fromString('2A19'), - descriptors: [], - properties: [ - GATTCharacteristicProperty.read, - GATTCharacteristicProperty.notify, - ], - permissions: [ - GATTCharacteristicPermission.read, - ], - ), - ], - includedServices: [], - ), - ); - - // Unknown Service - await peripheralManager.addService( - GATTService( - uuid: UUID.fromString(ZwiftConstants.ZWIFT_RIDE_CUSTOM_SERVICE_UUID_SHORT), - isPrimary: true, - characteristics: [ - _asyncCharacteristic!, - GATTCharacteristic.mutable( - uuid: UUID.fromString(ZwiftConstants.ZWIFT_SYNC_RX_CHARACTERISTIC_UUID), - descriptors: [], - properties: [ - GATTCharacteristicProperty.writeWithoutResponse, - ], - permissions: [], - ), - syncTxCharacteristic, - GATTCharacteristic.mutable( - uuid: UUID.fromString('00000005-19CA-4651-86E5-FA29DCDD09D1'), - descriptors: [], - properties: [ - GATTCharacteristicProperty.notify, - ], - permissions: [], - ), - GATTCharacteristic.mutable( - uuid: UUID.fromString('00000006-19CA-4651-86E5-FA29DCDD09D1'), - descriptors: [], - properties: [ - GATTCharacteristicProperty.indicate, - GATTCharacteristicProperty.read, - GATTCharacteristicProperty.writeWithoutResponse, - GATTCharacteristicProperty.write, - ], - permissions: [ - GATTCharacteristicPermission.read, - GATTCharacteristicPermission.write, - ], - ), - ], - includedServices: [], - ), - ); - _isServiceAdded = true; - } - - final advertisement = Advertisement( - name: 'SwiftControl', - serviceUUIDs: [UUID.fromString(ZwiftConstants.ZWIFT_RIDE_CUSTOM_SERVICE_UUID_SHORT)], - serviceData: { - UUID.fromString(ZwiftConstants.ZWIFT_RIDE_CUSTOM_SERVICE_UUID_SHORT): Uint8List.fromList([0x02]), - }, - manufacturerSpecificData: [ - ManufacturerSpecificData( - id: 0x094A, - data: Uint8List.fromList([ZwiftConstants.CLICK_V2_LEFT_SIDE, 0x13, 0x37]), - ), - ], - ); - print('Starting advertising with HID service...'); - - await peripheralManager.startAdvertising(advertisement); - _isAdvertising = true; - onUpdate(); - } - @override Widget? build(BuildContext context, VoidCallback onUpdate) { return _PairWidget(onUpdate: onUpdate, requirement: this); @@ -315,33 +30,7 @@ class ZwiftRequirement extends PlatformRequirement { @override Future getStatus() async { - status = (actionHandler as RemoteActions).isConnected || screenshotMode; - } - - int counter = 0; - - void writeCommand() { - final status = RideKeyPadStatus() - //..buttonMap = (~RideButtonMask.SHFT_UP_R_BTN.mask) & 0xFFFFFFFF - ..buttonMap = (~RideButtonMask.SHFT_UP_L_BTN.mask) & 0xFFFFFFFF - ..buttonMap = (~RideButtonMask.LEFT_BTN.mask) & 0xFFFFFFFF - ..analogPaddles.clear(); - - // Serialize to bytes if you need to send it - final bytes = status.writeToBuffer(); - - //..buttonMinus = !down ? PlayButtonStatus.ON : PlayButtonStatus.OFF; - final commandProto = Uint8List.fromList([ - Opcode.CONTROLLER_NOTIFICATION.value, - ...bytes, - ]); - - print('Constructed proto : ${commandProto.map((e) => e.toRadixString(16).padLeft(2, '0')).join(' ')}'); - - peripheralManager.notifyCharacteristic(_central!, _asyncCharacteristic!, value: commandProto); - - final zero = Uint8List.fromList([0x23, 0x08, 0xFF, 0xFF, 0xFF, 0xFF, 0x0F]); - peripheralManager.notifyCharacteristic(_central!, _asyncCharacteristic!, value: zero); + status = zwiftEmulator.isConnected.value || screenshotMode; } } @@ -383,22 +72,13 @@ class _PairWidgetState extends State<_PairWidget> { print('Error toggling advertising: $e'); } }, - child: Text(_isAdvertising ? 'Stop Pairing' : 'Start Pairing'), + child: Text(zwiftEmulator.isAdvertising ? 'Stop Pairing' : 'Start Pairing'), ), - if (_isAdvertising || _isLoading) SizedBox(height: 20, width: 20, child: SmallProgressIndicator()), + if (zwiftEmulator.isAdvertising || zwiftEmulator.isLoading) + SizedBox(height: 20, width: 20, child: SmallProgressIndicator()), ], ), - if (settings.getTrainerApp() is MyWhoosh) - ElevatedButton( - onPressed: () async { - widget.requirement.writeCommand(); - }, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: Text('Send command'), - ), - ), - if (_isAdvertising) ...[ + if (zwiftEmulator.isAdvertising) ...[ TextButton( onPressed: () { Navigator.push(context, MaterialPageRoute(builder: (c) => MarkdownPage(assetPath: 'TROUBLESHOOTING.md'))); @@ -411,18 +91,12 @@ class _PairWidgetState extends State<_PairWidget> { } Future toggle() async { - if (_isAdvertising) { - await peripheralManager.stopAdvertising(); - _isAdvertising = false; - (actionHandler as RemoteActions).setConnectedCentral(null, null); + if (zwiftEmulator.isAdvertising) { + await zwiftEmulator.stopAdvertising(); widget.onUpdate(); - _isLoading = false; setState(() {}); } else { - _isLoading = true; - setState(() {}); - await widget.requirement.startAdvertising(widget.onUpdate); - _isLoading = false; + await zwiftEmulator.startAdvertising(widget.onUpdate); if (mounted) setState(() {}); } } diff --git a/lib/utils/settings/settings.dart b/lib/utils/settings/settings.dart index 7f90708..2ce1b79 100644 --- a/lib/utils/settings/settings.dart +++ b/lib/utils/settings/settings.dart @@ -15,7 +15,7 @@ class Settings { Future init() async { prefs = await SharedPreferences.getInstance(); - initializeActions(getLastTarget()?.connectionType ?? ConnectionType.unknown); + initializeActions(getTrainerApp()?.connectionType ?? getLastTarget()?.connectionType ?? ConnectionType.unknown); if (actionHandler is DesktopActions) { // Must add this line. diff --git a/lib/widgets/keymap_explanation.dart b/lib/widgets/keymap_explanation.dart index a8dd95c..fbff77d 100644 --- a/lib/widgets/keymap_explanation.dart +++ b/lib/widgets/keymap_explanation.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:dartx/dartx.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:swift_control/bluetooth/devices/zwift/zwift_emulator.dart'; import 'package:swift_control/main.dart'; import 'package:swift_control/pages/device.dart'; import 'package:swift_control/utils/actions/base_actions.dart'; @@ -189,6 +190,52 @@ class _ButtonEditor extends StatelessWidget { ), ), ), + if (zwiftEmulator.isConnected.value) + PopupMenuItem( + child: PopupMenuButton( + itemBuilder: (_) => ZwiftEmulator.supportedActions.map( + (ingame) { + return PopupMenuItem( + value: ingame, + child: ingame.possibleValues != null + ? PopupMenuButton( + itemBuilder: (c) => ingame.possibleValues! + .map( + (value) => PopupMenuItem( + value: value, + child: Text(value.toString()), + onTap: () { + keyPair.inGameAction = ingame; + keyPair.inGameActionValue = value; + onUpdate(); + }, + ), + ) + .toList(), + child: Row( + children: [ + Expanded(child: Text(ingame.toString())), + Icon(Icons.arrow_right), + ], + ), + ) + : Text(ingame.toString()), + onTap: () { + keyPair.inGameAction = ingame; + keyPair.inGameActionValue = null; + onUpdate(); + }, + ); + }, + ).toList(), + child: Row( + children: [ + Expanded(child: Text('Zwift Action')), + Icon(Icons.arrow_right), + ], + ), + ), + ), if (actionHandler.supportedModes.contains(SupportedMode.keyboard)) PopupMenuItem( value: null,