Merge branch 'main' of github.com:jonasbark/swiftcontrol

This commit is contained in:
Jonas Bark
2025-11-07 19:41:15 +01:00
9 changed files with 428 additions and 17 deletions

View File

@@ -0,0 +1 @@
.

View File

@@ -88,6 +88,38 @@ void KeypressSimulatorWindowsPlugin::SimulateKeyPress(
}
}
// Helper function to send modifier key events
auto sendModifierKey = [](UINT vkCode, bool down) {
WORD sc = (WORD)MapVirtualKey(vkCode, MAPVK_VK_TO_VSC);
INPUT in = {0};
in.type = INPUT_KEYBOARD;
in.ki.wVk = 0;
in.ki.wScan = sc;
in.ki.dwFlags = KEYEVENTF_SCANCODE | (down ? 0 : KEYEVENTF_KEYUP);
SendInput(1, &in, sizeof(INPUT));
};
// Helper function to process modifiers
auto processModifiers = [&sendModifierKey](const std::vector<std::string>& mods, bool down) {
for (const std::string& modifier : mods) {
if (modifier == "shiftModifier") {
sendModifierKey(VK_SHIFT, down);
} else if (modifier == "controlModifier") {
sendModifierKey(VK_CONTROL, down);
} else if (modifier == "altModifier") {
sendModifierKey(VK_MENU, down);
} else if (modifier == "metaModifier") {
sendModifierKey(VK_LWIN, down);
}
}
};
// Press modifier keys first (if keyDown)
if (keyDown) {
processModifiers(modifiers, true);
}
// Send the main key
WORD sc = (WORD)MapVirtualKey(keyCode, MAPVK_VK_TO_VSC);
INPUT in = {0};
@@ -102,6 +134,11 @@ void KeypressSimulatorWindowsPlugin::SimulateKeyPress(
}
SendInput(1, &in, sizeof(INPUT));
// Release modifier keys (if keyUp)
if (!keyDown) {
processModifiers(modifiers, false);
}
/*BYTE byteValue = static_cast<BYTE>(keyCode);
keybd_event(byteValue, 0x45, keyDown ? 0 : KEYEVENTF_KEYUP, 0);*/

View File

