From 1129c9656290a160a4b0b2a33ae17c21b5925c81 Mon Sep 17 00:00:00 2001 From: Kilian Lackhove Date: Sun, 12 Apr 2026 13:56:46 +0200 Subject: [PATCH] initial support for Bosch ebike BLE power sensor --- README_TESTED_SENSORS.md | 12 + .../sensors/driver/BoschEbikeParserTest.java | 137 +++++++++++ .../driver/CyclingPowerBluetoothTest.java | 26 ++- .../sensors/driver/BoschEbikeParser.java | 217 ++++++++++++++++++ .../sensors/driver/CyclingPowerBluetooth.java | 18 +- .../BluetoothLeCyclingPowerPreference.java | 9 +- 6 files changed, 411 insertions(+), 8 deletions(-) create mode 100644 src/androidTest/java/de/dennisguse/opentracks/sensors/driver/BoschEbikeParserTest.java create mode 100644 src/main/java/de/dennisguse/opentracks/sensors/driver/BoschEbikeParser.java diff --git a/README_TESTED_SENSORS.md b/README_TESTED_SENSORS.md index 455e29339..358ef53fd 100644 --- a/README_TESTED_SENSORS.md +++ b/README_TESTED_SENSORS.md @@ -112,3 +112,15 @@ Here is a list of the smartphones tested: | Google | Nexus 4 | LineageOS 18.1 | Ok | | Samsung | S4 | Android 11 | Ok | | Sony | Xperia XZ1 Compact | LineageOS 16.1 | Failure | + +## Bosch Smart System eBike + +OpenTracks also supports Bosch Smart System eBikes via Bosch's custom BLE service `00000010-eaa2-11e9-81b4-2a2ae2dbcce4`. + +Tested with: + +* Bosch Perfomance Line CX (SW-Version 16.9.0) / Kiox 300 (SW-Version 16.4.0) + +Currently supported measurements: + +* Human power \ No newline at end of file diff --git a/src/androidTest/java/de/dennisguse/opentracks/sensors/driver/BoschEbikeParserTest.java b/src/androidTest/java/de/dennisguse/opentracks/sensors/driver/BoschEbikeParserTest.java new file mode 100644 index 000000000..63464e5a1 --- /dev/null +++ b/src/androidTest/java/de/dennisguse/opentracks/sensors/driver/BoschEbikeParserTest.java @@ -0,0 +1,137 @@ +package de.dennisguse.opentracks.sensors.driver; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +import org.junit.Test; + +public class BoschEbikeParserTest { + + @Test + public void parse_multipleFrames_returnsCombinedMeasurements() { + byte[] raw = new byte[] { + // Bosch cadence frame + 0x30, 0x04, (byte) 0x98, 0x5A, 0x08, 0x76, + // Bosch human power frame + 0x30, 0x05, (byte) 0x98, 0x5B, 0x08, (byte) 0x83, 0x01, + // Bosch speed frame + 0x30, 0x07, (byte) 0x98, 0x2D, 0x08, (byte) 0x94, 0x0B, 0x10, 0x01 + }; + + BoschEbikeParser.Data data = BoschEbikeParser.parse(raw); + + assertNotNull(data); + assertEquals(59.0f, data.cadence().getRPM(), 0.01f); + assertEquals(131.0f, data.humanPower().getW(), 0.01f); + assertEquals(14.28 / 3.6, data.speed().toMPS(), 0.0001); + } + + @Test + public void parse_multiByteVarint_returnsScaledSpeed() { + // Bosch speed frame using a multi-byte varint + BoschEbikeParser.Data data = BoschEbikeParser.parse(new byte[] { + 0x30, 0x07, (byte) 0x98, 0x2D, 0x08, (byte) 0xE1, 0x0E, 0x10, 0x01 + }); + + assertNotNull(data); + assertEquals(18.89 / 3.6, data.speed().toMPS(), 0.0001); + } + + @Test + public void parse_zeroValueFrame_returnsZeroCadence() { + // Bosch zero-value cadence frame + BoschEbikeParser.Data data = BoschEbikeParser.parse(new byte[] { + 0x30, 0x02, (byte) 0x98, 0x5A + }); + + assertNotNull(data); + assertEquals(0.0f, data.cadence().getRPM(), 0.01f); + assertNull(data.speed()); + assertNull(data.humanPower()); + } + + @Test + public void parse_unsupportedMeasurementsOnly_returnsNull() { + // Bosch frame for message 0x9865, which the parser does not map to a measurement. + BoschEbikeParser.Data data = BoschEbikeParser.parse(new byte[] { + 0x30, 0x02, (byte) 0x98, 0x65 + }); + + assertNull(data); + } + + @Test + public void parse_unimplementedMeasurementsOnly_returnsNull() { + // Bosch motor power frame: message 0x985D with value 0x42, currently ignored. + BoschEbikeParser.Data data = BoschEbikeParser.parse(new byte[] { + 0x30, 0x04, (byte) 0x98, 0x5D, 0x08, 0x42 + }); + + assertNull(data); + } + + @Test + public void parse_malformedVarint_returnsNull() { + // Truncated Bosch speed frame: starts like a real 0x982D frame but the varint is incomplete. + BoschEbikeParser.Data data = BoschEbikeParser.parse(new byte[] { + 0x30, 0x04, (byte) 0x98, 0x2D, 0x08, (byte) 0x94 + }); + + assertNull(data); + } + + @Test + public void parse_varintLargerThan32Bit_returnsNull() { + BoschEbikeParser.Data data = BoschEbikeParser.parse(new byte[] { + 0x30, 0x08, (byte) 0x98, 0x2D, 0x08, + (byte) 0x80, (byte) 0x80, (byte) 0x80, (byte) 0x80, (byte) 0x80, 0x01 + }); + + assertNull(data); + } + + @Test + public void parse_validMeasurementBeforeMalformedFrame_returnsValidMeasurement() { + BoschEbikeParser.Data data = BoschEbikeParser.parse(new byte[] { + // Bosch cadence frame: message 0x985A with value 0x76 + 0x30, 0x04, (byte) 0x98, 0x5A, 0x08, 0x76, + // Truncated Bosch speed frame: malformed trailing frame should not discard the valid cadence above. + 0x30, 0x04, (byte) 0x98, 0x2D, 0x08, (byte) 0x94 + }); + + assertNotNull(data); + assertEquals(59.0f, data.cadence().getRPM(), 0.01f); + assertNull(data.speed()); + } + + @Test + public void parse_malformedVarintDoesNotConsumeNextFrameStartByte() { + BoschEbikeParser.Data data = BoschEbikeParser.parse(new byte[] { + // Truncated Bosch speed frame: the varint continuation bit is set, but the frame ends + // before the next varint byte. The following frame start byte must not be consumed. + 0x30, 0x04, (byte) 0x98, 0x2D, 0x08, (byte) 0x94, + // Bosch cadence frame: message 0x985A with value 0x76 + 0x30, 0x04, (byte) 0x98, 0x5A, 0x08, 0x76 + }); + + assertNotNull(data); + assertNull(data.speed()); + assertEquals(59.0f, data.cadence().getRPM(), 0.01f); + } + + @Test + public void parse_leadingNoise_ignored() { + byte[] raw = new byte[] { + // Noise bytes to verify the parser resynchronizes on the Bosch frame start byte 0x30. + 0x01, 0x02, 0x03, + // Bosch cadence frame: message 0x985A with value 0x76 + 0x30, 0x04, (byte) 0x98, 0x5A, 0x08, 0x76 + }; + + BoschEbikeParser.Data data = BoschEbikeParser.parse(raw); + + assertNotNull(data); + assertEquals(59.0f, data.cadence().getRPM(), 0.01f); + } +} diff --git a/src/androidTest/java/de/dennisguse/opentracks/sensors/driver/CyclingPowerBluetoothTest.java b/src/androidTest/java/de/dennisguse/opentracks/sensors/driver/CyclingPowerBluetoothTest.java index 9fa28f13c..587adf122 100644 --- a/src/androidTest/java/de/dennisguse/opentracks/sensors/driver/CyclingPowerBluetoothTest.java +++ b/src/androidTest/java/de/dennisguse/opentracks/sensors/driver/CyclingPowerBluetoothTest.java @@ -1,6 +1,7 @@ package de.dennisguse.opentracks.sensors.driver; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; import android.bluetooth.BluetoothGattCharacteristic; @@ -35,4 +36,27 @@ public class CyclingPowerBluetoothTest { assertEquals(17125, powerCadence.crank().crankRevolutionsTime()); } -} \ No newline at end of file + @Test + public void parsePayload_boschPower() { + BluetoothGattCharacteristic characteristic = new BluetoothGattCharacteristic(BoschEbikeParser.BOSCH_EBIKE.measurementUUID(), 0, 0); + // Bosch human power frame: message 0x985B with value 0x7B => 123 W. + characteristic.setValue(new byte[] { 0x30, 0x04, (byte) 0x98, 0x5B, 0x08, 0x7B }); + + CyclingPowerBluetooth.Data power = new CyclingPowerBluetooth().parsePayload(BoschEbikeParser.BOSCH_EBIKE, null, characteristic); + + assertEquals(123, power.power().getW(), 0.01); + assertNull(power.crank()); + } + + @Test + public void parsePayload_boschCadenceOnly_returnsNull() { + BluetoothGattCharacteristic characteristic = new BluetoothGattCharacteristic(BoschEbikeParser.BOSCH_EBIKE.measurementUUID(), 0, 0); + // Bosch cadence-only frame: message 0x985A with value 0x01B8 => 220 / 2 = 110 rpm, so power parsing should ignore it. + characteristic.setValue(new byte[] { 0x30, 0x05, (byte) 0x98, 0x5A, 0x08, (byte) 0xB8, 0x01 }); + + CyclingPowerBluetooth.Data power = new CyclingPowerBluetooth().parsePayload(BoschEbikeParser.BOSCH_EBIKE, null, characteristic); + + assertNull(power); + } + +} diff --git a/src/main/java/de/dennisguse/opentracks/sensors/driver/BoschEbikeParser.java b/src/main/java/de/dennisguse/opentracks/sensors/driver/BoschEbikeParser.java new file mode 100644 index 000000000..d7398c823 --- /dev/null +++ b/src/main/java/de/dennisguse/opentracks/sensors/driver/BoschEbikeParser.java @@ -0,0 +1,217 @@ +package de.dennisguse.opentracks.sensors.driver; + +import android.util.Log; + +import androidx.annotation.Nullable; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; + +import de.dennisguse.opentracks.data.models.Cadence; +import de.dennisguse.opentracks.data.models.Power; +import de.dennisguse.opentracks.data.models.Speed; +import de.dennisguse.opentracks.sensors.ServiceMeasurementUUID; + +// Implementation of metrics from a Bosch Smart System eBike via BLE. +// as described and implemented here: https://github.com/RobbyPee/Bosch-Smart-System-Ebike-Garmin-Android +// +// The bike exposes a single custom BLE service with one notification characteristic onto +// which all measurements are multiplexed as concatenated frames. +// +// ``` +// 30 [08 ] +// ``` +// +// Frame format +// +// - `30` : start byte +// - `` - number of payload bytes following (min 2, which means value = 0) +// - two-byte big-endian message ID +// - `08` : varint type tag (absent when value is 0) +// - varint : strip MSB from each byte, 7-bit groups, little-endian concatenation +// +// Multiple frames may be packed into a single characteristic notification. +// + +public final class BoschEbikeParser { + + private static final String TAG = BoschEbikeParser.class.getSimpleName(); + + private static final int MESSAGE_ID_CADENCE = 0x985A; + private static final int MESSAGE_ID_HUMAN_POWER = 0x985B; + private static final int MESSAGE_ID_SPEED = 0x982D; + private static final int MESSAGE_ID_MOTOR_POWER = 0x985D; + private static final int MESSAGE_ID_BATTERY = 0x8088; + private static final int MESSAGE_ID_ASSIST_MODE = 0x9809; + + private static final int START_BYTE = 0x30; + private static final int VARINT_TAG = 0x08; + private static final int MAX_VARINT_32_BIT_BYTES = 5; + + public static final ServiceMeasurementUUID BOSCH_EBIKE = new ServiceMeasurementUUID( + UUID.fromString("00000010-eaa2-11e9-81b4-2a2ae2dbcce4"), + UUID.fromString("00000011-eaa2-11e9-81b4-2a2ae2dbcce4")); + + private BoschEbikeParser() { + } + + /** + * Decodes a single varint from {@code data}. Uses little-endian 7-bit groups; MSB=1 means another byte follows. + */ + private static Integer decodeVarint(byte[] data) { + if (data == null || data.length == 0) { + Log.d(TAG, "Malformed varint: empty payload"); + return null; + } + + int result = 0; + + for (int i = 0; i < data.length; i++) { + if (i >= MAX_VARINT_32_BIT_BYTES) { + Log.d(TAG, "Malformed varint: exceeds 32-bit limit after " + i + " bytes"); + return null; + } + + int byteValue = data[i] & 0x7F; + result |= byteValue << (7 * i); + + if ((data[i] & 0x80) == 0) { + return result; + } + } + + Log.d(TAG, "Malformed varint: truncated after " + data.length + " bytes"); + return null; + } + + /** + * Parses one Bosch notification payload, which may contain multiple concatenated frames, and returns the measurements recognized in that payload. + */ + static Data parse(byte[] raw) { + if (raw == null || raw.length == 0) { + return null; + } + + Data data = splitFrames(raw).stream() + .filter(BoschEbikeParser::isValidFrame) + .filter(BoschEbikeParser::isSupportedFrame) + .map(BoschEbikeParser::decodeFrame) + .filter(decodedFrame -> decodedFrame != null) + .reduce(null, + BoschEbikeParser::processMessage, + (left, right) -> right); + Log.d(TAG, "Parsed Bosch data: " + data); + return data; + } + + private static List splitFrames(byte[] raw) { + List frames = new ArrayList<>(); + + int i = 0; + while (i < raw.length) { + if (raw[i] != START_BYTE) { + i++; + continue; + } + + if (i + 1 >= raw.length) { + Log.d(TAG, "Malformed frame at offset " + i + ": missing length byte (raw length=" + raw.length + ")"); + i++; + continue; + } + + int length = raw[i + 1] & 0xFF; + int frameEnd = i + length + 2; + frames.add(Arrays.copyOfRange(raw, i, Math.min(frameEnd, raw.length))); + i = frameEnd; + } + + return frames; + } + + private static boolean isValidFrame(byte[] frame) { + int length = getFrameLength(frame); + if (length < 2) { + Log.d(TAG, "Malformed frame: payload length " + length + " is shorter than minimum 2 bytes"); + return false; + } + + if (frame.length != length + 2) { + Log.d(TAG, "Malformed frame: expected " + (length + 2) + " bytes, got " + frame.length); + return false; + } + + return true; + } + + private static boolean isSupportedFrame(byte[] frame) { + boolean supported = getFrameLength(frame) == 2 || (frame[4] & 0xFF) == VARINT_TAG; + if (!supported) { + Log.d(TAG, "Unsupported frame: tag 0x" + String.format("%02X", frame[4] & 0xFF)); + } + return supported; + } + + @Nullable + private static DecodedFrame decodeFrame(byte[] frame) { + if (getFrameLength(frame) == 2) { + return new DecodedFrame(getMessageId(frame), 0); + } + + Integer varintResult = decodeVarint(Arrays.copyOfRange(frame, 5, frame.length)); + if (varintResult == null) { + return null; + } + + return new DecodedFrame(getMessageId(frame), varintResult); + } + + private static int getFrameLength(byte[] frame) { + return frame[1] & 0xFF; + } + + private static int getMessageId(byte[] frame) { + return (frame[2] & 0xFF) << 8 | (frame[3] & 0xFF); + } + + /** + * Processes a message ID and value, updating the data with any measurements. + */ + @Nullable + private static Data processMessage(@Nullable Data data, DecodedFrame decodedFrame) { + switch (decodedFrame.messageId()) { + case MESSAGE_ID_CADENCE: + return new Data(null, Cadence.of(decodedFrame.value() / 2.0f), null, data); + case MESSAGE_ID_HUMAN_POWER: + return new Data(null, null, Power.of((float) decodedFrame.value()), data); + case MESSAGE_ID_SPEED: + double speed_kmh = decodedFrame.value() / 100.0; + return new Data(Speed.ofKMH(speed_kmh), null, null, data); + case MESSAGE_ID_MOTOR_POWER: + case MESSAGE_ID_BATTERY: + case MESSAGE_ID_ASSIST_MODE: + Log.d(TAG, "Ignoring unimplemented message: raw=" + decodedFrame.value()); + return data; + } + Log.d(TAG, "Ignoring unknown message: raw=" + decodedFrame.value()); + return data; + } + + public record Data( + @Nullable Speed speed, + @Nullable Cadence cadence, + @Nullable Power humanPower) { + + public Data(@Nullable Speed speed, @Nullable Cadence cadence, @Nullable Power humanPower, @Nullable Data oldData) { + this( + speed != null ? speed : oldData != null ? oldData.speed() : null, + cadence != null ? cadence : oldData != null ? oldData.cadence() : null, + humanPower != null ? humanPower : oldData != null ? oldData.humanPower() : null); + } + } + + private record DecodedFrame(int messageId, int value) { + } +} diff --git a/src/main/java/de/dennisguse/opentracks/sensors/driver/CyclingPowerBluetooth.java b/src/main/java/de/dennisguse/opentracks/sensors/driver/CyclingPowerBluetooth.java index 15c70c069..fe6e74aa2 100644 --- a/src/main/java/de/dennisguse/opentracks/sensors/driver/CyclingPowerBluetooth.java +++ b/src/main/java/de/dennisguse/opentracks/sensors/driver/CyclingPowerBluetooth.java @@ -18,14 +18,28 @@ public class CyclingPowerBluetooth implements BluetoothDriver.BluetoothParser SUPPORTED_SERVICES = List.of( + CYCLING_POWER, + BoschEbikeParser.BOSCH_EBIKE + ); + @Override public List getServices() { - return List.of(CYCLING_POWER); + return SUPPORTED_SERVICES; } @Override public CyclingPowerBluetooth.Data parsePayload(@NonNull ServiceMeasurementUUID serviceMeasurementUUID, String sensorName, BluetoothGattCharacteristic characteristic) { - return parseCyclingPower(characteristic); + if (serviceMeasurementUUID.equals(CYCLING_POWER)) { + return parseCyclingPower(characteristic); + } + if (serviceMeasurementUUID.equals(BoschEbikeParser.BOSCH_EBIKE)) { + BoschEbikeParser.Data boschData = BoschEbikeParser.parse(characteristic.getValue()); + if (boschData != null && boschData.humanPower() != null) { + return new Data(boschData.humanPower(), null); + } + } + return null; } diff --git a/src/main/java/de/dennisguse/opentracks/settings/bluetooth/BluetoothLeCyclingPowerPreference.java b/src/main/java/de/dennisguse/opentracks/settings/bluetooth/BluetoothLeCyclingPowerPreference.java index 8a2ac7294..4c8b0a928 100644 --- a/src/main/java/de/dennisguse/opentracks/settings/bluetooth/BluetoothLeCyclingPowerPreference.java +++ b/src/main/java/de/dennisguse/opentracks/settings/bluetooth/BluetoothLeCyclingPowerPreference.java @@ -4,9 +4,6 @@ import android.content.Context; import android.util.AttributeSet; import androidx.preference.PreferenceDialogFragmentCompat; - -import java.util.List; - import de.dennisguse.opentracks.sensors.driver.CyclingPowerBluetooth; public class BluetoothLeCyclingPowerPreference extends BluetoothLeSensorPreference { @@ -29,6 +26,8 @@ public class BluetoothLeCyclingPowerPreference extends BluetoothLeSensorPreferen @Override public PreferenceDialogFragmentCompat createInstance() { - return BluetoothLeSensorPreference.BluetoothLeSensorPreferenceDialog.newInstance(getKey(), List.of(CyclingPowerBluetooth.CYCLING_POWER)); + return BluetoothLeSensorPreference.BluetoothLeSensorPreferenceDialog.newInstance( + getKey(), + CyclingPowerBluetooth.SUPPORTED_SERVICES); } -} \ No newline at end of file +}