feat: record cadence from step detector during GPS workouts
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:
2026-04-11 15:26:29 +02:00
parent b209f6e936
commit 3b11cb9878
9 changed files with 123 additions and 5 deletions

View File

@@ -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
View File

@@ -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",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "bocken" name = "bocken"
version = "0.3.0" version = "0.4.0"
edition = "2021" edition = "2021"
[lib] [lib]

View File

@@ -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)

View File

@@ -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"

View File

@@ -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?' },

View File

@@ -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;
} }

View File

@@ -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 });

View File

@@ -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">