183 lines
5.7 KiB
JavaScript
183 lines
5.7 KiB
JavaScript
/**
|
|
* 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
|
|
};
|
|
})();
|