/** * 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 */ const BoschProtocol = (() => { // Bosch BLE UUIDs const SERVICE_UUID = '0000fee7-0000-1000-8000-00805f9b34fb'; const CHAR_UUID = '00000011-eaa2-11e9-81b4-2a2ae2dbcce4'; // Assist modes mapping const ASSIST_MODES = { 0: 'OFF', 1: 'ECO', 2: 'TOUR', 3: 'eMTB', 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 }; // 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. * 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 }; } /** * 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. */ function parseNotification(dataView) { const data = { speed: 0, torque: 0, totalKm: 0, mode: 'OFF', battery: null, cadence: null, raw: [], unknownFields: {} }; // 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; // 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); 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); 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; } if (bytesRead === 0) break; // Safety } } catch (e) { console.warn('Bosch parse error:', e); } return data; } return { SERVICE_UUID, CHAR_UUID, ASSIST_MODES, KNOWN_IDS, UNKNOWN_IDS, decodeVarint, decodeSignedVarint, parseNotification }; })();