OpusBike/cli/bridge.js

595 lines
22 KiB
JavaScript
Raw Normal View History

#!/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 = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OpusBike Noble BLE Dashboard</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #0a0a0a; color: #fff;
font-family: -apple-system, 'Segoe UI', Helvetica, Arial, sans-serif;
display: flex; flex-direction: column; align-items: center;
min-height: 100vh; padding: 20px;
}
h1 { font-size: 1.4rem; color: #4285F4; margin-bottom: 6px; }
.subtitle { font-size: 0.8rem; color: #666; margin-bottom: 20px; }
.demo-badge {
display: inline-block; background: #FF9800; color: #000;
font-weight: 700; font-size: 0.7rem; padding: 2px 8px;
border-radius: 4px; margin-left: 8px;
}
.status {
font-size: 0.75rem; padding: 4px 12px; border-radius: 12px;
margin-bottom: 16px;
}
.status.connected { background: #1b5e20; color: #81c784; }
.status.disconnected { background: #4a1010; color: #e57373; }
.conn-timer { font-size: 0.7rem; color: #555; margin-bottom: 12px; }
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px; width: 100%; max-width: 700px;
}
.card {
background: #1a1a1a; border-radius: 12px; padding: 16px;
text-align: center; border: 1px solid #222;
}
.card.hero { grid-column: 1 / -1; padding: 24px; }
.card .label { font-size: 0.7rem; color: #888; text-transform: uppercase; letter-spacing: 1px; }
.card .value { font-size: 2rem; font-weight: 700; margin: 4px 0; font-variant-numeric: tabular-nums; }
.card.hero .value { font-size: 4rem; color: #4285F4; }
.card .unit { font-size: 0.8rem; color: #666; }
.mode { font-size: 1.2rem; font-weight: 700; }
.mode.OFF { color: #666; } .mode.ECO { color: #4caf50; }
.mode.TOUR { color: #4285F4; } .mode.eMTB { color: #ff9800; }
.mode.TURBO { color: #f44336; }
.battery-bar {
width: 100%; height: 8px; background: #333; border-radius: 4px;
margin-top: 6px; overflow: hidden;
}
.battery-fill { height: 100%; border-radius: 4px; transition: width 0.3s; }
.battery-fill.green { background: #4caf50; }
.battery-fill.yellow { background: #ff9800; }
.battery-fill.red { background: #f44336; }
.timer { font-variant-numeric: tabular-nums; color: #aaa; }
</style>
</head>
<body>
<h1>OpusBike Noble BLE<span id="demoBadge" class="demo-badge" style="display:none">DEMO</span></h1>
<div class="subtitle">Node.js @abandonware/noble Bosch Smart System</div>
<div id="status" class="status disconnected">Disconnected</div>
<div id="connTimer" class="conn-timer"></div>
<div class="grid">
<div class="card hero">
<div class="label">Speed</div>
<div class="value" id="speed">0.0</div>
<div class="unit">km/h</div>
</div>
<div class="card">
<div class="label">Cadence</div>
<div class="value" id="cadence">0</div>
<div class="unit">rpm</div>
</div>
<div class="card">
<div class="label">Human Power</div>
<div class="value" id="human_power">0</div>
<div class="unit">W</div>
</div>
<div class="card">
<div class="label">Motor Power</div>
<div class="value" id="motor_power">0</div>
<div class="unit">W</div>
</div>
<div class="card">
<div class="label">Battery</div>
<div class="value" id="battery">0<span class="unit">%</span></div>
<div class="battery-bar"><div class="battery-fill green" id="batteryBar" style="width:0%"></div></div>
</div>
<div class="card">
<div class="label">Assist Mode</div>
<div class="mode OFF" id="mode">OFF</div>
</div>
<div class="card">
<div class="label">Ride Time</div>
<div class="value timer" id="ride_time">00:00:00</div>
</div>
</div>
<script>
async function poll() {
try {
const r = await fetch('/api/telemetry');
const d = await r.json();
document.getElementById('speed').textContent = d.speed.toFixed(1);
document.getElementById('cadence').textContent = d.cadence;
document.getElementById('human_power').textContent = d.human_power;
document.getElementById('motor_power').textContent = d.motor_power;
document.getElementById('battery').innerHTML = d.battery + '<span class="unit">%</span>';
const bar = document.getElementById('batteryBar');
bar.style.width = d.battery + '%';
bar.className = 'battery-fill ' + (d.battery > 50 ? 'green' : d.battery > 20 ? 'yellow' : 'red');
const modeEl = document.getElementById('mode');
modeEl.textContent = d.assist_mode;
modeEl.className = 'mode ' + d.assist_mode;
document.getElementById('ride_time').textContent = d.ride_time;
const st = document.getElementById('status');
if (d.connected || d.demo) {
st.textContent = d.demo ? 'Demo Mode' : 'BLE Connected';
st.className = 'status connected';
} else {
st.textContent = 'Disconnected';
st.className = 'status disconnected';
}
if (d.demo) document.getElementById('demoBadge').style.display = 'inline-block';
// Connection timer
const ct = document.getElementById('connTimer');
if (d.connection_seconds > 0) {
ct.textContent = 'Connection alive: ' + d.connection_seconds + 's' +
(d.connection_seconds > 37 ? ' ✓ past 37s bonding threshold' : ' (watching for 37s disconnect...)');
ct.style.color = d.connection_seconds > 37 ? '#4caf50' : '#ff9800';
}
} catch (e) {}
}
setInterval(poll, 500);
poll();
</script>
</body>
</html>`;
// ---------------------------------------------------------------------------
// 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);
});