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",
|
||||
"version": "1.25.1",
|
||||
"version": "1.25.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
@@ -144,7 +144,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bocken"
|
||||
version = "0.2.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "bocken"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
|
||||
@@ -7,6 +7,10 @@ import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
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.LocationManager
|
||||
import android.media.AudioAttributes
|
||||
@@ -24,15 +28,22 @@ import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.util.Collections
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
import kotlin.math.*
|
||||
|
||||
private const val TAG = "BockenTTS"
|
||||
|
||||
class LocationForegroundService : Service(), TextToSpeech.OnInitListener {
|
||||
class LocationForegroundService : Service(), TextToSpeech.OnInitListener, SensorEventListener {
|
||||
|
||||
private var locationManager: LocationManager? = null
|
||||
private var locationListener: LocationListener? = 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 startTimeMs: Long = 0L
|
||||
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
|
||||
|
||||
// --- 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() {
|
||||
super.onCreate()
|
||||
createNotificationChannel()
|
||||
@@ -249,6 +290,7 @@ class LocationForegroundService : Service(), TextToSpeech.OnInitListener {
|
||||
}
|
||||
|
||||
startLocationUpdates()
|
||||
startStepDetector()
|
||||
tracking = true
|
||||
instance = this
|
||||
|
||||
@@ -654,6 +696,17 @@ class LocationForegroundService : Service(), TextToSpeech.OnInitListener {
|
||||
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")
|
||||
private fun startLocationUpdates() {
|
||||
locationManager = getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
||||
@@ -664,11 +717,13 @@ class LocationForegroundService : Service(), TextToSpeech.OnInitListener {
|
||||
val now = location.time
|
||||
|
||||
// Always buffer GPS points (for track drawing) even when paused
|
||||
val cadence = computeCadence()
|
||||
val point = JSONObject().apply {
|
||||
put("lat", lat)
|
||||
put("lng", lng)
|
||||
if (location.hasAltitude()) put("altitude", location.altitude)
|
||||
if (location.hasSpeed()) put("speed", location.speed.toDouble())
|
||||
if (cadence != null) put("cadence", cadence)
|
||||
put("timestamp", location.time)
|
||||
}
|
||||
pointBuffer.add(point)
|
||||
@@ -764,6 +819,10 @@ class LocationForegroundService : Service(), TextToSpeech.OnInitListener {
|
||||
locationListener?.let { locationManager?.removeUpdates(it) }
|
||||
locationListener = null
|
||||
locationManager = null
|
||||
sensorManager?.unregisterListener(this)
|
||||
sensorManager = null
|
||||
stepDetector = null
|
||||
stepTimestamps.clear()
|
||||
abandonAudioFocus()
|
||||
|
||||
// Speak finish summary using the handed-off TTS instance (already initialized)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"productName": "Bocken",
|
||||
"identifier": "org.bocken.app",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"build": {
|
||||
"devUrl": "http://192.168.1.4:5173",
|
||||
"frontendDist": "https://bocken.org"
|
||||
|
||||
@@ -108,6 +108,8 @@ const translations: Translations = {
|
||||
elevation_unit: { en: 'm', de: 'm' },
|
||||
elevation_gain: { en: 'Gain', de: 'Anstieg' },
|
||||
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' },
|
||||
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?' },
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface GpsPoint {
|
||||
lng: number;
|
||||
altitude?: number;
|
||||
speed?: number;
|
||||
cadence?: number; // steps per minute, from step detector sensor
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface IGpsPoint {
|
||||
lng: number;
|
||||
altitude?: number;
|
||||
speed?: number;
|
||||
cadence?: number; // steps per minute, from step detector sensor
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
@@ -108,6 +109,7 @@ const GpsPointSchema = new mongoose.Schema({
|
||||
lng: { type: Number, required: true },
|
||||
altitude: Number,
|
||||
speed: Number,
|
||||
cadence: Number,
|
||||
timestamp: { type: Number, required: true }
|
||||
}, { _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 */
|
||||
async function uploadGpx(exIdx) {
|
||||
const input = document.createElement('input');
|
||||
@@ -754,6 +795,19 @@
|
||||
</div>
|
||||
{/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}
|
||||
{@const avgPace = splits.reduce((a, s) => a + s.pace, 0) / splits.length}
|
||||
<div class="splits-section">
|
||||
|
||||
Reference in New Issue
Block a user