543 lines
24 KiB
HTML
543 lines
24 KiB
HTML
<!DOCTYPE html>
|
|
<html>
|
|
|
|
<head>
|
|
<title>Exercise Bike Display</title>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<link href="https://unpkg.com/tailwindcss@^1.0/dist/tailwind.min.css" rel="stylesheet" />
|
|
<script src="{{ url_for('static', filename='js/bikes.js') }}"></script>
|
|
|
|
</head>
|
|
|
|
<body class="bg-gray-200">
|
|
<nav class="bg-white shadow-lg">
|
|
<div class="container mx-auto px-4">
|
|
<div class="flex justify-between">
|
|
<div class="flex items-center">
|
|
<a class="text-gray-800 text-xl font-bold" href="/">
|
|
<div class="flex py-3">
|
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
|
stroke="currentColor" class="w-6 h-6 mr-2">
|
|
<path stroke-linecap="round" stroke-linejoin="round"
|
|
d="M9 15L3 9m0 0l6-6M3 9h12a6 6 0 010 12h-3" />
|
|
</svg>
|
|
|
|
<div>
|
|
{{ user.name }} - {{ user.bike.display_name }}
|
|
</div>
|
|
</div>
|
|
</a>
|
|
</div>
|
|
<div class="flex items-center">
|
|
<button>
|
|
|
|
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve"
|
|
class="inline w-12 h-12 text-gray-700 fill-current" id="heart_rate_button"
|
|
viewBox="0 0 33.087 33.087">
|
|
<path
|
|
d="M6.802 13.924h.007c.25.003.477.15.58.379l1.482 3.288 1.142-1.517a.617.617 0 0 1 .559-.256c.217.015.412.14.518.33l1.939 3.497 1.606-5.947a.643.643 0 0 1 1.192-.13l1.244 2.377 2.316-4.499a.658.658 0 0 1 .666-.344.647.647 0 0 1 .54.521l1.254 6.762 2.903-2.826a.628.628 0 0 1 .574-.17c.206.04.381.182.466.376l.859 1.988 1.407-4.622c.06-.193.215-.314.396-.383.013-.384.029-.766-.012-1.157-.698-6.77-8.258-6.665-11.898-3.399-3.64-3.266-11.195-3.37-11.897 3.399-.164 1.58.133 3.094.711 4.517l.865-1.817a.64.64 0 0 1 .581-.367z">
|
|
</path>
|
|
<path
|
|
d="M21.883 20.146a.643.643 0 0 1-1.083-.345l-1.13-6.092-2.023 3.927a.642.642 0 0 1-.568.349h-.001a.647.647 0 0 1-.571-.345l-1.058-2.018-1.591 5.893a.647.647 0 0 1-.621.476.64.64 0 0 1-.562-.332l-2.229-4.019-1.2 1.593a.642.642 0 0 1-1.101-.124L6.787 16.1l-.711 1.494c3.192 5.625 10.468 9.468 10.468 9.468s5.849-3.095 9.359-7.791l-.925-2.138-3.095 3.013zM4.637 17.621l-1.6-1.185a.641.641 0 0 0-.621-.083l-2.009.797a.643.643 0 1 0 .473 1.196l1.678-.664 1.94 1.436a.642.642 0 0 0 .964-.24l.612-1.286a12.902 12.902 0 0 1-.719-1.484l-.718 1.513zM32.441 17.105h-1.912l-1.242-3.977a.64.64 0 0 0-.614-.451h-.003c-.081 0-.146.044-.217.072-.076 2.377-1.104 4.584-2.55 6.521l.262.604c.106.248.353.419.627.387a.647.647 0 0 0 .58-.455l1.309-4.303.763 2.438a.64.64 0 0 0 .614.451h2.385a.645.645 0 0 0 .645-.645.646.646 0 0 0-.647-.642z">
|
|
</path>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
|
|
<div class="mx-auto max-w-md p-6 bg-white rounded-md shadow-md md:mt-5">
|
|
<div class="">
|
|
|
|
<div class="">
|
|
<div class="flex mb-4 h-12 leading-10">
|
|
<div class="w-1/3 text-lg font-medium">Time</div>
|
|
<div class="w-3/4 bg-gray-200 text-3xl font-bold pl-3 rounded-md" id="duration-display">00:00</div>
|
|
</div>
|
|
<!-- <div class="flex mb-4 h-12 leading-10">
|
|
<div class="w-1/3 text-lg font-medium">Distance</div>
|
|
<div class="w-3/4 bg-gray-200 text-3xl font-bold pl-3 rounded-md" id="distance-display">0.0</div>
|
|
</div>
|
|
<div class="flex mb-4 h-12 leading-10">
|
|
<div class="w-1/3 text-lg font-medium">Calories</div>
|
|
<div class="w-3/4 bg-gray-200 text-3xl font-bold pl-3 rounded-md" id="calories-display">0.0</div>
|
|
</div> -->
|
|
|
|
<div class="flex mb-4">
|
|
<div class="w-1/3 bg-gray-200 text-center mx-2 rounded-md">
|
|
<div class="flex flex-col justify-between">
|
|
<div class="pb-2 text-lg font-medium">Distance</div>
|
|
<div class="text-3xl font-bold pb-2" id="distance-display">0</div>
|
|
</div>
|
|
</div>
|
|
<div class="w-1/3 bg-gray-200 text-center mx-2 rounded-md">
|
|
<div class="flex flex-col justify-between">
|
|
<div class="pb-2 text-lg font-medium">Calories</div>
|
|
<div class="text-3xl font-bold pb-2" id="calories-display">0.0</div>
|
|
</div>
|
|
</div>
|
|
<div class="w-1/3 bg-gray-200 text-center mx-2 rounded-md">
|
|
<div class="flex flex-col justify-between">
|
|
<div class="pb-2 text-lg font-medium">Heart</div>
|
|
<div class="text-3xl font-bold pb-2" id="heart-display">0</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex mb-4">
|
|
<div class="w-1/3 bg-gray-200 text-center mx-2 rounded-md">
|
|
<div class="flex flex-col justify-between">
|
|
<div class="pb-2 text-lg font-medium">Watts</div>
|
|
<div class="text-3xl font-bold pb-2" id="watts-display">0</div>
|
|
</div>
|
|
</div>
|
|
<div class="w-1/3 bg-gray-200 text-center mx-2 rounded-md">
|
|
<div class="flex flex-col justify-between">
|
|
<div class="pb-2 text-lg font-medium">Speed</div>
|
|
<div class="text-3xl font-bold pb-2" id="speed-display">0.0</div>
|
|
</div>
|
|
</div>
|
|
<div class="w-1/3 bg-gray-200 text-center mx-2 rounded-md">
|
|
<div class="flex flex-col justify-between">
|
|
<div class="pb-2 text-lg font-medium">RPM</div>
|
|
<div class="text-3xl font-bold pb-2" id="rpm-display">0</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mt-4">
|
|
<div class="flex justify-center items-center bg-gray-200">
|
|
<svg id="graph" class="bg-white shadow-xl"></svg>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mt-4 sm:w-full">
|
|
<button id="toggle-button"
|
|
class="px-4 py-2 text-white bg-blue-500 rounded-md hover:bg-blue-600 w-full">Start</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="https://d3js.org/d3.v7.min.js"></script>
|
|
<script src="https://unpkg.com/sweetalert/dist/sweetalert.min.js"></script>
|
|
<script>
|
|
// Get DOM elements
|
|
const rpmDisplay = document.getElementById('rpm-display');
|
|
const durationDisplay = document.getElementById('duration-display');
|
|
const distanceDisplay = document.getElementById('distance-display');
|
|
const caloriesDisplay = document.getElementById('calories-display');
|
|
const wattsDisplay = document.getElementById('watts-display');
|
|
const speedDisplay = document.getElementById('speed-display');
|
|
const heartDisplay = document.getElementById('heart-display');
|
|
|
|
const toggleButton = document.getElementById('toggle-button');
|
|
// Initialize variables
|
|
let rpm = 0;
|
|
let distance = 0;
|
|
let calories = 0;
|
|
let isRunning = false;
|
|
let startTime = 0;
|
|
let intervalId = null;
|
|
let workoutData = [];
|
|
let previousReadingTime = null;
|
|
|
|
//Heart rate variables
|
|
const heartRateButton = document.getElementById('heart_rate_button');
|
|
|
|
let isHearRateMonitorActive = false;
|
|
let heartRateCharateristic = null;
|
|
let heartRateData = [];
|
|
|
|
let screenLock;
|
|
document.addEventListener('visibilitychange', async () => {
|
|
if (screenLock !== null && document.visibilityState === 'visible') {
|
|
screenLock = await navigator.wakeLock.request('screen');
|
|
}
|
|
});
|
|
|
|
|
|
|
|
const integerNumber = (num) => parseInt(num);
|
|
const decimalNumber = (num) => parseFloat(num.toFixed(1));
|
|
|
|
function updateBikeDisplay(distance, calories, power, speed, rpm) {
|
|
distanceDisplay.textContent = decimalNumber(distance);
|
|
caloriesDisplay.textContent = decimalNumber(calories);
|
|
wattsDisplay.textContent = integerNumber(power);
|
|
speedDisplay.textContent = decimalNumber(speed);
|
|
rpmDisplay.textContent = integerNumber(rpm);
|
|
}
|
|
|
|
// Update Duration
|
|
function updateDuration() {
|
|
const now = new Date();
|
|
const diff = Math.abs(now - startTime) / 1000; // get difference in seconds
|
|
let hours = Math.floor(diff / (60 * 60));
|
|
let minutes = Math.floor(diff / 60) % 60;
|
|
let seconds = Math.floor(diff % 60);
|
|
|
|
let timeSegments = [];
|
|
if (hours > 0) {
|
|
timeSegments.push(hours);
|
|
}
|
|
timeSegments.push(minutes.toString().padStart(2, '0'));
|
|
timeSegments.push(seconds.toString().padStart(2, '0'));
|
|
|
|
durationDisplay.textContent = timeSegments.join(':'); // format as "1:32"
|
|
}
|
|
|
|
// Start workout
|
|
function startWorkout() {
|
|
// Connect to cadence sensor
|
|
connect({
|
|
onChange: parseCSC,
|
|
}).then(() => {
|
|
isRunning = true;
|
|
startTime = Date.now();
|
|
toggleButton.textContent = 'Stop';
|
|
intervalId = setInterval(() => {
|
|
updateDuration();
|
|
}, 1000);
|
|
})
|
|
.catch((err) => {
|
|
swal("Oops", err.message, "error");
|
|
});
|
|
}
|
|
|
|
// Stop workout
|
|
function stopWorkout() {
|
|
isRunning = false;
|
|
clearInterval(intervalId);
|
|
toggleButton.textContent = 'Start';
|
|
// Disconnect from cadence sensor
|
|
disconnect();
|
|
// Disconnect from heart rate sensor
|
|
disconnectHeartRateMonitor();
|
|
|
|
fetch("{{ url_for('create_workout', user_id=user.id) }}", {
|
|
method: "POST",
|
|
headers: {
|
|
Accept: "application/json",
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({ cadence_readings: workoutData, heart_rate_readings: heartRateData }),
|
|
}).then(res => res.text())
|
|
.then(txt => {
|
|
workoutData = [];
|
|
swal("Submitted", txt, "success");
|
|
})
|
|
.catch(err => swal("Failed to submit workout", err.message, "error"));
|
|
|
|
}
|
|
|
|
// Toggle workout
|
|
function toggleWorkout() {
|
|
if (isRunning) {
|
|
stopWorkout();
|
|
} else {
|
|
startWorkout();
|
|
}
|
|
}
|
|
|
|
// Attach event listener to toggle button
|
|
toggleButton.addEventListener('click', toggleWorkout);
|
|
|
|
// Set up the live plot
|
|
const svg = d3.select('#graph');
|
|
|
|
// Set the SVG viewBox and dimensions
|
|
svg.attr('viewBox', '0 0 600 400');
|
|
svg.attr('width', '100%');
|
|
svg.attr('height', '100%');
|
|
|
|
// Set up the x-axis and y-axis scales
|
|
const xScale = d3.scaleTime().range([50, 550]);
|
|
const yScale = d3.scaleLinear().range([350, 50]);
|
|
|
|
// Set up the x-axis and y-axis
|
|
const xAxis = d3.axisBottom(xScale).tickSize(0).tickFormat('');
|
|
const yAxis = d3.axisLeft(yScale).tickSize(0).tickFormat('');
|
|
|
|
// Add the x-axis and y-axis to the SVG
|
|
svg.append('g')
|
|
.attr('transform', 'translate(0, 350)')
|
|
.call(xAxis);
|
|
|
|
svg.append('g')
|
|
.attr('transform', 'translate(50, 0)')
|
|
.call(yAxis);
|
|
|
|
// Set up the line function
|
|
const line = d3.line()
|
|
.x((d) => xScale(d.timestamp))
|
|
.curve(d3.curveCardinal.tension(0.25));
|
|
|
|
// Create an empty data array
|
|
let data = [];
|
|
|
|
// Set up the graph update function
|
|
const updateGraph = () => {
|
|
// Update the x-axis and y-axis scales
|
|
const combinedData = heartRateData.concat(workoutData);
|
|
const maxValue = Math.max(...combinedData.flatMap(v => [v.rpm, v.bpm].filter(v => v)));
|
|
xScale.domain(d3.extent(combinedData, d => d.timestamp));
|
|
yScale.domain([0, maxValue]);
|
|
|
|
// Remove the old line path from the SVG
|
|
svg.selectAll('path').remove();
|
|
|
|
// Add a new line path to the SVG with the updated data
|
|
const workoutPath = svg.selectAll(".workout-line").data([workoutData]);
|
|
workoutPath.enter()
|
|
.append("path")
|
|
.attr("class", "workout-line")
|
|
.attr('fill', 'none')
|
|
.attr('stroke', 'blue')
|
|
.attr('stroke-width', '3')
|
|
.merge(workoutPath)
|
|
.attr("d", line.y(d => yScale(d.rpm)));
|
|
|
|
const heartRatePath = svg.selectAll(".heart-rate-line").data([heartRateData]);
|
|
heartRatePath.enter()
|
|
.append("path")
|
|
.attr("class", "heart-rate-line")
|
|
.attr('fill', 'none')
|
|
.attr('stroke', 'red')
|
|
.attr('stroke-width', '3')
|
|
.merge(heartRatePath)
|
|
.attr("d", line.y(d => yScale(d.bpm)));
|
|
};
|
|
|
|
// Call the updateGraph function to start the live updates
|
|
updateGraph();
|
|
|
|
|
|
// BLE code
|
|
let characteristic = null;
|
|
let prevRes = null;
|
|
|
|
function delay(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
async function connect(props) {
|
|
const device = await navigator.bluetooth.requestDevice({
|
|
filters: [{ services: ["cycling_speed_and_cadence"] }],
|
|
acceptAllDevices: false,
|
|
});
|
|
console.log(`%c\n👩🏼⚕️`, "font-size: 82px;", "Starting CSC...\n\n");
|
|
const server = await device.gatt.connect();
|
|
await delay(500);
|
|
console.log("Getting Service...");
|
|
const service = await server.getPrimaryService("cycling_speed_and_cadence");
|
|
console.log("Getting Characteristic...");
|
|
characteristic = await service.getCharacteristic("csc_measurement");
|
|
await characteristic.startNotifications();
|
|
console.log("> Notifications started");
|
|
characteristic.addEventListener("characteristicvaluechanged", props.onChange);
|
|
console.log("> Characteristic value changed event listener added");
|
|
}
|
|
|
|
async function disconnect() {
|
|
if (characteristic) {
|
|
try {
|
|
await characteristic.stopNotifications();
|
|
console.log("> Notifications stopped");
|
|
characteristic.removeEventListener(
|
|
"characteristicvaluechanged",
|
|
parseCSC
|
|
);
|
|
} catch (error) {
|
|
console.log("Argh! " + error);
|
|
swal("Oops", error, "error");
|
|
}
|
|
}
|
|
}
|
|
|
|
function parseCSC(e) {
|
|
const value = e.target.value;
|
|
const flags = value.getUint8(0, true);
|
|
const hasWheel = !!(flags & 0x01);
|
|
const hasCrank = !!(flags & 0x02);
|
|
let index = 1;
|
|
const res = {
|
|
wheelRevs: null,
|
|
wheelTime: null,
|
|
crankRevs: null,
|
|
crankTime: null,
|
|
};
|
|
if (hasWheel) {
|
|
res.wheelRevs = value.getUint32(index, true);
|
|
index += 4;
|
|
res.wheelTime = value.getUint16(index, true);
|
|
index += 2;
|
|
}
|
|
if (hasCrank) {
|
|
res.crankRevs = value.getUint16(index, true);
|
|
index += 2;
|
|
res.crankTime = value.getUint16(index, true);
|
|
index += 2;
|
|
}
|
|
if (prevRes) {
|
|
let rpm = revsToRPM(prevRes, res);
|
|
if (rpm > 0) {
|
|
const newData = {
|
|
x: data.length,
|
|
y: decimalNumber(rpm),
|
|
};
|
|
|
|
// Add the new data point to the data array
|
|
data.push(newData);
|
|
|
|
let power = generators['{{ user.bike.code_name }}'].rpm.power(rpm)
|
|
let speed = generators['{{ user.bike.code_name }}'].rpm.speed(rpm)
|
|
if (previousReadingTime) {
|
|
distance += calculateDistance(previousReadingTime, new Date(), speed)
|
|
calories += calculateCalories(previousReadingTime, new Date(), power)
|
|
}
|
|
|
|
previousReadingTime = new Date();
|
|
updateBikeDisplay(distance, calories, power, speed, rpm);
|
|
updateGraph();
|
|
|
|
console.log("rpm", rpm);
|
|
|
|
workoutData.push({ distance, calories, power, speed, rpm, timestamp: new Date() })
|
|
}
|
|
}
|
|
prevRes = res;
|
|
return res;
|
|
}
|
|
|
|
function revsToRPM(prevRes, res) {
|
|
const deltaRevs = res.crankRevs - prevRes.crankRevs;
|
|
if (deltaRevs === 0) {
|
|
// no rotation
|
|
return 0;
|
|
}
|
|
|
|
let deltaTime = (res.crankTime - prevRes.crankTime) / 1024;
|
|
if (deltaTime < 0) {
|
|
// time counter wraparound
|
|
deltaTime += Math.pow(2, 16) / 1024;
|
|
}
|
|
deltaTime /= 60; // seconds to minutes
|
|
|
|
const rpm = deltaRevs / deltaTime;
|
|
return rpm;
|
|
}
|
|
|
|
function calculateDistance(startDate, endDate, speed) {
|
|
// Convert speed from km/h to km/ms
|
|
const speedPerMs = speed / (60 * 60 * 1000);
|
|
|
|
// Calculate the time difference between the start and end dates in milliseconds
|
|
const timeDiffMs = endDate.getTime() - startDate.getTime();
|
|
|
|
// Calculate the distance traveled based on speed and time difference
|
|
const distance = speedPerMs * timeDiffMs;
|
|
|
|
return distance;
|
|
}
|
|
|
|
function calculateCalories(startDate, endDate, power) {
|
|
// Calculate the time difference between the start and end dates in seconds
|
|
const timeDiffSec = (endDate.getTime() - startDate.getTime()) / 1000;
|
|
|
|
// Calculate the energy(joules) expended based on power and time difference
|
|
const energy = power * timeDiffSec;
|
|
|
|
//Convert joules to calories
|
|
return (energy * 0.238902957619) / 1000;
|
|
}
|
|
|
|
// Heart Rate monitor code
|
|
|
|
heartRateButton.addEventListener('click', async () => {
|
|
if (isHearRateMonitorActive) {
|
|
console.log('Stopping heart rate monitor...');
|
|
await disconnectHeartRateMonitor();
|
|
|
|
isHearRateMonitorActive = false;
|
|
heartRateButton.classList.remove('text-red-800');
|
|
heartRateButton.classList.add('text-gray-700');
|
|
} else {
|
|
console.log('Starting heart rate monitor...');
|
|
await heartRateMonitorConnect();
|
|
|
|
isHearRateMonitorActive = true;
|
|
heartRateButton.classList.remove('text-gray-700');
|
|
heartRateButton.classList.add('text-red-800');
|
|
}
|
|
});
|
|
|
|
function parseHeartRate(value) {
|
|
const flags = value.getUint8(0);
|
|
const rate16Bits = flags & 0x1;
|
|
let heartRate;
|
|
|
|
if (rate16Bits) {
|
|
heartRate = value.getUint16(1, /*littleEndian=*/ true);
|
|
} else {
|
|
heartRate = value.getUint8(1);
|
|
}
|
|
|
|
return heartRate;
|
|
}
|
|
|
|
|
|
function heartRateChange(event) {
|
|
const currentHeartRate = parseHeartRate(event.target.value);
|
|
heartRateData = [...heartRateData, { timestamp: new Date(), bpm: currentHeartRate }];
|
|
console.log('currentHeartRate:', currentHeartRate);
|
|
heartDisplay.innerText = currentHeartRate;
|
|
updateGraph();
|
|
}
|
|
|
|
function heartRateMonitorConnect() {
|
|
return navigator.bluetooth.requestDevice({ filters: [{ services: ['heart_rate'] }] })
|
|
.then(device => {
|
|
return device.gatt.connect();
|
|
})
|
|
.then(server => {
|
|
return server.getPrimaryService('heart_rate')
|
|
})
|
|
.then(service => {
|
|
return service.getCharacteristic('heart_rate_measurement')
|
|
})
|
|
.then(character => {
|
|
heartRateCharateristic = character;
|
|
return heartRateCharateristic.startNotifications().then(_ => {
|
|
heartRateCharateristic.addEventListener('characteristicvaluechanged', heartRateChange);
|
|
})
|
|
.catch(e => console.error(e));
|
|
})
|
|
};
|
|
|
|
async function disconnectHeartRateMonitor() {
|
|
if (heartRateCharateristic) {
|
|
try {
|
|
await heartRateCharateristic.stopNotifications();
|
|
console.log("> Notifications stopped");
|
|
heartRateCharateristic.removeEventListener(
|
|
"characteristicvaluechanged", heartRateChange);
|
|
|
|
// Disconnect from GATT server
|
|
const device = heartRateCharateristic.service.device;
|
|
if (device.gatt.connected) {
|
|
device.gatt.disconnect();
|
|
console.log('Disconnected from heart rate monitor');
|
|
}
|
|
} catch (error) {
|
|
console.log("Argh! " + error);
|
|
swal("Oops", error, "error");
|
|
}
|
|
}
|
|
}
|
|
|
|
</script>
|
|
|
|
</body>
|
|
|
|
</html> |