mirror of
https://github.com/jonasbark/swiftcontrol.git
synced 2026-02-18 00:17:40 +01:00
refactor device handling to support more devices #1
This commit is contained in:
1
.github/workflows/build.yml
vendored
1
.github/workflows/build.yml
vendored
@@ -9,6 +9,7 @@ env:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: false
|
||||
name: Build & Release
|
||||
runs-on: macos-latest
|
||||
|
||||
|
||||
@@ -9,53 +9,40 @@ import 'package:swift_control/bluetooth/devices/zwift_play.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift_ride.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/actions/desktop.dart';
|
||||
import 'package:swift_control/utils/crypto/local_key_provider.dart';
|
||||
import 'package:swift_control/utils/crypto/zap_crypto.dart';
|
||||
import 'package:swift_control/utils/single_line_exception.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
import '../../utils/crypto/encryption_utils.dart';
|
||||
import '../../utils/keymap/buttons.dart';
|
||||
import '../messages/notification.dart';
|
||||
|
||||
abstract class BaseDevice {
|
||||
final BleDevice scanResult;
|
||||
final List<ZwiftButton> availableButtons;
|
||||
final List<ControllerButton> availableButtons;
|
||||
|
||||
BaseDevice(this.scanResult, {required this.availableButtons});
|
||||
|
||||
final zapEncryption = ZapCrypto(LocalKeyProvider());
|
||||
|
||||
bool isConnected = false;
|
||||
int? batteryLevel;
|
||||
String? firmwareVersion;
|
||||
|
||||
bool supportsEncryption = false;
|
||||
|
||||
BleCharacteristic? syncRxCharacteristic;
|
||||
Timer? _longPressTimer;
|
||||
Set<ZwiftButton> _previouslyPressedButtons = <ZwiftButton>{};
|
||||
|
||||
List<int> get startCommand => Constants.RIDE_ON + Constants.RESPONSE_START_CLICK;
|
||||
String get customServiceId => BleUuid.ZWIFT_CUSTOM_SERVICE_UUID;
|
||||
Set<ControllerButton> _previouslyPressedButtons = <ControllerButton>{};
|
||||
|
||||
static BaseDevice? fromScanResult(BleDevice scanResult) {
|
||||
// Use the name first as the "System Devices" and Web (android sometimes Windows) don't have manufacturer data
|
||||
final device =
|
||||
kIsWeb
|
||||
? switch (scanResult.name) {
|
||||
'Zwift Ride' => ZwiftRide(scanResult),
|
||||
'Zwift Play' => ZwiftPlay(scanResult),
|
||||
'Zwift Click' => ZwiftClickV2(scanResult),
|
||||
_ => null,
|
||||
}
|
||||
: switch (scanResult.name) {
|
||||
//'Zwift Ride' => ZwiftRide(scanResult), special case for Zwift Ride: we must only connect to the left controller
|
||||
// https://www.makinolo.com/blog/2024/07/26/zwift-ride-protocol/
|
||||
'Zwift Play' => ZwiftPlay(scanResult),
|
||||
//'Zwift Click' => ZwiftClick(scanResult), special case for Zwift Click v2: we must only connect to the left controller
|
||||
_ => null,
|
||||
};
|
||||
final device = kIsWeb
|
||||
? switch (scanResult.name) {
|
||||
'Zwift Ride' => ZwiftRide(scanResult),
|
||||
'Zwift Play' => ZwiftPlay(scanResult),
|
||||
'Zwift Click' => ZwiftClickV2(scanResult),
|
||||
_ => null,
|
||||
}
|
||||
: switch (scanResult.name) {
|
||||
//'Zwift Ride' => ZwiftRide(scanResult), special case for Zwift Ride: we must only connect to the left controller
|
||||
// https://www.makinolo.com/blog/2024/07/26/zwift-ride-protocol/
|
||||
'Zwift Play' => ZwiftPlay(scanResult),
|
||||
//'Zwift Click' => ZwiftClick(scanResult), special case for Zwift Click v2: we must only connect to the left controller
|
||||
_ => null,
|
||||
};
|
||||
|
||||
if (device != null) {
|
||||
return device;
|
||||
@@ -112,165 +99,15 @@ abstract class BaseDevice {
|
||||
}
|
||||
|
||||
final services = await UniversalBle.discoverServices(device.deviceId);
|
||||
await _handleServices(services);
|
||||
await handleServices(services);
|
||||
}
|
||||
|
||||
Future<void> _handleServices(List<BleService> services) async {
|
||||
final customService = services.firstOrNullWhere((service) => service.uuid == customServiceId);
|
||||
Future<void> handleServices(List<BleService> services);
|
||||
Future<void> processCharacteristic(String characteristic, Uint8List bytes);
|
||||
|
||||
if (customService == null) {
|
||||
throw Exception(
|
||||
'Custom service $customServiceId not found for device $this ${device.name ?? device.rawName}.\nYou may need to update the firmware in Zwift Companion app.\nWe found: ${services.joinToString(transform: (s) => s.uuid)}',
|
||||
);
|
||||
}
|
||||
Future<List<ControllerButton>?> processClickNotification(Uint8List message);
|
||||
|
||||
final deviceInformationService = services.firstOrNullWhere(
|
||||
(service) => service.uuid == BleUuid.DEVICE_INFORMATION_SERVICE_UUID,
|
||||
);
|
||||
final firmwareCharacteristic = deviceInformationService?.characteristics.firstOrNullWhere(
|
||||
(c) => c.uuid == BleUuid.DEVICE_INFORMATION_CHARACTERISTIC_FIRMWARE_REVISION,
|
||||
);
|
||||
if (firmwareCharacteristic != null) {
|
||||
final firmwareData = await UniversalBle.read(
|
||||
device.deviceId,
|
||||
deviceInformationService!.uuid,
|
||||
firmwareCharacteristic.uuid,
|
||||
);
|
||||
firmwareVersion = String.fromCharCodes(firmwareData);
|
||||
connection.signalChange(this);
|
||||
}
|
||||
|
||||
final asyncCharacteristic = customService.characteristics.firstOrNullWhere(
|
||||
(characteristic) => characteristic.uuid == BleUuid.ZWIFT_ASYNC_CHARACTERISTIC_UUID,
|
||||
);
|
||||
final syncTxCharacteristic = customService.characteristics.firstOrNullWhere(
|
||||
(characteristic) => characteristic.uuid == BleUuid.ZWIFT_SYNC_TX_CHARACTERISTIC_UUID,
|
||||
);
|
||||
syncRxCharacteristic = customService.characteristics.firstOrNullWhere(
|
||||
(characteristic) => characteristic.uuid == BleUuid.ZWIFT_SYNC_RX_CHARACTERISTIC_UUID,
|
||||
);
|
||||
|
||||
if (asyncCharacteristic == null || syncTxCharacteristic == null || syncRxCharacteristic == null) {
|
||||
throw Exception('Characteristics not found');
|
||||
}
|
||||
|
||||
await UniversalBle.subscribeNotifications(device.deviceId, customService.uuid, asyncCharacteristic.uuid);
|
||||
await UniversalBle.subscribeIndications(device.deviceId, customService.uuid, syncTxCharacteristic.uuid);
|
||||
|
||||
await setupHandshake();
|
||||
}
|
||||
|
||||
Future<void> setupHandshake() async {
|
||||
if (supportsEncryption) {
|
||||
await UniversalBle.write(
|
||||
device.deviceId,
|
||||
customServiceId,
|
||||
syncRxCharacteristic!.uuid,
|
||||
Uint8List.fromList([
|
||||
...Constants.RIDE_ON,
|
||||
...Constants.REQUEST_START,
|
||||
...zapEncryption.localKeyProvider.getPublicKeyBytes(),
|
||||
]),
|
||||
withoutResponse: true,
|
||||
);
|
||||
} else {
|
||||
await UniversalBle.write(
|
||||
device.deviceId,
|
||||
customServiceId,
|
||||
syncRxCharacteristic!.uuid,
|
||||
Constants.RIDE_ON,
|
||||
withoutResponse: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> processCharacteristic(String characteristic, Uint8List bytes) async {
|
||||
if (kDebugMode && false) {
|
||||
print(
|
||||
"${DateTime.now().toString().split(" ").last} Received data on $characteristic: ${bytes.map((e) => e.toRadixString(16).padLeft(2, '0')).join(' ')}",
|
||||
);
|
||||
}
|
||||
if (bytes.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (bytes.startsWith(startCommand)) {
|
||||
_processDevicePublicKeyResponse(bytes);
|
||||
} else if (!supportsEncryption || (bytes.length > Int32List.bytesPerElement + EncryptionUtils.MAC_LENGTH)) {
|
||||
processData(bytes);
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
print("Error processing data: $e");
|
||||
print("Stack Trace: $stackTrace");
|
||||
if (e is SingleLineException) {
|
||||
actionStreamInternal.add(LogNotification(e.message));
|
||||
} else {
|
||||
actionStreamInternal.add(LogNotification("$e\n$stackTrace"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _processDevicePublicKeyResponse(Uint8List bytes) {
|
||||
final devicePublicKeyBytes = bytes.sublist(Constants.RIDE_ON.length + Constants.RESPONSE_START_CLICK.length);
|
||||
if (kDebugMode) {
|
||||
print("Device Public Key - ${devicePublicKeyBytes.map((e) => e.toRadixString(16).padLeft(2, '0')).join(' ')}");
|
||||
}
|
||||
zapEncryption.initialise(devicePublicKeyBytes);
|
||||
}
|
||||
|
||||
Future<void> processData(Uint8List bytes) async {
|
||||
int type;
|
||||
Uint8List message;
|
||||
|
||||
if (supportsEncryption) {
|
||||
final counter = bytes.sublist(0, 4); // Int.SIZE_BYTES is 4
|
||||
final payload = bytes.sublist(4);
|
||||
|
||||
if (zapEncryption.encryptionKeyBytes == null) {
|
||||
actionStreamInternal.add(
|
||||
LogNotification(
|
||||
'Encryption not initialized, yet. You may need to update the firmware of your device with the Zwift Companion app.',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final data = zapEncryption.decrypt(counter, payload);
|
||||
type = data[0];
|
||||
message = data.sublist(1);
|
||||
} else {
|
||||
type = bytes[0];
|
||||
message = bytes.sublist(1);
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case Constants.EMPTY_MESSAGE_TYPE:
|
||||
//print("Empty Message"); // expected when nothing happening
|
||||
break;
|
||||
case Constants.BATTERY_LEVEL_TYPE:
|
||||
if (batteryLevel != message[1]) {
|
||||
batteryLevel = message[1];
|
||||
connection.signalChange(this);
|
||||
}
|
||||
break;
|
||||
case Constants.CLICK_NOTIFICATION_MESSAGE_TYPE:
|
||||
case Constants.PLAY_NOTIFICATION_MESSAGE_TYPE:
|
||||
case Constants.RIDE_NOTIFICATION_MESSAGE_TYPE:
|
||||
processClickNotification(message)
|
||||
.then((buttonsClicked) async {
|
||||
return handleButtonsClicked(buttonsClicked);
|
||||
})
|
||||
.catchError((e) {
|
||||
actionStreamInternal.add(LogNotification(e.toString()));
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<ZwiftButton>?> processClickNotification(Uint8List message);
|
||||
|
||||
Future<void> handleButtonsClicked(List<ZwiftButton>? buttonsClicked) async {
|
||||
Future<void> handleButtonsClicked(List<ControllerButton>? buttonsClicked) async {
|
||||
if (buttonsClicked == null) {
|
||||
// ignore, no changes
|
||||
} else if (buttonsClicked.isEmpty) {
|
||||
@@ -283,7 +120,7 @@ abstract class BaseDevice {
|
||||
buttonsReleased.singleOrNull != null &&
|
||||
actionHandler.supportedApp?.keymap.getKeyPair(buttonsReleased.single)?.isLongPress == true;
|
||||
if (buttonsReleased.isNotEmpty && isLongPress) {
|
||||
await _performRelease(buttonsReleased);
|
||||
await performRelease(buttonsReleased);
|
||||
}
|
||||
_previouslyPressedButtons.clear();
|
||||
} else {
|
||||
@@ -293,7 +130,7 @@ abstract class BaseDevice {
|
||||
buttonsReleased.singleOrNull != null &&
|
||||
actionHandler.supportedApp?.keymap.getKeyPair(buttonsReleased.single)?.isLongPress == true;
|
||||
if (buttonsReleased.isNotEmpty && wasLongPress) {
|
||||
await _performRelease(buttonsReleased);
|
||||
await performRelease(buttonsReleased);
|
||||
}
|
||||
|
||||
final isLongPress =
|
||||
@@ -301,30 +138,26 @@ abstract class BaseDevice {
|
||||
actionHandler.supportedApp?.keymap.getKeyPair(buttonsClicked.single)?.isLongPress == true;
|
||||
|
||||
if (!isLongPress &&
|
||||
!(buttonsClicked.singleOrNull == ZwiftButton.onOffLeft ||
|
||||
buttonsClicked.singleOrNull == ZwiftButton.onOffRight)) {
|
||||
!(buttonsClicked.singleOrNull == ControllerButton.onOffLeft ||
|
||||
buttonsClicked.singleOrNull == ControllerButton.onOffRight)) {
|
||||
// we don't want to trigger the long press timer for the on/off buttons, also not when it's a long press key
|
||||
_longPressTimer?.cancel();
|
||||
_longPressTimer = Timer.periodic(const Duration(milliseconds: 350), (timer) async {
|
||||
_performClick(buttonsClicked);
|
||||
performClick(buttonsClicked);
|
||||
});
|
||||
}
|
||||
// Update currently pressed buttons
|
||||
_previouslyPressedButtons = buttonsClicked.toSet();
|
||||
|
||||
if (isLongPress) {
|
||||
return _performDown(buttonsClicked);
|
||||
return performDown(buttonsClicked);
|
||||
} else {
|
||||
return _performClick(buttonsClicked);
|
||||
return performClick(buttonsClicked);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _performDown(List<ZwiftButton> buttonsClicked) async {
|
||||
if (buttonsClicked.any(((e) => e.action == InGameAction.shiftDown || e.action == InGameAction.shiftUp)) &&
|
||||
settings.getVibrationEnabled()) {
|
||||
await _vibrate();
|
||||
}
|
||||
Future<void> performDown(List<ControllerButton> buttonsClicked) async {
|
||||
for (final action in buttonsClicked) {
|
||||
// For repeated actions, don't trigger key down/up events (useful for long press)
|
||||
actionStreamInternal.add(
|
||||
@@ -333,11 +166,7 @@ abstract class BaseDevice {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _performClick(List<ZwiftButton> buttonsClicked) async {
|
||||
if (buttonsClicked.any(((e) => e.action == InGameAction.shiftDown || e.action == InGameAction.shiftUp)) &&
|
||||
settings.getVibrationEnabled()) {
|
||||
await _vibrate();
|
||||
}
|
||||
Future<void> performClick(List<ControllerButton> buttonsClicked) async {
|
||||
for (final action in buttonsClicked) {
|
||||
actionStreamInternal.add(
|
||||
LogNotification(await actionHandler.performAction(action, isKeyDown: true, isKeyUp: true)),
|
||||
@@ -345,7 +174,7 @@ abstract class BaseDevice {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _performRelease(List<ZwiftButton> buttonsReleased) async {
|
||||
Future<void> performRelease(List<ControllerButton> buttonsReleased) async {
|
||||
for (final action in buttonsReleased) {
|
||||
actionStreamInternal.add(
|
||||
LogNotification(await actionHandler.performAction(action, isKeyDown: false, isKeyUp: true)),
|
||||
@@ -353,17 +182,6 @@ abstract class BaseDevice {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _vibrate() async {
|
||||
final vibrateCommand = Uint8List.fromList([...Constants.VIBRATE_PATTERN, 0x20]);
|
||||
await UniversalBle.write(
|
||||
device.deviceId,
|
||||
customServiceId,
|
||||
syncRxCharacteristic!.uuid,
|
||||
supportsEncryption ? zapEncryption.encrypt(vibrateCommand) : vibrateCommand,
|
||||
withoutResponse: true,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
_longPressTimer?.cancel();
|
||||
// Release any held keys in long press mode
|
||||
|
||||
212
lib/bluetooth/devices/zwift/zwift_device.dart
Normal file
212
lib/bluetooth/devices/zwift/zwift_device.dart
Normal file
@@ -0,0 +1,212 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:swift_control/bluetooth/ble.dart';
|
||||
import 'package:swift_control/bluetooth/devices/base_device.dart';
|
||||
import 'package:swift_control/bluetooth/messages/notification.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/crypto/local_key_provider.dart';
|
||||
import 'package:swift_control/utils/crypto/zap_crypto.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:swift_control/utils/single_line_exception.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
import '../../../utils/crypto/encryption_utils.dart';
|
||||
|
||||
abstract class ZwiftDevice extends BaseDevice {
|
||||
final zapEncryption = ZapCrypto(LocalKeyProvider());
|
||||
|
||||
ZwiftDevice(super.scanResult, {required super.availableButtons});
|
||||
|
||||
bool supportsEncryption = false;
|
||||
|
||||
BleCharacteristic? syncRxCharacteristic;
|
||||
|
||||
List<int> get startCommand => Constants.RIDE_ON + Constants.RESPONSE_START_CLICK;
|
||||
String get customServiceId => BleUuid.ZWIFT_CUSTOM_SERVICE_UUID;
|
||||
|
||||
@override
|
||||
Future<void> handleServices(List<BleService> services) async {
|
||||
final customService = services.firstOrNullWhere((service) => service.uuid == customServiceId);
|
||||
|
||||
if (customService == null) {
|
||||
throw Exception(
|
||||
'Custom service $customServiceId not found for device $this ${device.name ?? device.rawName}.\nYou may need to update the firmware in Zwift Companion app.\nWe found: ${services.joinToString(transform: (s) => s.uuid)}',
|
||||
);
|
||||
}
|
||||
|
||||
final deviceInformationService = services.firstOrNullWhere(
|
||||
(service) => service.uuid == BleUuid.DEVICE_INFORMATION_SERVICE_UUID,
|
||||
);
|
||||
final firmwareCharacteristic = deviceInformationService?.characteristics.firstOrNullWhere(
|
||||
(c) => c.uuid == BleUuid.DEVICE_INFORMATION_CHARACTERISTIC_FIRMWARE_REVISION,
|
||||
);
|
||||
if (firmwareCharacteristic != null) {
|
||||
final firmwareData = await UniversalBle.read(
|
||||
device.deviceId,
|
||||
deviceInformationService!.uuid,
|
||||
firmwareCharacteristic.uuid,
|
||||
);
|
||||
firmwareVersion = String.fromCharCodes(firmwareData);
|
||||
connection.signalChange(this);
|
||||
}
|
||||
|
||||
final asyncCharacteristic = customService.characteristics.firstOrNullWhere(
|
||||
(characteristic) => characteristic.uuid == BleUuid.ZWIFT_ASYNC_CHARACTERISTIC_UUID,
|
||||
);
|
||||
final syncTxCharacteristic = customService.characteristics.firstOrNullWhere(
|
||||
(characteristic) => characteristic.uuid == BleUuid.ZWIFT_SYNC_TX_CHARACTERISTIC_UUID,
|
||||
);
|
||||
syncRxCharacteristic = customService.characteristics.firstOrNullWhere(
|
||||
(characteristic) => characteristic.uuid == BleUuid.ZWIFT_SYNC_RX_CHARACTERISTIC_UUID,
|
||||
);
|
||||
|
||||
if (asyncCharacteristic == null || syncTxCharacteristic == null || syncRxCharacteristic == null) {
|
||||
throw Exception('Characteristics not found');
|
||||
}
|
||||
|
||||
await UniversalBle.subscribeNotifications(device.deviceId, customService.uuid, asyncCharacteristic.uuid);
|
||||
await UniversalBle.subscribeIndications(device.deviceId, customService.uuid, syncTxCharacteristic.uuid);
|
||||
|
||||
await setupHandshake();
|
||||
}
|
||||
|
||||
Future<void> setupHandshake() async {
|
||||
if (supportsEncryption) {
|
||||
await UniversalBle.write(
|
||||
device.deviceId,
|
||||
customServiceId,
|
||||
syncRxCharacteristic!.uuid,
|
||||
Uint8List.fromList([
|
||||
...Constants.RIDE_ON,
|
||||
...Constants.REQUEST_START,
|
||||
...zapEncryption.localKeyProvider.getPublicKeyBytes(),
|
||||
]),
|
||||
withoutResponse: true,
|
||||
);
|
||||
} else {
|
||||
await UniversalBle.write(
|
||||
device.deviceId,
|
||||
customServiceId,
|
||||
syncRxCharacteristic!.uuid,
|
||||
Constants.RIDE_ON,
|
||||
withoutResponse: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> processCharacteristic(String characteristic, Uint8List bytes) async {
|
||||
if (kDebugMode && false) {
|
||||
print(
|
||||
"${DateTime.now().toString().split(" ").last} Received data on $characteristic: ${bytes.map((e) => e.toRadixString(16).padLeft(2, '0')).join(' ')}",
|
||||
);
|
||||
}
|
||||
if (bytes.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (bytes.startsWith(startCommand)) {
|
||||
_processDevicePublicKeyResponse(bytes);
|
||||
} else if (!supportsEncryption || (bytes.length > Int32List.bytesPerElement + EncryptionUtils.MAC_LENGTH)) {
|
||||
processData(bytes);
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
print("Error processing data: $e");
|
||||
print("Stack Trace: $stackTrace");
|
||||
if (e is SingleLineException) {
|
||||
actionStreamInternal.add(LogNotification(e.message));
|
||||
} else {
|
||||
actionStreamInternal.add(LogNotification("$e\n$stackTrace"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _processDevicePublicKeyResponse(Uint8List bytes) {
|
||||
final devicePublicKeyBytes = bytes.sublist(Constants.RIDE_ON.length + Constants.RESPONSE_START_CLICK.length);
|
||||
if (kDebugMode) {
|
||||
print("Device Public Key - ${devicePublicKeyBytes.map((e) => e.toRadixString(16).padLeft(2, '0')).join(' ')}");
|
||||
}
|
||||
zapEncryption.initialise(devicePublicKeyBytes);
|
||||
}
|
||||
|
||||
Future<void> processData(Uint8List bytes) async {
|
||||
int type;
|
||||
Uint8List message;
|
||||
|
||||
if (supportsEncryption) {
|
||||
final counter = bytes.sublist(0, 4); // Int.SIZE_BYTES is 4
|
||||
final payload = bytes.sublist(4);
|
||||
|
||||
if (zapEncryption.encryptionKeyBytes == null) {
|
||||
actionStreamInternal.add(
|
||||
LogNotification(
|
||||
'Encryption not initialized, yet. You may need to update the firmware of your device with the Zwift Companion app.',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final data = zapEncryption.decrypt(counter, payload);
|
||||
type = data[0];
|
||||
message = data.sublist(1);
|
||||
} else {
|
||||
type = bytes[0];
|
||||
message = bytes.sublist(1);
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case Constants.EMPTY_MESSAGE_TYPE:
|
||||
//print("Empty Message"); // expected when nothing happening
|
||||
break;
|
||||
case Constants.BATTERY_LEVEL_TYPE:
|
||||
if (batteryLevel != message[1]) {
|
||||
batteryLevel = message[1];
|
||||
connection.signalChange(this);
|
||||
}
|
||||
break;
|
||||
case Constants.CLICK_NOTIFICATION_MESSAGE_TYPE:
|
||||
case Constants.PLAY_NOTIFICATION_MESSAGE_TYPE:
|
||||
case Constants.RIDE_NOTIFICATION_MESSAGE_TYPE:
|
||||
processClickNotification(message)
|
||||
.then((buttonsClicked) async {
|
||||
return handleButtonsClicked(buttonsClicked);
|
||||
})
|
||||
.catchError((e) {
|
||||
actionStreamInternal.add(LogNotification(e.toString()));
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> performDown(List<ControllerButton> buttonsClicked) async {
|
||||
if (buttonsClicked.any(((e) => e.action == InGameAction.shiftDown || e.action == InGameAction.shiftUp)) &&
|
||||
settings.getVibrationEnabled()) {
|
||||
await _vibrate();
|
||||
}
|
||||
return super.performDown(buttonsClicked);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> performClick(List<ControllerButton> buttonsClicked) async {
|
||||
if (buttonsClicked.any(((e) => e.action == InGameAction.shiftDown || e.action == InGameAction.shiftUp)) &&
|
||||
settings.getVibrationEnabled()) {
|
||||
await _vibrate();
|
||||
}
|
||||
return super.performClick(buttonsClicked);
|
||||
}
|
||||
|
||||
Future<void> _vibrate() async {
|
||||
final vibrateCommand = Uint8List.fromList([...Constants.VIBRATE_PATTERN, 0x20]);
|
||||
await UniversalBle.write(
|
||||
device.deviceId,
|
||||
customServiceId,
|
||||
syncRxCharacteristic!.uuid,
|
||||
supportsEncryption ? zapEncryption.encrypt(vibrateCommand) : vibrateCommand,
|
||||
withoutResponse: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,17 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:swift_control/bluetooth/devices/base_device.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_device.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
|
||||
import '../messages/click_notification.dart';
|
||||
|
||||
class ZwiftClick extends BaseDevice {
|
||||
ZwiftClick(super.scanResult) : super(availableButtons: [ZwiftButton.shiftUpRight, ZwiftButton.shiftDownLeft]);
|
||||
class ZwiftClick extends ZwiftDevice {
|
||||
ZwiftClick(super.scanResult)
|
||||
: super(availableButtons: [ControllerButton.shiftUpRight, ControllerButton.shiftDownLeft]);
|
||||
|
||||
ClickNotification? _lastClickNotification;
|
||||
|
||||
@override
|
||||
Future<List<ZwiftButton>?> processClickNotification(Uint8List message) async {
|
||||
Future<List<ControllerButton>?> processClickNotification(Uint8List message) async {
|
||||
final ClickNotification clickNotification = ClickNotification(message);
|
||||
if (_lastClickNotification == null || _lastClickNotification != clickNotification) {
|
||||
_lastClickNotification = clickNotification;
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:swift_control/bluetooth/devices/base_device.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_device.dart';
|
||||
import 'package:swift_control/bluetooth/messages/play_notification.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
|
||||
import '../ble.dart';
|
||||
|
||||
class ZwiftPlay extends BaseDevice {
|
||||
class ZwiftPlay extends ZwiftDevice {
|
||||
ZwiftPlay(super.scanResult)
|
||||
: super(
|
||||
availableButtons: [
|
||||
ZwiftButton.y,
|
||||
ZwiftButton.z,
|
||||
ZwiftButton.a,
|
||||
ZwiftButton.b,
|
||||
ZwiftButton.onOffRight,
|
||||
ZwiftButton.sideButtonRight,
|
||||
ZwiftButton.paddleRight,
|
||||
ZwiftButton.navigationUp,
|
||||
ZwiftButton.navigationLeft,
|
||||
ZwiftButton.navigationRight,
|
||||
ZwiftButton.navigationDown,
|
||||
ZwiftButton.onOffLeft,
|
||||
ZwiftButton.sideButtonLeft,
|
||||
ZwiftButton.paddleLeft,
|
||||
ControllerButton.y,
|
||||
ControllerButton.z,
|
||||
ControllerButton.a,
|
||||
ControllerButton.b,
|
||||
ControllerButton.onOffRight,
|
||||
ControllerButton.sideButtonRight,
|
||||
ControllerButton.paddleRight,
|
||||
ControllerButton.navigationUp,
|
||||
ControllerButton.navigationLeft,
|
||||
ControllerButton.navigationRight,
|
||||
ControllerButton.navigationDown,
|
||||
ControllerButton.onOffLeft,
|
||||
ControllerButton.sideButtonLeft,
|
||||
ControllerButton.paddleLeft,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -34,7 +34,7 @@ class ZwiftPlay extends BaseDevice {
|
||||
List<int> get startCommand => Constants.RIDE_ON + Constants.RESPONSE_START_PLAY;
|
||||
|
||||
@override
|
||||
Future<List<ZwiftButton>?> processClickNotification(Uint8List message) async {
|
||||
Future<List<ControllerButton>?> processClickNotification(Uint8List message) async {
|
||||
final PlayNotification clickNotification = PlayNotification(message);
|
||||
if (_lastControllerNotification == null || _lastControllerNotification != clickNotification) {
|
||||
_lastControllerNotification = clickNotification;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:protobuf/protobuf.dart' as $pb;
|
||||
import 'package:swift_control/bluetooth/devices/base_device.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_device.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift_clickv2.dart';
|
||||
import 'package:swift_control/bluetooth/messages/ride_notification.dart';
|
||||
import 'package:swift_control/bluetooth/protocol/zp_vendor.pb.dart';
|
||||
@@ -13,7 +13,7 @@ import '../ble.dart';
|
||||
import '../messages/notification.dart';
|
||||
import '../protocol/zp.pb.dart';
|
||||
|
||||
class ZwiftRide extends BaseDevice {
|
||||
class ZwiftRide extends ZwiftDevice {
|
||||
/// Minimum absolute analog value (0-100) required to trigger paddle button press.
|
||||
/// Values below this threshold are ignored to prevent accidental triggers from
|
||||
/// analog drift or light touches.
|
||||
@@ -22,24 +22,24 @@ class ZwiftRide extends BaseDevice {
|
||||
ZwiftRide(super.scanResult)
|
||||
: super(
|
||||
availableButtons: [
|
||||
ZwiftButton.navigationLeft,
|
||||
ZwiftButton.navigationRight,
|
||||
ZwiftButton.navigationUp,
|
||||
ZwiftButton.navigationDown,
|
||||
ZwiftButton.a,
|
||||
ZwiftButton.b,
|
||||
ZwiftButton.y,
|
||||
ZwiftButton.z,
|
||||
ZwiftButton.shiftUpLeft,
|
||||
ZwiftButton.shiftDownLeft,
|
||||
ZwiftButton.shiftUpRight,
|
||||
ZwiftButton.shiftDownRight,
|
||||
ZwiftButton.powerUpLeft,
|
||||
ZwiftButton.powerUpRight,
|
||||
ZwiftButton.onOffLeft,
|
||||
ZwiftButton.onOffRight,
|
||||
ZwiftButton.paddleLeft,
|
||||
ZwiftButton.paddleRight,
|
||||
ControllerButton.navigationLeft,
|
||||
ControllerButton.navigationRight,
|
||||
ControllerButton.navigationUp,
|
||||
ControllerButton.navigationDown,
|
||||
ControllerButton.a,
|
||||
ControllerButton.b,
|
||||
ControllerButton.y,
|
||||
ControllerButton.z,
|
||||
ControllerButton.shiftUpLeft,
|
||||
ControllerButton.shiftDownLeft,
|
||||
ControllerButton.shiftUpRight,
|
||||
ControllerButton.shiftDownRight,
|
||||
ControllerButton.powerUpLeft,
|
||||
ControllerButton.powerUpRight,
|
||||
ControllerButton.onOffLeft,
|
||||
ControllerButton.onOffRight,
|
||||
ControllerButton.paddleLeft,
|
||||
ControllerButton.paddleRight,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -202,7 +202,7 @@ class ZwiftRide extends BaseDevice {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ZwiftButton>?> processClickNotification(Uint8List message) async {
|
||||
Future<List<ControllerButton>?> processClickNotification(Uint8List message) async {
|
||||
final RideNotification clickNotification = RideNotification(
|
||||
message,
|
||||
analogPaddleThreshold: analogPaddleThreshold,
|
||||
|
||||
@@ -8,13 +8,13 @@ import '../protocol/zwift.pb.dart';
|
||||
import 'notification.dart';
|
||||
|
||||
class ClickNotification extends BaseNotification {
|
||||
late List<ZwiftButton> buttonsClicked;
|
||||
late List<ControllerButton> buttonsClicked;
|
||||
|
||||
ClickNotification(Uint8List message) {
|
||||
final status = ClickKeyPadStatus.fromBuffer(message);
|
||||
buttonsClicked = [
|
||||
if (status.buttonPlus == PlayButtonStatus.ON) ZwiftButton.shiftUpRight,
|
||||
if (status.buttonMinus == PlayButtonStatus.ON) ZwiftButton.shiftDownLeft,
|
||||
if (status.buttonPlus == PlayButtonStatus.ON) ControllerButton.shiftUpRight,
|
||||
if (status.buttonMinus == PlayButtonStatus.ON) ControllerButton.shiftDownLeft,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -7,29 +7,29 @@ import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:swift_control/widgets/keymap_explanation.dart';
|
||||
|
||||
class PlayNotification extends BaseNotification {
|
||||
late List<ZwiftButton> buttonsClicked;
|
||||
late List<ControllerButton> buttonsClicked;
|
||||
|
||||
PlayNotification(Uint8List message) {
|
||||
final status = PlayKeyPadStatus.fromBuffer(message);
|
||||
|
||||
buttonsClicked = [
|
||||
if (status.rightPad == PlayButtonStatus.ON) ...[
|
||||
if (status.buttonYUp == PlayButtonStatus.ON) ZwiftButton.y,
|
||||
if (status.buttonZLeft == PlayButtonStatus.ON) ZwiftButton.z,
|
||||
if (status.buttonARight == PlayButtonStatus.ON) ZwiftButton.a,
|
||||
if (status.buttonBDown == PlayButtonStatus.ON) ZwiftButton.b,
|
||||
if (status.buttonOn == PlayButtonStatus.ON) ZwiftButton.onOffRight,
|
||||
if (status.buttonShift == PlayButtonStatus.ON) ZwiftButton.sideButtonRight,
|
||||
if (status.analogLR.abs() == 100) ZwiftButton.paddleRight,
|
||||
if (status.buttonYUp == PlayButtonStatus.ON) ControllerButton.y,
|
||||
if (status.buttonZLeft == PlayButtonStatus.ON) ControllerButton.z,
|
||||
if (status.buttonARight == PlayButtonStatus.ON) ControllerButton.a,
|
||||
if (status.buttonBDown == PlayButtonStatus.ON) ControllerButton.b,
|
||||
if (status.buttonOn == PlayButtonStatus.ON) ControllerButton.onOffRight,
|
||||
if (status.buttonShift == PlayButtonStatus.ON) ControllerButton.sideButtonRight,
|
||||
if (status.analogLR.abs() == 100) ControllerButton.paddleRight,
|
||||
],
|
||||
if (status.rightPad == PlayButtonStatus.OFF) ...[
|
||||
if (status.buttonYUp == PlayButtonStatus.ON) ZwiftButton.navigationUp,
|
||||
if (status.buttonZLeft == PlayButtonStatus.ON) ZwiftButton.navigationLeft,
|
||||
if (status.buttonARight == PlayButtonStatus.ON) ZwiftButton.navigationRight,
|
||||
if (status.buttonBDown == PlayButtonStatus.ON) ZwiftButton.navigationDown,
|
||||
if (status.buttonOn == PlayButtonStatus.ON) ZwiftButton.onOffLeft,
|
||||
if (status.buttonShift == PlayButtonStatus.ON) ZwiftButton.sideButtonLeft,
|
||||
if (status.analogLR.abs() == 100) ZwiftButton.paddleLeft,
|
||||
if (status.buttonYUp == PlayButtonStatus.ON) ControllerButton.navigationUp,
|
||||
if (status.buttonZLeft == PlayButtonStatus.ON) ControllerButton.navigationLeft,
|
||||
if (status.buttonARight == PlayButtonStatus.ON) ControllerButton.navigationRight,
|
||||
if (status.buttonBDown == PlayButtonStatus.ON) ControllerButton.navigationDown,
|
||||
if (status.buttonOn == PlayButtonStatus.ON) ControllerButton.onOffLeft,
|
||||
if (status.buttonShift == PlayButtonStatus.ON) ControllerButton.sideButtonLeft,
|
||||
if (status.analogLR.abs() == 100) ControllerButton.paddleLeft,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:swift_control/bluetooth/messages/notification.dart';
|
||||
@@ -34,8 +32,8 @@ enum _RideButtonMask {
|
||||
}
|
||||
|
||||
class RideNotification extends BaseNotification {
|
||||
late List<ZwiftButton> buttonsClicked;
|
||||
late List<ZwiftButton> analogButtons;
|
||||
late List<ControllerButton> buttonsClicked;
|
||||
late List<ControllerButton> analogButtons;
|
||||
|
||||
RideNotification(Uint8List message, {required int analogPaddleThreshold}) {
|
||||
final status = RideKeyPadStatus.fromBuffer(message);
|
||||
@@ -44,23 +42,31 @@ class RideNotification extends BaseNotification {
|
||||
|
||||
// Process DIGITAL buttons separately
|
||||
buttonsClicked = [
|
||||
if (status.buttonMap & _RideButtonMask.LEFT_BTN.mask == PlayButtonStatus.ON.value) ZwiftButton.navigationLeft,
|
||||
if (status.buttonMap & _RideButtonMask.RIGHT_BTN.mask == PlayButtonStatus.ON.value) ZwiftButton.navigationRight,
|
||||
if (status.buttonMap & _RideButtonMask.UP_BTN.mask == PlayButtonStatus.ON.value) ZwiftButton.navigationUp,
|
||||
if (status.buttonMap & _RideButtonMask.DOWN_BTN.mask == PlayButtonStatus.ON.value) ZwiftButton.navigationDown,
|
||||
if (status.buttonMap & _RideButtonMask.A_BTN.mask == PlayButtonStatus.ON.value) ZwiftButton.a,
|
||||
if (status.buttonMap & _RideButtonMask.B_BTN.mask == PlayButtonStatus.ON.value) ZwiftButton.b,
|
||||
if (status.buttonMap & _RideButtonMask.Y_BTN.mask == PlayButtonStatus.ON.value) ZwiftButton.y,
|
||||
if (status.buttonMap & _RideButtonMask.Z_BTN.mask == PlayButtonStatus.ON.value) ZwiftButton.z,
|
||||
if (status.buttonMap & _RideButtonMask.SHFT_UP_L_BTN.mask == PlayButtonStatus.ON.value) ZwiftButton.shiftUpLeft,
|
||||
if (status.buttonMap & _RideButtonMask.SHFT_DN_L_BTN.mask == PlayButtonStatus.ON.value) ZwiftButton.shiftDownLeft,
|
||||
if (status.buttonMap & _RideButtonMask.SHFT_UP_R_BTN.mask == PlayButtonStatus.ON.value) ZwiftButton.shiftUpRight,
|
||||
if (status.buttonMap & _RideButtonMask.LEFT_BTN.mask == PlayButtonStatus.ON.value)
|
||||
ControllerButton.navigationLeft,
|
||||
if (status.buttonMap & _RideButtonMask.RIGHT_BTN.mask == PlayButtonStatus.ON.value)
|
||||
ControllerButton.navigationRight,
|
||||
if (status.buttonMap & _RideButtonMask.UP_BTN.mask == PlayButtonStatus.ON.value) ControllerButton.navigationUp,
|
||||
if (status.buttonMap & _RideButtonMask.DOWN_BTN.mask == PlayButtonStatus.ON.value)
|
||||
ControllerButton.navigationDown,
|
||||
if (status.buttonMap & _RideButtonMask.A_BTN.mask == PlayButtonStatus.ON.value) ControllerButton.a,
|
||||
if (status.buttonMap & _RideButtonMask.B_BTN.mask == PlayButtonStatus.ON.value) ControllerButton.b,
|
||||
if (status.buttonMap & _RideButtonMask.Y_BTN.mask == PlayButtonStatus.ON.value) ControllerButton.y,
|
||||
if (status.buttonMap & _RideButtonMask.Z_BTN.mask == PlayButtonStatus.ON.value) ControllerButton.z,
|
||||
if (status.buttonMap & _RideButtonMask.SHFT_UP_L_BTN.mask == PlayButtonStatus.ON.value)
|
||||
ControllerButton.shiftUpLeft,
|
||||
if (status.buttonMap & _RideButtonMask.SHFT_DN_L_BTN.mask == PlayButtonStatus.ON.value)
|
||||
ControllerButton.shiftDownLeft,
|
||||
if (status.buttonMap & _RideButtonMask.SHFT_UP_R_BTN.mask == PlayButtonStatus.ON.value)
|
||||
ControllerButton.shiftUpRight,
|
||||
if (status.buttonMap & _RideButtonMask.SHFT_DN_R_BTN.mask == PlayButtonStatus.ON.value)
|
||||
ZwiftButton.shiftDownRight,
|
||||
if (status.buttonMap & _RideButtonMask.POWERUP_L_BTN.mask == PlayButtonStatus.ON.value) ZwiftButton.powerUpLeft,
|
||||
if (status.buttonMap & _RideButtonMask.POWERUP_R_BTN.mask == PlayButtonStatus.ON.value) ZwiftButton.powerUpRight,
|
||||
if (status.buttonMap & _RideButtonMask.ONOFF_L_BTN.mask == PlayButtonStatus.ON.value) ZwiftButton.onOffLeft,
|
||||
if (status.buttonMap & _RideButtonMask.ONOFF_R_BTN.mask == PlayButtonStatus.ON.value) ZwiftButton.onOffRight,
|
||||
ControllerButton.shiftDownRight,
|
||||
if (status.buttonMap & _RideButtonMask.POWERUP_L_BTN.mask == PlayButtonStatus.ON.value)
|
||||
ControllerButton.powerUpLeft,
|
||||
if (status.buttonMap & _RideButtonMask.POWERUP_R_BTN.mask == PlayButtonStatus.ON.value)
|
||||
ControllerButton.powerUpRight,
|
||||
if (status.buttonMap & _RideButtonMask.ONOFF_L_BTN.mask == PlayButtonStatus.ON.value) ControllerButton.onOffLeft,
|
||||
if (status.buttonMap & _RideButtonMask.ONOFF_R_BTN.mask == PlayButtonStatus.ON.value) ControllerButton.onOffRight,
|
||||
];
|
||||
|
||||
// Process ANALOG inputs separately - now properly separated from digital
|
||||
@@ -71,9 +77,9 @@ class RideNotification extends BaseNotification {
|
||||
if (paddle.hasLocation() && paddle.hasAnalogValue()) {
|
||||
if (paddle.analogValue.abs() >= analogPaddleThreshold) {
|
||||
final button = switch (paddle.location.value) {
|
||||
0 => ZwiftButton.paddleLeft, // L0 = left paddle
|
||||
1 => ZwiftButton.paddleRight, // L1 = right paddle
|
||||
_ => null, // L2, L3 unused
|
||||
0 => ControllerButton.paddleLeft, // L0 = left paddle
|
||||
1 => ControllerButton.paddleRight, // L1 = right paddle
|
||||
_ => null, // L2, L3 unused
|
||||
};
|
||||
|
||||
if (button != null) {
|
||||
|
||||
@@ -35,7 +35,7 @@ class TouchAreaSetupPage extends StatefulWidget {
|
||||
class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
|
||||
File? _backgroundImage;
|
||||
late StreamSubscription<BaseNotification> _actionSubscription;
|
||||
ZwiftButton? _pressedButton;
|
||||
ControllerButton? _pressedButton;
|
||||
final TransformationController _transformationController = TransformationController();
|
||||
|
||||
late Rect _imageRect;
|
||||
|
||||
@@ -25,7 +25,7 @@ class AndroidActions extends BaseActions {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> performAction(ZwiftButton button, {bool isKeyDown = true, bool isKeyUp = false}) async {
|
||||
Future<String> performAction(ControllerButton button, {bool isKeyDown = true, bool isKeyUp = false}) async {
|
||||
if (supportedApp == null) {
|
||||
return ("Could not perform ${button.name.splitByUpperCase()}: No keymap set");
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ abstract class BaseActions {
|
||||
this.supportedApp = supportedApp;
|
||||
}
|
||||
|
||||
Offset resolveTouchPosition({required ZwiftButton action, required WindowEvent? windowInfo}) {
|
||||
Offset resolveTouchPosition({required ControllerButton action, required WindowEvent? windowInfo}) {
|
||||
final keyPair = supportedApp!.keymap.getKeyPair(action);
|
||||
if (keyPair != null && keyPair.touchPosition != Offset.zero) {
|
||||
// convert relative position to absolute position based on window info
|
||||
@@ -38,14 +38,14 @@ abstract class BaseActions {
|
||||
return Offset.zero;
|
||||
}
|
||||
|
||||
Future<String> performAction(ZwiftButton action, {bool isKeyDown = true, bool isKeyUp = false});
|
||||
Future<String> performAction(ControllerButton action, {bool isKeyDown = true, bool isKeyUp = false});
|
||||
}
|
||||
|
||||
class StubActions extends BaseActions {
|
||||
StubActions({super.supportedModes = const []});
|
||||
|
||||
@override
|
||||
Future<String> performAction(ZwiftButton action, {bool isKeyDown = true, bool isKeyUp = false}) {
|
||||
Future<String> performAction(ControllerButton action, {bool isKeyDown = true, bool isKeyUp = false}) {
|
||||
return Future.value(action.name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ class DesktopActions extends BaseActions {
|
||||
// Track keys that are currently held down in long press mode
|
||||
|
||||
@override
|
||||
Future<String> performAction(ZwiftButton action, {bool isKeyDown = true, bool isKeyUp = false}) async {
|
||||
Future<String> performAction(ControllerButton action, {bool isKeyDown = true, bool isKeyUp = false}) async {
|
||||
if (supportedApp == null) {
|
||||
return ('Supported app is not set');
|
||||
}
|
||||
@@ -49,7 +49,7 @@ class DesktopActions extends BaseActions {
|
||||
}
|
||||
|
||||
// Release all held keys (useful for cleanup)
|
||||
Future<void> releaseAllHeldKeys(List<ZwiftButton> list) async {
|
||||
Future<void> releaseAllHeldKeys(List<ControllerButton> list) async {
|
||||
for (final action in list) {
|
||||
final keyPair = supportedApp?.keymap.getKeyPair(action);
|
||||
if (keyPair?.physicalKey != null) {
|
||||
|
||||
@@ -16,7 +16,7 @@ class RemoteActions extends BaseActions {
|
||||
RemoteActions({super.supportedModes = const [SupportedMode.touch]});
|
||||
|
||||
@override
|
||||
Future<String> performAction(ZwiftButton action, {bool isKeyDown = true, bool isKeyUp = false}) async {
|
||||
Future<String> performAction(ControllerButton action, {bool isKeyDown = true, bool isKeyUp = false}) async {
|
||||
if (supportedApp == null) {
|
||||
return 'Supported app is not set';
|
||||
}
|
||||
@@ -43,7 +43,7 @@ class RemoteActions extends BaseActions {
|
||||
}
|
||||
|
||||
@override
|
||||
Offset resolveTouchPosition({required ZwiftButton action, required WindowEvent? windowInfo}) {
|
||||
Offset resolveTouchPosition({required ControllerButton action, required WindowEvent? windowInfo}) {
|
||||
// for remote actions we use the relative position only
|
||||
final keyPair = supportedApp!.keymap.getKeyPair(action);
|
||||
if (keyPair != null && keyPair.touchPosition != Offset.zero) {
|
||||
|
||||
@@ -13,27 +13,27 @@ class Biketerra extends SupportedApp {
|
||||
keymap: Keymap(
|
||||
keyPairs: [
|
||||
KeyPair(
|
||||
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.shiftDown).toList(),
|
||||
buttons: ControllerButton.values.filter((e) => e.action == InGameAction.shiftDown).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.keyS,
|
||||
logicalKey: LogicalKeyboardKey.keyS,
|
||||
),
|
||||
KeyPair(
|
||||
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.shiftUp).toList(),
|
||||
buttons: ControllerButton.values.filter((e) => e.action == InGameAction.shiftUp).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.keyW,
|
||||
logicalKey: LogicalKeyboardKey.keyW,
|
||||
),
|
||||
KeyPair(
|
||||
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.navigateRight).toList(),
|
||||
buttons: ControllerButton.values.filter((e) => e.action == InGameAction.navigateRight).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.arrowRight,
|
||||
logicalKey: LogicalKeyboardKey.arrowRight,
|
||||
),
|
||||
KeyPair(
|
||||
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.navigateLeft).toList(),
|
||||
buttons: ControllerButton.values.filter((e) => e.action == InGameAction.navigateLeft).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.arrowLeft,
|
||||
logicalKey: LogicalKeyboardKey.arrowLeft,
|
||||
),
|
||||
KeyPair(
|
||||
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.toggleUi).toList(),
|
||||
buttons: ControllerButton.values.filter((e) => e.action == InGameAction.toggleUi).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.keyU,
|
||||
logicalKey: LogicalKeyboardKey.keyU,
|
||||
),
|
||||
|
||||
@@ -35,7 +35,7 @@ class CustomApp extends SupportedApp {
|
||||
}
|
||||
|
||||
void setKey(
|
||||
ZwiftButton zwiftButton, {
|
||||
ControllerButton zwiftButton, {
|
||||
required PhysicalKeyboardKey? physicalKey,
|
||||
required LogicalKeyboardKey? logicalKey,
|
||||
bool isLongPress = false,
|
||||
|
||||
@@ -13,33 +13,33 @@ class MyWhoosh extends SupportedApp {
|
||||
keymap: Keymap(
|
||||
keyPairs: [
|
||||
KeyPair(
|
||||
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.shiftDown).toList(),
|
||||
buttons: ControllerButton.values.filter((e) => e.action == InGameAction.shiftDown).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.keyI,
|
||||
logicalKey: LogicalKeyboardKey.keyI,
|
||||
touchPosition: Offset(80, 94),
|
||||
),
|
||||
KeyPair(
|
||||
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.shiftUp).toList(),
|
||||
buttons: ControllerButton.values.filter((e) => e.action == InGameAction.shiftUp).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.keyK,
|
||||
logicalKey: LogicalKeyboardKey.keyK,
|
||||
touchPosition: Offset(98, 94),
|
||||
),
|
||||
KeyPair(
|
||||
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.navigateRight).toList(),
|
||||
buttons: ControllerButton.values.filter((e) => e.action == InGameAction.navigateRight).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.keyD,
|
||||
logicalKey: LogicalKeyboardKey.keyD,
|
||||
touchPosition: Offset(98, 80),
|
||||
isLongPress: true,
|
||||
),
|
||||
KeyPair(
|
||||
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.navigateLeft).toList(),
|
||||
buttons: ControllerButton.values.filter((e) => e.action == InGameAction.navigateLeft).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.keyA,
|
||||
logicalKey: LogicalKeyboardKey.keyA,
|
||||
touchPosition: Offset(32, 80),
|
||||
isLongPress: true,
|
||||
),
|
||||
KeyPair(
|
||||
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.toggleUi).toList(),
|
||||
buttons: ControllerButton.values.filter((e) => e.action == InGameAction.toggleUi).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.keyH,
|
||||
logicalKey: LogicalKeyboardKey.keyH,
|
||||
),
|
||||
|
||||
@@ -14,39 +14,39 @@ class TrainingPeaks extends SupportedApp {
|
||||
keyPairs: [
|
||||
// https://help.trainingpeaks.com/hc/en-us/articles/31340399556877-TrainingPeaks-Virtual-Controls-and-Keyboard-Shortcuts
|
||||
KeyPair(
|
||||
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.shiftDown).toList(),
|
||||
buttons: ControllerButton.values.filter((e) => e.action == InGameAction.shiftDown).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.numpadSubtract,
|
||||
logicalKey: LogicalKeyboardKey.numpadSubtract,
|
||||
touchPosition: Offset(50 * 1.32, 74),
|
||||
),
|
||||
KeyPair(
|
||||
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.shiftUp).toList(),
|
||||
buttons: ControllerButton.values.filter((e) => e.action == InGameAction.shiftUp).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.numpadAdd,
|
||||
logicalKey: LogicalKeyboardKey.numpadAdd,
|
||||
touchPosition: Offset(50 * 1.15, 74),
|
||||
),
|
||||
KeyPair(
|
||||
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.navigateRight).toList(),
|
||||
buttons: ControllerButton.values.filter((e) => e.action == InGameAction.navigateRight).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.arrowRight,
|
||||
logicalKey: LogicalKeyboardKey.arrowRight,
|
||||
),
|
||||
KeyPair(
|
||||
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.navigateLeft).toList(),
|
||||
buttons: ControllerButton.values.filter((e) => e.action == InGameAction.navigateLeft).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.arrowLeft,
|
||||
logicalKey: LogicalKeyboardKey.arrowLeft,
|
||||
),
|
||||
KeyPair(
|
||||
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.toggleUi).toList(),
|
||||
buttons: ControllerButton.values.filter((e) => e.action == InGameAction.toggleUi).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.keyH,
|
||||
logicalKey: LogicalKeyboardKey.keyH,
|
||||
),
|
||||
KeyPair(
|
||||
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.increaseResistance).toList(),
|
||||
buttons: ControllerButton.values.filter((e) => e.action == InGameAction.increaseResistance).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.pageUp,
|
||||
logicalKey: LogicalKeyboardKey.pageUp,
|
||||
),
|
||||
KeyPair(
|
||||
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.decreaseResistance).toList(),
|
||||
buttons: ControllerButton.values.filter((e) => e.action == InGameAction.decreaseResistance).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.pageDown,
|
||||
logicalKey: LogicalKeyboardKey.pageDown,
|
||||
),
|
||||
|
||||
@@ -15,7 +15,7 @@ enum InGameAction {
|
||||
}
|
||||
}
|
||||
|
||||
enum ZwiftButton {
|
||||
enum ControllerButton {
|
||||
// left controller
|
||||
navigationUp._(InGameAction.increaseResistance, icon: Icons.keyboard_arrow_up, color: Colors.black),
|
||||
navigationDown._(InGameAction.decreaseResistance, icon: Icons.keyboard_arrow_down, color: Colors.black),
|
||||
@@ -47,7 +47,7 @@ enum ZwiftButton {
|
||||
final InGameAction? action;
|
||||
final Color? color;
|
||||
final IconData? icon;
|
||||
const ZwiftButton._(this.action, {this.color, this.icon});
|
||||
const ControllerButton._(this.action, {this.color, this.icon});
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
|
||||
@@ -24,12 +24,12 @@ class Keymap {
|
||||
);
|
||||
}
|
||||
|
||||
PhysicalKeyboardKey? getPhysicalKey(ZwiftButton action) {
|
||||
PhysicalKeyboardKey? getPhysicalKey(ControllerButton action) {
|
||||
// get the key pair by in game action
|
||||
return keyPairs.firstOrNullWhere((element) => element.buttons.contains(action))?.physicalKey;
|
||||
}
|
||||
|
||||
KeyPair? getKeyPair(ZwiftButton action) {
|
||||
KeyPair? getKeyPair(ControllerButton action) {
|
||||
// get the key pair by in game action
|
||||
return keyPairs.firstOrNullWhere((element) => element.buttons.contains(action));
|
||||
}
|
||||
@@ -40,7 +40,7 @@ class Keymap {
|
||||
}
|
||||
|
||||
class KeyPair {
|
||||
final List<ZwiftButton> buttons;
|
||||
final List<ControllerButton> buttons;
|
||||
PhysicalKeyboardKey? physicalKey;
|
||||
LogicalKeyboardKey? logicalKey;
|
||||
Offset touchPosition;
|
||||
@@ -117,7 +117,7 @@ class KeyPair {
|
||||
|
||||
return KeyPair(
|
||||
buttons: decoded['actions']
|
||||
.map<ZwiftButton>((e) => ZwiftButton.values.firstWhere((element) => element.name == e))
|
||||
.map<ControllerButton>((e) => ControllerButton.values.firstWhere((element) => element.name == e))
|
||||
.toList(),
|
||||
logicalKey: decoded.containsKey('logicalKey') && int.parse(decoded['logicalKey']) != 0
|
||||
? LogicalKeyboardKey(int.parse(decoded['logicalKey']))
|
||||
|
||||
@@ -27,7 +27,7 @@ class _HotKeyListenerState extends State<HotKeyListenerDialog> {
|
||||
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
KeyDownEvent? _pressedKey;
|
||||
ZwiftButton? _pressedButton;
|
||||
ControllerButton? _pressedButton;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -84,24 +84,23 @@ class _HotKeyListenerState extends State<HotKeyListenerDialog> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
content:
|
||||
_pressedButton == null
|
||||
? Text('Press a button on your Click device')
|
||||
: KeyboardListener(
|
||||
focusNode: _focusNode,
|
||||
autofocus: true,
|
||||
onKeyEvent: _onKey,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: 20,
|
||||
children: [
|
||||
Text("Press a key on your keyboard to assign to ${_pressedButton.toString()}"),
|
||||
Text(_formatKey(_pressedKey)),
|
||||
],
|
||||
),
|
||||
content: _pressedButton == null
|
||||
? Text('Press a button on your Click device')
|
||||
: KeyboardListener(
|
||||
focusNode: _focusNode,
|
||||
autofocus: true,
|
||||
onKeyEvent: _onKey,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: 20,
|
||||
children: [
|
||||
Text("Press a key on your keyboard to assign to ${_pressedButton.toString()}"),
|
||||
Text(_formatKey(_pressedKey)),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
actions: [TextButton(onPressed: () => Navigator.of(context).pop(_pressedKey), child: Text("OK"))],
|
||||
);
|
||||
|
||||
@@ -153,7 +153,7 @@ class KeyWidget extends StatelessWidget {
|
||||
}
|
||||
|
||||
class ButtonWidget extends StatelessWidget {
|
||||
final ZwiftButton button;
|
||||
final ControllerButton button;
|
||||
final bool big;
|
||||
const ButtonWidget({super.key, required this.button, this.big = false});
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ class MenuButton extends StatelessWidget {
|
||||
child: PopupMenuButton(
|
||||
child: Text("Simulate buttons"),
|
||||
itemBuilder: (_) {
|
||||
return ZwiftButton.values
|
||||
return ControllerButton.values
|
||||
.map(
|
||||
(e) => PopupMenuItem(
|
||||
child: Text(e.name),
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:swift_control/utils/keymap/keymap.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:swift_control/utils/keymap/keymap.dart';
|
||||
|
||||
void main() {
|
||||
group('Long Press KeyPair Tests', () {
|
||||
test('KeyPair should encode and decode isLongPress property', () {
|
||||
// Create a KeyPair with long press enabled
|
||||
final keyPair = KeyPair(
|
||||
buttons: [ZwiftButton.a],
|
||||
buttons: [ControllerButton.a],
|
||||
physicalKey: PhysicalKeyboardKey.keyA,
|
||||
logicalKey: LogicalKeyboardKey.keyA,
|
||||
isLongPress: true,
|
||||
@@ -16,14 +16,14 @@ void main() {
|
||||
|
||||
// Encode the KeyPair
|
||||
final encoded = keyPair.encode();
|
||||
|
||||
|
||||
// Decode the KeyPair
|
||||
final decoded = KeyPair.decode(encoded);
|
||||
|
||||
|
||||
// Verify the decoded KeyPair has the correct properties
|
||||
expect(decoded, isNotNull);
|
||||
expect(decoded!.isLongPress, true);
|
||||
expect(decoded.buttons, equals([ZwiftButton.a]));
|
||||
expect(decoded.buttons, equals([ControllerButton.a]));
|
||||
expect(decoded.physicalKey, equals(PhysicalKeyboardKey.keyA));
|
||||
expect(decoded.logicalKey, equals(LogicalKeyboardKey.keyA));
|
||||
});
|
||||
@@ -41,7 +41,7 @@ void main() {
|
||||
|
||||
// Decode the legacy KeyPair
|
||||
final decoded = KeyPair.decode(legacyEncoded);
|
||||
|
||||
|
||||
// Verify the decoded KeyPair defaults isLongPress to false
|
||||
expect(decoded, isNotNull);
|
||||
expect(decoded!.isLongPress, false);
|
||||
@@ -49,7 +49,7 @@ void main() {
|
||||
|
||||
test('KeyPair constructor should default isLongPress to false', () {
|
||||
final keyPair = KeyPair(
|
||||
buttons: [ZwiftButton.a],
|
||||
buttons: [ControllerButton.a],
|
||||
physicalKey: PhysicalKeyboardKey.keyA,
|
||||
logicalKey: LogicalKeyboardKey.keyA,
|
||||
);
|
||||
@@ -59,7 +59,7 @@ void main() {
|
||||
|
||||
test('KeyPair should correctly encode isLongPress false', () {
|
||||
final keyPair = KeyPair(
|
||||
buttons: [ZwiftButton.a],
|
||||
buttons: [ControllerButton.a],
|
||||
physicalKey: PhysicalKeyboardKey.keyA,
|
||||
logicalKey: LogicalKeyboardKey.keyA,
|
||||
isLongPress: false,
|
||||
@@ -67,9 +67,9 @@ void main() {
|
||||
|
||||
final encoded = keyPair.encode();
|
||||
final decoded = KeyPair.decode(encoded);
|
||||
|
||||
|
||||
expect(decoded, isNotNull);
|
||||
expect(decoded!.isLongPress, false);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ void main() {
|
||||
group('Percentage-based Keymap Tests', () {
|
||||
test('Should encode touch position as percentage using fallback screen size', () {
|
||||
final keyPair = KeyPair(
|
||||
buttons: [ZwiftButton.paddleRight],
|
||||
buttons: [ControllerButton.paddleRight],
|
||||
physicalKey: null,
|
||||
logicalKey: null,
|
||||
touchPosition: Offset(960, 540), // Center of 1920x1080 fallback
|
||||
@@ -22,7 +22,7 @@ void main() {
|
||||
|
||||
test('Should encode touch position as percentages with fallback when screen size not available', () {
|
||||
final keyPair = KeyPair(
|
||||
buttons: [ZwiftButton.paddleRight],
|
||||
buttons: [ControllerButton.paddleRight],
|
||||
physicalKey: null,
|
||||
logicalKey: null,
|
||||
touchPosition: Offset(960, 540), // Center of 1920x1080 fallback
|
||||
@@ -57,7 +57,7 @@ void main() {
|
||||
|
||||
test('Should handle zero touch position correctly', () {
|
||||
final keyPair = KeyPair(
|
||||
buttons: [ZwiftButton.paddleRight],
|
||||
buttons: [ControllerButton.paddleRight],
|
||||
physicalKey: PhysicalKeyboardKey.keyA,
|
||||
logicalKey: LogicalKeyboardKey.keyA,
|
||||
touchPosition: Offset.zero,
|
||||
@@ -72,7 +72,7 @@ void main() {
|
||||
|
||||
test('Should encode and decode with fallback screen size', () {
|
||||
final keyPair = KeyPair(
|
||||
buttons: [ZwiftButton.paddleRight],
|
||||
buttons: [ControllerButton.paddleRight],
|
||||
physicalKey: null,
|
||||
logicalKey: null,
|
||||
touchPosition: Offset(480, 270), // 25% of 1920x1080
|
||||
|
||||
Reference in New Issue
Block a user