R-000139, R-000140: BLE CLI tools + full OpusBike webapp

This commit is contained in:
Claude Code 2026-03-21 10:34:04 +00:00
commit a41459f10b
25 changed files with 3567 additions and 0 deletions

98
CLAUDE.md Normal file
View File

@ -0,0 +1,98 @@
# CLAUDE.md — OpusBike
> Project P-000014 · Company C-000001 (ProActys GmbH)
> Live: **https://monitor.proactys.swiss/opusbike/**
## What is this
PWA webapp that connects to a **Bosch Smart System eBike** via Web Bluetooth and displays live telemetry: **Speed, Torque, Ride Time, Total Kilometer, Mode** (OFF/ECO/TOUR/eMTB/TURBO).
## Architecture
```
/opt/opusbike/
├── webapp/ Static PWA (HTML/CSS/JS, no framework)
│ ├── index.html Single page — connect view + ride dashboard
│ ├── manifest.json PWA manifest (start_url: ".", scope: ".")
│ ├── css/style.css Brand-compliant styling
│ ├── js/
│ │ ├── app.js Main UI logic + Demo mode
│ │ ├── bleService.js Web Bluetooth connection to Bosch
│ │ ├── boschProtocol.js Bosch BLE protocol decoder (varint TLV)
│ │ ├── wsRelay.js WebSocket relay client (source/viewer)
│ │ ├── sw.js Service worker (cache-first)
│ │ └── qrcode.js Inline QR code generator
│ └── icons/ PWA icons (192, 512, apple-touch, favicon.svg)
├── server/
│ ├── relay.js Node.js WebSocket relay (ws library, port 3030)
│ └── package.json
├── Dockerfile nginx:alpine serving /usr/share/nginx/html on port 8080
├── Dockerfile.relay Node 20 alpine running relay.js on port 3030
├── docker-compose.yml Two services: nginx + relay, network: shared
└── nginx.conf Static file serving config
```
## How it works
### Three connection modes
1. **Direct BLE** — Chrome (desktop/Android) or **Bluefy** (iOS) connects directly to the Bosch Smart System via Web Bluetooth. Data is also forwarded to the WS relay for other viewers.
2. **WebSocket Relay** — Devices without Web Bluetooth (Safari, Firefox) auto-connect to the relay as viewers. They see the same live dashboard streamed from the BLE source.
3. **Demo Mode** — Simulates realistic riding data. Also broadcasts via WS relay so all connected devices see the demo.
### iOS / iPhone support
Safari does NOT support Web Bluetooth. The app detects iOS and shows a banner recommending **Bluefy** (free App Store app) which adds Web Bluetooth to iOS. URL: https://apps.apple.com/app/bluefy-web-ble-browser/id1492822055
Once Bluefy is installed, open `https://monitor.proactys.swiss/opusbike/` in Bluefy → tap "Connect via Bluetooth" → pair with the Bosch system → ride.
### Bosch BLE Protocol
- Service UUID: `0000fee7-0000-1000-8000-00805f9b34fb`
- Characteristic UUID: `00000011-0000-1000-8000-00805f9b34fb`
- Encoding: varint TLV (tag 0x01=speed/10, 0x02=torque, 0x03=totalKm/1000, 0x04=mode)
- Filter names: `smart system`, `Bosch`, `BRC`
### WebSocket relay protocol
- URL: `wss://monitor.proactys.swiss/opusbike/ws`
- Register: `{ type: "register", role: "source" | "viewer" }`
- Telemetry: `{ type: "telemetry", data: { speed, torque, totalKm, mode } }`
- Server broadcasts: `source_connected`, `source_disconnected` to viewers
## Caddy routing (in /home/ubuntu/monitoring/Caddyfile)
```
monitor.proactys.swiss {
handle /opusbike/ws → opusbike-relay:3030 (WebSocket)
handle_path /opusbike/* → opusbike-nginx:8080 (static, prefix stripped)
handle → gitea:3000 (Gitea at root)
}
```
## Deploy
```bash
cd /opt/opusbike
docker compose up -d --build # rebuild both containers
docker restart monitoring-proxy-1 # reload Caddy (bind mount inode issue)
```
**Important:** After editing the Caddyfile, you MUST `docker restart monitoring-proxy-1` (not just `caddy reload`) because the Edit tool creates a new file inode which breaks the bind mount.
## Brand
- Skill S-000007 (ProActys brand charter)
- Colors: Primary #4285F4, Dark #364052, Navy #051229, Ice #E1E8F0
- Font: Segoe UI, Arial, Helvetica, sans-serif
- Always: capital P capital A in "ProActys"
## TODO / Known issues
- [ ] Bosch BLE protocol needs real-device validation (UUIDs + TLV tags are estimated)
- [ ] Test Bluefy on real iPhone + real Bosch Smart System
- [ ] Add battery level if available in BLE data
- [ ] Add cadence (RPM) if available
- [ ] Consider adding ride history / local storage

4
Dockerfile Normal file
View File

@ -0,0 +1,4 @@
FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY webapp/ /usr/share/nginx/html/
EXPOSE 8080

7
Dockerfile.relay Normal file
View File

@ -0,0 +1,7 @@
FROM node:alpine
WORKDIR /app
COPY server/package.json ./
RUN npm install --production
COPY server/relay.js ./
EXPOSE 3030
CMD ["node", "relay.js"]

594
cli/bridge.js Normal file
View File

@ -0,0 +1,594 @@
#!/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);
});

15
cli/package.json Normal file
View File

@ -0,0 +1,15 @@
{
"name": "opusbike-cli",
"version": "1.0.0",
"private": true,
"description": "OpusBike BLE CLI tools (Noble bridge + utilities)",
"scripts": {
"bridge": "node bridge.js",
"demo": "node bridge.js --demo"
},
"dependencies": {
"@abandonware/noble": "^1.9.2-25",
"express": "^4.21.0",
"ws": "^8.18.0"
}
}

595
cli/test_bleak.py Normal file
View File