@@ -40,11 +40,11 @@ class EliteSquare extends BluetoothDevice {
actionStreamInternal.add(LogNotification('Received $fullValue - vs $currentValue (last: $_lastValue)'));
if (_lastValue != null) {
final currentRelevantPart = fullValue.length >= 19
? fullValue.substring(6, fullValue.length - 13)
final currentRelevantPart = fullValue.length >= 14
? fullValue.substring(6, 14)
: fullValue.substring(6);
final lastRelevantPart = _lastValue!.length >= 19
? _lastValue!.substring(6, _lastValue!.length - 13)
final lastRelevantPart = _lastValue!.length >= 14
? _lastValue!.substring(6, 14)
: _lastValue!.substring(6);
if (currentRelevantPart != lastRelevantPart) {

View File

@@ -30,14 +30,14 @@ class DesktopActions extends BaseActions {
return zwiftEmulator.sendAction(keyPair.inGameAction!, keyPair.inGameActionValue);
} else if (keyPair.physicalKey != null) {
if (isKeyDown && isKeyUp) {
await keyPressSimulator.simulateKeyDown(keyPair.physicalKey);
await keyPressSimulator.simulateKeyUp(keyPair.physicalKey);
await keyPressSimulator.simulateKeyDown(keyPair.physicalKey, keyPair.modifiers);
await keyPressSimulator.simulateKeyUp(keyPair.physicalKey, keyPair.modifiers);
return 'Key clicked: $keyPair';
} else if (isKeyDown) {
await keyPressSimulator.simulateKeyDown(keyPair.physicalKey);
await keyPressSimulator.simulateKeyDown(keyPair.physicalKey, keyPair.modifiers);
return 'Key pressed: $keyPair';
} else {
await keyPressSimulator.simulateKeyUp(keyPair.physicalKey);
await keyPressSimulator.simulateKeyUp(keyPair.physicalKey, keyPair.modifiers);
return 'Key released: $keyPair';
}
} else {

View File

@@ -52,6 +52,7 @@ class CustomApp extends SupportedApp {
ControllerButton zwiftButton, {
required PhysicalKeyboardKey? physicalKey,
required LogicalKeyboardKey? logicalKey,
List<ModifierKey> modifiers = const [],
bool isLongPress = false,
Offset? touchPosition,
InGameAction? inGameAction,
@@ -62,6 +63,7 @@ class CustomApp extends SupportedApp {
if (keyPair != null) {
keyPair.physicalKey = physicalKey;
keyPair.logicalKey = logicalKey;
keyPair.modifiers = modifiers;
keyPair.isLongPress = isLongPress;
keyPair.touchPosition = touchPosition ?? Offset.zero;
keyPair.inGameAction = inGameAction;
@@ -72,6 +74,7 @@ class CustomApp extends SupportedApp {
buttons: [zwiftButton],
physicalKey: physicalKey,
logicalKey: logicalKey,
modifiers: modifiers,
isLongPress: isLongPress,
touchPosition: touchPosition ?? Offset.zero,
inGameAction: inGameAction,

View File

@@ -66,6 +66,7 @@ class KeyPair {
final List<ControllerButton> buttons;
PhysicalKeyboardKey? physicalKey;
LogicalKeyboardKey? logicalKey;
List<ModifierKey> modifiers;
Offset touchPosition;
bool isLongPress;
InGameAction? inGameAction;
@@ -75,6 +76,7 @@ class KeyPair {
required this.buttons,
required this.physicalKey,
required this.logicalKey,
this.modifiers = const [],
this.touchPosition = Offset.zero,
this.isLongPress = false,
this.inGameAction,
@@ -109,7 +111,7 @@ class KeyPair {
@override
String toString() {
return logicalKey?.keyLabel ??
final baseKey = logicalKey?.keyLabel ??
switch (physicalKey) {
PhysicalKeyboardKey.mediaPlayPause => 'Play/Pause',
PhysicalKeyboardKey.mediaTrackNext => 'Next Track',
@@ -119,6 +121,24 @@ class KeyPair {
PhysicalKeyboardKey.audioVolumeDown => 'Volume Down',
_ => 'Not assigned',
};
if (modifiers.isEmpty || baseKey == 'Not assigned') {
return baseKey;
}
// Format modifiers + key (e.g., "Ctrl+Alt+R")
final modifierStrings = modifiers.map((m) {
return switch (m) {
ModifierKey.shiftModifier => 'Shift',
ModifierKey.controlModifier => 'Ctrl',
ModifierKey.altModifier => 'Alt',
ModifierKey.metaModifier => 'Meta',
ModifierKey.functionModifier => 'Fn',
_ => m.name,
};
}).toList();
return '${modifierStrings.join('+')}+$baseKey';
}
String encode() {
@@ -128,6 +148,7 @@ class KeyPair {
'actions': buttons.map((e) => e.name).toList(),
if (logicalKey != null) 'logicalKey': logicalKey?.keyId.toString(),
if (physicalKey != null) 'physicalKey': physicalKey?.usbHidUsage.toString() ?? '0',
if (modifiers.isNotEmpty) 'modifiers': modifiers.map((e) => e.name).toList(),
if (touchPosition != Offset.zero) 'touchPosition': {'x': touchPosition.dx, 'y': touchPosition.dy},
'isLongPress': isLongPress,
'inGameAction': inGameAction?.name,
@@ -156,6 +177,15 @@ class KeyPair {
if (buttons.isEmpty) {
return null;
}
// Decode modifiers if present
final List<ModifierKey> modifiers = decoded.containsKey('modifiers')
? (decoded['modifiers'] as List)
.map<ModifierKey?>((e) => ModifierKey.values.firstOrNullWhere((element) => element.name == e))
.whereType<ModifierKey>()
.toList()
: [];
return KeyPair(
buttons: buttons,
logicalKey: decoded.containsKey('logicalKey') && int.parse(decoded['logicalKey']) != 0
@@ -164,6 +194,7 @@ class KeyPair {
physicalKey: decoded.containsKey('physicalKey') && int.parse(decoded['physicalKey']) != 0
? PhysicalKeyboardKey(int.parse(decoded['physicalKey']))
: null,
modifiers: modifiers,
touchPosition: touchPosition,
isLongPress: decoded['isLongPress'] ?? false,
inGameAction: decoded.containsKey('inGameAction')

View File

@@ -25,6 +25,7 @@ class _HotKeyListenerState extends State<HotKeyListenerDialog> {
final FocusNode _focusNode = FocusNode();
KeyDownEvent? _pressedKey;
ControllerButton? _pressedButton;
final Set<ModifierKey> _activeModifiers = {};
@override
void initState() {
@@ -52,20 +53,84 @@ class _HotKeyListenerState extends State<HotKeyListenerDialog> {
void _onKey(KeyEvent event) {
setState(() {
// Track modifier keys
if (event is KeyDownEvent) {
_pressedKey = event;
widget.customApp.setKey(
_pressedButton!,
physicalKey: _pressedKey!.physicalKey,
logicalKey: _pressedKey!.logicalKey,
touchPosition: widget.keyPair?.touchPosition,
);
final wasModifier = _updateModifierState(event.logicalKey, add: true);
// Regular key pressed - record it along with active modifiers
if (!wasModifier) {
_pressedKey = event;
widget.customApp.setKey(
_pressedButton!,
physicalKey: _pressedKey!.physicalKey,
logicalKey: _pressedKey!.logicalKey,
modifiers: _activeModifiers.toList(),
touchPosition: widget.keyPair?.touchPosition,
);
}
} else if (event is KeyUpEvent) {
// Clear modifier when released
_updateModifierState(event.logicalKey, add: false);
}
});
}
bool _updateModifierState(LogicalKeyboardKey key, {required bool add}) {
ModifierKey? modifier;
if (key == LogicalKeyboardKey.shift ||
key == LogicalKeyboardKey.shiftLeft ||
key == LogicalKeyboardKey.shiftRight) {
modifier = ModifierKey.shiftModifier;
} else if (key == LogicalKeyboardKey.control ||
key == LogicalKeyboardKey.controlLeft ||
key == LogicalKeyboardKey.controlRight) {
modifier = ModifierKey.controlModifier;
} else if (key == LogicalKeyboardKey.alt ||
key == LogicalKeyboardKey.altLeft ||
key == LogicalKeyboardKey.altRight) {
modifier = ModifierKey.altModifier;
} else if (key == LogicalKeyboardKey.meta ||
key == LogicalKeyboardKey.metaLeft ||
key == LogicalKeyboardKey.metaRight) {
modifier = ModifierKey.metaModifier;
} else if (key == LogicalKeyboardKey.fn) {
modifier = ModifierKey.functionModifier;
}
if (modifier != null) {
if (add) {
_activeModifiers.add(modifier);
} else {
_activeModifiers.remove(modifier);
}
return true;
}
return false;
}
String _formatModifierName(ModifierKey m) {
return switch (m) {
ModifierKey.shiftModifier => 'Shift',
ModifierKey.controlModifier => 'Ctrl',
ModifierKey.altModifier => 'Alt',
ModifierKey.metaModifier => 'Meta',
ModifierKey.functionModifier => 'Fn',
_ => m.name,
};
}
String _formatKey(KeyDownEvent? key) {
return key?.logicalKey.keyLabel ?? 'Waiting...';
if (key == null) {
return _activeModifiers.isEmpty ? 'Waiting...' : '${_activeModifiers.map(_formatModifierName).join('+')}+...';
}
if (_activeModifiers.isEmpty) {
return key.logicalKey.keyLabel;
}
final modifierStrings = _activeModifiers.map(_formatModifierName);
return '${modifierStrings.join('+')}+${key.logicalKey.keyLabel}';
}
@override

132
test/elite_square_test.dart Normal file
View File

@@ -0,0 +1,132 @@
import 'package:flutter_test/flutter_test.dart';
// Helper function matching the Elite Square implementation
// Extracts the 8-character button code from positions 6-14 of the hex string
String extractButtonCode(String hexValue) {
if (hexValue.length >= 14) {
return hexValue.substring(6, 14);
}
return hexValue.substring(6);
}
String bytesToHex(List<int> bytes) {
return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join();
}
void main() {
group('Elite Square Button Detection Tests', () {
test('Should extract correct button code from hex string', () {
// Test with actual dump data
expect(extractButtonCode('030153000000020318f40101'), equals('00000002'));
expect(extractButtonCode('030153000000010318f40101'), equals('00000001'));
expect(extractButtonCode('030153000004000318f40101'), equals('00000400'));
expect(extractButtonCode('030153000001000318f40101'), equals('00000100'));
expect(extractButtonCode('030153000008000318f40101'), equals('00000800'));
expect(extractButtonCode('030153000002000318f40101'), equals('00000200'));
expect(extractButtonCode('030153000000000318f40101'), equals('00000000'));
});
test('Should detect button changes correctly', () {
// Test that button code extraction is consistent for comparison
final idleState = '030153000000000318f40101';
final buttonPressed = '030153000000020318f40101';
final idleCode = extractButtonCode(idleState);
final pressedCode = extractButtonCode(buttonPressed);
expect(idleCode, equals('00000000'));
expect(pressedCode, equals('00000002'));
expect(idleCode != pressedCode, isTrue);
});
test('Should handle button release correctly', () {
// Simulate button press and release
final states = [
'030153000000000318f40101', // idle
'030153000000020318f40101', // button pressed
'030153000000000318f40101', // button released (back to idle)
];
final parts = states.map(extractButtonCode).toList();
expect(parts[0], equals('00000000')); // idle
expect(parts[1], equals('00000002')); // pressed
expect(parts[2], equals('00000000')); // released
// Verify state transitions
expect(parts[0] != parts[1], isTrue); // idle -> pressed
expect(parts[1] != parts[2], isTrue); // pressed -> released
expect(parts[0] == parts[2], isTrue); // back to idle
});
test('Should handle all button codes from mapping', () {
// Test all button codes from the mapping
final buttonCodes = {
"00000200": "up",
"00000100": "left",
"00000800": "down",
"00000400": "right",
"00002000": "x",
"00001000": "square",
"00008000": "campagnoloLeft",
"00004000": "leftBrake",
"00000002": "leftShift1",
"00000001": "leftShift2",
"02000000": "y",
"01000000": "a",
"08000000": "b",
"04000000": "z",
"20000000": "circle",
"10000000": "triangle",
"80000000": "campagnoloRight",
"40000000": "rightBrake",
"00020000": "rightShift1",
"00010000": "rightShift2",
};
// Verify all button codes are 8 characters
for (final code in buttonCodes.keys) {
expect(code.length, equals(8), reason: 'Button code $code should be 8 characters');
}
});
test('Should convert bytes to hex correctly', () {
// Test with sample data from the dump
// 030153000000020318f40101 = [0x03, 0x01, 0x53, 0x00, 0x00, 0x00, 0x02, 0x03, 0x18, 0xf4, 0x01, 0x01]
final bytes = [0x03, 0x01, 0x53, 0x00, 0x00, 0x00, 0x02, 0x03, 0x18, 0xf4, 0x01, 0x01];
final hex = bytesToHex(bytes);
expect(hex, equals('030153000000020318f40101'));
});
test('Should handle edge cases', () {
// Test with short strings
expect(extractButtonCode('0123456789'), equals('6789'));
expect(extractButtonCode('01234567'), equals('67'));
// Test with exact length (14 chars extracts positions 6-14)
expect(extractButtonCode('01234567890123'), equals('67890123'));
// Test strings shorter than 14 extract from position 6 to end
expect(extractButtonCode('0123456789ABC'), equals('6789ABC'));
});
});
group('Elite Square Protocol Tests', () {
test('Should recognize button press pattern', () {
// According to the dump, the pattern is:
// Base: 030153000000000318f40101
// Byte positions 6-13 (8 chars) change to indicate button
final baseHex = '030153000000000318f40101';
final buttonHex = '030153000000020318f40101';
// Extract the button part (positions 6-14)
final baseButton = baseHex.substring(6, 14);
final pressedButton = buttonHex.substring(6, 14);
expect(baseButton, equals('00000000'));
expect(pressedButton, equals('00000002'));
});
});
}

View File

@@ -0,0 +1,142 @@
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:swift_control/bluetooth/devices/zwift/constants.dart';
import 'package:swift_control/utils/keymap/keymap.dart';
void main() {
group('Modifier Keys KeyPair Tests', () {
test('KeyPair should encode and decode modifiers property', () {
// Create a KeyPair with modifiers
final keyPair = KeyPair(
buttons: [ZwiftButtons.a],
physicalKey: PhysicalKeyboardKey.keyR,
logicalKey: LogicalKeyboardKey.keyR,
modifiers: [ModifierKey.controlModifier, ModifierKey.altModifier],
);
// 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!.modifiers.length, 2);
expect(decoded.modifiers, contains(ModifierKey.controlModifier));
expect(decoded.modifiers, contains(ModifierKey.altModifier));
expect(decoded.buttons, equals([ZwiftButtons.a]));
expect(decoded.physicalKey, equals(PhysicalKeyboardKey.keyR));
expect(decoded.logicalKey, equals(LogicalKeyboardKey.keyR));
});
test('KeyPair should default modifiers to empty list when not specified in decode', () {
// Create a legacy encoded KeyPair without modifiers property
const legacyEncoded = '''
{
"actions": ["a"],
"logicalKey": "97",
"physicalKey": "458752",
"touchPosition": {"x": 0.0, "y": 0.0},
"isLongPress": false
}
''';
// Decode the legacy KeyPair
final decoded = KeyPair.decode(legacyEncoded);
// Verify the decoded KeyPair defaults modifiers to empty
expect(decoded, isNotNull);
expect(decoded!.modifiers, isEmpty);
});
test('KeyPair constructor should default modifiers to empty list', () {
final keyPair = KeyPair(
buttons: [ZwiftButtons.a],
physicalKey: PhysicalKeyboardKey.keyA,
logicalKey: LogicalKeyboardKey.keyA,
);
expect(keyPair.modifiers, isEmpty);
});
test('KeyPair should correctly encode empty modifiers', () {
final keyPair = KeyPair(
buttons: [ZwiftButtons.a],
physicalKey: PhysicalKeyboardKey.keyA,
logicalKey: LogicalKeyboardKey.keyA,
modifiers: [],
);
final encoded = keyPair.encode();
final decoded = KeyPair.decode(encoded);
expect(decoded, isNotNull);
expect(decoded!.modifiers, isEmpty);
});
test('KeyPair toString should format modifiers correctly', () {
final keyPairWithCtrlAlt = KeyPair(
buttons: [ZwiftButtons.a],
physicalKey: PhysicalKeyboardKey.keyR,
logicalKey: LogicalKeyboardKey.keyR,
modifiers: [ModifierKey.controlModifier, ModifierKey.altModifier],
);
final result = keyPairWithCtrlAlt.toString();
expect(result, contains('Ctrl'));
expect(result, contains('Alt'));
expect(result, contains('R'));
});
test('KeyPair toString should handle single modifier', () {
final keyPairWithShift = KeyPair(
buttons: [ZwiftButtons.a],
physicalKey: PhysicalKeyboardKey.keyA,
logicalKey: LogicalKeyboardKey.keyA,
modifiers: [ModifierKey.shiftModifier],
);
final result = keyPairWithShift.toString();
expect(result, contains('Shift'));
expect(result, contains('A'));
});
test('KeyPair toString should handle no modifiers', () {
final keyPairNoModifier = KeyPair(
buttons: [ZwiftButtons.a],
physicalKey: PhysicalKeyboardKey.keyA,
logicalKey: LogicalKeyboardKey.keyA,
modifiers: [],
);
final result = keyPairNoModifier.toString();
expect(result, equals('A'));
expect(result, isNot(contains('+')));
});
test('KeyPair should encode and decode all modifier types', () {
final keyPair = KeyPair(
buttons: [ZwiftButtons.a],
physicalKey: PhysicalKeyboardKey.keyF,
logicalKey: LogicalKeyboardKey.keyF,
modifiers: [
ModifierKey.shiftModifier,
ModifierKey.controlModifier,
ModifierKey.altModifier,
ModifierKey.metaModifier,
],
);
final encoded = keyPair.encode();
final decoded = KeyPair.decode(encoded);
expect(decoded, isNotNull);
expect(decoded!.modifiers.length, 4);
expect(decoded.modifiers, contains(ModifierKey.shiftModifier));
expect(decoded.modifiers, contains(ModifierKey.controlModifier));
expect(decoded.modifiers, contains(ModifierKey.altModifier));
expect(decoded.modifiers, contains(ModifierKey.metaModifier));
});
});
}