595 lines
22 KiB
JavaScript
595 lines
22 KiB
JavaScript
#!/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);
|
|
});
|