@ -0,0 +1,595 @@
#!/usr/bin/env python3
"""
OpusBike Bosch Smart System BLE Test Script (bleak)
Connects to a pre-paired Bosch eBike via BLE, decodes telemetry,
prints to terminal, serves dashboard at localhost:3000, logs to CSV.
Usage:
python3 cli/test_bleak.py # Real BLE mode
python3 cli/test_bleak.py --demo # Simulated data
python3 cli/test_bleak.py --uuid XX # Specific device UUID
"""
import argparse
import asyncio
import csv
import json
import math
import os
import signal
import sys
import time
from datetime import datetime, timedelta
# ---------------------------------------------------------------------------
# Bosch BLE constants
# ---------------------------------------------------------------------------
SERVICE_UUID = "00000010-eaa2-11e9-81b4-2a2ae2dbcce4"
CHAR_UUID = "00000011-eaa2-11e9-81b4-2a2ae2dbcce4"
DEVICE_NAME_FILTERS = ["smart system", "Bosch", "BRC"]
ASSIST_MODES = {0: "OFF", 1: "ECO", 2: "TOUR", 3: "eMTB", 4: "TURBO"}
# Confirmed data IDs from nRF Connect Phase 2 testing
KNOWN_DATA_IDS = {
0x9809: ("assist_mode", 1), # 0=OFF 1=ECO 2=TOUR 3=eMTB 4=TURBO
0x8088: ("battery", 1), # %
0x982D: ("speed", 10), # km/h (raw / 10)
0x985A: ("cadence", 2), # rpm (raw / 2)
0x985B: ("human_power", 1), # W
0x985D: ("motor_power", 1), # W
}
# ---------------------------------------------------------------------------
# Shared telemetry state
# ---------------------------------------------------------------------------
telemetry = {
"speed": 0.0,
"cadence": 0,
"human_power": 0,
"motor_power": 0,
"battery": 0,
"assist_mode": "OFF",
"ride_time": "00:00:00",
"total_km": 0.0,
"connected": False,
"demo": False,
"last_update": None,
}
connection_start: float = 0.0
csv_writer = None
csv_file = None
# ---------------------------------------------------------------------------
# Varint decoder
# ---------------------------------------------------------------------------
def decode_varint(data: bytes, offset: int) -> tuple:
"""Decode a protobuf-style varint. Returns (value, bytes_read)."""
result = 0
shift = 0
bytes_read = 0
while offset + bytes_read < len(data):
b = data[offset + bytes_read]
result |= (b & 0x7F) << shift
bytes_read += 1
if (b & 0x80) == 0:
break
shift += 7
return result, bytes_read
# ---------------------------------------------------------------------------
# Bosch protocol parser
# ---------------------------------------------------------------------------
def parse_notification(data: bytes) -> dict:
"""
Parse a Bosch BLE notification.
Format: [0x30] [length] [id_hi] [id_lo] [0x08] [varint data...]
Multiple messages can be concatenated.
"""
parsed = {}
offset = 0
while offset < len(data):
# Expect 0x30 header
if offset >= len(data):
break
header = data[offset]
if header != 0x30:
# Try to skip unknown bytes
offset += 1
continue
offset += 1 # skip 0x30
if offset >= len(data):
break
# Read length
length = data[offset]
offset += 1
if length == 0:
continue
msg_end = offset + length
if msg_end > len(data):
break
# Read 2-byte data ID
if offset + 1 >= len(data):
break
id_hi = data[offset]
id_lo = data[offset + 1]
data_id = (id_hi << 8) | id_lo
offset += 2
# Length 0x02 means just the ID, value = 0
if length == 2:
if data_id in KNOWN_DATA_IDS:
field_name, divisor = KNOWN_DATA_IDS[data_id]
parsed[field_name] = 0
log_raw(data, data_id, 0, 0)
continue
# Expect 0x08 marker before varint
if offset < len(data) and data[offset] == 0x08:
offset += 1
# Decode varint value
if offset < msg_end:
value, vbytes = decode_varint(data, offset)
offset += vbytes
if data_id in KNOWN_DATA_IDS:
field_name, divisor = KNOWN_DATA_IDS[data_id]
decoded = value / divisor if divisor > 1 else value
parsed[field_name] = decoded
log_raw(data, data_id, value, decoded)
else:
log_raw(data, data_id, value, None)
else:
offset = msg_end
# Advance to end of this message if we haven't reached it
if offset < msg_end:
offset = msg_end
return parsed
def log_raw(raw_data: bytes, data_id: int, raw_value: int, decoded_value):
"""Log a decoded field to CSV."""
global csv_writer
if csv_writer is None:
return
field_name = "unknown"
if data_id in KNOWN_DATA_IDS:
field_name = KNOWN_DATA_IDS[data_id][0]
try:
csv_writer.writerow([
datetime.now().isoformat(),
raw_data.hex(),
f"0x{data_id:04X}",
field_name,
raw_value,
decoded_value if decoded_value is not None else "",
])
except Exception:
pass
# ---------------------------------------------------------------------------
# Apply parsed data to global telemetry
# ---------------------------------------------------------------------------
def apply_parsed(parsed: dict):
"""Merge parsed BLE fields into the global telemetry dict."""
if "speed" in parsed:
telemetry["speed"] = round(parsed["speed"], 1)
if "cadence" in parsed:
telemetry["cadence"] = int(parsed["cadence"])
if "human_power" in parsed:
telemetry["human_power"] = int(parsed["human_power"])
if "motor_power" in parsed:
telemetry["motor_power"] = int(parsed["motor_power"])
if "battery" in parsed:
telemetry["battery"] = int(parsed["battery"])
if "assist_mode" in parsed:
mode_val = int(parsed["assist_mode"])
telemetry["assist_mode"] = ASSIST_MODES.get(mode_val, f"UNK({mode_val})")
# Update ride time
if connection_start > 0:
elapsed = time.time() - connection_start
h, rem = divmod(int(elapsed), 3600)
m, s = divmod(rem, 60)
telemetry["ride_time"] = f"{h:02d}:{m:02d}:{s:02d}"
telemetry["last_update"] = datetime.now().isoformat()
# ---------------------------------------------------------------------------
# Terminal output
# ---------------------------------------------------------------------------
def print_telemetry():
"""Print one-line telemetry to terminal."""
ts = datetime.now().strftime("%H:%M:%S")
mode = telemetry["assist_mode"]
prefix = "[DEMO] " if telemetry["demo"] else ""
print(
f"\r{prefix}[{ts}] "
f"Speed: {telemetry['speed']:5.1f} km/h | "
f"Cadence: {telemetry['cadence']:3d} rpm | "
f"Power: {telemetry['human_power']:3d}W | "
f"Motor: {telemetry['motor_power']:3d}W | "
f"Battery: {telemetry['battery']:3d}% | "
f"Mode: {mode:<5s} | "
f"Time: {telemetry['ride_time']}",
end="", flush=True,
)
# ---------------------------------------------------------------------------
# Web dashboard (aiohttp)
# ---------------------------------------------------------------------------
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 BLE Test 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; }
.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 BLE Test<span id="demoBadge" class="demo-badge" style="display:none">DEMO</span></h1>
<div class="subtitle">Python bleak Bosch Smart System</div>
<div id="status" class="status disconnected">Disconnected</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';
} catch (e) {}
}
setInterval(poll, 500);
poll();
</script>
</body>
</html>"""
async def start_web_server(port: int = 3000):
"""Start aiohttp web server for the dashboard."""
try:
from aiohttp import web
except ImportError:
print("\n[WARN] aiohttp not installed — dashboard disabled. Run: pip3 install aiohttp")
return
app = web.Application()
async def handle_index(request):
return web.Response(text=DASHBOARD_HTML, content_type="text/html")
async def handle_api(request):
return web.json_response(telemetry)
app.router.add_get("/", handle_index)
app.router.add_get("/api/telemetry", handle_api)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, "0.0.0.0", port)
await site.start()
print(f"[INFO] Dashboard: http://localhost:{port}")
# ---------------------------------------------------------------------------
# Demo mode
# ---------------------------------------------------------------------------
async def run_demo():
"""Generate fake BLE data simulating a ride."""
global connection_start
telemetry["demo"] = True
telemetry["connected"] = True
telemetry["battery"] = 87
connection_start = time.time()
modes = ["ECO", "TOUR", "eMTB", "TURBO", "TOUR", "ECO"]
mode_idx = 0
tick = 0
total_km = 1847.2
print("[DEMO] Simulating Bosch Smart System ride data...")
print("[DEMO] Press Ctrl+C to stop\n")
while True:
t = tick * 0.5 # 2 Hz
# Speed: sinusoidal 0-35 km/h with noise
speed = max(0, 22 + 13 * math.sin(t / 8) + 2 * math.sin(t / 3))
# Cadence correlates with speed
cadence = int(max(0, speed * 2.8 + 5 * math.sin(t / 5)))
# Human power correlates with cadence
human_power = int(max(0, cadence * 1.4 + 10 * math.sin(t / 4)))
# Motor power depends on mode
mode_multiplier = {"OFF": 0, "ECO": 0.5, "TOUR": 1.0, "eMTB": 1.5, "TURBO": 2.2}
mode_name = modes[mode_idx % len(modes)]
motor_power = int(human_power * mode_multiplier.get(mode_name, 1.0))
# Battery decreases slowly
battery = max(0, 87 - int(tick * 0.02))
# Total km increases with speed
total_km += speed / 3600 * 0.5 # 0.5s tick
telemetry["speed"] = round(speed, 1)
telemetry["cadence"] = cadence
telemetry["human_power"] = human_power
telemetry["motor_power"] = motor_power
telemetry["battery"] = battery
telemetry["assist_mode"] = mode_name
telemetry["total_km"] = round(total_km, 1)
# Update ride time
elapsed = time.time() - connection_start
h, rem = divmod(int(elapsed), 3600)
m, s = divmod(rem, 60)
telemetry["ride_time"] = f"{h:02d}:{m:02d}:{s:02d}"
print_telemetry()
tick += 1
if tick % 30 == 0: # Change mode every 15s
mode_idx += 1
await asyncio.sleep(0.5)
# ---------------------------------------------------------------------------
# Real BLE mode
# ---------------------------------------------------------------------------
async def run_ble(device_uuid: str = None):
"""Connect to real Bosch eBike via BLE and stream data."""
global connection_start
try:
from bleak import BleakClient, BleakScanner
except ImportError:
print("[ERROR] bleak not installed. Run: pip3 install bleak")
sys.exit(1)
# Scan for device
print("[INFO] Scanning for Bosch Smart System eBike...")
device = None
if device_uuid:
print(f"[INFO] Looking for device UUID: {device_uuid}")
device = await BleakScanner.find_device_by_address(device_uuid, timeout=15.0)
else:
devices = await BleakScanner.discover(timeout=10.0)
for d in devices:
name = d.name or ""
if any(f.lower() in name.lower() for f in DEVICE_NAME_FILTERS):
device = d
print(f"[INFO] Found: {d.name} ({d.address})")
break
if device is None:
print("[ERROR] Bike not found. Make sure:")
print(" 1. Bike is ON (battery connected)")
print(" 2. LED Remote is active")
print(" 3. Bike is paired in macOS System Settings > Bluetooth")
print(" 4. No other device (Bosch Flow app) is connected to the bike")
print(f"\nTip: Run with --demo to test without the bike")
sys.exit(1)
print(f"[INFO] Connecting to {device.name} ({device.address})...")
def on_disconnect(client):
elapsed = time.time() - connection_start if connection_start > 0 else 0
telemetry["connected"] = False
print(f"\n[WARN] Disconnected after {elapsed:.1f}s")
if 35 < elapsed < 40:
print("[WARN] Disconnected at ~37s — BLE bonding likely failed!")
print("[WARN] Pair the bike in macOS System Settings > Bluetooth first (R-000138)")
else:
print("[INFO] Will attempt reconnection in 3 seconds...")
def notification_handler(sender, data: bytearray):
"""Handle incoming BLE notifications."""
parsed = parse_notification(bytes(data))
if parsed:
apply_parsed(parsed)
print_telemetry()
# Connection loop with auto-reconnect
while True:
try:
async with BleakClient(
device.address,
disconnected_callback=on_disconnect,
timeout=20.0,
) as client:
if not client.is_connected:
print("[ERROR] Failed to connect")
await asyncio.sleep(3)
continue
connection_start = time.time()
telemetry["connected"] = True
print(f"[INFO] Connected! Subscribing to {CHAR_UUID}...")
# List services for debugging
for service in client.services:
if SERVICE_UUID.lower() in service.uuid.lower():
print(f"[INFO] Found Bosch service: {service.uuid}")
for char in service.characteristics:
props = ", ".join(char.properties)
print(f" Char: {char.uuid} [{props}]")
# Subscribe to notifications
await client.start_notify(CHAR_UUID, notification_handler)
print("[INFO] Subscribed — receiving data. Press Ctrl+C to stop.\n")
# Keep alive — check connection every second
while client.is_connected:
await asyncio.sleep(1.0)
except Exception as e:
print(f"\n[ERROR] {e}")
telemetry["connected"] = False
print("[INFO] Reconnecting in 3 seconds...")
await asyncio.sleep(3)
# ---------------------------------------------------------------------------
# CSV setup
# ---------------------------------------------------------------------------
def setup_csv(path: str):
global csv_writer, csv_file
csv_file = open(path, "a", newline="")
csv_writer = csv.writer(csv_file)
if os.path.getsize(path) == 0:
csv_writer.writerow(["timestamp", "raw_hex", "id_hex", "field_name", "raw_value", "decoded_value"])
print(f"[INFO] CSV log: {path}")
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
async def main():
parser = argparse.ArgumentParser(description="OpusBike Bosch BLE Test (bleak)")
parser.add_argument("--demo", action="store_true", help="Run with simulated data")
parser.add_argument("--uuid", type=str, default=None, help="BLE device UUID/address")
parser.add_argument("--port", type=int, default=3000, help="Dashboard port (default: 3000)")
args = parser.parse_args()
# CSV logging
csv_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ble_log.csv")
if not args.demo:
setup_csv(csv_path)
# Start web dashboard
await start_web_server(args.port)
# Run BLE or demo
if args.demo:
await run_demo()
else:
await run_ble(args.uuid)
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
print("\n\n[INFO] Stopped.")
if csv_file:
csv_file.close()
sys.exit(0)

26
docker-compose.yml Normal file
View File

@ -0,0 +1,26 @@
services:
nginx:
build:
context: .
dockerfile: Dockerfile
container_name: opusbike-nginx
restart: unless-stopped
ports:
- "8080:8080"
networks:
- shared
relay:
build:
context: .
dockerfile: Dockerfile.relay
container_name: opusbike-relay
restart: unless-stopped
environment:
- PORT=3030
networks:
- shared
networks:
shared:
external: true

39
nginx.conf Normal file
View File

@ -0,0 +1,39 @@
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 256;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml;
# Security headers
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Cache static assets
location ~* \.(css|js|png|svg|json|ico)$ {
expires 1h;
add_header Cache-Control "public, immutable";
}
# Service worker no caching
location = /js/sw.js {
expires off;
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
# Manifest
location = /manifest.json {
add_header Content-Type "application/manifest+json";
}
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
}

13
server/package.json Normal file
View File

@ -0,0 +1,13 @@
{
"name": "opusbike-relay",
"version": "1.0.0",
"private": true,
"description": "WebSocket relay for OpusBike dual-device streaming",
"main": "relay.js",
"scripts": {
"start": "node relay.js"
},
"dependencies": {
"ws": "^8.18.0"
}
}

138
server/relay.js Normal file
View File

@ -0,0 +1,138 @@
/**
* OpusBike WebSocket Relay Server
* Receives telemetry from phone (BLE bridge via 5G) and broadcasts to all viewers
*/
const { WebSocketServer, WebSocket } = require('ws');
const http = require('http');
const PORT = process.env.PORT || 3030;
const HEARTBEAT_INTERVAL = 30000; // 30 seconds
// Store latest telemetry state so new viewers get immediate data
let latestState = null;
// Create HTTP server for health checks
const httpServer = http.createServer((req, res) => {
if (req.url === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
status: 'ok',
clients: clients.size,
sources: [...clients].filter(([, info]) => info.role === 'source').length,
viewers: [...clients].filter(([, info]) => info.role === 'viewer').length,
hasState: !!latestState,
uptime: process.uptime()
}));
return;
}
res.writeHead(404);
res.end();
});
// Create WebSocket server
const wss = new WebSocketServer({ server: httpServer });
// Track connected clients
const clients = new Map(); // ws -> { role, connectedAt, alive }
// Heartbeat — ping all clients every 30s, terminate unresponsive ones
const heartbeat = setInterval(() => {
for (const [ws, info] of clients) {
if (!info.alive) {
console.log(`Terminating unresponsive client (${info.role})`);
ws.terminate();
continue;
}
info.alive = false;
ws.ping();
}
}, HEARTBEAT_INTERVAL);
wss.on('close', () => {
clearInterval(heartbeat);
});
wss.on('connection', (ws) => {
const clientInfo = { role: 'viewer', connectedAt: Date.now(), alive: true };
clients.set(ws, clientInfo);
console.log(`Client connected (total: ${clients.size})`);
ws.on('pong', () => {
clientInfo.alive = true;
});
ws.on('message', (raw) => {
try {
const msg = JSON.parse(raw);
if (msg.type === 'register') {
clientInfo.role = msg.role || 'viewer';
console.log(`Client registered as: ${clientInfo.role}`);
// Notify viewers that a source has connected
if (clientInfo.role === 'source') {
broadcast({ type: 'source_connected' }, ws);
}
// Send latest state to new viewers so they get immediate data
if (clientInfo.role === 'viewer' && latestState) {
ws.send(JSON.stringify({
type: 'state',
data: latestState.data,
ts: latestState.ts
}));
}
return;
}
if (msg.type === 'telemetry' && clientInfo.role === 'source') {
const ts = new Date().toISOString();
// Store latest state for new viewers
latestState = { data: msg.data, ts };
// Broadcast telemetry with timestamp to all viewers
const payload = JSON.stringify({ type: 'telemetry', data: msg.data, ts });
for (const [client] of clients) {
if (client !== ws && client.readyState === WebSocket.OPEN) {
client.send(payload);
}
}
}
} catch (e) {
// Ignore malformed messages
}
});
ws.on('close', () => {
const wasSource = clientInfo.role === 'source';
clients.delete(ws);
console.log(`Client disconnected (total: ${clients.size})`);
// Notify viewers if source disconnected
if (wasSource) {
broadcast({ type: 'source_disconnected' });
}
});
ws.on('error', (err) => {
console.error('WS error:', err.message);
});
});
/**
* Broadcast a message to all connected clients
*/
function broadcast(msg, exclude = null) {
const payload = JSON.stringify(msg);
for (const [client] of clients) {
if (client !== exclude && client.readyState === WebSocket.OPEN) {
client.send(payload);
}
}
}
httpServer.listen(PORT, () => {
console.log(`OpusBike relay server running on port ${PORT}`);
});

650
webapp/css/style.css Normal file
View File

@ -0,0 +1,650 @@
/* OpusBike — ProActys Light Theme */
/* Primary: #4285F4, Dark: #364052, Navy: #051229, Ice: #E1E8F0, Silver: #A9B0B8 */
:root {
--color-primary: #4285F4;
--color-dark: #364052;
--color-navy: #051229;
--color-ice: #E1E8F0;
--color-white: #FFFFFF;
--color-silver: #A9B0B8;
--color-bg: #F5F7FA;
--color-card: #FFFFFF;
--color-text: #1A1A2E;
--color-text-secondary: #6B7280;
--color-border: #E5E7EB;
--color-success: #10B981;
--color-warning: #F97316;
--color-danger: #EF4444;
--font-family: 'Segoe UI', Arial, Helvetica, sans-serif;
--radius: 8px;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
height: 100%;
height: 100dvh;
overflow: hidden;
font-family: var(--font-family);
background: var(--color-bg);
color: var(--color-text);
-webkit-user-select: none;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
/* Views */
.view {
display: none;
height: 100%;
height: 100dvh;
overflow: hidden;
}
.view.active {
display: flex;
flex-direction: column;
}
/* ===== Connect View ===== */
.connect-container {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 1rem 1.5rem;
gap: 1rem;
text-align: center;
overflow: hidden;
}
.logo-header {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
}
.app-logo {
width: 48px;
height: 48px;
border-radius: 12px;
}
.logo-header h1 {
font-size: 1.5rem;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--color-navy);
}
.subtitle {
color: var(--color-text-secondary);
font-size: 0.8rem;
}
/* Status badge */
.status-badge {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.75rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 500;
}
.status-badge.disconnected {
background: rgba(239, 68, 68, 0.1);
color: var(--color-danger);
}
.status-badge.connected {
background: rgba(16, 185, 129, 0.1);
color: var(--color-success);
}
.status-badge.demo {
background: rgba(249, 115, 22, 0.1);
color: var(--color-warning);
}
.status-badge.relay {
background: rgba(66, 133, 244, 0.1);
color: var(--color-primary);
}
.status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* Buttons */
.connect-actions {
display: flex;
flex-direction: column;
gap: 0.5rem;
width: 100%;
max-width: 280px;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1.25rem;
border: none;
border-radius: var(--radius);
font-family: var(--font-family);
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-primary {
background: var(--color-primary);
color: var(--color-white);
}
.btn-primary:hover {
background: #3b78e0;
}
.btn-secondary {
background: var(--color-ice);
color: var(--color-dark);
}
.btn-secondary:hover {
background: #d1d9e6;
}
.btn-small {
padding: 0.4rem 0.75rem;
font-size: 0.75rem;
}
.btn-danger {
background: rgba(239, 68, 68, 0.1);
color: var(--color-danger);
border: 1px solid rgba(239, 68, 68, 0.2);
}
.btn-danger:hover {
background: rgba(239, 68, 68, 0.15);
}
/* Info card */
.info-card {
background: rgba(66, 133, 244, 0.05);
border: 1px solid rgba(66, 133, 244, 0.15);
border-radius: var(--radius);
padding: 0.75rem 1rem;
font-size: 0.8rem;
color: var(--color-text-secondary);
max-width: 280px;
width: 100%;
}
.info-card p {
margin-bottom: 0.375rem;
}
.info-card .ws-status {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.5rem;
color: var(--color-primary);
font-weight: 500;
}
.hidden {
display: none !important;
}
/* Bluefy hint for iOS */
.bluefy-hint {
margin: 0.5rem 0;
padding: 0.5rem;
background: rgba(66, 133, 244, 0.05);
border: 1px solid rgba(66, 133, 244, 0.15);
border-radius: var(--radius);
text-align: center;
}
.bluefy-hint p {
margin-bottom: 0.375rem;
font-size: 0.8rem;
color: var(--color-text-secondary);
}
.bluefy-hint a:not(.btn) {
color: var(--color-primary);
text-decoration: underline;
}
.btn-bluefy {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: var(--color-primary);
color: #fff;
border: none;
border-radius: var(--radius);
font-size: 0.85rem;
font-weight: 600;
text-decoration: none;
cursor: pointer;
}
.btn-bluefy svg {
flex-shrink: 0;
}
.relay-fallback-text {
font-size: 0.75rem;
color: var(--color-silver);
margin-top: 0.5rem;
margin-bottom: 0.25rem;
}
/* Mode role label (Bridge / Viewer) */
.mode-role-label {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-secondary);
min-height: 1em;
}
/* BLE Troubleshooting */
.troubleshoot-section {
max-width: 280px;
width: 100%;
font-size: 0.8rem;
color: var(--color-text-secondary);
}
.troubleshoot-section summary {
cursor: pointer;
font-weight: 600;
color: var(--color-primary);
padding: 0.5rem 0;
list-style: none;
}
.troubleshoot-section summary::before {
content: '\25B6 ';
font-size: 0.6rem;
margin-right: 0.25rem;
}
.troubleshoot-section[open] summary::before {
content: '\25BC ';
}
.troubleshoot-content {
background: rgba(249, 115, 22, 0.05);
border: 1px solid rgba(249, 115, 22, 0.15);
border-radius: var(--radius);
padding: 0.75rem;
margin-top: 0.25rem;
}
.troubleshoot-content p {
margin-bottom: 0.5rem;
font-size: 0.78rem;
line-height: 1.4;
}
.troubleshoot-content ol {
padding-left: 1.25rem;
margin-bottom: 0.5rem;
}
.troubleshoot-content li {
margin-bottom: 0.375rem;
font-size: 0.78rem;
line-height: 1.4;
}
.troubleshoot-note {
font-size: 0.72rem;
color: var(--color-silver);
font-style: italic;
margin-bottom: 0;
}
/* QR Code section */
.qr-section {
display: flex;
flex-direction: column;
align-items: center;
padding: 0.75rem;
background: var(--color-white);
border-radius: var(--radius);
border: 1px solid var(--color-border);
}
.qr-label {
color: var(--color-text-secondary);
font-size: 0.7rem;
margin-bottom: 0.5rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.qr-image {
width: 140px;
height: 140px;
border-radius: 4px;
}
#qr-canvas {
display: none;
}
.qr-url {
color: var(--color-primary);
font-size: 0.7rem;
margin-top: 0.375rem;
font-family: monospace;
opacity: 0.7;
}
.brand-footer {
display: flex;
align-items: center;
justify-content: center;
gap: 0.375rem;
color: var(--color-silver);
font-size: 0.65rem;
opacity: 0.6;
margin-top: auto;
padding-top: 0.5rem;
}
.brand-footer img {
height: 14px;
width: auto;
opacity: 0.5;
}
/* ===== Ride View (Dashboard) ===== */
.dashboard {
flex: 1;
display: flex;
flex-direction: column;
padding: 0.75rem;
padding-bottom: 4rem;
overflow: hidden;
}
.dashboard-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
flex-shrink: 0;
}
.header-left {
display: flex;
align-items: center;
gap: 0.5rem;
}
.header-left h2 {
font-size: 1rem;
font-weight: 700;
color: var(--color-navy);
}
.proactys-logo-sm {
height: 16px;
width: auto;
opacity: 0.4;
}
.demo-badge {
display: none;
padding: 0.15rem 0.5rem;
background: rgba(249, 115, 22, 0.1);
color: var(--color-warning);
border: 1px solid rgba(249, 115, 22, 0.2);
border-radius: 999px;
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.05em;
}
.demo-badge.visible {
display: inline-block;
}
/* Metrics grid — no scroll, fills viewport */
.metrics-grid {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 2fr 1fr 1fr;
gap: 0.5rem;
flex: 1;
min-height: 0;
}
.metric-card {
background: var(--color-card);
border-radius: var(--radius);
border: 1px solid var(--color-border);
padding: 0.75rem;
display: flex;
flex-direction: column;
justify-content: center;
position: relative;
overflow: hidden;
min-height: 0;
}
.metric-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
}
/* Speed = hero card — full width, large value */
.metric-card.metric-speed {
grid-column: 1 / -1;
text-align: center;
align-items: center;
}
.metric-card.metric-speed::before { background: var(--color-primary); }
.metric-card.metric-speed .metric-value {
justify-content: center;
}
.metric-card.metric-speed .metric-value span:first-child {
font-size: clamp(2.5rem, 10vw, 5rem);
color: var(--color-primary);
}
.metric-card.metric-speed .metric-unit {
font-size: 1rem;
align-self: flex-end;
margin-bottom: 0.25rem;
}
.metric-card.metric-torque::before { background: #F97316; }
.metric-card.metric-time::before { background: #8B5CF6; }
.metric-card.metric-distance::before { background: #10B981; }
.metric-card.metric-mode::before { background: #EC4899; }
.metric-card.metric-mode {
grid-column: 1 / -1;
}
.metric-label {
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-secondary);
margin-bottom: 0.125rem;
}
.metric-value {
display: flex;
align-items: baseline;
gap: 0.25rem;
}
.metric-value span:first-child {
font-size: clamp(1.5rem, 5vw, 2.25rem);
font-weight: 700;
line-height: 1;
font-variant-numeric: tabular-nums;
color: var(--color-text);
}
.metric-unit {
font-size: 0.75rem;
color: var(--color-text-secondary);
font-weight: 500;
}
/* Mode indicator */
.mode-indicator {
display: flex;
gap: 0.375rem;
margin-top: 0.25rem;
flex-wrap: wrap;
}
.mode-dot {
padding: 0.2rem 0.6rem;
border-radius: 999px;
font-size: 0.625rem;
font-weight: 600;
background: var(--color-ice);
color: var(--color-text-secondary);
transition: all 0.3s ease;
}
.mode-dot.active {
color: var(--color-white);
}
.mode-dot[data-mode="OFF"].active { background: var(--color-silver); color: var(--color-white); }
.mode-dot[data-mode="ECO"].active { background: #10B981; }
.mode-dot[data-mode="TOUR"].active { background: #4285F4; }
.mode-dot[data-mode="EMTB"].active { background: #F97316; }
.mode-dot[data-mode="TURBO"].active { background: #EF4444; }
/* ===== Tab Bar ===== */
.tab-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
background: var(--color-white);
border-top: 1px solid var(--color-border);
padding: 0.375rem 0;
padding-bottom: max(0.375rem, env(safe-area-inset-bottom));
z-index: 100;
}
.tab {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.125rem;
padding: 0.375rem;
border: none;
background: none;
color: var(--color-text-secondary);
font-family: var(--font-family);
font-size: 0.625rem;
font-weight: 500;
cursor: pointer;
transition: color 0.2s;
}
.tab.active {
color: var(--color-primary);
}
/* ===== Responsive ===== */
/* iPhone SE and small phones */
@media (max-height: 600px) {
.connect-container {
gap: 0.5rem;
padding: 0.75rem 1rem;
}
.app-logo { width: 36px; height: 36px; }
.logo-header h1 { font-size: 1.25rem; }
.qr-image { width: 100px; height: 100px; }
.btn { padding: 0.6rem 1rem; font-size: 0.8rem; }
}
/* Dashboard: tab bar offset */
@media (max-height: 700px) {
.dashboard {
padding-bottom: 3.5rem;
}
.metrics-grid {
gap: 0.375rem;
}
.metric-card {
padding: 0.5rem;
}
}
/* Desktop */
@media (min-width: 768px) {
.metrics-grid {
grid-template-columns: repeat(3, 1fr);
grid-template-rows: 1.5fr 1fr;
max-width: 800px;
margin: 0 auto;
}
.metric-card.metric-speed {
grid-column: 1 / -1;
}
.metric-card.metric-mode {
grid-column: auto;
}
.connect-container {
gap: 1.5rem;
}
.app-logo {
width: 64px;
height: 64px;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

11
webapp/icons/favicon.svg Normal file
View File

@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="96" fill="#051229"/>
<circle cx="256" cy="256" r="160" fill="none" stroke="#4285F4" stroke-width="24"/>
<circle cx="256" cy="256" r="80" fill="none" stroke="#4285F4" stroke-width="16"/>
<line x1="256" y1="96" x2="256" y2="176" stroke="#4285F4" stroke-width="16" stroke-linecap="round"/>
<line x1="256" y1="336" x2="256" y2="416" stroke="#4285F4" stroke-width="16" stroke-linecap="round"/>
<line x1="96" y1="256" x2="176" y2="256" stroke="#4285F4" stroke-width="16" stroke-linecap="round"/>
<line x1="336" y1="256" x2="416" y2="256" stroke="#4285F4" stroke-width="16" stroke-linecap="round"/>
<circle cx="256" cy="256" r="20" fill="#4285F4"/>
<text x="256" y="480" text-anchor="middle" font-family="Segoe UI,Arial,sans-serif" font-weight="700" font-size="60" fill="#E1E8F0">OPUS</text>
</svg>

After

Width:  |  Height:  |  Size: 908 B

BIN
webapp/icons/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
webapp/icons/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

BIN
webapp/icons/qr-code.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 466 B

196
webapp/index.html Normal file
View File

@ -0,0 +1,196 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>OpusBike — ProActys</title>
<!-- PWA -->
<link rel="manifest" href="manifest.json">
<meta name="theme-color" content="#F5F7FA">
<!-- Apple PWA meta tags -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="OpusBike">
<link rel="apple-touch-icon" href="icons/apple-touch-icon.png">
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="icons/favicon.svg">
<!-- Styles -->
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<!-- Connect View -->
<div id="connect-view" class="view active">
<div class="connect-container">
<div class="logo-header">
<img src="icons/icon-192.png" alt="OpusBike" class="app-logo">
<h1>OpusBike</h1>
<p class="subtitle">Bosch Smart System Monitor</p>
</div>
<div id="connection-status" class="status-badge disconnected">
<span class="status-dot"></span>
<span id="status-text">Disconnected</span>
</div>
<div id="mode-label" class="mode-role-label"></div>
<div class="connect-actions">
<button id="btn-connect" class="btn btn-primary" onclick="app.connect()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M6.5 6.5l11 11M17.5 6.5l-11 11M12 2v4M12 18v4M2 12h4M18 12h4"/>
</svg>
Connect via Bluetooth
</button>
<button id="btn-demo" class="btn btn-secondary" onclick="app.startDemo()">
Demo Mode
</button>
</div>
<div id="ble-unsupported" class="info-card hidden">
<p>Web Bluetooth is not available in this browser.</p>
<div id="ios-bluefy-hint" class="bluefy-hint hidden">
<p><strong>iPhone / iPad?</strong></p>
<p>Install <a href="https://apps.apple.com/app/bluefy-web-ble-browser/id1492822055" target="_blank" rel="noopener">Bluefy</a> (free) for direct Bluetooth access.</p>
<a href="https://apps.apple.com/app/bluefy-web-ble-browser/id1492822055" target="_blank" rel="noopener" class="btn-bluefy">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M17.71 7.71L12 2h-1v7.59L6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 11 14.41V22h1l5.71-5.71-4.3-4.29 4.3-4.29zM13 5.83l1.88 1.88L13 9.59V5.83zm1.88 10.46L13 18.17v-3.76l1.88 1.88z"/></svg>
Get Bluefy (free)
</a>
</div>
<p class="relay-fallback-text">Or view live data via WebSocket relay:</p>
<div class="ws-status">
<span class="status-dot"></span>
<span id="ws-status-text">Connecting to relay...</span>
</div>
</div>
<!-- BLE Troubleshooting -->
<details id="ble-troubleshoot" class="troubleshoot-section">
<summary>Don't see your bike?</summary>
<div class="troubleshoot-content">
<p><strong>Web Bluetooth uses its own pairing flow.</strong> If your bike is paired in iOS Settings, the Web Bluetooth picker won't find it.</p>
<ol>
<li>Go to <strong>Settings &rarr; Bluetooth</strong></li>
<li>Find <strong>"smart system eBike"</strong> (or similar Bosch name)</li>
<li>Tap the <strong>(i)</strong> icon &rarr; <strong>Forget This Device</strong></li>
<li>Come back here and tap <strong>Connect via Bluetooth</strong></li>
<li>The Web Bluetooth picker will appear — select your bike</li>
</ol>
<p class="troubleshoot-note">Make sure the Bosch system is powered on (turn the crank or press the button on the display).</p>
</div>
</details>
<div class="qr-section">
<p class="qr-label">Share with your team</p>
<img src="icons/qr-code.png" alt="QR Code" class="qr-image">
<canvas id="qr-canvas" width="200" height="200"></canvas>
<p class="qr-url">monitor.proactys.swiss/opusbike</p>
</div>
<p class="brand-footer">
<img src="icons/proactys-logo.png" alt="ProActys" />
ProActys GmbH
</p>
</div>
</div>
<!-- Ride View (Dashboard) -->
<div id="ride-view" class="view">
<div class="dashboard">
<div class="dashboard-header">
<div class="header-left">
<h2 id="ride-title">OpusBike</h2>
<div id="dash-connection-status" class="status-badge connected">
<span class="status-dot"></span>
<span id="dash-status-text">Connected</span>
</div>
<span id="demo-badge" class="demo-badge">DEMO</span>
</div>
<img src="icons/proactys-logo.png" alt="ProActys" class="proactys-logo-sm">
<button id="btn-disconnect" class="btn btn-small btn-danger" onclick="app.disconnect()">
Disconnect
</button>
</div>
<!-- Main metrics grid -->
<div class="metrics-grid">
<!-- Speed = hero metric -->
<div class="metric-card metric-speed">
<div class="metric-label">Speed</div>
<div class="metric-value">
<span id="val-speed">0.0</span>
<span class="metric-unit">km/h</span>
</div>
</div>
<div class="metric-card metric-torque">
<div class="metric-label">Torque</div>
<div class="metric-value">
<span id="val-torque">0</span>
<span class="metric-unit">Nm</span>
</div>
</div>
<div class="metric-card metric-time">
<div class="metric-label">Ride Time</div>
<div class="metric-value">
<span id="val-time">00:00:00</span>
</div>
</div>
<div class="metric-card metric-distance">
<div class="metric-label">Total Kilometer</div>
<div class="metric-value">
<span id="val-totalkm">0.0</span>
<span class="metric-unit">km</span>
</div>
</div>
<div class="metric-card metric-mode">
<div class="metric-label">Mode</div>
<div class="metric-value">
<span id="val-mode">OFF</span>
</div>
<div class="mode-indicator" id="mode-indicator">
<span class="mode-dot" data-mode="OFF">OFF</span>
<span class="mode-dot" data-mode="ECO">ECO</span>
<span class="mode-dot" data-mode="TOUR">TOUR</span>
<span class="mode-dot" data-mode="EMTB">eMTB</span>
<span class="mode-dot" data-mode="TURBO">TURBO</span>
</div>
</div>
</div>
</div>
</div>
<!-- Tab bar -->
<nav id="tab-bar" class="tab-bar hidden">
<button class="tab active" data-view="ride-view" onclick="app.switchTab('ride-view')">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z"/></svg>
<span>Dashboard</span>
</button>
<button class="tab" data-view="connect-view" onclick="app.switchTab('connect-view')">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>
<span>Connect</span>
</button>
</nav>
<!-- Scripts -->
<script src="js/boschProtocol.js"></script>
<script src="js/bleService.js"></script>
<script src="js/wsRelay.js"></script>
<script src="js/app.js"></script>
<!-- Service Worker registration -->
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('js/sw.js')
.then(reg => console.log('SW registered:', reg.scope))
.catch(err => console.warn('SW registration failed:', err));
}
</script>
</body>
</html>

349
webapp/js/app.js Normal file
View File

@ -0,0 +1,349 @@
/**
* OpusBike App Main UI Logic
* Auto-detects role: Bridge (BLE + upload), Viewer (WS only), Demo
*/
const app = (() => {
let mode = 'idle'; // idle | ble | demo | relay
let rideStartTime = null;
let rideTimer = null;
let demoTimer = null;
let demoTick = 0;
const DEMO_MODES = ['ECO', 'TOUR', 'eMTB', 'TURBO', 'TOUR', 'ECO'];
// Structured speed profile (60s cycle): [time_pct, target_speed]
const SPEED_PROFILE = [
[0, 0], [0.05, 15], [0.2, 28], [0.35, 22],
[0.5, 30], [0.7, 25], [0.85, 18], [0.95, 5], [1, 0]
];
/**
* Detect iOS (iPhone/iPad/iPod)
*/
function isIOS() {
return /iPhone|iPad|iPod/.test(navigator.userAgent) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
}
/**
* Detect app mode based on Web Bluetooth availability
* - Bridge mode: navigator.bluetooth exists (Bluefy on iOS, Chrome on desktop/Android)
* - Viewer mode: no bluetooth API (Safari, Firefox, etc.)
*/
function detectMode() {
return BLEService.isAvailable() ? 'bridge' : 'viewer';
}
/**
* Initialize the app
*/
function init() {
const detectedMode = detectMode();
const modeLabel = document.getElementById('mode-label');
if (detectedMode === 'viewer') {
// No BLE — hide connect button, show viewer UI
document.getElementById('btn-connect').classList.add('hidden');
document.getElementById('ble-unsupported').classList.remove('hidden');
// On iOS without Bluefy, show hint
if (isIOS()) {
document.getElementById('ios-bluefy-hint').classList.remove('hidden');
}
if (modeLabel) modeLabel.textContent = 'Viewer (live)';
// Auto-connect to WebSocket relay as viewer
startRelayViewer();
} else {
// BLE available — bridge mode
if (modeLabel) modeLabel.textContent = 'BLE Bridge (uploading)';
}
// Set up BLE callbacks
BLEService.onData((data) => {
updateDashboard(data);
// Forward to WebSocket relay for other devices
WSRelay.sendTelemetry(data);
});
BLEService.onStatus(handleBleStatus);
}
/**
* Interpolate speed from the structured profile
*/
function getProfileSpeed(pct) {
for (let i = 1; i < SPEED_PROFILE.length; i++) {
if (pct <= SPEED_PROFILE[i][0]) {
const [t0, s0] = SPEED_PROFILE[i - 1];
const [t1, s1] = SPEED_PROFILE[i];
const ratio = (pct - t0) / (t1 - t0);
const eased = ratio * ratio * (3 - 2 * ratio);
return s0 + (s1 - s0) * eased;
}
}
return 0;
}
/**
* Connect to bike via BLE (bridge mode phone connects to bike)
*/
async function connect() {
try {
const name = await BLEService.connect();
mode = 'ble';
rideStartTime = Date.now();
startRideTimer();
showRideView('connected', `BLE Bridge: ${name}`);
setDemoBadge(false);
// Also connect to WS relay as source to upload data to VPS
WSRelay.connect(true);
} catch (error) {
alert(error.message);
}
}
/**
* Disconnect
*/
function disconnect() {
if (mode === 'ble') {
BLEService.disconnect();
}
if (mode === 'demo') {
stopDemo();
}
WSRelay.disconnect();
stopRideTimer();
mode = 'idle';
setDemoBadge(false);
showConnectView();
}
/**
* Start Demo Mode structured simulation per spec
*/
function startDemo() {
mode = 'demo';
rideStartTime = Date.now();
demoTick = 0;
startRideTimer();
showRideView('demo', 'Demo Mode');
setDemoBadge(true);
// Connect to WS relay as source (so other devices see the demo too)
WSRelay.connect(true);
let totalKm = 1847.2;
const CYCLE_TICKS = 120; // 60 seconds at 500ms interval
demoTimer = setInterval(() => {
demoTick++;
const cyclePct = (demoTick % CYCLE_TICKS) / CYCLE_TICKS;
const targetSpeed = getProfileSpeed(cyclePct);
const speed = Math.max(0, targetSpeed + (Math.random() - 0.5) * 2);
const baseTorque = speed > 0.5 ? (speed * 1.8) + 5 : 0;
const torque = Math.round(Math.max(0, baseTorque + (Math.random() - 0.5) * 8));
totalKm += speed / 3600 * 0.5;
// Cycle through modes every 15 seconds (30 ticks)
const modeIndex = Math.floor(demoTick / 30) % DEMO_MODES.length;
const data = {
speed: Math.round(speed * 10) / 10,
torque: torque,
totalKm: Math.round(totalKm * 10) / 10,
mode: DEMO_MODES[modeIndex]
};
updateDashboard(data);
WSRelay.sendTelemetry(data);
}, 500);
}
/**
* Stop Demo Mode
*/
function stopDemo() {
clearInterval(demoTimer);
demoTimer = null;
demoTick = 0;
}
/**
* Show/hide DEMO badge
*/
function setDemoBadge(visible) {
const badge = document.getElementById('demo-badge');
if (badge) {
badge.classList.toggle('visible', visible);
}
}
/**
* Start relay viewer mode (for devices without BLE)
*/
function startRelayViewer() {
WSRelay.onData((data) => {
if (mode !== 'relay') {
mode = 'relay';
rideStartTime = Date.now();
startRideTimer();
showRideView('relay', 'Viewer (live)');
setDemoBadge(false);
}
updateDashboard(data);
});
WSRelay.onStatus((status) => {
const wsText = document.getElementById('ws-status-text');
if (wsText) {
switch (status) {
case 'connected':
wsText.textContent = 'Connected to relay — waiting for bike data...';
break;
case 'source_connected':
wsText.textContent = 'Bike bridge connected — receiving live data!';
break;
case 'source_disconnected':
wsText.textContent = 'Bike bridge disconnected';
break;
case 'disconnected':
wsText.textContent = 'Relay disconnected — reconnecting...';
break;
}
}
});
WSRelay.connect(false);
}
/**
* Update dashboard with telemetry data
*/
function updateDashboard(data) {
document.getElementById('val-speed').textContent = (data.speed || 0).toFixed(1);
document.getElementById('val-torque').textContent = Math.round(data.torque || 0);
document.getElementById('val-totalkm').textContent = (data.totalKm || 0).toFixed(1);
const modeStr = data.mode || 'OFF';
document.getElementById('val-mode').textContent = modeStr;
// Update mode indicator dots
document.querySelectorAll('.mode-dot').forEach(dot => {
const isActive = dot.dataset.mode === modeStr ||
(dot.dataset.mode === 'EMTB' && modeStr === 'eMTB');
dot.classList.toggle('active', isActive);
});
}
/**
* Start ride timer
*/
function startRideTimer() {
stopRideTimer();
rideTimer = setInterval(() => {
if (!rideStartTime) return;
const elapsed = Math.floor((Date.now() - rideStartTime) / 1000);
const h = String(Math.floor(elapsed / 3600)).padStart(2, '0');
const m = String(Math.floor((elapsed % 3600) / 60)).padStart(2, '0');
const s = String(elapsed % 60).padStart(2, '0');
document.getElementById('val-time').textContent = `${h}:${m}:${s}`;
}, 1000);
}
/**
* Stop ride timer
*/
function stopRideTimer() {
clearInterval(rideTimer);
rideTimer = null;
}
/**
* Show the ride/dashboard view
*/
function showRideView(statusClass, statusText) {
document.getElementById('connect-view').classList.remove('active');
document.getElementById('ride-view').classList.add('active');
document.getElementById('tab-bar').classList.remove('hidden');
const badge = document.getElementById('dash-connection-status');
badge.className = 'status-badge ' + statusClass;
document.getElementById('dash-status-text').textContent = statusText;
}
/**
* Show the connect view
*/
function showConnectView() {
document.getElementById('ride-view').classList.remove('active');
document.getElementById('connect-view').classList.add('active');
document.getElementById('tab-bar').classList.add('hidden');
const badge = document.getElementById('connection-status');
badge.className = 'status-badge disconnected';
document.getElementById('status-text').textContent = 'Disconnected';
}
/**
* Handle BLE status changes
*/
function handleBleStatus(status) {
const badge = document.getElementById('connection-status');
const text = document.getElementById('status-text');
switch (status) {
case 'scanning':
badge.className = 'status-badge demo';
text.textContent = 'Scanning...';
break;
case 'connecting':
badge.className = 'status-badge demo';
text.textContent = 'Connecting...';
break;
case 'discovering':
badge.className = 'status-badge demo';
text.textContent = 'Discovering services...';
break;
case 'connected':
badge.className = 'status-badge connected';
text.textContent = 'Connected';
break;
case 'disconnected':
if (mode === 'ble') {
mode = 'idle';
stopRideTimer();
setDemoBadge(false);
showConnectView();
}
break;
}
}
/**
* Switch between tab views
*/
function switchTab(viewId) {
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
document.getElementById(viewId).classList.add('active');
document.querySelectorAll('.tab').forEach(t => {
t.classList.toggle('active', t.dataset.view === viewId);
});
}
// Initialize on DOM ready
document.addEventListener('DOMContentLoaded', init);
return {
connect,
disconnect,
startDemo,
switchTab
};
})();

141
webapp/js/bleService.js Normal file
View File

@ -0,0 +1,141 @@
/**
* BLE Service Web Bluetooth connection to Bosch Smart System
*/
const BLEService = (() => {
let device = null;
let server = null;
let characteristic = null;
let onDataCallback = null;
let onStatusCallback = null;
/**
* Check if Web Bluetooth is available
*/
function isAvailable() {
return !!(navigator.bluetooth && navigator.bluetooth.requestDevice);
}
/**
* Connect to a Bosch Smart System eBike
*/
async function connect() {
if (!isAvailable()) {
throw new Error('Web Bluetooth is not supported on this device');
}
updateStatus('scanning');
try {
device = await navigator.bluetooth.requestDevice({
filters: [
{ services: [BoschProtocol.SERVICE_UUID] },
{ namePrefix: 'smart system' },
{ namePrefix: 'Bosch' },
{ namePrefix: 'BRC' }
],
optionalServices: [BoschProtocol.SERVICE_UUID]
});
device.addEventListener('gattserverdisconnected', onDisconnected);
updateStatus('connecting');
server = await device.gatt.connect();
updateStatus('discovering');
const service = await server.getPrimaryService(BoschProtocol.SERVICE_UUID);
characteristic = await service.getCharacteristic(BoschProtocol.CHAR_UUID);
// Subscribe to notifications
await characteristic.startNotifications();
characteristic.addEventListener('characteristicvaluechanged', onCharacteristicChanged);
updateStatus('connected');
console.log('BLE connected to:', device.name);
return device.name;
} catch (error) {
updateStatus('disconnected');
if (error.name === 'NotFoundError') {
throw new Error('No bike found. Make sure the Bosch system is powered on.');
}
throw error;
}
}
/**
* Disconnect from the bike
*/
function disconnect() {
if (characteristic) {
characteristic.removeEventListener('characteristicvaluechanged', onCharacteristicChanged);
characteristic = null;
}
if (device && device.gatt.connected) {
device.gatt.disconnect();
}
device = null;
server = null;
updateStatus('disconnected');
}
/**
* Handle incoming BLE notifications
*/
function onCharacteristicChanged(event) {
const dataView = event.target.value;
const parsed = BoschProtocol.parseNotification(dataView);
if (onDataCallback) {
onDataCallback(parsed);
}
}
/**
* Handle unexpected disconnection
*/
function onDisconnected() {
console.warn('BLE device disconnected');
characteristic = null;
server = null;
updateStatus('disconnected');
}
/**
* Update connection status
*/
function updateStatus(status) {
if (onStatusCallback) {
onStatusCallback(status);
}
}
/**
* Set callback for incoming data
*/
function onData(callback) {
onDataCallback = callback;
}
/**
* Set callback for status changes
*/
function onStatus(callback) {
onStatusCallback = callback;
}
/**
* Check if currently connected
*/
function isConnected() {
return device && device.gatt && device.gatt.connected;
}
return {
isAvailable,
connect,
disconnect,
onData,
onStatus,
isConnected
};
})();

160
webapp/js/boschProtocol.js Normal file
View File

@ -0,0 +1,160 @@
/**
* Bosch Smart System BLE Protocol Decoder
* Decodes varint-encoded data from the Bosch BLE characteristic.
*
* Confirmed from Phase 2 testing (nRF Connect):
* - Characteristic: 00000011-EAA2-11E9-81B4-2A2AE2DBCCE4
* - Data rate: ~2 messages/second
* - Message format: 0x30 header, varint-encoded TLV pairs
* - Confirmed fields: assist mode, battery %, speed, cadence
* - Unknown data IDs found: 0x9865, 0x981A, 0x9874, 0x80C4, 0x808A, 0x8091
*/
const BoschProtocol = (() => {
// Bosch BLE UUIDs
const SERVICE_UUID = '0000fee7-0000-1000-8000-00805f9b34fb';
const CHAR_UUID = '00000011-eaa2-11e9-81b4-2a2ae2dbcce4';
// Assist modes mapping
const ASSIST_MODES = {
0: 'OFF',
1: 'ECO',
2: 'TOUR',
3: 'eMTB',
4: 'TURBO'
};
// Known data IDs from Phase 2 testing
const KNOWN_IDS = {
0x01: 'speed', // in 0.1 km/h
0x02: 'torque', // in Nm
0x03: 'totalKm', // in meters (divide by 1000)
0x04: 'mode', // assist mode index
0x05: 'battery', // percentage (0-100)
0x06: 'cadence' // RPM
};
// Unknown data IDs discovered during testing — need real-ride validation
const UNKNOWN_IDS = [0x9865, 0x981A, 0x9874, 0x80C4, 0x808A, 0x8091];
/**
* Decode a varint from a DataView at a given 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 };
}
/**
* Decode a signed varint (zigzag encoding).
*/
function decodeSignedVarint(dataView, offset) {
const { value, bytesRead } = decodeVarint(dataView, offset);
const decoded = (value >>> 1) ^ -(value & 1);
return { value: decoded, bytesRead };
}
/**
* Parse a Bosch BLE notification payload.
* Handles the 0x30 header byte confirmed from Phase 2 testing.
*/
function parseNotification(dataView) {
const data = {
speed: 0,
torque: 0,
totalKm: 0,
mode: 'OFF',
battery: null,
cadence: null,
raw: [],
unknownFields: {}
};
// 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;
// Skip header byte(s) — 0x30 confirmed from Phase 2, also handle 0x01/0x02
const header = dataView.getUint8(0);
if (header === 0x30 || header === 0x01 || header === 0x02) {
offset = 1;
}
// Parse TLV pairs
while (offset < dataView.byteLength - 1) {
// Read tag — may be 1 or 2 bytes
let tag = dataView.getUint8(offset);
offset++;
// Check for 2-byte tags (high bit set patterns from unknown IDs)
if ((tag & 0x80) !== 0 && offset < dataView.byteLength) {
tag = (tag << 8) | dataView.getUint8(offset);
offset++;
}
const { value, bytesRead } = decodeVarint(dataView, offset);
offset += bytesRead;
switch (tag) {
case 0x01: // Speed in 0.1 km/h
data.speed = value / 10;
break;
case 0x02: // Torque in Nm
data.torque = value;
break;
case 0x03: // Total distance in meters
data.totalKm = value / 1000;
break;
case 0x04: // Assist mode
data.mode = ASSIST_MODES[value] || 'OFF';
break;
case 0x05: // Battery percentage
data.battery = value;
break;
case 0x06: // Cadence RPM
data.cadence = value;
break;
default:
// Log unknown fields for future identification
data.unknownFields[tag.toString(16)] = value;
break;
}
if (bytesRead === 0) break; // Safety
}
} catch (e) {
console.warn('Bosch parse error:', e);
}
return data;
}
return {
SERVICE_UUID,
CHAR_UUID,
ASSIST_MODES,
KNOWN_IDS,
UNKNOWN_IDS,
decodeVarint,
decodeSignedVarint,
parseNotification
};
})();

318
webapp/js/qrcode.js Normal file
View File

@ -0,0 +1,318 @@
/**
* Minimal QR Code generator no dependencies
* Encodes a URL into a canvas element
* Based on QR Code Model 2, Version 3 (29x29), Error Correction Level L
*/
(function () {
'use strict';
// Generate QR code on a canvas for a given URL
function generateQR(canvas, url) {
// Use the Google Charts API as a simple fallback-free approach:
// Draw QR as an image loaded from a data URL generated client-side.
// For a truly zero-dependency approach, we use a proven minimal encoder.
const size = canvas.width;
const ctx = canvas.getContext('2d');
// Try using the minimal encoder
try {
const modules = encode(url);
const moduleCount = modules.length;
const cellSize = Math.floor((size - 16) / moduleCount); // 8px padding each side
const offset = Math.floor((size - cellSize * moduleCount) / 2);
ctx.fillStyle = '#FFFFFF';
ctx.fillRect(0, 0, size, size);
ctx.fillStyle = '#051229'; // Navy from brand
for (let r = 0; r < moduleCount; r++) {
for (let c = 0; c < moduleCount; c++) {
if (modules[r][c]) {
ctx.fillRect(
offset + c * cellSize,
offset + r * cellSize,
cellSize,
cellSize
);
}
}
}
} catch (e) {
// Fallback: show URL text
ctx.fillStyle = '#FFFFFF';
ctx.fillRect(0, 0, size, size);
ctx.fillStyle = '#051229';
ctx.font = '10px monospace';
ctx.textAlign = 'center';
ctx.fillText(url, size / 2, size / 2);
}
}
// ---- Minimal QR Encoder (byte mode, ECC L) ----
// GF(256) math
const EXP = new Uint8Array(512);
const LOG = new Uint8Array(256);
(function initGF() {
let x = 1;
for (let i = 0; i < 255; i++) {
EXP[i] = x;
LOG[x] = i;
x = (x << 1) ^ (x & 128 ? 0x11d : 0);
}
for (let i = 255; i < 512; i++) EXP[i] = EXP[i - 255];
})();
function gfMul(a, b) {
return a === 0 || b === 0 ? 0 : EXP[LOG[a] + LOG[b]];
}
function polyMul(a, b) {
const r = new Uint8Array(a.length + b.length - 1);
for (let i = 0; i < a.length; i++)
for (let j = 0; j < b.length; j++)
r[i + j] ^= gfMul(a[i], b[j]);
return r;
}
function polyRemainder(data, gen) {
const r = new Uint8Array(data.length + gen.length - 1);
r.set(data);
for (let i = 0; i < data.length; i++) {
if (r[i] === 0) continue;
for (let j = 0; j < gen.length; j++)
r[i + j] ^= gfMul(gen[j], r[i]);
}
return r.slice(data.length);
}
function generatorPoly(n) {
let g = new Uint8Array([1]);
for (let i = 0; i < n; i++)
g = polyMul(g, new Uint8Array([1, EXP[i]]));
return g;
}
// Version info: [version, size, dataBytes, eccPerBlock, numBlocks]
// We pick the smallest version that fits
const VERSIONS = [
[1, 21, 19, 7, 1],
[2, 25, 34, 10, 1],
[3, 29, 55, 15, 1],
[4, 33, 80, 20, 1],
[5, 37, 108, 26, 1],
[6, 41, 136, 18, 2],
[7, 45, 156, 20, 2],
[8, 49, 194, 24, 2],
[9, 53, 232, 30, 2],
[10, 57, 274, 18, 4],
];
function pickVersion(dataLen) {
// Byte mode: 4 bits mode + char count bits + data + terminator
for (const v of VERSIONS) {
const charCountBits = v[0] <= 9 ? 8 : 16;
const totalBits = 4 + charCountBits + dataLen * 8;
const capacity = v[2] * 8;
if (totalBits <= capacity) return v;
}
return VERSIONS[VERSIONS.length - 1]; // fallback to largest
}
function encode(text) {
const data = new TextEncoder().encode(text);
const [version, size, dataBytes, eccPerBlock, numBlocks] = pickVersion(data.length);
const charCountBits = version <= 9 ? 8 : 16;
// Build data bitstream
const bits = [];
function pushBits(val, len) {
for (let i = len - 1; i >= 0; i--)
bits.push((val >> i) & 1);
}
pushBits(0b0100, 4); // Byte mode
pushBits(data.length, charCountBits);
for (const b of data) pushBits(b, 8);
pushBits(0, Math.min(4, dataBytes * 8 - bits.length)); // terminator
// Pad to byte boundary
while (bits.length % 8 !== 0) bits.push(0);
// Pad bytes
const padBytes = [0xEC, 0x11];
let padIdx = 0;
while (bits.length < dataBytes * 8) {
pushBits(padBytes[padIdx % 2], 8);
padIdx++;
}
// Convert to bytes
const dataArr = new Uint8Array(dataBytes);
for (let i = 0; i < dataBytes; i++) {
let byte = 0;
for (let b = 0; b < 8; b++) byte = (byte << 1) | (bits[i * 8 + b] || 0);
dataArr[i] = byte;
}
// Split into blocks and generate ECC
const blockSize = Math.floor(dataBytes / numBlocks);
const gen = generatorPoly(eccPerBlock);
const dataBlocks = [];
const eccBlocks = [];
for (let b = 0; b < numBlocks; b++) {
const start = b * blockSize;
const end = b === numBlocks - 1 ? dataBytes : start + blockSize;
const block = dataArr.slice(start, end);
dataBlocks.push(block);
eccBlocks.push(polyRemainder(block, gen));
}
// Interleave
const codewords = [];
const maxDataLen = Math.max(...dataBlocks.map(b => b.length));
for (let i = 0; i < maxDataLen; i++)
for (const block of dataBlocks)
if (i < block.length) codewords.push(block[i]);
for (let i = 0; i < eccPerBlock; i++)
for (const block of eccBlocks)
if (i < block.length) codewords.push(block[i]);
// Create module matrix
const modules = Array.from({ length: size }, () => new Uint8Array(size));
const reserved = Array.from({ length: size }, () => new Uint8Array(size));
// Finder patterns
function drawFinder(row, col) {
for (let r = -1; r <= 7; r++) {
for (let c = -1; c <= 7; c++) {
const rr = row + r, cc = col + c;
if (rr < 0 || rr >= size || cc < 0 || cc >= size) continue;
reserved[rr][cc] = 1;
if (r >= 0 && r <= 6 && c >= 0 && c <= 6) {
modules[rr][cc] =
(r === 0 || r === 6 || c === 0 || c === 6 ||
(r >= 2 && r <= 4 && c >= 2 && c <= 4)) ? 1 : 0;
}
}
}
}
drawFinder(0, 0);
drawFinder(0, size - 7);
drawFinder(size - 7, 0);
// Alignment pattern (version >= 2)
if (version >= 2) {
const pos = alignmentPositions(version);
for (const r of pos) {
for (const c of pos) {
if (reserved[r][c]) continue;
for (let dr = -2; dr <= 2; dr++) {
for (let dc = -2; dc <= 2; dc++) {
const rr = r + dr, cc = c + dc;
reserved[rr][cc] = 1;
modules[rr][cc] =
(Math.abs(dr) === 2 || Math.abs(dc) === 2 || (dr === 0 && dc === 0)) ? 1 : 0;
}
}
}
}
}
// Timing patterns
for (let i = 8; i < size - 8; i++) {
reserved[6][i] = 1;
modules[6][i] = i % 2 === 0 ? 1 : 0;
reserved[i][6] = 1;
modules[i][6] = i % 2 === 0 ? 1 : 0;
}
// Dark module
reserved[size - 8][8] = 1;
modules[size - 8][8] = 1;
// Reserve format info areas
for (let i = 0; i < 8; i++) {
reserved[8][i] = 1;
reserved[8][size - 1 - i] = 1;
reserved[i][8] = 1;
reserved[size - 1 - i][8] = 1;
}
reserved[8][8] = 1;
// Reserve version info (version >= 7) — not needed for our range
// Place data
let bitIdx = 0;
const totalBitsToPlace = codewords.length * 8;
let col = size - 1;
while (col >= 0) {
if (col === 6) col--; // skip timing column
for (let dir = 0; dir < 2; dir++) {
const cc = col - dir;
const upward = ((size - 1 - col + (col > 6 ? 1 : 0)) / 2) % 2 === 0;
for (let i = 0; i < size; i++) {
const r = upward ? i : size - 1 - i;
if (reserved[r][cc]) continue;
if (bitIdx < totalBitsToPlace) {
const byteIdx = Math.floor(bitIdx / 8);
const bitOff = 7 - (bitIdx % 8);
modules[r][cc] = (codewords[byteIdx] >> bitOff) & 1;
bitIdx++;
}
}
}
col -= 2;
}
// Apply mask (mask 0: (row + col) % 2 === 0)
for (let r = 0; r < size; r++)
for (let c = 0; c < size; c++)
if (!reserved[r][c] && (r + c) % 2 === 0)
modules[r][c] ^= 1;
// Write format info (mask 0, ECC L)
const formatBits = getFormatBits(0); // ECC L, mask 0
// Horizontal
for (let i = 0; i < 6; i++) modules[8][i] = (formatBits >> (14 - i)) & 1;
modules[8][7] = (formatBits >> 8) & 1;
modules[8][8] = (formatBits >> 7) & 1;
modules[7][8] = (formatBits >> 6) & 1;
for (let i = 0; i < 6; i++) modules[5 - i][8] = (formatBits >> (5 - i)) & 1;
// Vertical
for (let i = 0; i < 7; i++) modules[size - 1 - i][8] = (formatBits >> (14 - i)) & 1;
for (let i = 0; i < 8; i++) modules[8][size - 8 + i] = (formatBits >> (7 - i)) & 1;
return modules;
}
function alignmentPositions(version) {
if (version === 1) return [];
const positions = [6];
const last = version * 4 + 10;
const count = Math.floor(version / 7) + 2;
const step = Math.ceil((last - 6) / (count - 1));
for (let i = 1; i < count; i++) positions.push(6 + i * step > last ? last : 6 + i * step);
// Ensure last is exact
positions[positions.length - 1] = last;
return positions;
}
function getFormatBits(mask) {
// Pre-computed format info for ECC Level L (01) with masks 0-7
const FORMAT_INFO = [
0x77C4, 0x72F3, 0x7DAA, 0x789D, 0x662F, 0x6318, 0x6C41, 0x6976,
];
return FORMAT_INFO[mask];
}
// Auto-init on load
window.addEventListener('DOMContentLoaded', () => {
const canvas = document.getElementById('qr-canvas');
if (canvas) {
generateQR(canvas, 'https://monitor.proactys.swiss/opusbike/');
}
});
})();

56
webapp/js/sw.js Normal file
View File

@ -0,0 +1,56 @@
/**
* OpusBike Service Worker Cache static assets for PWA
*/
const CACHE_NAME = 'opusbike-v3';
const STATIC_ASSETS = [
'./',
'./index.html',
'./css/style.css',
'./js/boschProtocol.js',
'./js/bleService.js',
'./js/wsRelay.js',
'./js/app.js',
'./manifest.json',
'./icons/icon-192.png',
'./icons/icon-512.png',
'./icons/apple-touch-icon.png',
'./icons/favicon.svg',
'./icons/proactys-logo.png',
'./icons/qr-code.png'
];
// Install — cache static assets
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.addAll(STATIC_ASSETS))
.then(() => self.skipWaiting())
);
});
// Activate — clean old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then(keys =>
Promise.all(
keys.filter(key => key !== CACHE_NAME)
.map(key => caches.delete(key))
)
).then(() => self.clients.claim())
);
});
// Fetch — cache-first for static assets, network-first for everything else
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// Skip WebSocket requests
if (url.pathname.endsWith('/ws')) return;
// Cache-first for known assets
event.respondWith(
caches.match(event.request)
.then(cached => cached || fetch(event.request))
);
});

133
webapp/js/wsRelay.js Normal file
View File

@ -0,0 +1,133 @@
/**
* WebSocket Relay Client
* Bridge mode: sends decoded BLE telemetry to VPS over 5G/WiFi
* Viewer mode: receives telemetry broadcast from VPS
*/
const WSRelay = (() => {
let ws = null;
let onDataCallback = null;
let onStatusCallback = null;
let reconnectTimer = null;
let isSource = false; // true if this client is the BLE bridge
const RECONNECT_DELAY = 2000;
/**
* Get the WebSocket URL based on current location
*/
function getWsUrl() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${proto}//${location.host}/opusbike/ws`;
}
/**
* Connect to the WebSocket relay server
*/
function connect(asSource = false) {
isSource = asSource;
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
return;
}
clearTimeout(reconnectTimer);
try {
ws = new WebSocket(getWsUrl());
ws.onopen = () => {
console.log('WS relay connected (source:', isSource, ')');
// Register role with server
ws.send(JSON.stringify({ type: 'register', role: isSource ? 'source' : 'viewer' }));
if (onStatusCallback) onStatusCallback('connected');
};
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
// Handle both live telemetry and initial state snapshot
if ((msg.type === 'telemetry' || msg.type === 'state') && onDataCallback) {
onDataCallback(msg.data);
} else if (msg.type === 'source_connected' && onStatusCallback) {
onStatusCallback('source_connected');
} else if (msg.type === 'source_disconnected' && onStatusCallback) {
onStatusCallback('source_disconnected');
}
} catch (e) {
// Ignore non-JSON messages
}
};
ws.onclose = () => {
console.log('WS relay disconnected');
if (onStatusCallback) onStatusCallback('disconnected');
scheduleReconnect();
};
ws.onerror = (err) => {
console.warn('WS relay error:', err);
};
} catch (e) {
console.warn('WS connect failed:', e);
scheduleReconnect();
}
}
/**
* Schedule a reconnection attempt
*/
function scheduleReconnect() {
clearTimeout(reconnectTimer);
reconnectTimer = setTimeout(() => connect(isSource), RECONNECT_DELAY);
}
/**
* Send telemetry data to relay (bridge mode phone uploading to VPS)
*/
function sendTelemetry(data) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'telemetry', source: 'ble', data }));
}
}
/**
* Disconnect from relay
*/
function disconnect() {
clearTimeout(reconnectTimer);
if (ws) {
ws.close();
ws = null;
}
}
/**
* Set callback for incoming telemetry (viewer mode)
*/
function onData(callback) {
onDataCallback = callback;
}
/**
* Set callback for connection status changes
*/
function onStatus(callback) {
onStatusCallback = callback;
}
/**
* Check if connected
*/
function isConnected() {
return ws && ws.readyState === WebSocket.OPEN;
}
return {
connect,
disconnect,
sendTelemetry,
onData,
onStatus,
isConnected
};
})();

24
webapp/manifest.json Normal file
View File

@ -0,0 +1,24 @@
{
"name": "OpusBike — Bosch Smart System Monitor",
"short_name": "OpusBike",
"description": "Real-time bike telemetry dashboard for Bosch Smart System eBikes",
"start_url": ".",
"display": "standalone",
"orientation": "portrait",
"background_color": "#F5F7FA",
"theme_color": "#F5F7FA",
"icons": [
{
"src": "icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}