#!/usr/bin/env node /** * OpusBike — Bosch Smart System BLE Bridge (Noble) * Connects to a pre-paired Bosch eBike via BLE on Mac, decodes telemetry, * prints to terminal, serves dashboard at localhost:3000, logs to CSV. * * Usage: * node cli/bridge.js # Real BLE mode * node cli/bridge.js --demo # Simulated data */ const http = require('http'); const fs = require('fs'); const path = require('path'); // --------------------------------------------------------------------------- // CLI arguments // --------------------------------------------------------------------------- const args = process.argv.slice(2); const DEMO_MODE = args.includes('--demo'); const PORT = parseInt(args.find((a, i) => args[i - 1] === '--port') || '3000', 10); // --------------------------------------------------------------------------- // Bosch BLE constants // --------------------------------------------------------------------------- const SERVICE_UUID = '00000010eaa211e981b42a2ae2dbcce4'; // Noble strips dashes, lowercase const CHAR_UUID = '00000011eaa211e981b42a2ae2dbcce4'; const DEVICE_NAME_FILTERS = ['smart system', 'bosch', 'brc']; const ASSIST_MODES = { 0: 'OFF', 1: 'ECO', 2: 'TOUR', 3: 'eMTB', 4: 'TURBO' }; // Confirmed data IDs from nRF Connect Phase 2 testing const KNOWN_DATA_IDS = { 0x9809: { field: 'assist_mode', divisor: 1 }, 0x8088: { field: 'battery', divisor: 1 }, 0x982D: { field: 'speed', divisor: 10 }, 0x985A: { field: 'cadence', divisor: 2 }, 0x985B: { field: 'human_power', divisor: 1 }, 0x985D: { field: 'motor_power', divisor: 1 }, }; // --------------------------------------------------------------------------- // Shared telemetry state // --------------------------------------------------------------------------- const telemetry = { speed: 0, cadence: 0, human_power: 0, motor_power: 0, battery: 0, assist_mode: 'OFF', ride_time: '00:00:00', total_km: 0, connected: false, demo: false, last_update: null, connection_seconds: 0, }; let connectionStart = 0; let csvStream = null; // --------------------------------------------------------------------------- // Varint decoder // --------------------------------------------------------------------------- function decodeVarint(buf, offset) { let result = 0; let shift = 0; let bytesRead = 0; while (offset + bytesRead < buf.length) { const byte = buf[offset + bytesRead]; result |= (byte & 0x7F) << shift; bytesRead++; if ((byte & 0x80) === 0) break; shift += 7; } return { value: result, bytesRead }; } // --------------------------------------------------------------------------- // Bosch protocol parser // --------------------------------------------------------------------------- function parseNotification(buf) { const parsed = {}; let offset = 0; while (offset < buf.length) { if (offset >= buf.length) break; const header = buf[offset]; if (header !== 0x30) { offset++; continue; } offset++; // skip 0x30 if (offset >= buf.length) break; const length = buf[offset]; offset++; if (length === 0) continue; const msgEnd = offset + length; if (msgEnd > buf.length) break; // 2-byte data ID if (offset + 1 >= buf.length) break; const dataId = (buf[offset] << 8) | buf[offset + 1]; offset += 2; // Length 0x02 = just the ID, value = 0 if (length === 2) { const info = KNOWN_DATA_IDS[dataId]; if (info) parsed[info.field] = 0; logRaw(buf, dataId, 0, 0); continue; } // Skip 0x08 marker if (offset < buf.length && buf[offset] === 0x08) { offset++; } // Decode varint if (offset < msgEnd) { const { value, bytesRead } = decodeVarint(buf, offset); offset += bytesRead; const info = KNOWN_DATA_IDS[dataId]; if (info) { const decoded = info.divisor > 1 ? value / info.divisor : value; parsed[info.field] = decoded; logRaw(buf, dataId, value, decoded); } else { logRaw(buf, dataId, value, null); } } if (offset < msgEnd) offset = msgEnd; } return parsed; } // --------------------------------------------------------------------------- // Apply parsed data to telemetry // --------------------------------------------------------------------------- function applyParsed(parsed) { if ('speed' in parsed) telemetry.speed = Math.round(parsed.speed * 10) / 10; if ('cadence' in parsed) telemetry.cadence = Math.floor(parsed.cadence); if ('human_power' in parsed) telemetry.human_power = Math.floor(parsed.human_power); if ('motor_power' in parsed) telemetry.motor_power = Math.floor(parsed.motor_power); if ('battery' in parsed) telemetry.battery = Math.floor(parsed.battery); if ('assist_mode' in parsed) { const v = Math.floor(parsed.assist_mode); telemetry.assist_mode = ASSIST_MODES[v] || `UNK(${v})`; } // Update ride time if (connectionStart > 0) { const elapsed = Math.floor((Date.now() - connectionStart) / 1000); telemetry.connection_seconds = elapsed; const h = Math.floor(elapsed / 3600); const m = Math.floor((elapsed % 3600) / 60); const s = elapsed % 60; telemetry.ride_time = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; } telemetry.last_update = new Date().toISOString(); } // --------------------------------------------------------------------------- // CSV logging // --------------------------------------------------------------------------- function setupCSV() { const csvPath = path.join(__dirname, 'ble_log_noble.csv'); const exists = fs.existsSync(csvPath) && fs.statSync(csvPath).size > 0; csvStream = fs.createWriteStream(csvPath, { flags: 'a' }); if (!exists) { csvStream.write('timestamp,raw_hex,id_hex,field_name,raw_value,decoded_value\n'); } console.log(`[INFO] CSV log: ${csvPath}`); } function logRaw(buf, dataId, rawValue, decodedValue) { if (!csvStream) return; const info = KNOWN_DATA_IDS[dataId]; const fieldName = info ? info.field : 'unknown'; csvStream.write(`${new Date().toISOString()},${buf.toString('hex')},0x${dataId.toString(16).padStart(4, '0')},${fieldName},${rawValue},${decodedValue !== null ? decodedValue : ''}\n`); } // --------------------------------------------------------------------------- // Terminal output // --------------------------------------------------------------------------- function printTelemetry() { const ts = new Date().toLocaleTimeString('en-GB'); const prefix = telemetry.demo ? '[DEMO] ' : ''; process.stdout.write( `\r${prefix}[${ts}] ` + `Speed: ${String(telemetry.speed.toFixed(1)).padStart(5)} km/h | ` + `Cadence: ${String(telemetry.cadence).padStart(3)} rpm | ` + `Power: ${String(telemetry.human_power).padStart(3)}W | ` + `Motor: ${String(telemetry.motor_power).padStart(3)}W | ` + `Battery: ${String(telemetry.battery).padStart(3)}% | ` + `Mode: ${telemetry.assist_mode.padEnd(5)} | ` + `Time: ${telemetry.ride_time}` ); } // --------------------------------------------------------------------------- // Web dashboard // --------------------------------------------------------------------------- const DASHBOARD_HTML = ` OpusBike — Noble BLE Dashboard

