feat: record cadence from step detector during GPS workouts
All checks were successful
CI / update (push) Successful in 3m43s
All checks were successful
CI / update (push) Successful in 3m43s
Use Android TYPE_STEP_DETECTOR sensor in LocationForegroundService to count steps in a 15s rolling window. Cadence (spm) is computed at each GPS point and stored alongside lat/lng/altitude/speed. Session detail page shows cadence chart when data is available. No additional permissions required — step detector is not a restricted sensor. Gracefully skipped on devices without the sensor.
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "homepage",
|
"name": "homepage",
|
||||||
"version": "1.25.1",
|
"version": "1.25.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
@@ -144,7 +144,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bocken"
|
name = "bocken"
|
||||||
version = "0.2.1"
|
version = "0.4.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "bocken"
|
name = "bocken"
|
||||||
version = "0.3.0"
|
version = "0.4.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import android.app.PendingIntent
|
|||||||
import android.app.Service
|
import android.app.Service
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.hardware.Sensor
|
||||||
|
import android.hardware.SensorEvent
|
||||||
|
import android.hardware.SensorEventListener
|
||||||
|
import android.hardware.SensorManager
|
||||||
import android.location.LocationListener
|
import android.location.LocationListener
|
||||||
import android.location.LocationManager
|
import android.location.LocationManager
|
||||||
import android.media.AudioAttributes
|
import android.media.AudioAttributes
|
||||||
@@ -24,15 +28,22 @@ import org.json.JSONArray
|
|||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import java.util.Collections
|
import java.util.Collections
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
import java.util.concurrent.ConcurrentLinkedQueue
|
||||||
import kotlin.math.*
|
import kotlin.math.*
|
||||||
|
|
||||||
private const val TAG = "BockenTTS"
|
private const val TAG = "BockenTTS"
|
||||||
|
|
||||||
class LocationForegroundService : Service(), TextToSpeech.OnInitListener {
|
class LocationForegroundService : Service(), TextToSpeech.OnInitListener, SensorEventListener {
|
||||||
|
|
||||||
private var locationManager: LocationManager? = null
|
private var locationManager: LocationManager? = null
|
||||||
private var locationListener: LocationListener? = null
|
private var locationListener: LocationListener? = null
|
||||||
private var notificationManager: NotificationManager? = null
|
private var notificationManager: NotificationManager? = null
|
||||||
|
|
||||||
|
// Step detector for cadence
|
||||||
|
private var sensorManager: SensorManager? = null
|
||||||
|
private var stepDetector: Sensor? = null
|
||||||
|
private val stepTimestamps = ConcurrentLinkedQueue<Long>()
|
||||||
|
private val CADENCE_WINDOW_MS = 15_000L // 15 second rolling window
|
||||||
private var pendingIntent: PendingIntent? = null
|
private var pendingIntent: PendingIntent? = null
|
||||||
private var startTimeMs: Long = 0L
|
private var startTimeMs: Long = 0L
|
||||||
private var pausedAccumulatedMs: Long = 0L // total time spent paused
|
private var pausedAccumulatedMs: Long = 0L // total time spent paused
|
||||||
@@ -191,6 +202,36 @@ class LocationForegroundService : Service(), TextToSpeech.OnInitListener {
|
|||||||
|
|
||||||
override fun onBind(intent: Intent?): IBinder? = null
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
// --- Step detector sensor callbacks ---
|
||||||
|
|
||||||
|
override fun onSensorChanged(event: SensorEvent?) {
|
||||||
|
if (event?.sensor?.type == Sensor.TYPE_STEP_DETECTOR) {
|
||||||
|
if (!paused) {
|
||||||
|
stepTimestamps.add(System.currentTimeMillis())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute cadence (steps per minute) from recent step detector events.
|
||||||
|
* Returns null if no steps detected in the rolling window.
|
||||||
|
*/
|
||||||
|
private fun computeCadence(): Double? {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val cutoff = now - CADENCE_WINDOW_MS
|
||||||
|
// Prune old timestamps
|
||||||
|
while (stepTimestamps.peek()?.let { it < cutoff } == true) {
|
||||||
|
stepTimestamps.poll()
|
||||||
|
}
|
||||||
|
val count = stepTimestamps.size
|
||||||
|
if (count < 2) return null
|
||||||
|
val windowMs = now - (stepTimestamps.peek() ?: now)
|
||||||
|
if (windowMs < 2000) return null // need at least 2s of data
|
||||||
|
return count.toDouble() / (windowMs / 60000.0)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
createNotificationChannel()
|
createNotificationChannel()
|
||||||
@@ -249,6 +290,7 @@ class LocationForegroundService : Service(), TextToSpeech.OnInitListener {
|
|||||||
}
|
}
|
||||||
|
|
||||||
startLocationUpdates()
|
startLocationUpdates()
|
||||||
|
startStepDetector()
|
||||||
tracking = true
|
tracking = true
|
||||||
instance = this
|
instance = this
|
||||||
|
|
||||||
@@ -654,6 +696,17 @@ class LocationForegroundService : Service(), TextToSpeech.OnInitListener {
|
|||||||
notificationManager?.notify(NOTIFICATION_ID, notification)
|
notificationManager?.notify(NOTIFICATION_ID, notification)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun startStepDetector() {
|
||||||
|
sensorManager = getSystemService(Context.SENSOR_SERVICE) as SensorManager
|
||||||
|
stepDetector = sensorManager?.getDefaultSensor(Sensor.TYPE_STEP_DETECTOR)
|
||||||
|
if (stepDetector != null) {
|
||||||
|
sensorManager?.registerListener(this, stepDetector, SensorManager.SENSOR_DELAY_FASTEST)
|
||||||
|
Log.d(TAG, "Step detector sensor registered")
|
||||||
|
} else {
|
||||||
|
Log.d(TAG, "Step detector sensor not available on this device")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Suppress("MissingPermission")
|
@Suppress("MissingPermission")
|
||||||
private fun startLocationUpdates() {
|
private fun startLocationUpdates() {
|
||||||
locationManager = getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
locationManager = getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
||||||
@@ -664,11 +717,13 @@ class LocationForegroundService : Service(), TextToSpeech.OnInitListener {
|
|||||||
val now = location.time
|
val now = location.time
|
||||||
|
|
||||||
// Always buffer GPS points (for track drawing) even when paused
|
// Always buffer GPS points (for track drawing) even when paused
|
||||||
|
val cadence = computeCadence()
|
||||||
val point = JSONObject().apply {
|
val point = JSONObject().apply {
|
||||||
put("lat", lat)
|
put("lat", lat)
|
||||||
put("lng", lng)
|
put("lng", lng)
|
||||||
if (location.hasAltitude()) put("altitude", location.altitude)
|
if (location.hasAltitude()) put("altitude", location.altitude)
|
||||||
if (location.hasSpeed()) put("speed", location.speed.toDouble())
|
if (location.hasSpeed()) put("speed", location.speed.toDouble())
|
||||||
|
if (cadence != null) put("cadence", cadence)
|
||||||
put("timestamp", location.time)
|
put("timestamp", location.time)
|
||||||
}
|
}
|
||||||
pointBuffer.add(point)
|
pointBuffer.add(point)
|
||||||
@@ -764,6 +819,10 @@ class LocationForegroundService : Service(), TextToSpeech.OnInitListener {
|
|||||||
locationListener?.let { locationManager?.removeUpdates(it) }
|
locationListener?.let { locationManager?.removeUpdates(it) }
|
||||||
locationListener = null
|
locationListener = null
|
||||||
locationManager = null
|
locationManager = null
|
||||||
|
sensorManager?.unregisterListener(this)
|
||||||
|
sensorManager = null
|
||||||
|
stepDetector = null
|
||||||
|
stepTimestamps.clear()
|
||||||
abandonAudioFocus()
|
abandonAudioFocus()
|
||||||
|
|
||||||
// Speak finish summary using the handed-off TTS instance (already initialized)
|
// Speak finish summary using the handed-off TTS instance (already initialized)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"productName": "Bocken",
|
"productName": "Bocken",
|
||||||
"identifier": "org.bocken.app",
|
"identifier": "org.bocken.app",
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"build": {
|
"build": {
|
||||||
"devUrl": "http://192.168.1.4:5173",
|
"devUrl": "http://192.168.1.4:5173",
|
||||||
"frontendDist": "https://bocken.org"
|
"frontendDist": "https://bocken.org"
|
||||||
|
|||||||
@@ -108,6 +108,8 @@ const translations: Translations = {
|
|||||||
elevation_unit: { en: 'm', de: 'm' },
|
elevation_unit: { en: 'm', de: 'm' },
|
||||||
elevation_gain: { en: 'Gain', de: 'Anstieg' },
|
elevation_gain: { en: 'Gain', de: 'Anstieg' },
|
||||||
elevation_loss: { en: 'Loss', de: 'Abstieg' },
|
elevation_loss: { en: 'Loss', de: 'Abstieg' },
|
||||||
|
cadence: { en: 'Cadence', de: 'Kadenz' },
|
||||||
|
cadence_unit: { en: 'spm', de: 'spm' },
|
||||||
personal_records: { en: 'Personal Records', de: 'Persönliche Rekorde' },
|
personal_records: { en: 'Personal Records', de: 'Persönliche Rekorde' },
|
||||||
delete_session_confirm: { en: 'Delete this workout session?', de: 'Dieses Training löschen?' },
|
delete_session_confirm: { en: 'Delete this workout session?', de: 'Dieses Training löschen?' },
|
||||||
remove_gps_confirm: { en: 'Remove GPS track from this exercise?', de: 'GPS-Track von dieser Übung entfernen?' },
|
remove_gps_confirm: { en: 'Remove GPS track from this exercise?', de: 'GPS-Track von dieser Übung entfernen?' },
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface GpsPoint {
|
|||||||
lng: number;
|
lng: number;
|
||||||
altitude?: number;
|
altitude?: number;
|
||||||
speed?: number;
|
speed?: number;
|
||||||
|
cadence?: number; // steps per minute, from step detector sensor
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export interface IGpsPoint {
|
|||||||
lng: number;
|
lng: number;
|
||||||
altitude?: number;
|
altitude?: number;
|
||||||
speed?: number;
|
speed?: number;
|
||||||
|
cadence?: number; // steps per minute, from step detector sensor
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,6 +109,7 @@ const GpsPointSchema = new mongoose.Schema({
|
|||||||
lng: { type: Number, required: true },
|
lng: { type: Number, required: true },
|
||||||
altitude: Number,
|
altitude: Number,
|
||||||
speed: Number,
|
speed: Number,
|
||||||
|
cadence: Number,
|
||||||
timestamp: { type: Number, required: true }
|
timestamp: { type: Number, required: true }
|
||||||
}, { _id: false });
|
}, { _id: false });
|
||||||
|
|
||||||
|
|||||||
@@ -473,6 +473,47 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute cadence samples over distance from GPS track.
|
||||||
|
* Returns array of { dist (km), cadence (spm) } — only for points with cadence data.
|
||||||
|
* @param {any[]} track
|
||||||
|
*/
|
||||||
|
function computeCadenceSamples(track) {
|
||||||
|
/** @type {Array<{dist: number, cadence: number}>} */
|
||||||
|
const samples = [];
|
||||||
|
let cumDist = 0;
|
||||||
|
for (let i = 0; i < track.length; i++) {
|
||||||
|
if (track[i].cadence == null) continue;
|
||||||
|
if (i > 0) cumDist += haversine(track[i - 1], track[i]);
|
||||||
|
samples.push({ dist: cumDist, cadence: Math.round(track[i].cadence) });
|
||||||
|
}
|
||||||
|
return samples;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build Chart.js data for cadence over distance
|
||||||
|
* @param {Array<{dist: number, cadence: number}>} samples
|
||||||
|
*/
|
||||||
|
function buildCadenceChartData(samples) {
|
||||||
|
const step = Math.max(1, Math.floor(samples.length / 50));
|
||||||
|
const filtered = samples.filter((_, i) => i % step === 0 || i === samples.length - 1);
|
||||||
|
const color = dark ? '#B48EAD' : '#5E81AC';
|
||||||
|
const fill = dark ? 'rgba(180, 142, 173, 0.12)' : 'rgba(94, 129, 172, 0.12)';
|
||||||
|
return {
|
||||||
|
labels: filtered.map(s => s.dist.toFixed(2)),
|
||||||
|
datasets: [{
|
||||||
|
label: t('cadence', lang),
|
||||||
|
data: filtered.map(s => s.cadence),
|
||||||
|
borderColor: color,
|
||||||
|
backgroundColor: fill,
|
||||||
|
borderWidth: 1.5,
|
||||||
|
pointRadius: 0,
|
||||||
|
tension: 0.3,
|
||||||
|
fill: true
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** @param {number} exIdx */
|
/** @param {number} exIdx */
|
||||||
async function uploadGpx(exIdx) {
|
async function uploadGpx(exIdx) {
|
||||||
const input = document.createElement('input');
|
const input = document.createElement('input');
|
||||||
@@ -754,6 +795,19 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{@const cadenceSamples = computeCadenceSamples(ex.gpsTrack)}
|
||||||
|
{#if cadenceSamples.length > 1}
|
||||||
|
{@const avgCadence = Math.round(cadenceSamples.reduce((a, s) => a + s.cadence, 0) / cadenceSamples.length)}
|
||||||
|
<div class="chart-section">
|
||||||
|
<FitnessChart
|
||||||
|
data={buildCadenceChartData(cadenceSamples)}
|
||||||
|
title="{t('cadence', lang)} ({t('cadence_unit', lang)}) · {t('avg', lang)} {avgCadence}"
|
||||||
|
height="160px"
|
||||||
|
yUnit=" spm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if splits.length > 1}
|
{#if splits.length > 1}
|
||||||
{@const avgPace = splits.reduce((a, s) => a + s.pace, 0) / splits.length}
|
{@const avgPace = splits.reduce((a, s) => a + s.pace, 0) / splits.length}
|
||||||
<div class="splits-section">
|
<div class="splits-section">
|
||||||
|
|||||||
Reference in New Issue
Block a user