Live plot cadence and heart rate when both devices are connected and display heart rate graph in workout view

This commit is contained in:
Peter Stockings
2023-05-07 21:36:48 +10:00
parent 43e5f66cc1
commit 01078d6b08
3 changed files with 72 additions and 25 deletions

12
app.py
View File

@@ -125,7 +125,7 @@ def workouts(user_id):
app.logger.info(f'Creating workout for user {user.name} ({user.id})') app.logger.info(f'Creating workout for user {user.name} ({user.id})')
data = request.json data = request.json
workout = data['workout'] or [] workout_data = data['workout'] or []
heart_rate = data['heart_rate'] or [] heart_rate = data['heart_rate'] or []
# create a new workout # create a new workout
@@ -134,10 +134,10 @@ def workouts(user_id):
db.session.commit() db.session.commit()
app.logger.info( app.logger.info(
f'Workout({workout.id}) created for user {user.name} ({user.id}) with {len(workout)} cadence readings and {len(heart_rate)} heart rate readings') f'Workout({workout.id}) created for user {user.name} ({user.id}) with {len(workout_data)} cadence readings and {len(heart_rate)} heart rate readings')
# add cadence readings to the workout # add cadence readings to the workout
for w in workout: for w in workout_data:
cadence_reading = CadenceReading( cadence_reading = CadenceReading(
workout_id=workout.id, created_at=w['timestamp'], rpm=w['rpm'], distance=w['distance'], speed=w['speed'], calories=w['calories'], power=w['power']) workout_id=workout.id, created_at=w['timestamp'], rpm=w['rpm'], distance=w['distance'], speed=w['speed'], calories=w['calories'], power=w['power'])
db.session.add(cadence_reading) db.session.add(cadence_reading)
@@ -176,6 +176,12 @@ def workout(user_id, workout_id, graph_type):
elif graph_type == 'power': elif graph_type == 'power':
y_values = [reading.power for reading in cadence_readings] y_values = [reading.power for reading in cadence_readings]
return create_graph(x_values=x_values, y_values=y_values, y_label='Power (WATTS)', filename='power'), 200 return create_graph(x_values=x_values, y_values=y_values, y_label='Power (WATTS)', filename='power'), 200
heart_rate_readings = HeartRateReading.query.filter_by(
workout_id=workout_id).all()
if heart_rate_readings:
x_values = [reading.created_at for reading in heart_rate_readings]
y_values = [reading.bpm for reading in heart_rate_readings]
return create_graph(x_values=x_values, y_values=y_values, y_label='Heart Rate (BPM)', filename='heart_rate'), 200
else: else:
return jsonify({'message': 'No cadence readings for workout {}.'.format(workout_id)}), 404 return jsonify({'message': 'No cadence readings for workout {}.'.format(workout_id)}), 404
else: else:

View File

