From 2c7e7148564ca809a76416ea1b2a6f81774f7777 Mon Sep 17 00:00:00 2001 From: Javier Moro Sotelo <810976+jmoro@users.noreply.github.com> Date: Wed, 8 Oct 2025 09:34:40 +0200 Subject: [PATCH] fixup! feature: add analog paddle support for Zwift Ride --- lib/bluetooth/devices/zwift_ride.dart | 85 ++------ lib/bluetooth/messages/ride_notification.dart | 6 +- lib/bluetooth/protocol/protobuf_parser.dart | 200 ------------------ lib/bluetooth/protocol/zwift.pb.dart | 65 +----- lib/bluetooth/protocol/zwift.pbjson.dart | 19 +- lib/bluetooth/protocol/zwift.proto | 7 +- lib/utils/keymap/buttons.dart | 8 +- 7 files changed, 37 insertions(+), 353 deletions(-) delete mode 100644 lib/bluetooth/protocol/protobuf_parser.dart diff --git a/lib/bluetooth/devices/zwift_ride.dart b/lib/bluetooth/devices/zwift_ride.dart index f9bd99e..c8ee85b 100644 --- a/lib/bluetooth/devices/zwift_ride.dart +++ b/lib/bluetooth/devices/zwift_ride.dart @@ -4,8 +4,8 @@ import 'package:protobuf/protobuf.dart' as $pb; import 'package:swift_control/bluetooth/devices/base_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/protobuf_parser.dart'; import 'package:swift_control/bluetooth/protocol/zp_vendor.pb.dart'; +import 'package:swift_control/bluetooth/protocol/zwift.pb.dart'; import 'package:swift_control/utils/keymap/buttons.dart'; import 'package:universal_ble/universal_ble.dart'; @@ -208,74 +208,31 @@ class ZwiftRide extends BaseDevice { Future?> processClickNotification(Uint8List message) async { final RideNotification clickNotification = RideNotification(message); - // Parse embedded analog paddle data from controller notification message. - // The Zwift Ride paddles send analog pressure values (-100 to 100) that need to be - // extracted from the raw Protocol Buffer message structure. - // - // Message structure (after button map at offset 7): - // - Location 0 (left paddle): Embedded directly without 0x1a prefix - // - Location 1-3 (right paddle, unused): Prefixed with 0x1a marker - // - // This implementation mirrors the JavaScript reference from: - // https://www.makinolo.com/blog/2024/07/26/zwift-ride-protocol/ - final allAnalogValues = {}; - var offset = 7; // Skip message type (1), field number (1), and button map (5) + // Parse analog paddle data using the auto-generated protobuf classes. + // All analog paddles (L0-L3) appear in field 3 as repeated RideAnalogKeyPress + try { + final status = RideKeyPadStatus.fromBuffer(message); - // Parse first analog location (L0 - left paddle) which appears directly - // in the message without the 0x1a section marker - if (offset < message.length && message[offset] != 0x1a) { - try { - final firstAnalog = ProtobufParser.parseKeyGroup(message.sublist(offset)); - allAnalogValues.addAll(firstAnalog); + // Process all analog paddles + for (final paddle in status.analogPaddles) { + 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 + }; - // Advance to next 0x1a section - while (offset < message.length && message[offset] != 0x1a) { - offset++; - } - } catch (e) { - // Skip to 0x1a sections on parse error - while (offset < message.length && message[offset] != 0x1a) { - offset++; - } - } - } - - // Parse remaining analog locations (L1, L2, L3, etc.) which are wrapped - // in Protocol Buffer message sections prefixed with 0x1a - while (offset < message.length) { - if (offset < message.length && message[offset] == 0x1a) { - try { - final analogData = message.sublist(offset); - // Each analog section starts with 0x1a, skip it and parse the rest - if (analogData.isNotEmpty && analogData[0] == 0x1a) { - final parsedData = ProtobufParser.parseKeyGroup(analogData.sublist(1)); - allAnalogValues.addAll(parsedData); + if (button != null) { + clickNotification.buttonsClicked.add(button); + clickNotification.analogButtons.add(button); + } } - - offset += ProtobufParser.findNextMarker(analogData, 0x1a); - } catch (e) { - offset++; } - } else { - offset++; } - } - - // Convert analog values to button presses when they exceed threshold. - // Location 0 = left paddle, Location 1 = right paddle - // Values range from -100 to 100, we use absolute value for threshold check - for (final entry in allAnalogValues.entries) { - if (entry.value.abs() >= analogPaddleThreshold) { - final button = switch (entry.key) { - 0 => ZwiftButton.paddleLeft, - 1 => ZwiftButton.paddleRight, - _ => null, - }; - - if (button != null) { - clickNotification.buttonsClicked.add(button); - clickNotification.analogButtons.add(button); - } + } catch (e) { + if (kDebugMode) { + print('Error parsing analog paddle data: $e'); } } diff --git a/lib/bluetooth/messages/ride_notification.dart b/lib/bluetooth/messages/ride_notification.dart index b653144..5eeb00b 100644 --- a/lib/bluetooth/messages/ride_notification.dart +++ b/lib/bluetooth/messages/ride_notification.dart @@ -64,12 +64,8 @@ class RideNotification extends BaseNotification { // Process ANALOG inputs separately - now properly separated from digital // Note: Analog paddle parsing is handled in ZwiftRide.processClickNotification - // by manually parsing the embedded Protocol Buffer data, as the protobuf - // structure doesn't correctly expose paddle analog values. + // using the auto-generated protobuf classes (field 3 AnalogPaddles). analogButtons = []; - - // Combine digital and analog buttons for backward compatibility - buttonsClicked.addAll(analogButtons); } @override diff --git a/lib/bluetooth/protocol/protobuf_parser.dart b/lib/bluetooth/protocol/protobuf_parser.dart deleted file mode 100644 index 29bd3dc..0000000 --- a/lib/bluetooth/protocol/protobuf_parser.dart +++ /dev/null @@ -1,200 +0,0 @@ -import 'dart:typed_data'; - -/// Utility class for parsing Protocol Buffer messages manually. -/// -/// This is needed when the auto-generated protobuf classes don't correctly -/// handle embedded or non-standard Protocol Buffer structures, such as the -/// Zwift Ride analog paddle data. -/// -/// Reference: https://developers.google.com/protocol-buffers/docs/encoding -class ProtobufParser { - /// Decodes a ZigZag-encoded signed integer to its original value. - /// - /// ZigZag encoding maps signed integers to unsigned integers: - /// - 0 -> 0, -1 -> 1, 1 -> 2, -2 -> 3, 2 -> 4, etc. - /// - /// Formula: (n >>> 1) ^ -(n & 1) - static int zigzagDecode(int encoded) { - return (encoded >>> 1) ^ -(encoded & 1); - } - - /// Decodes a Protocol Buffer varint from a buffer at the given offset. - /// - /// Returns a record of (decodedValue, bytesConsumed). - /// - /// Varints use the most significant bit (MSB) of each byte as a continuation - /// flag. If MSB is 1, there are more bytes to read. If MSB is 0, it's the - /// last byte. - static (int, int) decodeVarint(Uint8List buffer, int offset) { - var value = 0; - var shift = 0; - var bytesRead = 0; - - while (offset + bytesRead < buffer.length) { - final byte = buffer[offset + bytesRead]; - value |= (byte & 0x7f) << shift; - bytesRead++; - - if ((byte & 0x80) == 0) { - // MSB is 0, we're done - break; - } - shift += 7; - } - - return (value, bytesRead); - } - - /// Extracts the field number from a Protocol Buffer tag byte. - /// - /// Tag format: (field_number << 3) | wire_type - static int getFieldNumber(int tag) => tag >> 3; - - /// Extracts the wire type from a Protocol Buffer tag byte. - /// - /// Wire types: - /// - 0: Varint (int32, int64, uint32, uint64, sint32, sint64, bool, enum) - /// - 1: 64-bit (fixed64, sfixed64, double) - /// - 2: Length-delimited (string, bytes, embedded messages, packed repeated) - /// - 3: Start group (deprecated) - /// - 4: End group (deprecated) - /// - 5: 32-bit (fixed32, sfixed32, float) - static int getWireType(int tag) => tag & 0x7; - - /// Skips a Protocol Buffer field based on its wire type. - /// - /// Returns the number of bytes skipped. - static int skipField(Uint8List buffer, int offset, int wireType) { - var bytesSkipped = 0; - - switch (wireType) { - case 0: // Varint - while (offset + bytesSkipped < buffer.length) { - final byte = buffer[offset + bytesSkipped]; - bytesSkipped++; - if ((byte & 0x80) == 0) break; // MSB is 0, done - } - break; - - case 2: // Length-delimited - if (offset + bytesSkipped < buffer.length) { - final length = buffer[offset + bytesSkipped]; - bytesSkipped += 1 + length; - } - break; - - // Wire types 1 (64-bit) and 5 (32-bit) are not used in our case - // but could be implemented if needed - default: - // Unknown wire type, skip one byte - bytesSkipped = 1; - } - - return bytesSkipped; - } - - /// Parses a Protocol Buffer message containing location and analog value. - /// - /// Expected fields: - /// - Field 1: location (varint) - /// - Field 2: analogValue (sint32, ZigZag encoded) - /// - /// Returns a map with 'location' and 'value' keys. - static Map parseLocationValue(Uint8List buffer) { - int? location; - int? analogValue; - var offset = 0; - - while (offset < buffer.length) { - final tag = buffer[offset]; - final fieldNum = getFieldNumber(tag); - final wireType = getWireType(tag); - offset++; - - if (fieldNum == 1 && wireType == 0) { - // Parse location field (varint) - final (value, bytesRead) = decodeVarint(buffer, offset); - location = value; - offset += bytesRead; - } else if (fieldNum == 2 && wireType == 0) { - // Parse analog value field (ZigZag encoded sint32) - final (value, bytesRead) = decodeVarint(buffer, offset); - analogValue = zigzagDecode(value); - offset += bytesRead; - } else { - // Skip unknown fields - offset += skipField(buffer, offset, wireType); - } - } - - return { - 'location': location ?? 0, - 'value': analogValue ?? 0, - }; - } - - /// Parses a Protocol Buffer key group containing analog sensor data. - /// - /// Handles two formats: - /// 1. Field 3 (wire type 2): Nested message containing location and value - /// 2. Field 1 + Field 2 (wire type 0): Direct location and value fields - /// - /// Returns a map of location IDs to analog values. - static Map parseKeyGroup(Uint8List buffer) { - final groupStatus = {}; - var offset = 0; - - while (offset < buffer.length) { - final tag = buffer[offset]; - final fieldNum = getFieldNumber(tag); - final wireType = getWireType(tag); - offset++; - - if (fieldNum == 3 && wireType == 2) { - // Nested message format - final length = buffer[offset++]; - final messageBuffer = buffer.sublist(offset, offset + length); - final res = parseLocationValue(messageBuffer); - groupStatus[res['location']!] = res['value']!; - offset += length; - } else if (fieldNum == 1 && wireType == 0) { - // Direct field format - parse location - final (location, locationBytes) = decodeVarint(buffer, offset); - offset += locationBytes; - - // Parse corresponding value field - if (offset < buffer.length) { - final valueTag = buffer[offset]; - final valueFieldNum = getFieldNumber(valueTag); - final valueWireType = getWireType(valueTag); - offset++; - - if (valueFieldNum == 2 && valueWireType == 0) { - final (value, valueBytes) = decodeVarint(buffer, offset); - final decodedValue = zigzagDecode(value); - groupStatus[location] = decodedValue; - offset += valueBytes; - } - } - } else { - // Skip unknown fields - offset += skipField(buffer, offset, wireType); - } - } - - return groupStatus; - } - - /// Finds the offset to the next section with the given marker byte. - /// - /// Returns the number of bytes to skip to reach the next section, - /// or the total length if no more sections exist. - static int findNextMarker(Uint8List data, int marker) { - for (var i = 1; i < data.length; i++) { - if (data[i] == marker) { - return i; - } - } - return data.length; - } -} diff --git a/lib/bluetooth/protocol/zwift.pb.dart b/lib/bluetooth/protocol/zwift.pb.dart index 4198cce..cb04bea 100644 --- a/lib/bluetooth/protocol/zwift.pb.dart +++ b/lib/bluetooth/protocol/zwift.pb.dart @@ -480,62 +480,19 @@ class RideAnalogKeyPress extends $pb.GeneratedMessage { void clearAnalogValue() => clearField(2); } -class RideAnalogKeyGroup extends $pb.GeneratedMessage { - factory RideAnalogKeyGroup({ - $core.Iterable? groupStatus, - }) { - final $result = create(); - if (groupStatus != null) { - $result.groupStatus.addAll(groupStatus); - } - return $result; - } - RideAnalogKeyGroup._() : super(); - factory RideAnalogKeyGroup.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); - factory RideAnalogKeyGroup.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); - - static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'RideAnalogKeyGroup', package: const $pb.PackageName(_omitMessageNames ? '' : 'de.jonasbark'), createEmptyInstance: create) - ..pc(1, _omitFieldNames ? '' : 'GroupStatus', $pb.PbFieldType.PM, protoName: 'GroupStatus', subBuilder: RideAnalogKeyPress.create) - ..hasRequiredFields = false - ; - - @$core.Deprecated( - 'Using this can add significant overhead to your binary. ' - 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' - 'Will be removed in next major version') - RideAnalogKeyGroup clone() => RideAnalogKeyGroup()..mergeFromMessage(this); - @$core.Deprecated( - 'Using this can add significant overhead to your binary. ' - 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' - 'Will be removed in next major version') - RideAnalogKeyGroup copyWith(void Function(RideAnalogKeyGroup) updates) => super.copyWith((message) => updates(message as RideAnalogKeyGroup)) as RideAnalogKeyGroup; - - $pb.BuilderInfo get info_ => _i; - - @$core.pragma('dart2js:noInline') - static RideAnalogKeyGroup create() => RideAnalogKeyGroup._(); - RideAnalogKeyGroup createEmptyInstance() => create(); - static $pb.PbList createRepeated() => $pb.PbList(); - @$core.pragma('dart2js:noInline') - static RideAnalogKeyGroup getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); - static RideAnalogKeyGroup? _defaultInstance; - - @$pb.TagNumber(1) - $core.List get groupStatus => $_getList(0); -} - /// The command code prepending this message is 0x23 +/// All analog paddles (L0-L3) appear as repeated RideAnalogKeyPress in field 3 class RideKeyPadStatus extends $pb.GeneratedMessage { factory RideKeyPadStatus({ $core.int? buttonMap, - RideAnalogKeyGroup? analogButtons, + $core.Iterable? analogPaddles, }) { final $result = create(); if (buttonMap != null) { $result.buttonMap = buttonMap; } - if (analogButtons != null) { - $result.analogButtons = analogButtons; + if (analogPaddles != null) { + $result.analogPaddles.addAll(analogPaddles); } return $result; } @@ -545,7 +502,7 @@ class RideKeyPadStatus extends $pb.GeneratedMessage { static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'RideKeyPadStatus', package: const $pb.PackageName(_omitMessageNames ? '' : 'de.jonasbark'), createEmptyInstance: create) ..a<$core.int>(1, _omitFieldNames ? '' : 'ButtonMap', $pb.PbFieldType.OU3, protoName: 'ButtonMap') - ..aOM(2, _omitFieldNames ? '' : 'AnalogButtons', protoName: 'AnalogButtons', subBuilder: RideAnalogKeyGroup.create) + ..pc(3, _omitFieldNames ? '' : 'AnalogPaddles', $pb.PbFieldType.PM, protoName: 'AnalogPaddles', subBuilder: RideAnalogKeyPress.create) ..hasRequiredFields = false ; @@ -579,16 +536,8 @@ class RideKeyPadStatus extends $pb.GeneratedMessage { @$pb.TagNumber(1) void clearButtonMap() => clearField(1); - @$pb.TagNumber(2) - RideAnalogKeyGroup get analogButtons => $_getN(1); - @$pb.TagNumber(2) - set analogButtons(RideAnalogKeyGroup v) { setField(2, v); } - @$pb.TagNumber(2) - $core.bool hasAnalogButtons() => $_has(1); - @$pb.TagNumber(2) - void clearAnalogButtons() => clearField(2); - @$pb.TagNumber(2) - RideAnalogKeyGroup ensureAnalogButtons() => $_ensure(1); + @$pb.TagNumber(3) + $core.List get analogPaddles => $_getList(1); } /// ------------------ Zwift Click messages diff --git a/lib/bluetooth/protocol/zwift.pbjson.dart b/lib/bluetooth/protocol/zwift.pbjson.dart index a151db6..9951bb1 100644 --- a/lib/bluetooth/protocol/zwift.pbjson.dart +++ b/lib/bluetooth/protocol/zwift.pbjson.dart @@ -170,33 +170,20 @@ final $typed_data.Uint8List rideAnalogKeyPressDescriptor = $convert.base64Decode 'lkZUFuYWxvZ0xvY2F0aW9uUghMb2NhdGlvbhIgCgtBbmFsb2dWYWx1ZRgCIAEoEVILQW5hbG9n' 'VmFsdWU='); -@$core.Deprecated('Use rideAnalogKeyGroupDescriptor instead') -const RideAnalogKeyGroup$json = { - '1': 'RideAnalogKeyGroup', - '2': [ - {'1': 'GroupStatus', '3': 1, '4': 3, '5': 11, '6': '.de.jonasbark.RideAnalogKeyPress', '10': 'GroupStatus'}, - ], -}; - -/// Descriptor for `RideAnalogKeyGroup`. Decode as a `google.protobuf.DescriptorProto`. -final $typed_data.Uint8List rideAnalogKeyGroupDescriptor = $convert.base64Decode( - 'ChJSaWRlQW5hbG9nS2V5R3JvdXASQgoLR3JvdXBTdGF0dXMYASADKAsyIC5kZS5qb25hc2Jhcm' - 'suUmlkZUFuYWxvZ0tleVByZXNzUgtHcm91cFN0YXR1cw=='); - @$core.Deprecated('Use rideKeyPadStatusDescriptor instead') const RideKeyPadStatus$json = { '1': 'RideKeyPadStatus', '2': [ {'1': 'ButtonMap', '3': 1, '4': 1, '5': 13, '10': 'ButtonMap'}, - {'1': 'AnalogButtons', '3': 2, '4': 1, '5': 11, '6': '.de.jonasbark.RideAnalogKeyGroup', '10': 'AnalogButtons'}, + {'1': 'AnalogPaddles', '3': 3, '4': 3, '5': 11, '6': '.de.jonasbark.RideAnalogKeyPress', '10': 'AnalogPaddles'}, ], }; /// Descriptor for `RideKeyPadStatus`. Decode as a `google.protobuf.DescriptorProto`. final $typed_data.Uint8List rideKeyPadStatusDescriptor = $convert.base64Decode( 'ChBSaWRlS2V5UGFkU3RhdHVzEhwKCUJ1dHRvbk1hcBgBIAEoDVIJQnV0dG9uTWFwEkYKDUFuYW' - 'xvZ0J1dHRvbnMYAiABKAsyIC5kZS5qb25hc2JhcmsuUmlkZUFuYWxvZ0tleUdyb3VwUg1BbmFs' - 'b2dCdXR0b25z'); + 'xvZ1BhZGRsZXMYAyADKAsyIC5kZS5qb25hc2JhcmsuUmlkZUFuYWxvZ0tleVByZXNzUg1BbmFs' + 'b2dQYWRkbGVz'); @$core.Deprecated('Use clickKeyPadStatusDescriptor instead') const ClickKeyPadStatus$json = { diff --git a/lib/bluetooth/protocol/zwift.proto b/lib/bluetooth/protocol/zwift.proto index e4c3e4e..c14a3ad 100644 --- a/lib/bluetooth/protocol/zwift.proto +++ b/lib/bluetooth/protocol/zwift.proto @@ -79,14 +79,11 @@ message RideAnalogKeyPress { optional sint32 AnalogValue = 2; } -message RideAnalogKeyGroup { - repeated RideAnalogKeyPress GroupStatus = 1; -} - // The command code prepending this message is 0x23 +// All analog paddles (L0-L3) appear as repeated RideAnalogKeyPress in field 3 message RideKeyPadStatus { optional uint32 ButtonMap = 1; - optional RideAnalogKeyGroup AnalogButtons = 2; + repeated RideAnalogKeyPress AnalogPaddles = 3; // Field 3 contains all paddles } //------------------ Zwift Click messages diff --git a/lib/utils/keymap/buttons.dart b/lib/utils/keymap/buttons.dart index a0b9881..493f4f8 100644 --- a/lib/utils/keymap/buttons.dart +++ b/lib/utils/keymap/buttons.dart @@ -5,9 +5,7 @@ enum InGameAction { navigateRight, toggleUi, increaseResistance, - decreaseResistance, - steerLeft, - steerRight; + decreaseResistance; @override String toString() { @@ -23,7 +21,7 @@ enum ZwiftButton { navigationRight._(InGameAction.navigateRight), onOffLeft._(InGameAction.toggleUi), sideButtonLeft._(InGameAction.shiftDown), - paddleLeft._(null), + paddleLeft._(InGameAction.shiftDown), // zwift ride only shiftUpLeft._(InGameAction.shiftDown), @@ -37,7 +35,7 @@ enum ZwiftButton { y._(null), onOffRight._(InGameAction.toggleUi), sideButtonRight._(InGameAction.shiftUp), - paddleRight._(null), + paddleRight._(InGameAction.shiftUp), // zwift ride only shiftUpRight._(InGameAction.shiftUp),