From 906fde8ad154f187ba0fbbc8a812bbb41b744320 Mon Sep 17 00:00:00 2001 From: Admin Date: Sat, 21 Mar 2026 15:36:48 +0000 Subject: [PATCH] Fix Bosch BLE protocol: correct UUIDs, 2-byte data IDs, new dashboard fields --- webapp/js/boschProtocol.js | 188 +++++++++++++++++++++---------------- 1 file changed, 105 insertions(+), 83 deletions(-) diff --git a/webapp/js/boschProtocol.js b/webapp/js/boschProtocol.js index 7fd5df4..bc54301 100644 --- a/webapp/js/boschProtocol.js +++ b/webapp/js/boschProtocol.js @@ -1,21 +1,30 @@ /** * Bosch Smart System BLE Protocol Decoder - * Decodes varint-encoded data from the Bosch BLE characteristic. * - * Confirmed from Phase 2 testing (nRF Connect): - * - Characteristic: 00000011-EAA2-11E9-81B4-2A2AE2DBCCE4 - * - Data rate: ~2 messages/second - * - Message format: 0x30 header, varint-encoded TLV pairs - * - Confirmed fields: assist mode, battery %, speed, cadence - * - Unknown data IDs found: 0x9865, 0x981A, 0x9874, 0x80C4, 0x808A, 0x8091 + * Real protocol confirmed from: + * - Endless Sphere reverse engineering + * - Soarcer/bosch-garmin-bridge + * - Phase 2 nRF Connect captures + * + * Message format: [0x30] [length] [id_hi] [id_lo] [0x08] [varint value...] + * Multiple messages can be concatenated in a single notification. + * + * Known data IDs: + * 0x982D = Speed (raw / 10 = km/h) + * 0x985A = Cadence (raw / 2 = RPM) + * 0x985B = Human Power (W) + * 0x985D = Motor Power (W) + * 0x8088 = Battery (%) + * 0x9809 = Assist Mode (0=OFF 1=ECO 2=TOUR 3=eMTB 4=TURBO) */ const BoschProtocol = (() => { - // Bosch BLE UUIDs - const SERVICE_UUID = '0000fee7-0000-1000-8000-00805f9b34fb'; - const CHAR_UUID = '00000011-eaa2-11e9-81b4-2a2ae2dbcce4'; + // Correct Bosch Smart System BLE UUIDs (eaa2 base) + const SERVICE_UUID = '00000010-eaa2-11e9-81b4-2a2ae2dbcce4'; + const CHAR_UUID = '00000011-eaa2-11e9-81b4-2a2ae2dbcce4'; + const WRITE_UUID = '00000012-eaa2-11e9-81b4-2a2ae2dbcce4'; - // Assist modes mapping + // Assist modes const ASSIST_MODES = { 0: 'OFF', 1: 'ECO', @@ -24,21 +33,18 @@ const BoschProtocol = (() => { 4: 'TURBO' }; - // Known data IDs from Phase 2 testing - const KNOWN_IDS = { - 0x01: 'speed', // in 0.1 km/h - 0x02: 'torque', // in Nm - 0x03: 'totalKm', // in meters (divide by 1000) - 0x04: 'mode', // assist mode index - 0x05: 'battery', // percentage (0-100) - 0x06: 'cadence' // RPM + // Confirmed data IDs from Endless Sphere + Soarcer research + const DATA_IDS = { + 0x982D: { name: 'speed', divisor: 10 }, // km/h + 0x985A: { name: 'cadence', divisor: 2 }, // RPM + 0x985B: { name: 'humanPower', divisor: 1 }, // W + 0x985D: { name: 'motorPower', divisor: 1 }, // W + 0x8088: { name: 'battery', divisor: 1 }, // % + 0x9809: { name: 'mode', divisor: 1 }, // assist mode index }; - // Unknown data IDs discovered during testing — need real-ride validation - const UNKNOWN_IDS = [0x9865, 0x981A, 0x9874, 0x80C4, 0x808A, 0x8091]; - /** - * Decode a varint from a DataView at a given offset. + * Decode a protobuf-style varint from a DataView at offset. * Returns { value, bytesRead } */ function decodeVarint(dataView, offset) { @@ -58,28 +64,21 @@ const BoschProtocol = (() => { } /** - * Decode a signed varint (zigzag encoding). - */ - function decodeSignedVarint(dataView, offset) { - const { value, bytesRead } = decodeVarint(dataView, offset); - const decoded = (value >>> 1) ^ -(value & 1); - return { value: decoded, bytesRead }; - } - - /** - * Parse a Bosch BLE notification payload. - * Handles the 0x30 header byte confirmed from Phase 2 testing. + * Parse a Bosch BLE notification. + * Handles both 0x30 (telemetry) and 0x10 (config) headers. + * Multiple messages can be concatenated. */ function parseNotification(dataView) { const data = { - speed: 0, - torque: 0, - totalKm: 0, - mode: 'OFF', - battery: null, + speed: null, cadence: null, + humanPower: null, + motorPower: null, + battery: null, + mode: null, raw: [], - unknownFields: {} + unknownFields: {}, + messageCount: 0 }; // Store raw bytes for debugging @@ -92,53 +91,57 @@ const BoschProtocol = (() => { try { let offset = 0; - // Skip header byte(s) — 0x30 confirmed from Phase 2, also handle 0x01/0x02 - const header = dataView.getUint8(0); - if (header === 0x30 || header === 0x01 || header === 0x02) { - offset = 1; - } - - // Parse TLV pairs - while (offset < dataView.byteLength - 1) { - // Read tag — may be 1 or 2 bytes - let tag = dataView.getUint8(offset); + while (offset < dataView.byteLength) { + // Read header — expect 0x30 (telemetry) or 0x10 (config) + const header = dataView.getUint8(offset); + if (header !== 0x30 && header !== 0x10) { + offset++; + continue; + } offset++; - // Check for 2-byte tags (high bit set patterns from unknown IDs) - if ((tag & 0x80) !== 0 && offset < dataView.byteLength) { - tag = (tag << 8) | dataView.getUint8(offset); + if (offset >= dataView.byteLength) break; + + // Read length + const length = dataView.getUint8(offset); + offset++; + + if (length === 0) continue; + + const msgEnd = offset + length; + if (msgEnd > dataView.byteLength) break; + + // Read 2-byte data ID + if (offset + 1 >= dataView.byteLength) break; + const idHi = dataView.getUint8(offset); + const idLo = dataView.getUint8(offset + 1); + const dataId = (idHi << 8) | idLo; + offset += 2; + + // Length 0x02 = just the ID, value is 0 + if (length === 2) { + applyField(data, dataId, 0); + data.messageCount++; + continue; + } + + // Skip 0x08 marker if present + if (offset < msgEnd && dataView.getUint8(offset) === 0x08) { offset++; } - const { value, bytesRead } = decodeVarint(dataView, offset); - offset += bytesRead; - - switch (tag) { - case 0x01: // Speed in 0.1 km/h - data.speed = value / 10; - break; - case 0x02: // Torque in Nm - data.torque = value; - break; - case 0x03: // Total distance in meters - data.totalKm = value / 1000; - break; - case 0x04: // Assist mode - data.mode = ASSIST_MODES[value] || 'OFF'; - break; - case 0x05: // Battery percentage - data.battery = value; - break; - case 0x06: // Cadence RPM - data.cadence = value; - break; - default: - // Log unknown fields for future identification - data.unknownFields[tag.toString(16)] = value; - break; + // Decode varint value + if (offset < msgEnd) { + const { value, bytesRead } = decodeVarint(dataView, offset); + offset += bytesRead; + applyField(data, dataId, value); + data.messageCount++; } - if (bytesRead === 0) break; // Safety + // Skip to end of this message + if (offset < msgEnd) { + offset = msgEnd; + } } } catch (e) { console.warn('Bosch parse error:', e); @@ -147,14 +150,33 @@ const BoschProtocol = (() => { return data; } + /** + * Apply a decoded data field to the result object. + */ + function applyField(data, dataId, rawValue) { + const field = DATA_IDS[dataId]; + if (field) { + const value = field.divisor > 1 ? rawValue / field.divisor : rawValue; + switch (field.name) { + case 'speed': data.speed = Math.round(value * 10) / 10; break; + case 'cadence': data.cadence = Math.round(value); break; + case 'humanPower': data.humanPower = rawValue; break; + case 'motorPower': data.motorPower = rawValue; break; + case 'battery': data.battery = rawValue; break; + case 'mode': data.mode = ASSIST_MODES[rawValue] || 'OFF'; break; + } + } else { + data.unknownFields['0x' + dataId.toString(16).toUpperCase()] = rawValue; + } + } + return { SERVICE_UUID, CHAR_UUID, + WRITE_UUID, ASSIST_MODES, - KNOWN_IDS, - UNKNOWN_IDS, + DATA_IDS, decodeVarint, - decodeSignedVarint, parseNotification }; })();