/** * 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 }; })();