OpusBike/webapp/js/app.js

350 lines
11 KiB
JavaScript
Raw Normal View History

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