mirror of
https://github.com/jonasbark/swiftcontrol.git
synced 2026-02-18 00:17:40 +01:00
integration #1
This commit is contained in:
@@ -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,
|
||||
);
|
||||
|
||||
356
lib/bluetooth/devices/zwift/zwift_emulator.dart
Normal file
356
lib/bluetooth/devices/zwift/zwift_emulator.dart
Normal file
@@ -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<InGameAction> supportedActions = InGameAction.values;
|
||||
|
||||
ValueNotifier<bool> isConnected = ValueNotifier<bool>(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<void> reconnect() async {
|
||||
await peripheralManager.stopAdvertising();
|
||||
await peripheralManager.removeAllServices();
|
||||
_isServiceAdded = false;
|
||||
_isAdvertising = false;
|
||||
startAdvertising(() {});
|
||||
}
|
||||
|
||||
Future<void> 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<void> stopAdvertising() async {
|
||||
await peripheralManager.stopAdvertising();
|
||||
_isAdvertising = false;
|
||||
_isLoading = false;
|
||||
}
|
||||
|
||||
Future<String> 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'}');
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ enum ConnectionType {
|
||||
unknown,
|
||||
local,
|
||||
remote,
|
||||
zwift,
|
||||
}
|
||||
|
||||
Future<void> initializeActions(ConnectionType connectionType) async {
|
||||
@@ -42,18 +43,21 @@ Future<void> 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(),
|
||||
};
|
||||
|
||||
@@ -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<DevicePage> 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<DevicePage> with WidgetsBindingObserver {
|
||||
|
||||
if (settings.getTrainerApp() is MyWhoosh && !whooshLink.isConnected.value)
|
||||
LinkDevice('').showInformation(context),
|
||||
if (settings.getTrainerApp() is Zwift) ZwiftEmulatorInformation(),
|
||||
|
||||
if (actionHandler is RemoteActions)
|
||||
Row(
|
||||
|
||||
@@ -175,7 +175,9 @@ class _RequirementsPageState extends State<RequirementsPage> 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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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: []),
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<Target> 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<SupportedApp> supportedApps = [MyWhoosh(), TrainingPeaks(), Biketerra(), CustomApp()];
|
||||
static final List<SupportedApp> supportedApps = [MyWhoosh(), Zwift(), TrainingPeaks(), Biketerra(), CustomApp()];
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
|
||||
@@ -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
|
||||
|
||||
23
lib/utils/keymap/apps/zwift.dart
Normal file
23
lib/utils/keymap/apps/zwift.dart
Normal file
@@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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<SupportedApp>(
|
||||
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<Target>(
|
||||
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<SupportedApp>(
|
||||
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<Target>(
|
||||
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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ Future<List<PlatformRequirement>> getRequirements(ConnectionType connectionType)
|
||||
BluetoothTurnedOn(),
|
||||
switch (connectionType) {
|
||||
ConnectionType.local => KeyboardRequirement(),
|
||||
ConnectionType.zwift => ZwiftRequirement(),
|
||||
ConnectionType.remote => RemoteRequirement(),
|
||||
ConnectionType.unknown => PlaceholderRequirement(),
|
||||
},
|
||||
@@ -55,6 +56,7 @@ Future<List<PlatformRequirement>> getRequirements(ConnectionType connectionType)
|
||||
BluetoothTurnedOn(),
|
||||
switch (connectionType) {
|
||||
ConnectionType.local => RemoteRequirement(),
|
||||
ConnectionType.zwift => ZwiftRequirement(),
|
||||
ConnectionType.remote => RemoteRequirement(),
|
||||
ConnectionType.unknown => PlaceholderRequirement(),
|
||||
},
|
||||
@@ -65,6 +67,7 @@ Future<List<PlatformRequirement>> getRequirements(ConnectionType connectionType)
|
||||
BluetoothTurnedOn(),
|
||||
switch (connectionType) {
|
||||
ConnectionType.local => KeyboardRequirement(),
|
||||
ConnectionType.zwift => ZwiftRequirement(),
|
||||
ConnectionType.remote => RemoteRequirement(),
|
||||
ConnectionType.unknown => PlaceholderRequirement(),
|
||||
},
|
||||
@@ -84,7 +87,8 @@ Future<List<PlatformRequirement>> getRequirements(ConnectionType connectionType)
|
||||
],
|
||||
switch (connectionType) {
|
||||
ConnectionType.local => AccessibilityRequirement(),
|
||||
ConnectionType.remote => ZwiftRequirement(),
|
||||
ConnectionType.zwift => ZwiftRequirement(),
|
||||
ConnectionType.remote => RemoteRequirement(),
|
||||
ConnectionType.unknown => PlaceholderRequirement(),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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<void> reconnect() async {
|
||||
await peripheralManager.stopAdvertising();
|
||||
await peripheralManager.removeAllServices();
|
||||
_isServiceAdded = false;
|
||||
_isAdvertising = false;
|
||||
(actionHandler as RemoteActions).setConnectedCentral(null, null);
|
||||
startAdvertising(() {});
|
||||
}
|
||||
|
||||
Future<void> 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<void> 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<void> 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(() {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ class Settings {
|
||||
|
||||
Future<void> 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.
|
||||
|
||||
@@ -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<PhysicalKeyboardKey>(
|
||||
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<PhysicalKeyboardKey>(
|
||||
value: null,
|
||||
|
||||
Reference in New Issue
Block a user