375 lines
12 KiB
JavaScript
375 lines
12 KiB
JavaScript
/**
|
|
* 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 cadence = speed > 0.5 ? Math.round(speed * 2.8 + (Math.random() - 0.5) * 10) : 0;
|
|
const humanPower = speed > 0.5 ? Math.round(cadence * 1.3 + (Math.random() - 0.5) * 20) : 0;
|
|
const modeMultiplier = { OFF: 0, ECO: 0.5, TOUR: 1.0, eMTB: 1.5, TURBO: 2.2 };
|
|
const motorPower = Math.round(humanPower * (modeMultiplier[DEMO_MODES[modeIndex]] || 1.0));
|
|
const battery = Math.max(0, 87 - Math.floor(demoTick * 0.02));
|
|
|
|
const data = {
|
|
speed: Math.round(speed * 10) / 10,
|
|
cadence: Math.max(0, cadence),
|
|
humanPower: Math.max(0, humanPower),
|
|
motorPower: Math.max(0, motorPower),
|
|
battery: battery,
|
|
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) {
|
|
if (data.speed !== null && data.speed !== undefined) {
|
|
document.getElementById('val-speed').textContent = (data.speed || 0).toFixed(1);
|
|
}
|
|
if (data.cadence !== null && data.cadence !== undefined) {
|
|
document.getElementById('val-cadence').textContent = Math.round(data.cadence || 0);
|
|
}
|
|
if (data.humanPower !== null && data.humanPower !== undefined) {
|
|
document.getElementById('val-humanpower').textContent = Math.round(data.humanPower || 0);
|
|
}
|
|
if (data.motorPower !== null && data.motorPower !== undefined) {
|
|
document.getElementById('val-motorpower').textContent = Math.round(data.motorPower || 0);
|
|
}
|
|
if (data.battery !== null && data.battery !== undefined) {
|
|
document.getElementById('val-battery').textContent = data.battery;
|
|
const bar = document.getElementById('battery-bar');
|
|
if (bar) {
|
|
bar.style.width = data.battery + '%';
|
|
bar.style.background = data.battery > 50 ? '#4caf50' : data.battery > 20 ? '#ff9800' : '#f44336';
|
|
}
|
|
}
|
|
|
|
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
|
|
};
|
|
})();
|