#!/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 BLEDEMO
Node.js @abandonware/noble — Bosch Smart System
Disconnected
`;
// ---------------------------------------------------------------------------
// 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);
});