OpusBike Noble BLE

Node.js @abandonware/noble — Bosch Smart System
Disconnected
Speed
0.0
km/h
Cadence
0
rpm
Human Power
0
W
Motor Power
0
W
Battery
0%
Assist Mode
OFF
Ride Time
00:00:00
`; // --------------------------------------------------------------------------- // HTTP server for dashboard // --------------------------------------------------------------------------- function startWebServer() { const server = http.createServer((req, res) => { if (req.url === '/api/telemetry') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(telemetry)); return; } res.writeHead(200, { 'Content-Type': 'text/html' }); res.end(DASHBOARD_HTML); }); server.listen(PORT, () => { console.log(`[INFO] Dashboard: http://localhost:${PORT}`); }); } // --------------------------------------------------------------------------- // Demo mode // --------------------------------------------------------------------------- function runDemo() { telemetry.demo = true; telemetry.connected = true; telemetry.battery = 87; connectionStart = Date.now(); const modes = ['ECO', 'TOUR', 'eMTB', 'TURBO', 'TOUR', 'ECO']; let modeIdx = 0; let tick = 0; let totalKm = 1847.2; console.log('[DEMO] Simulating Bosch Smart System ride data...'); console.log('[DEMO] Press Ctrl+C to stop\n'); setInterval(() => { const t = tick * 0.5; const speed = Math.max(0, 22 + 13 * Math.sin(t / 8) + 2 * Math.sin(t / 3)); const cadence = Math.max(0, Math.floor(speed * 2.8 + 5 * Math.sin(t / 5))); const humanPower = Math.max(0, Math.floor(cadence * 1.4 + 10 * Math.sin(t / 4))); const modeMultiplier = { OFF: 0, ECO: 0.5, TOUR: 1.0, eMTB: 1.5, TURBO: 2.2 }; const modeName = modes[modeIdx % modes.length]; const motorPower = Math.floor(humanPower * (modeMultiplier[modeName] || 1.0)); const battery = Math.max(0, 87 - Math.floor(tick * 0.02)); totalKm += speed / 3600 * 0.5; telemetry.speed = Math.round(speed * 10) / 10; telemetry.cadence = cadence; telemetry.human_power = humanPower; telemetry.motor_power = motorPower; telemetry.battery = battery; telemetry.assist_mode = modeName; telemetry.total_km = Math.round(totalKm * 10) / 10; applyParsed({}); // updates ride_time printTelemetry(); tick++; if (tick % 30 === 0) modeIdx++; }, 500); } // --------------------------------------------------------------------------- // Real BLE mode (Noble) // --------------------------------------------------------------------------- function runBLE() { let noble; try { noble = require('@abandonware/noble'); } catch (e) { console.error('[ERROR] @abandonware/noble not installed. Run: cd cli && npm install'); process.exit(1); } setupCSV(); console.log('[INFO] Initializing Noble BLE...'); console.log('[INFO] Make sure the bike is:'); console.log(' 1. ON (battery connected)'); console.log(' 2. Paired in macOS System Settings > Bluetooth'); console.log(' 3. Not connected to another app (Bosch Flow, nRF Connect)'); console.log(''); let targetPeripheral = null; noble.on('stateChange', (state) => { console.log(`[INFO] BLE adapter state: ${state}`); if (state === 'poweredOn') { console.log('[INFO] Scanning for Bosch Smart System eBike...'); noble.startScanning([], false); // scan all services, no duplicates } else { noble.stopScanning(); } }); noble.on('discover', (peripheral) => { const name = (peripheral.advertisement.localName || '').toLowerCase(); if (DEVICE_NAME_FILTERS.some(f => name.includes(f))) { console.log(`[INFO] Found: ${peripheral.advertisement.localName} (${peripheral.id})`); noble.stopScanning(); targetPeripheral = peripheral; connectToPeripheral(peripheral); } }); function connectToPeripheral(peripheral) { console.log(`[INFO] Connecting to ${peripheral.advertisement.localName}...`); peripheral.connect((err) => { if (err) { console.error(`[ERROR] Connection failed: ${err.message}`); scheduleReconnect(peripheral); return; } connectionStart = Date.now(); telemetry.connected = true; console.log('[INFO] Connected! Discovering services...'); // Monitor connection duration const connTimer = setInterval(() => { if (!telemetry.connected) { clearInterval(connTimer); return; } const secs = Math.floor((Date.now() - connectionStart) / 1000); telemetry.connection_seconds = secs; }, 1000); peripheral.once('disconnect', () => { clearInterval(connTimer); const elapsed = (Date.now() - connectionStart) / 1000; telemetry.connected = false; console.log(`\n[WARN] Disconnected after ${elapsed.toFixed(1)}s`); if (elapsed > 35 && elapsed < 40) { console.log('[WARN] Disconnected at ~37s — BLE bonding likely failed!'); console.log('[WARN] Pair the bike in macOS System Settings > Bluetooth first (R-000138)'); } scheduleReconnect(peripheral); }); // Discover services and characteristics peripheral.discoverSomeServicesAndCharacteristics( [SERVICE_UUID], [CHAR_UUID], (err, services, characteristics) => { if (err) { console.error(`[ERROR] Service discovery failed: ${err.message}`); // Try discovering all services as fallback discoverAll(peripheral); return; } if (!characteristics || characteristics.length === 0) { console.log('[WARN] Target characteristic not found via filter, trying full discovery...'); discoverAll(peripheral); return; } const char = characteristics[0]; subscribeToChar(char); } ); }); } function discoverAll(peripheral) { peripheral.discoverAllServicesAndCharacteristics((err, services, characteristics) => { if (err) { console.error(`[ERROR] Full discovery failed: ${err.message}`); return; } console.log(`[INFO] Found ${services.length} services, ${characteristics.length} characteristics`); // Find our target characteristic const target = characteristics.find(c => c.uuid.replace(/-/g, '').toLowerCase() === CHAR_UUID ); if (target) { console.log(`[INFO] Found target characteristic: ${target.uuid}`); subscribeToChar(target); } else { // Log all for debugging for (const s of services) { console.log(` Service: ${s.uuid}`); for (const c of s.characteristics) { console.log(` Char: ${c.uuid} [${c.properties.join(', ')}]`); } } console.log('[ERROR] Target characteristic not found. Check UUIDs above.'); } }); } function subscribeToChar(characteristic) { console.log(`[INFO] Subscribing to notifications on ${characteristic.uuid}...`); characteristic.on('data', (data, isNotification) => { const parsed = parseNotification(Buffer.from(data)); if (Object.keys(parsed).length > 0) { applyParsed(parsed); printTelemetry(); } }); characteristic.subscribe((err) => { if (err) { console.error(`[ERROR] Subscribe failed: ${err.message}`); return; } console.log('[INFO] Subscribed — receiving data. Press Ctrl+C to stop.\n'); }); } function scheduleReconnect(peripheral) { console.log('[INFO] Reconnecting in 3 seconds...'); setTimeout(() => { if (peripheral) { connectToPeripheral(peripheral); } else { console.log('[INFO] Restarting scan...'); noble.startScanning([], false); } }, 3000); } } // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- startWebServer(); if (DEMO_MODE) { runDemo(); } else { runBLE(); } process.on('SIGINT', () => { console.log('\n\n[INFO] Stopped.'); if (csvStream) csvStream.end(); process.exit(0); });