fixup! feature: add analog paddle support for Zwift Ride

This commit is contained in:
Javier Moro Sotelo
2025-10-08 09:34:40 +02:00
parent d2be747fc1
commit 2c7e714856
7 changed files with 37 additions and 353 deletions

View File

@@ -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<List<ZwiftButton>?> 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 = <int, int>{};
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');
}
}

View File

@@ -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

View File

@@ -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<String, int> 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<int, int> parseKeyGroup(Uint8List buffer) {
final groupStatus = <int, int>{};
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;
}
}

View File

@@ -480,62 +480,19 @@ class RideAnalogKeyPress extends $pb.GeneratedMessage {
void clearAnalogValue() => clearField(2);
}
class RideAnalogKeyGroup extends $pb.GeneratedMessage {
factory RideAnalogKeyGroup({
$core.Iterable<RideAnalogKeyPress>? 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<RideAnalogKeyPress>(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<RideAnalogKeyGroup> createRepeated() => $pb.PbList<RideAnalogKeyGroup>();
@$core.pragma('dart2js:noInline')
static RideAnalogKeyGroup getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor<RideAnalogKeyGroup>(create);
static RideAnalogKeyGroup? _defaultInstance;
@$pb.TagNumber(1)
$core.List<RideAnalogKeyPress> 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<RideAnalogKeyPress>? 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<RideAnalogKeyGroup>(2, _omitFieldNames ? '' : 'AnalogButtons', protoName: 'AnalogButtons', subBuilder: RideAnalogKeyGroup.create)
..pc<RideAnalogKeyPress>(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<RideAnalogKeyPress> get analogPaddles => $_getList(1);
}
/// ------------------ Zwift Click messages

View File

@@ -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 = {

View File

@@ -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

View File

@@ -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),