@@ -32,7 +32,7 @@
<button> <button>
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" <svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve"
class="inline w-12 h-12 text-red-800 fill-current" id="heart_rate_button" class="inline w-12 h-12 text-gray-700 fill-current" id="heart_rate_button"
viewBox="0 0 33.087 33.087"> viewBox="0 0 33.087 33.087">
<path <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"> 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">
@@ -55,13 +55,34 @@
<div class="w-1/3 text-lg font-medium">Time</div> <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 class="w-3/4 bg-gray-200 text-3xl font-bold pl-3 rounded-md" id="duration-display">00:00</div>
</div> </div>
<div class="flex mb-4 h-12 leading-10"> <!-- <div class="flex mb-4 h-12 leading-10">
<div class="w-1/3 text-lg font-medium">Distance</div> <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 class="w-3/4 bg-gray-200 text-3xl font-bold pl-3 rounded-md" id="distance-display">0.0</div>
</div> </div>
<div class="flex mb-4 h-12 leading-10"> <div class="flex mb-4 h-12 leading-10">
<div class="w-1/3 text-lg font-medium">Calories</div> <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 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>
<div class="flex mb-4"> <div class="flex mb-4">
@@ -108,6 +129,7 @@
const caloriesDisplay = document.getElementById('calories-display'); const caloriesDisplay = document.getElementById('calories-display');
const wattsDisplay = document.getElementById('watts-display'); const wattsDisplay = document.getElementById('watts-display');
const speedDisplay = document.getElementById('speed-display'); const speedDisplay = document.getElementById('speed-display');
const heartDisplay = document.getElementById('heart-display');
const toggleButton = document.getElementById('toggle-button'); const toggleButton = document.getElementById('toggle-button');
// Initialize variables // Initialize variables
@@ -117,9 +139,16 @@
let isRunning = false; let isRunning = false;
let startTime = 0; let startTime = 0;
let intervalId = null; let intervalId = null;
let workout = []; let workoutData = [];
let previousReadingTime = null; let previousReadingTime = null;
//Heart rate variables
const heartRateButton = document.getElementById('heart_rate_button');
let isHearRateMonitorActive = false;
let heartRateCharateristic = null;
let heartRateData = [];
let screenLock; let screenLock;
document.addEventListener('visibilitychange', async () => { document.addEventListener('visibilitychange', async () => {
if (screenLock !== null && document.visibilityState === 'visible') { if (screenLock !== null && document.visibilityState === 'visible') {
@@ -192,10 +221,10 @@
Accept: "application/json", Accept: "application/json",
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ workout: workout, heart_rate: heartRateData }), body: JSON.stringify({ workout: workoutData, heart_rate: heartRateData }),
}).then(res => res.json()) }).then(res => res.json())
.then(res => { .then(res => {
workout = []; workoutData = [];
swal("Submitted", JSON.stringify(res), "success").then(isConfirm => window.location.href = '/'); swal("Submitted", JSON.stringify(res), "success").then(isConfirm => window.location.href = '/');
}) })
.catch(err => swal("Failed to submit workout", err.message, "error")); .catch(err => swal("Failed to submit workout", err.message, "error"));
@@ -223,7 +252,7 @@
svg.attr('height', '100%'); svg.attr('height', '100%');
// Set up the x-axis and y-axis scales // Set up the x-axis and y-axis scales
const xScale = d3.scaleLinear().range([50, 550]); const xScale = d3.scaleTime().range([50, 550]);
const yScale = d3.scaleLinear().range([350, 50]); const yScale = d3.scaleLinear().range([350, 50]);
// Set up the x-axis and y-axis // Set up the x-axis and y-axis
@@ -241,8 +270,7 @@
// Set up the line function // Set up the line function
const line = d3.line() const line = d3.line()
.x((d) => xScale(d.x)) .x((d) => xScale(d.timestamp))
.y((d) => yScale(d.y))
.curve(d3.curveCardinal.tension(0.25)); .curve(d3.curveCardinal.tension(0.25));
// Create an empty data array // Create an empty data array
@@ -251,19 +279,34 @@
// Set up the graph update function // Set up the graph update function
const updateGraph = () => { const updateGraph = () => {
// Update the x-axis and y-axis scales // Update the x-axis and y-axis scales
xScale.domain([0, data.length]); const combinedData = heartRateData.concat(workoutData);
yScale.domain([0, d3.max(data, d => d.y)]); 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 // Remove the old line path from the SVG
svg.selectAll('path').remove(); svg.selectAll('path').remove();
// Add a new line path to the SVG with the updated data // Add a new line path to the SVG with the updated data
svg.append('path') const workoutPath = svg.selectAll(".workout-line").data([workoutData]);
.datum(data) workoutPath.enter()
.attr('d', line) .append("path")
.attr("class", "workout-line")
.attr('fill', 'none') .attr('fill', 'none')
.attr('stroke', 'blue') .attr('stroke', 'blue')
.attr('stroke-width', '2'); .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 // Call the updateGraph function to start the live updates
@@ -336,7 +379,6 @@
res.crankTime = value.getUint16(index, true); res.crankTime = value.getUint16(index, true);
index += 2; index += 2;
} }
console.log("CSC", res);
if (prevRes) { if (prevRes) {
let rpm = revsToRPM(prevRes, res); let rpm = revsToRPM(prevRes, res);
if (rpm > 0) { if (rpm > 0) {
@@ -359,7 +401,9 @@
updateBikeDisplay(distance, calories, power, speed, rpm); updateBikeDisplay(distance, calories, power, speed, rpm);
updateGraph(); updateGraph();
workout.push({ distance, calories, power, speed, rpm, timestamp: new Date() }) console.log("rpm", rpm);
workoutData.push({ distance, calories, power, speed, rpm, timestamp: new Date() })
} }
} }
prevRes = res; prevRes = res;
@@ -409,11 +453,6 @@
} }
// Heart Rate monitor code // Heart Rate monitor code
const heartRateButton = document.getElementById('heart_rate_button');
let isHearRateMonitorActive = false;
let heartRateCharateristic = null;
let heartRateData = [];
heartRateButton.addEventListener('click', async () => { heartRateButton.addEventListener('click', async () => {
console.log('heartRateButton clicked, isHearRateMonitorActive: ', isHearRateMonitorActive); console.log('heartRateButton clicked, isHearRateMonitorActive: ', isHearRateMonitorActive);
@@ -451,8 +490,9 @@
function heartRateChange(event) { function heartRateChange(event) {
const currentHeartRate = parseHeartRate(event.target.value); const currentHeartRate = parseHeartRate(event.target.value);
heartRateData = [...heartRateData, { timestamp: new Date(), heartRate: currentHeartRate }]; heartRateData = [...heartRateData, { timestamp: new Date(), bpm: currentHeartRate }];
console.log('currentHeartRate:', currentHeartRate); console.log('currentHeartRate:', currentHeartRate);
heartDisplay.innerText = currentHeartRate;
} }
function heartRateMonitorConnect() { function heartRateMonitorConnect() {

View File

@@ -8,6 +8,7 @@
<option value="power" {% if 'power' in graph_types %} selected {% endif%}>Power</option> <option value="power" {% if 'power' in graph_types %} selected {% endif%}>Power</option>
<option value="distance" {% if 'distance' in graph_types %} selected {% endif%}>Distance</option> <option value="distance" {% if 'distance' in graph_types %} selected {% endif%}>Distance</option>
<option value="calories" {% if 'calories' in graph_types %} selected {% endif%}>Calories</option> <option value="calories" {% if 'calories' in graph_types %} selected {% endif%}>Calories</option>
<option value="heart_rate" {% if 'heart_rate' in graph_types %} selected {% endif%}>Heart Rate</option>
</select> </select>
</div> </div>
</div> </div>