/** * Bosch Smart System BLE Protocol Decoder * * 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 = (() => { // 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 const ASSIST_MODES = { 0: 'OFF', 1: 'ECO', 2: 'TOUR', 3: 'eMTB', 4: 'TURBO' }; // 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 }; /** * Decode a protobuf-style varint from a DataView at offset. * Returns { value, bytesRead } */ function decodeVarint(dataView, offset) { let result = 0; let shift = 0; let bytesRead = 0; while (offset + bytesRead < dataView.byteLength) { const byte = dataView.getUint8(offset + bytesRead); result |= (byte & 0x7F) << shift; bytesRead++; if ((byte & 0x80) === 0) break; shift += 7; } return { value: result, bytesRead }; } /** * Parse a Bosch BLE notification. * Handles both 0x30 (telemetry) and 0x10 (config) headers. * Multiple messages can be concatenated. */ function parseNotification(dataView) { const data = { speed: null, cadence: null, humanPower: null, motorPower: null, battery: null, mode: null, raw: [], unknownFields: {}, messageCount: 0 }; // Store raw bytes for debugging for (let i = 0; i < dataView.byteLength; i++) { data.raw.push(dataView.getUint8(i)); } if (dataView.byteLength < 4) return data; try { let offset = 0; 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++; 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++; } // Decode varint value if (offset < msgEnd) { const { value, bytesRead } = decodeVarint(dataView, offset); offset += bytesRead; applyField(data, dataId, value); data.messageCount++; } // Skip to end of this message if (offset < msgEnd) { offset = msgEnd; } } } catch (e) { console.warn('Bosch parse error:', e); } 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, DATA_IDS, decodeVarint, parseNotification }; })();