commit a41459f10bf6a450fa5bf82d827e1064b18dc022 Author: Claude Code Date: Sat Mar 21 10:34:04 2026 +0000 R-000139, R-000140: BLE CLI tools + full OpusBike webapp diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7249575 --- /dev/null +++ b/CLAUDE.md @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..48217a1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,4 @@ +FROM nginx:alpine +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY webapp/ /usr/share/nginx/html/ +EXPOSE 8080 diff --git a/Dockerfile.relay b/Dockerfile.relay new file mode 100644 index 0000000..180b107 --- /dev/null +++ b/Dockerfile.relay @@ -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"] diff --git a/cli/bridge.js b/cli/bridge.js new file mode 100644 index 0000000..0ae7857 --- /dev/null +++ b/cli/bridge.js @@ -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 = ` + + + + +OpusBike — Noble BLE Dashboard + + + +

OpusBike Noble BLE

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

OpusBike BLE Test

+
Python bleak — Bosch Smart System
+
Disconnected
+
+
+
Speed
+
0.0
+
km/h
+
+
+
Cadence
+
0
+
rpm
+
+
+
Human Power
+
0
+
W
+
+
+
Motor Power
+
0
+
W
+
+
+
Battery
+
0%
+
+
+
+
Assist Mode
+
OFF
+
+
+
Ride Time
+
00:00:00
+
+
+ + +""" + + +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) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..32dbd99 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..471bb6d --- /dev/null +++ b/nginx.conf @@ -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; + } +} diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..9fd2139 --- /dev/null +++ b/server/package.json @@ -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" + } +} diff --git a/server/relay.js b/server/relay.js new file mode 100644 index 0000000..35d32b7 --- /dev/null +++ b/server/relay.js @@ -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}`); +}); diff --git a/webapp/css/style.css b/webapp/css/style.css new file mode 100644 index 0000000..08de71b --- /dev/null +++ b/webapp/css/style.css @@ -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; + } +} diff --git a/webapp/icons/apple-touch-icon.png b/webapp/icons/apple-touch-icon.png new file mode 100644 index 0000000..c2ff5e6 Binary files /dev/null and b/webapp/icons/apple-touch-icon.png differ diff --git a/webapp/icons/favicon.svg b/webapp/icons/favicon.svg new file mode 100644 index 0000000..493161d --- /dev/null +++ b/webapp/icons/favicon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + OPUS + diff --git a/webapp/icons/icon-192.png b/webapp/icons/icon-192.png new file mode 100644 index 0000000..f7adefa Binary files /dev/null and b/webapp/icons/icon-192.png differ diff --git a/webapp/icons/icon-512.png b/webapp/icons/icon-512.png new file mode 100644 index 0000000..fe2604d Binary files /dev/null and b/webapp/icons/icon-512.png differ diff --git a/webapp/icons/proactys-logo.png b/webapp/icons/proactys-logo.png new file mode 100644 index 0000000..4e03c30 Binary files /dev/null and b/webapp/icons/proactys-logo.png differ diff --git a/webapp/icons/qr-code.png b/webapp/icons/qr-code.png new file mode 100644 index 0000000..5a8a33e Binary files /dev/null and b/webapp/icons/qr-code.png differ diff --git a/webapp/index.html b/webapp/index.html new file mode 100644 index 0000000..c32e7e3 --- /dev/null +++ b/webapp/index.html @@ -0,0 +1,196 @@ + + + + + + OpusBike — ProActys + + + + + + + + + + + + + + + + + + + +
+
+
+ +

OpusBike

+

Bosch Smart System Monitor

+
+ +
+ + Disconnected +
+ +
+ +
+ + +
+ + + + +
+ Don't see your bike? +
+

Web Bluetooth uses its own pairing flow. If your bike is paired in iOS Settings, the Web Bluetooth picker won't find it.

+
    +
  1. Go to Settings → Bluetooth
  2. +
  3. Find "smart system eBike" (or similar Bosch name)
  4. +
  5. Tap the (i) icon → Forget This Device
  6. +
  7. Come back here and tap Connect via Bluetooth
  8. +
  9. The Web Bluetooth picker will appear — select your bike
  10. +
+

Make sure the Bosch system is powered on (turn the crank or press the button on the display).

+
+
+ +
+

Share with your team

+ QR Code + +

monitor.proactys.swiss/opusbike

+
+ + +
+
+ + +
+
+
+
+

OpusBike

+
+ + Connected +
+ DEMO +
+ ProActys + +
+ + +
+ +
+
Speed
+
+ 0.0 + km/h +
+
+ +
+
Torque
+
+ 0 + Nm +
+
+ +
+
Ride Time
+
+ 00:00:00 +
+
+ +
+
Total Kilometer
+
+ 0.0 + km +
+
+ +
+
Mode
+
+ OFF +
+
+ OFF + ECO + TOUR + eMTB + TURBO +
+
+
+
+
+ + + + + + + + + + + + + + diff --git a/webapp/js/app.js b/webapp/js/app.js new file mode 100644 index 0000000..6c2fb19 --- /dev/null +++ b/webapp/js/app.js @@ -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 + }; +})(); diff --git a/webapp/js/bleService.js b/webapp/js/bleService.js new file mode 100644 index 0000000..a0d3caa --- /dev/null +++ b/webapp/js/bleService.js @@ -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 + }; +})(); diff --git a/webapp/js/boschProtocol.js b/webapp/js/boschProtocol.js new file mode 100644 index 0000000..7fd5df4 --- /dev/null +++ b/webapp/js/boschProtocol.js @@ -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 + }; +})(); diff --git a/webapp/js/qrcode.js b/webapp/js/qrcode.js new file mode 100644 index 0000000..8162948 --- /dev/null +++ b/webapp/js/qrcode.js @@ -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/'); + } + }); +})(); diff --git a/webapp/js/sw.js b/webapp/js/sw.js new file mode 100644 index 0000000..d66e832 --- /dev/null +++ b/webapp/js/sw.js @@ -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)) + ); +}); diff --git a/webapp/js/wsRelay.js b/webapp/js/wsRelay.js new file mode 100644 index 0000000..e72d2c6 --- /dev/null +++ b/webapp/js/wsRelay.js @@ -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 + }; +})(); diff --git a/webapp/manifest.json b/webapp/manifest.json new file mode 100644 index 0000000..1c957fe --- /dev/null +++ b/webapp/manifest.json @@ -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" + } + ] +}