Files
pyBTMCP/src/web/static/index.html
Matt Hills c95cd33343 v1.2.0: Firmware refactor to service-oriented architecture + WiFi fixes
Firmware:
- Fixed WiFi reconnection bug after power cycle by adding WiFi.persistent(false)
  and disabling auto-connect/auto-reconnect
- Fixed AP mode not broadcasting by properly handling WIFI_AP_STA mode transitions
- Refactored to service-oriented architecture:
  - device_state: Central state management with event callbacks
  - config_service: NVS persistence (renamed from config_manager)
  - wifi_service: WiFi STA/AP management (extracted from main.cpp)
  - mqtt_service: MQTT client and message routing (extracted from main.cpp)
  - ble_service: BLE GATT services (renamed from ble_services)
  - web_service: HTTP configuration portal (renamed from web_portal)
- main.cpp reduced from ~470 lines to ~60 lines (thin orchestrator)
- Each service is self-contained with setup()/loop() pattern

Backend:
- Added heart rate variation endpoint
- UI improvements

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 16:49:54 -05:00

991 lines
36 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>pyBTMCP - BLE Simulator</title>
<style>
:root {
--bg: #1a1a2e;
--card-bg: #16213e;
--accent: #0f3460;
--text: #e4e4e4;
--text-dim: #888;
--success: #4ade80;
--warning: #fbbf24;
--error: #f87171;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
padding: 2rem;
}
h1 { margin-bottom: 0.5rem; }
.subtitle { color: var(--text-dim); margin-bottom: 2rem; }
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1.5rem;
}
.header-buttons {
display: flex;
gap: 0.5rem;
}
.btn {
background: var(--accent);
border: none;
color: var(--text);
padding: 0.5rem 1rem;
border-radius: 8px;
cursor: pointer;
font-size: 0.875rem;
}
.btn:hover { background: #1a4a7a; }
/* Settings Panel */
.settings-bar {
background: var(--card-bg);
border-radius: 12px;
padding: 1rem 1.5rem;
margin-bottom: 1.5rem;
display: flex;
align-items: center;
gap: 2rem;
flex-wrap: wrap;
}
.setting-item {
display: flex;
align-items: center;
gap: 0.5rem;
}
.setting-item label {
color: var(--text-dim);
font-size: 0.875rem;
}
.toggle {
position: relative;
width: 50px;
height: 26px;
}
.toggle input { opacity: 0; width: 0; height: 0; }
.toggle-slider {
position: absolute;
cursor: pointer;
top: 0; left: 0; right: 0; bottom: 0;
background: var(--accent);
border-radius: 26px;
transition: 0.3s;
}
.toggle-slider:before {
position: absolute;
content: "";
height: 20px;
width: 20px;
left: 3px;
bottom: 3px;
background: var(--text);
border-radius: 50%;
transition: 0.3s;
}
.toggle input:checked + .toggle-slider { background: var(--success); }
.toggle input:checked + .toggle-slider:before { transform: translateX(24px); }
.unit-label {
font-size: 0.75rem;
color: var(--text-dim);
min-width: 60px;
}
/* Devices */
.devices {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 1.5rem;
}
.device-card {
background: var(--card-bg);
border-radius: 12px;
padding: 1.5rem;
border: 1px solid var(--accent);
}
.device-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.device-id { font-weight: 600; font-size: 1.1rem; }
.device-status {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--success);
}
.status-dot.offline { background: var(--error); }
.control-group { margin-bottom: 1rem; }
.control-group label {
display: block;
color: var(--text-dim);
font-size: 0.875rem;
margin-bottom: 0.5rem;
}
.slider-container {
display: flex;
align-items: center;
gap: 1rem;
}
input[type="range"] {
flex: 1;
height: 6px;
border-radius: 3px;
background: var(--accent);
-webkit-appearance: none;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--success);
cursor: pointer;
}
.value-display {
min-width: 80px;
text-align: right;
font-weight: 600;
font-family: monospace;
}
select {
width: 100%;
padding: 0.75rem;
background: var(--accent);
border: none;
border-radius: 8px;
color: var(--text);
font-size: 1rem;
}
/* Presets */
.presets {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.preset-btn {
padding: 0.5rem 1rem;
background: var(--accent);
border: none;
border-radius: 20px;
color: var(--text);
font-size: 0.875rem;
cursor: pointer;
transition: background 0.2s;
}
.preset-btn:hover { background: #1a4a7a; }
.preset-btn.active { background: var(--success); color: var(--bg); }
.no-devices {
text-align: center;
color: var(--text-dim);
padding: 4rem 2rem;
background: var(--card-bg);
border-radius: 12px;
}
/* HR Variation indicator */
.variation-indicator {
font-size: 0.75rem;
color: var(--text-dim);
margin-left: 0.5rem;
}
/* Device info row */
.device-info {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0;
margin-bottom: 0.5rem;
border-bottom: 1px solid var(--accent);
font-size: 0.8rem;
}
.device-link {
color: var(--success);
text-decoration: none;
}
.device-link:hover {
text-decoration: underline;
}
.device-version {
color: var(--text-dim);
}
.device-mac {
color: var(--text-dim);
font-family: monospace;
font-size: 0.75rem;
}
.delete-btn {
background: var(--error);
color: white;
border: none;
border-radius: 4px;
width: 20px;
height: 20px;
font-size: 14px;
cursor: pointer;
margin-left: 0.5rem;
line-height: 1;
}
.delete-btn:hover {
opacity: 0.8;
}
/* WebSocket connection status */
.connection-status {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
padding: 0.5rem 1rem;
background: var(--card-bg);
border-radius: 8px;
}
.connection-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--error);
transition: background 0.3s;
}
.connection-dot.connected { background: var(--success); }
.connection-dot.connecting { background: var(--warning); animation: pulse 1s infinite; }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
</style>
</head>
<body>
<div class="header">
<div>
<h1>pyBTMCP</h1>
<p class="subtitle">BLE Device Simulator &bull; UI v1.4.0 &bull; API <span id="backendVersion"></span></p>
</div>
<div class="header-buttons">
<div class="connection-status">
<span class="connection-dot" id="connectionDot"></span>
<span id="connectionText">Disconnected</span>
</div>
<button class="btn" onclick="loadDevices()">Refresh</button>
</div>
</div>
<div class="settings-bar">
<div class="setting-item">
<label>Units:</label>
<span class="unit-label" id="unitLabel">Metric</span>
<label class="toggle">
<input type="checkbox" id="imperialToggle" onchange="toggleUnits()">
<span class="toggle-slider"></span>
</label>
<span class="unit-label">Imperial</span>
</div>
<div class="setting-item">
<label>HR Variation:</label>
<label class="toggle">
<input type="checkbox" id="hrVariationToggle" onchange="toggleHrVariation()" checked>
<span class="toggle-slider"></span>
</label>
<span class="unit-label" id="hrVariationLabel">On (±3 BPM)</span>
</div>
</div>
<div id="devices" class="devices">
<div class="no-devices">
<p>No devices connected</p>
<p style="margin-top: 0.5rem; font-size: 0.875rem;">
Connect ESP32 devices to see them here
</p>
</div>
</div>
<script>
const API_BASE = '/api';
// WebSocket connection
let ws = null;
let wsReconnectTimeout = null;
let wsReconnectAttempts = 0;
const WS_RECONNECT_DELAY_BASE = 1000;
const WS_RECONNECT_MAX_DELAY = 30000;
function getWebSocketUrl() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${protocol}//${window.location.host}/ws`;
}
function updateConnectionStatus(status) {
const dot = document.getElementById('connectionDot');
const text = document.getElementById('connectionText');
dot.className = 'connection-dot';
if (status === 'connected') {
dot.classList.add('connected');
text.textContent = 'Live';
} else if (status === 'connecting') {
dot.classList.add('connecting');
text.textContent = 'Connecting...';
} else {
text.textContent = 'Disconnected';
}
}
function connectWebSocket() {
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
return;
}
updateConnectionStatus('connecting');
try {
ws = new WebSocket(getWebSocketUrl());
ws.onopen = () => {
console.log('WebSocket connected');
updateConnectionStatus('connected');
wsReconnectAttempts = 0;
};
ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
handleWebSocketMessage(message);
} catch (e) {
console.error('Failed to parse WebSocket message:', e);
}
};
ws.onclose = () => {
console.log('WebSocket disconnected');
updateConnectionStatus('disconnected');
scheduleReconnect();
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
updateConnectionStatus('disconnected');
};
} catch (e) {
console.error('Failed to create WebSocket:', e);
updateConnectionStatus('disconnected');
scheduleReconnect();
}
}
function scheduleReconnect() {
if (wsReconnectTimeout) {
clearTimeout(wsReconnectTimeout);
}
// Exponential backoff with max delay
const delay = Math.min(
WS_RECONNECT_DELAY_BASE * Math.pow(2, wsReconnectAttempts),
WS_RECONNECT_MAX_DELAY
);
wsReconnectAttempts++;
console.log(`Reconnecting WebSocket in ${delay}ms (attempt ${wsReconnectAttempts})`);
wsReconnectTimeout = setTimeout(connectWebSocket, delay);
}
function handleWebSocketMessage(message) {
console.log('WebSocket message:', message.type);
if (message.type === 'initial_state') {
// Full device list received on connect
renderDevices(message.devices);
} else if (message.type === 'device_update') {
// Single device updated - update UI efficiently
updateDeviceInUI(message.device_id, message.data);
} else if (message.type === 'hr_update') {
// HR value update from server-side variation
updateHrDisplay(message.device_id, message.heart_rate);
}
}
function updateHrDisplay(deviceId, hr) {
const display = document.getElementById(`hr-display-${deviceId}`);
if (display) {
display.textContent = `${hr} BPM`;
}
}
function updateDeviceInUI(deviceId, deviceData) {
const container = document.getElementById('devices');
const existingCard = container.querySelector(`[data-id="${deviceId}"]`);
if (!existingCard) {
// New device - reload all devices
loadDevices();
return;
}
// Check if device type changed (need full card re-render)
if (existingCard.dataset.type !== (deviceData.type || '')) {
loadDevices();
return;
}
// Update online/offline status
const dot = existingCard.querySelector('.status-dot');
const text = existingCard.querySelector('.status-text');
if (dot) dot.className = `status-dot ${deviceData.online ? '' : 'offline'}`;
if (text) text.textContent = deviceData.online ? 'Online' : 'Offline';
// Update IP, MAC, and version if present
const infoDiv = existingCard.querySelector('.device-info');
if (infoDiv) {
let infoHtml = '';
if (deviceData.ip) {
infoHtml += `<a href="http://${deviceData.ip}" target="_blank" class="device-link">Admin: ${deviceData.ip}</a>`;
}
if (deviceData.bt_mac) {
infoHtml += `<span class="device-mac">BT: ${deviceData.bt_mac}</span>`;
}
if (deviceData.firmware_version) {
infoHtml += `<span class="device-version">v${deviceData.firmware_version}</span>`;
}
infoDiv.innerHTML = infoHtml;
}
// Update values if present
const values = deviceData.values || {};
if (values.heart_rate !== undefined) {
updateHrDisplay(deviceId, values.heart_rate);
}
if (values.speed !== undefined) {
const speedDisplay = document.getElementById(`speed-display-${deviceId}`);
if (speedDisplay) speedDisplay.textContent = formatSpeed(values.speed);
}
if (values.distance !== undefined) {
const distDisplay = document.getElementById(`distance-display-${deviceId}`);
if (distDisplay) distDisplay.textContent = `${values.distance} m`;
}
if (values.battery !== undefined) {
const battDisplay = document.getElementById(`battery-display-${deviceId}`);
if (battDisplay) battDisplay.textContent = `${values.battery}%`;
}
}
// Settings (persisted to localStorage)
let settings = {
imperial: false,
hrVariation: true
};
// Track active devices (for UI state only - business logic is server-side)
let activeDevices = new Set();
// Treadmill presets (stored in metric - km/h)
const treadmillPresets = [
{ name: 'Walk', speed: 5.0, incline: 1 },
{ name: 'Jog', speed: 8.0, incline: 1 },
{ name: 'Run', speed: 11.0, incline: 1.5 },
{ name: 'Sprint', speed: 16.0, incline: 0.5 },
{ name: 'Hill', speed: 6.5, incline: 8 }
];
// HR presets
const hrPresets = [
{ name: 'Rest', hr: 65 },
{ name: 'Warm Up', hr: 100 },
{ name: 'Fat Burn', hr: 130 },
{ name: 'Cardio', hr: 150 },
{ name: 'Peak', hr: 175 }
];
// Load settings from localStorage
function loadSettings() {
const saved = localStorage.getItem('pybtmcp_settings');
if (saved) {
settings = { ...settings, ...JSON.parse(saved) };
}
document.getElementById('imperialToggle').checked = settings.imperial;
document.getElementById('hrVariationToggle').checked = settings.hrVariation;
updateSettingsDisplay();
}
function saveSettings() {
localStorage.setItem('pybtmcp_settings', JSON.stringify(settings));
}
function toggleUnits() {
settings.imperial = document.getElementById('imperialToggle').checked;
saveSettings();
updateSettingsDisplay();
loadDevices(); // Re-render with new units
}
async function toggleHrVariation() {
settings.hrVariation = document.getElementById('hrVariationToggle').checked;
saveSettings();
updateSettingsDisplay();
// Update all HR devices via API
const devices = document.querySelectorAll('.device-card[data-type="heart_rate"]');
for (const card of devices) {
const deviceId = card.dataset.id;
try {
await fetch(`${API_BASE}/devices/${deviceId}/hr-variation`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: settings.hrVariation })
});
} catch (error) {
console.error('Failed to toggle HR variation:', error);
}
}
}
function updateSettingsDisplay() {
document.getElementById('hrVariationLabel').textContent =
settings.hrVariation ? 'On (±3 BPM)' : 'Off';
}
// Unit conversion helpers (display only)
function kmhToMph(kmh) { return kmh * 0.621371; }
function formatSpeed(kmh) {
if (settings.imperial) {
return `${kmhToMph(kmh).toFixed(1)} mph`;
}
return `${parseFloat(kmh).toFixed(1)} km/h`;
}
function getSpeedMax() { return settings.imperial ? 15.5 : 25; }
function getSpeedStep() { return settings.imperial ? 0.1 : 0.1; }
function displayToMetric(displaySpeed) {
return settings.imperial ? displaySpeed / 0.621371 : displaySpeed;
}
function metricToDisplay(metricSpeed) {
return settings.imperial ? metricSpeed * 0.621371 : metricSpeed;
}
async function loadDevices() {
try {
const response = await fetch(`${API_BASE}/devices`);
const data = await response.json();
renderDevices(data.devices);
} catch (error) {
console.error('Failed to load devices:', error);
}
}
function renderDevices(devices) {
const container = document.getElementById('devices');
if (!devices || devices.length === 0) {
container.innerHTML = `
<div class="no-devices">
<p>No devices connected</p>
<p style="margin-top: 0.5rem; font-size: 0.875rem;">
Connect ESP32 devices to see them here
</p>
</div>
`;
return;
}
// Check if we need full re-render or just status update
const existingCards = container.querySelectorAll('.device-card');
const existingIds = new Set([...existingCards].map(c => c.dataset.id));
const newIds = new Set(devices.map(d => d.id));
// Check if device types changed (need full re-render for controls)
let typesChanged = false;
devices.forEach(device => {
const card = container.querySelector(`[data-id="${device.id}"]`);
if (card && card.dataset.type !== (device.type || '')) {
typesChanged = true;
}
});
// Full re-render if device list or types changed
const needsFullRender = typesChanged ||
existingIds.size !== newIds.size ||
[...existingIds].some(id => !newIds.has(id)) ||
[...newIds].some(id => !existingIds.has(id));
if (needsFullRender) {
container.innerHTML = devices.map(device => `
<div class="device-card" data-id="${device.id}" data-type="${device.type || ''}">
<div class="device-header">
<span class="device-id">${device.id}</span>
<div class="device-status">
<span class="status-dot ${device.online ? '' : 'offline'}"></span>
<span class="status-text">${device.online ? 'Online' : 'Offline'}</span>
<button class="delete-btn" onclick="deleteDevice('${device.id}')" title="Remove device">×</button>
</div>
</div>
<div class="device-info">
${device.ip ? `<a href="http://${device.ip}" target="_blank" class="device-link">Admin: ${device.ip}</a>` : ''}
${device.bt_mac ? `<span class="device-mac">BT: ${device.bt_mac}</span>` : ''}
${device.firmware_version ? `<span class="device-version">v${device.firmware_version}</span>` : ''}
</div>
<div class="control-group">
<label>Device Type</label>
<select onchange="configureDevice('${device.id}', this.value)">
<option value="" ${!device.type ? 'selected' : ''}>Select type...</option>
<option value="heart_rate" ${device.type === 'heart_rate' ? 'selected' : ''}>Heart Rate Monitor</option>
<option value="treadmill" ${device.type === 'treadmill' ? 'selected' : ''}>Treadmill</option>
<option value="bike" ${device.type === 'bike' ? 'selected' : ''}>Bike / Trainer</option>
</select>
</div>
${renderControls(device)}
</div>
`).join('');
// Enable HR variation for new HR devices if setting is on
devices.forEach(device => {
if (device.type === 'heart_rate' && settings.hrVariation) {
enableHrVariationForDevice(device.id, device.values?.heart_rate || 70);
}
});
} else {
// Just update online/offline status
devices.forEach(device => {
const card = container.querySelector(`[data-id="${device.id}"]`);
if (card) {
const dot = card.querySelector('.status-dot');
const text = card.querySelector('.status-text');
if (dot) dot.className = `status-dot ${device.online ? '' : 'offline'}`;
if (text) text.textContent = device.online ? 'Online' : 'Offline';
}
});
}
}
function renderControls(device) {
if (!device.type) return '';
const values = device.values || {};
if (device.type === 'heart_rate') {
const hr = values.heart_rate || 70;
return `
<div class="presets">
${hrPresets.map(p => `
<button class="preset-btn"
onclick="setHrTarget('${device.id}', ${p.hr})">${p.name}</button>
`).join('')}
</div>
<div class="control-group">
<label>Heart Rate Target</label>
<div class="slider-container">
<input type="range" min="30" max="220" value="${hr}"
oninput="setHrTargetDebounced('${device.id}', this.value)">
<span class="value-display" id="hr-display-${device.id}">${hr} BPM</span>
</div>
</div>
<div class="control-group">
<label>Battery Level</label>
<div class="slider-container">
<input type="range" min="0" max="100" value="${values.battery || 100}"
oninput="updateBattery('${device.id}', this.value)">
<span class="value-display" id="battery-display-${device.id}">${values.battery || 100}%</span>
</div>
</div>
`;
}
if (device.type === 'treadmill') {
const speed = values.speed || 0;
const incline = values.incline || 0;
const displaySpeed = metricToDisplay(speed);
return `
<div class="presets">
${treadmillPresets.map(p => `
<button class="preset-btn" onclick="applyTreadmillPreset('${device.id}', ${p.speed}, ${p.incline})">${p.name}</button>
`).join('')}
</div>
<div class="control-group">
<label>Speed</label>
<div class="slider-container">
<input type="range" min="0" max="${getSpeedMax()}" step="${getSpeedStep()}"
value="${displaySpeed.toFixed(1)}"
oninput="updateSpeed('${device.id}', this.value)">
<span class="value-display" id="speed-display-${device.id}">${formatSpeed(speed)}</span>
</div>
</div>
<div class="control-group">
<label>Incline</label>
<div class="slider-container">
<input type="range" min="-5" max="30" step="0.5" value="${incline}"
oninput="updateValue('${device.id}', 'incline', this.value)">
<span class="value-display" id="incline-display-${device.id}">${incline}%</span>
</div>
</div>
<div class="control-group">
<label>Distance</label>
<div class="slider-container" style="justify-content: space-between;">
<span class="value-display" id="distance-display-${device.id}">${values.distance || 0} m</span>
<button class="preset-btn" onclick="resetDistance('${device.id}')" style="margin: 0; padding: 0.25rem 0.75rem;">Reset</button>
</div>
</div>
`;
}
if (device.type === 'bike') {
return `
<div class="control-group">
<label>Power</label>
<div class="slider-container">
<input type="range" min="0" max="500" value="${values.power || 0}"
oninput="updateValue('${device.id}', 'power', this.value)">
<span class="value-display">${values.power || 0} W</span>
</div>
</div>
<div class="control-group">
<label>Cadence</label>
<div class="slider-container">
<input type="range" min="0" max="150" value="${values.cadence || 0}"
oninput="updateValue('${device.id}', 'cadence', this.value)">
<span class="value-display">${values.cadence || 0} RPM</span>
</div>
</div>
`;
}
return '';
}
// HR control functions - business logic is server-side
const hrTargetDebounceTimers = {};
async function enableHrVariationForDevice(deviceId, startHr) {
try {
await fetch(`${API_BASE}/devices/${deviceId}/hr-variation`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: true, target: startHr })
});
} catch (error) {
console.error('Failed to enable HR variation:', error);
}
}
async function setHrTarget(deviceId, targetHr) {
// Update slider UI immediately
const card = document.querySelector(`[data-id="${deviceId}"]`);
const slider = card?.querySelector('input[type="range"]');
if (slider) slider.value = targetHr;
try {
await fetch(`${API_BASE}/devices/${deviceId}/hr-target`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target: targetHr })
});
} catch (error) {
console.error('Failed to send HR:', error);
}
}
function setHrTargetDebounced(deviceId, value) {
const hr = parseInt(value);
// Update display immediately
const display = document.getElementById(`hr-display-${deviceId}`);
if (display) display.textContent = `${hr} BPM`;
// Debounce the API call
if (hrTargetDebounceTimers[deviceId]) {
clearTimeout(hrTargetDebounceTimers[deviceId]);
}
hrTargetDebounceTimers[deviceId] = setTimeout(() => {
setHrTarget(deviceId, hr);
}, 100);
}
function updateSpeed(deviceId, displayValue) {
const metricSpeed = displayToMetric(parseFloat(displayValue));
const display = document.getElementById(`speed-display-${deviceId}`);
if (display) display.textContent = formatSpeed(metricSpeed);
updateValueDebounced(deviceId, 'speed', metricSpeed);
}
function updateBattery(deviceId, value) {
const display = document.getElementById(`battery-display-${deviceId}`);
if (display) display.textContent = `${value}%`;
updateValueDebounced(deviceId, 'battery', parseInt(value));
}
async function resetDistance(deviceId) {
const display = document.getElementById(`distance-display-${deviceId}`);
if (display) display.textContent = '0 m';
try {
await fetch(`${API_BASE}/devices/${deviceId}/values`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ distance: 0 })
});
} catch (error) {
console.error('Failed to reset distance:', error);
}
}
function applyTreadmillPreset(deviceId, speedKmh, incline) {
const card = document.querySelector(`[data-id="${deviceId}"]`);
// Update sliders
const speedSlider = card.querySelector('input[oninput*="updateSpeed"]');
const inclineSlider = card.querySelector('input[oninput*="incline"]');
if (speedSlider) speedSlider.value = metricToDisplay(speedKmh).toFixed(1);
if (inclineSlider) inclineSlider.value = incline;
// Update displays
const speedDisplay = document.getElementById(`speed-display-${deviceId}`);
const inclineDisplay = document.getElementById(`incline-display-${deviceId}`);
if (speedDisplay) speedDisplay.textContent = formatSpeed(speedKmh);
if (inclineDisplay) inclineDisplay.textContent = `${incline}%`;
// Send values
sendTreadmillValues(deviceId, speedKmh, incline);
}
async function sendTreadmillValues(deviceId, speed, incline) {
try {
await fetch(`${API_BASE}/devices/${deviceId}/values`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ speed, incline })
});
} catch (error) {
console.error('Failed to send treadmill values:', error);
}
}
async function configureDevice(deviceId, deviceType) {
if (!deviceType) return;
try {
await fetch(`${API_BASE}/devices/${deviceId}/configure`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device_type: deviceType })
});
loadDevices();
} catch (error) {
console.error('Failed to configure device:', error);
}
}
async function deleteDevice(deviceId) {
if (!confirm(`Remove device ${deviceId} from the list?`)) return;
try {
await fetch(`${API_BASE}/devices/${deviceId}`, {
method: 'DELETE'
});
loadDevices();
} catch (error) {
console.error('Failed to delete device:', error);
}
}
let updateTimeouts = {};
function updateValueDebounced(deviceId, key, value) {
const timeoutKey = `${deviceId}-${key}`;
clearTimeout(updateTimeouts[timeoutKey]);
updateTimeouts[timeoutKey] = setTimeout(async () => {
try {
await fetch(`${API_BASE}/devices/${deviceId}/values`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [key]: value })
});
} catch (error) {
console.error('Failed to update value:', error);
}
}, 100);
}
function updateValue(deviceId, key, value) {
const numValue = parseFloat(value);
// Update display
const display = document.getElementById(`${key}-display-${deviceId}`);
if (display) {
const units = { incline: '%', power: ' W', cadence: ' RPM' };
display.textContent = `${numValue}${units[key] || ''}`;
}
updateValueDebounced(deviceId, key, numValue);
}
// Load backend version
async function loadBackendVersion() {
try {
const response = await fetch('/health');
const data = await response.json();
const versionEl = document.getElementById('backendVersion');
if (versionEl && data.version) {
versionEl.textContent = `v${data.version}`;
}
} catch (error) {
console.error('Failed to load backend version:', error);
}
}
// Initialize
loadSettings();
loadBackendVersion();
connectWebSocket();
loadDevices();
// Keep polling as fallback (reduced frequency since WebSocket handles most updates)
setInterval(loadDevices, 5000);
</script>
</body>
</html>