Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- <!doctype html>
- <html>
- <head>
- <meta charset="UTF-8" />
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
- <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net https://unpkg.com; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://unpkg.com; connect-src 'self' https://cdn.jsdelivr.net https://unpkg.com" />
- <title>FTMS Treadmill Dashboard</title>
- <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
- <script src="https://unpkg.com/lucide@latest"></script>
- <style type="text/tailwindcss">
- @theme {
- --color-primary: #3b82f6;
- --color-success: #10b981;
- --color-warning: #f59e0b;
- --color-danger: #ef4444;
- }
- body {
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
- min-height: 100vh;
- }
- .metric-card {
- backdrop-filter: blur(10px);
- background: rgba(255, 255, 255, 0.95);
- transition: all 0.3s ease;
- }
- .metric-card:hover {
- transform: translateY(-4px);
- box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
- }
- .pulse-animation {
- animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
- }
- @keyframes pulse {
- 0%, 100% {
- opacity: 1;
- }
- 50% {
- opacity: .7;
- }
- }
- </style>
- </head>
- <body class="p-4 md:p-8">
- <div class="max-w-7xl mx-auto">
- <!-- Header -->
- <div class="text-center mb-6 md:mb-8">
- <h1 class="text-2xl md:text-5xl font-bold text-white mb-4 drop-shadow-lg">
- Treadmill Dashboard
- </h1>
- </div>
- <!-- Connection Card -->
- <div class="bg-white rounded-2xl shadow-2xl p-6 mb-6 md:mb-8">
- <div class="flex items-center justify-between flex-wrap gap-4">
- <div class="flex items-center gap-3">
- <div id="connectionStatus" class="w-3 h-3 rounded-full bg-gray-400"></div>
- <span id="connectionText" class="text-gray-600 font-medium">Not connected</span>
- </div>
- <button id="bleConnectionButton" class="bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white font-bold py-3 px-8 rounded-xl shadow-lg transition-all duration-300 hover:scale-105 flex items-center gap-2">
- <i data-lucide="bluetooth" class="w-5 h-5"></i>
- Connect Treadmill
- </button>
- </div>
- </div>
- <!-- Control Buttons -->
- <div class="bg-white rounded-2xl shadow-2xl p-4 mb-6 md:mb-8">
- <div class="flex items-center justify-center flex-wrap gap-2 mb:gap-4">
- <button id="startStopButton" disabled class="bg-green-500 hover:bg-green-600 disabled:bg-gray-300 disabled:cursor-not-allowed text-white font-bold py-3 px-3 md:px-6 rounded-xl shadow-lg transition-all duration-300 hover:scale-105 flex items-center gap-2">
- <i data-lucide="play" class="w-5 h-5"></i>
- Start
- </button>
- <button id="pauseUnpauseButton" disabled class="bg-yellow-500 hover:bg-yellow-600 disabled:bg-gray-300 disabled:cursor-not-allowed text-white font-bold py-3 px-3 md:px-6 rounded-xl shadow-lg transition-all duration-300 hover:scale-105 flex items-center gap-2">
- <i data-lucide="pause" class="w-5 h-5"></i>
- Pause
- </button>
- <button id="decreaseSpeedButton" disabled class="bg-orange-500 hover:bg-orange-600 disabled:bg-gray-300 disabled:cursor-not-allowed text-white font-bold py-3 px-3 md:px-6 rounded-xl shadow-lg transition-all duration-300 hover:scale-105 flex items-center gap-2">
- <i data-lucide="minus" class="w-5 h-5"></i>
- 0.5 <span class="hidden md:inline">km/h</span>
- </button>
- <button id="increaseSpeedButton" disabled class="bg-blue-500 hover:bg-blue-600 disabled:bg-gray-300 disabled:cursor-not-allowed text-white font-bold py-3 px-3 md:px-6 rounded-xl shadow-lg transition-all duration-300 hover:scale-105 flex items-center gap-2">
- <i data-lucide="plus" class="w-5 h-5"></i>
- 0.5 <span class="hidden md:inline">km/h</span>
- </button>
- </div>
- </div>
- <!-- Metrics Grid -->
- <div class="grid grid-cols-2 md:grid-cols-4 gap-4 md:gap-6">
- <!-- Speed Card -->
- <div class="metric-card rounded-2xl shadow-xl p-6">
- <div class="flex items-center justify-between mb-4">
- <div class="bg-blue-100 p-3 rounded-xl">
- <i data-lucide="gauge" class="w-8 h-8 text-blue-600"></i>
- </div>
- <span class="text-sm font-medium text-gray-500">Speed</span>
- </div>
- <div class="text-4xl font-bold text-gray-800" id="speedValue">0.0</div>
- <div class="text-sm text-gray-500 mt-1">km/h</div>
- </div>
- <!-- Distance Card -->
- <div class="metric-card rounded-2xl shadow-xl p-6">
- <div class="flex items-center justify-between mb-4">
- <div class="bg-green-100 p-3 rounded-xl">
- <i data-lucide="map-pin" class="w-8 h-8 text-green-600"></i>
- </div>
- <span class="text-sm font-medium text-gray-500">Distance</span>
- </div>
- <div class="text-4xl font-bold text-gray-800" id="distanceValue">0</div>
- <div class="text-sm text-gray-500 mt-1">meters</div>
- </div>
- <!-- Calories Card -->
- <div class="metric-card rounded-2xl shadow-xl p-6">
- <div class="flex items-center justify-between mb-4">
- <div class="bg-orange-100 p-3 rounded-xl">
- <i data-lucide="flame" class="w-8 h-8 text-orange-600"></i>
- </div>
- <span class="text-sm font-medium text-gray-500">Calories</span>
- </div>
- <div class="text-4xl font-bold text-gray-800" id="kcalValue">0</div>
- <div class="text-sm text-gray-500 mt-1">kcal</div>
- </div>
- <!-- Time Card -->
- <div class="metric-card rounded-2xl shadow-xl p-6">
- <div class="flex items-center justify-between mb-4">
- <div class="bg-indigo-100 p-3 rounded-xl">
- <i data-lucide="timer" class="w-8 h-8 text-indigo-600"></i>
- </div>
- <span class="text-sm font-medium text-gray-500">Time</span>
- </div>
- <div class="text-4xl font-bold text-gray-800" id="timeValue">00:00</div>
- <div class="text-sm text-gray-500 mt-1">minutes</div>
- </div>
- </div>
- </div>
- </body>
- <script>
- const serviceFTMS = 0x1826;
- const treadmillData = 0x2acd;
- const treadmillControl = 0x2ad9;
- const fitnessMachineStatus = 0x2ada;
- let controlPointCharacteristic = null;
- let isRunning = false;
- let isPaused = false;
- let currentSpeed = 2.0;
- // Client-side state management
- // NOTE: We manage state client-side because the treadmill's pause/resume commands
- // don't work as expected (pause seems to fully stop/reset instead of preserving session).
- // So we track accumulated values ourselves and only reset on "stop".
- let accumulatedState = {
- distance: 0, // meters
- calories: 0, // kcal
- time: 0, // seconds
- speed: 2.0 // km/h - saved speed for unpause
- };
- let lastDeviceValues = {
- distance: 0,
- calories: 0,
- time: 0
- };
- // Initialize Lucide icons
- document.addEventListener('DOMContentLoaded', () => {
- lucide.createIcons();
- });
- // Helper function to format time
- function formatTime(seconds) {
- const mins = Math.floor(seconds / 60);
- const secs = seconds % 60;
- return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
- }
- // Helper function to update UI
- function updateMetric(elementId, value) {
- const element = document.getElementById(elementId);
- if (element) {
- element.textContent = value;
- }
- }
- // Helper function to enable/disable control buttons
- function enableControlButtons(enabled) {
- document.getElementById('startStopButton').disabled = !enabled;
- document.getElementById('pauseUnpauseButton').disabled = !enabled;
- document.getElementById('increaseSpeedButton').disabled = !enabled;
- document.getElementById('decreaseSpeedButton').disabled = !enabled;
- if (enabled) {
- updatePauseUnpauseButton();
- }
- }
- // Update pause/unpause button appearance based on state
- function updatePauseUnpauseButton() {
- const pauseUnpauseButton = document.getElementById('pauseUnpauseButton');
- if (isPaused) {
- // Currently paused: show "Unpause" button
- pauseUnpauseButton.innerHTML = '<i data-lucide="play" class="w-5 h-5"></i> Unpause';
- pauseUnpauseButton.className = 'bg-green-500 hover:bg-green-600 text-white font-bold py-3 px-6 rounded-xl shadow-lg transition-all duration-300 hover:scale-105 flex items-center gap-2';
- } else {
- // Not paused: show "Pause" button
- pauseUnpauseButton.innerHTML = '<i data-lucide="pause" class="w-5 h-5"></i> Pause';
- pauseUnpauseButton.className = 'bg-yellow-500 hover:bg-yellow-600 text-white font-bold py-3 px-6 rounded-xl shadow-lg transition-all duration-300 hover:scale-105 flex items-center gap-2';
- }
- lucide.createIcons();
- }
- // Command functions
- async function sendCommand(data) {
- try {
- const result = await controlPointCharacteristic.writeValueWithResponse(data);
- console.log('Command sent successfully');
- return result;
- } catch (error) {
- console.error('Error sending command:', error);
- throw error;
- }
- }
- async function requestControl() {
- const data = new Uint8Array([0]);
- await sendCommand(data);
- }
- async function startTreadmill() {
- const data = new Uint8Array([7]);
- console.log('Sending START/RESUME command [7]');
- await sendCommand(data);
- }
- async function stopTreadmill() {
- // NOTE: We use STOP for both stop and pause since PAUSE command doesn't work properly
- const data = new Uint8Array([8, 1]);
- console.log('Sending STOP command [8, 1]');
- await sendCommand(data);
- }
- async function setSpeed(value) {
- const buffer = new ArrayBuffer(3);
- const view = new DataView(buffer);
- view.setUint8(0, 2);
- view.setUint16(1, value * 100, true);
- console.log('Setting speed to:', value, 'km/h');
- await sendCommand(buffer);
- }
- document.querySelector('#bleConnectionButton').addEventListener('click', function() {
- if (isWebBluetoothEnabled()) {
- connect();
- }
- });
- // Start/Stop button - toggles based on current running state
- document.getElementById('startStopButton').addEventListener('click', async function() {
- if (!isRunning) {
- // Start the treadmill at 2km/h (keeps accumulated state)
- console.log('Starting treadmill...');
- await startTreadmill();
- await setSpeed(2.0);
- currentSpeed = 2.0;
- accumulatedState.speed = 2.0;
- isRunning = true;
- isPaused = false;
- // Update button appearance
- this.innerHTML = '<i data-lucide="square" class="w-5 h-5"></i> Stop';
- this.className = 'bg-red-500 hover:bg-red-600 text-white font-bold py-3 px-6 rounded-xl shadow-lg transition-all duration-300 hover:scale-105 flex items-center gap-2';
- lucide.createIcons();
- updatePauseUnpauseButton();
- } else {
- // Stop the treadmill and RESET all accumulated state
- console.log('Stopping treadmill and resetting state...');
- await stopTreadmill();
- isRunning = false;
- isPaused = false;
- // Reset accumulated state
- accumulatedState = { distance: 0, calories: 0, time: 0, speed: 2.0 };
- lastDeviceValues = { distance: 0, calories: 0, time: 0 };
- // Update UI to show zeros
- updateMetric('distanceValue', 0);
- updateMetric('kcalValue', 0);
- updateMetric('timeValue', '00:00');
- currentSpeed = 2.0;
- // Update button appearance
- this.innerHTML = '<i data-lucide="play" class="w-5 h-5"></i> Start';
- this.className = 'bg-green-500 hover:bg-green-600 text-white font-bold py-3 px-6 rounded-xl shadow-lg transition-all duration-300 hover:scale-105 flex items-center gap-2';
- lucide.createIcons();
- updatePauseUnpauseButton();
- }
- });
- // Pause/Unpause button - toggles based on current paused state
- // NOTE: Treadmill doesn't have proper pause, so we fake it client-side
- document.getElementById('pauseUnpauseButton').addEventListener('click', async function() {
- if (!isPaused) {
- // Currently running: pause it
- console.log('Pausing (saving state including speed, sending stop command)...');
- // Save current accumulated values before stopping
- accumulatedState.distance += lastDeviceValues.distance;
- accumulatedState.calories += lastDeviceValues.calories;
- accumulatedState.time += lastDeviceValues.time;
- accumulatedState.speed = currentSpeed; // Save current speed
- lastDeviceValues = { distance: 0, calories: 0, time: 0 };
- await stopTreadmill();
- isPaused = true;
- isRunning = false;
- updatePauseUnpauseButton();
- } else {
- // Currently paused: unpause it
- console.log('Unpausing (starting treadmill at saved speed:', accumulatedState.speed, 'km/h)...');
- await startTreadmill();
- await setSpeed(accumulatedState.speed); // Restore saved speed
- currentSpeed = accumulatedState.speed;
- isPaused = false;
- isRunning = true;
- updatePauseUnpauseButton();
- }
- });
- // Increase speed button
- document.getElementById('increaseSpeedButton').addEventListener('click', async function() {
- if (isRunning && !isPaused) {
- currentSpeed += 0.5;
- accumulatedState.speed = currentSpeed; // Update saved speed
- await setSpeed(currentSpeed);
- }
- });
- // Decrease speed button
- document.getElementById('decreaseSpeedButton').addEventListener('click', async function() {
- if (isRunning && !isPaused && currentSpeed > 0.5) {
- currentSpeed -= 0.5;
- accumulatedState.speed = currentSpeed; // Update saved speed
- await setSpeed(currentSpeed);
- }
- });
- function isWebBluetoothEnabled() {
- if (navigator.bluetooth) {
- return true;
- } else {
- ChromeSamples.setStatus('Web Bluetooth API is not available.\n' +
- 'Please make sure the "Experimental Web Platform features" flag is enabled.');
- return false;
- }
- }
- async function connect() {
- try {
- document.getElementById('connectionText').textContent = 'Connecting...';
- document.getElementById('connectionStatus').className = 'w-3 h-3 rounded-full bg-yellow-400';
- const device = await navigator.bluetooth.requestDevice({
- filters:[{
- services: [serviceFTMS],
- }]
- });
- const server = await device.gatt.connect();
- const service = await server.getPrimaryService(serviceFTMS);
- const treadmillDataCharacteristic = await service.getCharacteristic(treadmillData);
- await treadmillDataCharacteristic.startNotifications();
- treadmillDataCharacteristic.addEventListener('characteristicvaluechanged', handleNotifications);
- controlPointCharacteristic = await service.getCharacteristic(treadmillControl);
- await requestControl();
- // Subscribe to Fitness Machine Status for automatic state detection
- try {
- const statusCharacteristic = await service.getCharacteristic(fitnessMachineStatus);
- await statusCharacteristic.startNotifications();
- statusCharacteristic.addEventListener('characteristicvaluechanged', handleStatusNotification);
- console.log('Subscribed to Fitness Machine Status');
- } catch (error) {
- console.log('Fitness Machine Status not available:', error);
- }
- document.getElementById('connectionText').textContent = 'Connected';
- document.getElementById('connectionStatus').className = 'w-3 h-3 rounded-full bg-green-500 animate-pulse';
- enableControlButtons(true);
- } catch (error) {
- console.error('Connection error:', error);
- document.getElementById('connectionText').textContent = 'Connection failed';
- document.getElementById('connectionStatus').className = 'w-3 h-3 rounded-full bg-red-500';
- }
- }
- function handleNotifications(event) {
- let value = event.target.value;
- var flags = value.getUint16(0, /*littleEndian=*/true);
- // 2octets for flags, 2octets for instant speed, nextPosition is incremented following the number of octets for each value
- var nextPosition = 4;
- if ((flags & (1 << 1)) != 0) {posAvgSpeed=nextPosition; nextPosition+=2;}
- if ((flags & (1 << 2)) != 0) {posTotDistance=nextPosition; nextPosition+=3;}//4
- if ((flags & (1 << 3)) != 0) {posInclination=nextPosition; nextPosition+=4;}//8
- if ((flags & (1 << 4)) != 0) {posElevGain=nextPosition; nextPosition+=4;}
- if ((flags & (1 << 5)) != 0) {posInsPace=nextPosition; nextPosition+=1;}
- if ((flags & (1 << 6)) != 0) {posAvgPace=nextPosition; nextPosition+=1;}
- if ((flags & (1 << 7)) != 0) {posKcal=nextPosition; nextPosition+=5;}
- if ((flags & (1 << 8)) != 0) {posHR=nextPosition; nextPosition+=1;}
- if ((flags & (1 << 9)) != 0) {posMET=nextPosition; nextPosition+=1;}
- if ((flags & (1 << 10)) != 0) {posElaspedTime=nextPosition; nextPosition+=2;}
- if ((flags & (1 << 11)) != 0) {posRemainTime=nextPosition; nextPosition+=2;}
- if ((flags & (1 << 12)) != 0) {posForceBelt=nextPosition; nextPosition+=4;}
- // instantaneous speed
- speed = value.getUint16(2, /*littleEndian=*/true)/100;
- updateMetric('speedValue', speed.toFixed(1));
- currentSpeed = speed;
- // Auto-detect start/stop state based on speed
- const startStopButton = document.getElementById('startStopButton');
- if (speed > 0 && !isRunning && !isPaused) {
- // Treadmill started externally, update button to "Stop"
- isRunning = true;
- startStopButton.innerHTML = '<i data-lucide="square" class="w-5 h-5"></i> Stop';
- startStopButton.className = 'bg-red-500 hover:bg-red-600 text-white font-bold py-3 px-6 rounded-xl shadow-lg transition-all duration-300 hover:scale-105 flex items-center gap-2';
- lucide.createIcons();
- } else if (speed === 0 && isRunning && !isPaused) {
- // Treadmill stopped externally, update button to "Start"
- isRunning = false;
- startStopButton.innerHTML = '<i data-lucide="play" class="w-5 h-5"></i> Start';
- startStopButton.className = 'bg-green-500 hover:bg-green-600 text-white font-bold py-3 px-6 rounded-xl shadow-lg transition-all duration-300 hover:scale-105 flex items-center gap-2';
- lucide.createIcons();
- }
- // Read device values
- //distance
- let deviceDistance = value.getUint16(posTotDistance, /*littleEndian=*/true);
- let distance_complement = value.getUint8(posTotDistance + 2, /*littleEndian=*/true);
- distance_complement = distance_complement << 16;
- deviceDistance = deviceDistance + distance_complement;
- lastDeviceValues.distance = deviceDistance;
- let deviceCalories = 0;
- if (typeof posKcal != "undefined")
- {
- deviceCalories = value.getUint16(posKcal, /*littleEndian=*/true);
- lastDeviceValues.calories = deviceCalories;
- }
- let deviceTime = 0;
- if (typeof posElaspedTime != "undefined")
- {
- deviceTime = value.getUint16(posElaspedTime, /*littleEndian=*/true);
- lastDeviceValues.time = deviceTime;
- }
- // Display accumulated + current device values
- // This way pause/unpause works correctly by keeping accumulated state
- updateMetric('distanceValue', accumulatedState.distance + deviceDistance);
- updateMetric('kcalValue', accumulatedState.calories + deviceCalories);
- updateMetric('timeValue', formatTime(accumulatedState.time + deviceTime));
- }
- // Handle Fitness Machine Status notifications (0x2ADA)
- // This automatically detects treadmill state changes
- function handleStatusNotification(event) {
- let value = event.target.value;
- const opCode = value.getUint8(0);
- console.log('Fitness Machine Status - Op Code:', '0x' + opCode.toString(16).padStart(2, '0'));
- const startStopButton = document.getElementById('startStopButton');
- switch(opCode) {
- case 0x01: // Reset
- console.log('Status: Reset');
- isRunning = false;
- isPaused = false;
- startStopButton.innerHTML = '<i data-lucide="play" class="w-5 h-5"></i> Start';
- startStopButton.className = 'bg-green-500 hover:bg-green-600 text-white font-bold py-3 px-6 rounded-xl shadow-lg transition-all duration-300 hover:scale-105 flex items-center gap-2';
- lucide.createIcons();
- updatePauseUnpauseButton();
- break;
- case 0x02: // Stopped or Paused by User
- if (value.byteLength > 1) {
- const controlInfo = value.getUint8(1);
- if (controlInfo === 0x01) {
- console.log('Status: Stopped by User');
- isRunning = false;
- isPaused = false;
- startStopButton.innerHTML = '<i data-lucide="play" class="w-5 h-5"></i> Start';
- startStopButton.className = 'bg-green-500 hover:bg-green-600 text-white font-bold py-3 px-6 rounded-xl shadow-lg transition-all duration-300 hover:scale-105 flex items-center gap-2';
- } else if (controlInfo === 0x02) {
- console.log('Status: Paused by User');
- isRunning = false;
- isPaused = true;
- }
- lucide.createIcons();
- updatePauseUnpauseButton();
- }
- break;
- case 0x03: // Stopped by Safety Key
- console.log('Status: Stopped by Safety Key');
- isRunning = false;
- isPaused = false;
- startStopButton.innerHTML = '<i data-lucide="play" class="w-5 h-5"></i> Start';
- startStopButton.className = 'bg-green-500 hover:bg-green-600 text-white font-bold py-3 px-6 rounded-xl shadow-lg transition-all duration-300 hover:scale-105 flex items-center gap-2';
- lucide.createIcons();
- updatePauseUnpauseButton();
- break;
- case 0x04: // Started or Resumed by User
- console.log('Status: Started or Resumed by User');
- isRunning = true;
- isPaused = false;
- startStopButton.innerHTML = '<i data-lucide="square" class="w-5 h-5"></i> Stop';
- startStopButton.className = 'bg-red-500 hover:bg-red-600 text-white font-bold py-3 px-6 rounded-xl shadow-lg transition-all duration-300 hover:scale-105 flex items-center gap-2';
- lucide.createIcons();
- updatePauseUnpauseButton();
- break;
- case 0xFF: // Control Permission Lost
- console.log('Status: Control Permission Lost');
- alert('Control permission lost! Another device may have taken control.');
- break;
- default:
- console.log('Status: Unknown Op Code 0x' + opCode.toString(16));
- break;
- }
- }
- </script>
- </html>
Advertisement
Add Comment
Please, Sign In to add comment