R-000139, R-000140: BLE CLI tools + full OpusBike webapp
This commit is contained in:
commit
a41459f10b
98
CLAUDE.md
Normal file
98
CLAUDE.md
Normal 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
4
Dockerfile
Normal 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
7
Dockerfile.relay
Normal 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
594
cli/bridge.js
Normal 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
15
cli/package.json
Normal 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
595
cli/test_bleak.py
Normal 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
26
docker-compose.yml
Normal 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
39
nginx.conf
Normal 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
13
server/package.json
Normal 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
138
server/relay.js
Normal 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
650
webapp/css/style.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
webapp/icons/apple-touch-icon.png
Normal file
BIN
webapp/icons/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
11
webapp/icons/favicon.svg
Normal file
11
webapp/icons/favicon.svg
Normal 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
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
BIN
webapp/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
BIN
webapp/icons/proactys-logo.png
Normal file
BIN
webapp/icons/proactys-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 79 KiB |
BIN
webapp/icons/qr-code.png
Normal file
BIN
webapp/icons/qr-code.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 466 B |
196
webapp/index.html
Normal file
196
webapp/index.html
Normal 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 → Bluetooth</strong></li>
|
||||||
|
<li>Find <strong>"smart system eBike"</strong> (or similar Bosch name)</li>
|
||||||
|
<li>Tap the <strong>(i)</strong> icon → <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
349
webapp/js/app.js
Normal 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
141
webapp/js/bleService.js
Normal 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
160
webapp/js/boschProtocol.js
Normal 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
318
webapp/js/qrcode.js
Normal 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
56
webapp/js/sw.js
Normal 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
133
webapp/js/wsRelay.js
Normal 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
24
webapp/manifest.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user