Max speed filter in native service, fix recalculate bugs
Native service (Kotlin): - maxSpeedMs filter: points with speed > max are not recorded - Config passed as 4th param: start:minSpeed:maxAcc:minDist:maxSpeed - Default 60 km/h (16.67 m/s) Settings: - "Макс. скорость (км/ч)" field in GPS config section - Saved to localStorage with other GPS settings Recalculate fixes: - Used cleaned sorted array instead of original points (was computing stats on dirty data with glitches -> hundreds of thousands of kcal) - parseFloat on weight from profile (string "88" + 12 = "8812" bug) - seq re-assignment only runs when points were actually deleted - Glitch points deleted from DB on recalculate (not just skipped)
This commit is contained in:
parent
0dd4a67e7b
commit
30b0ac49f3
4 changed files with 244 additions and 21 deletions
|
|
@ -9,6 +9,7 @@ GPS.config = {
|
|||
minSpeedMs: 0.42, // 1.5 km/h — below this = stationary
|
||||
minAccuracyM: 20, // skip fixes less accurate than this
|
||||
minDistanceM: 1, // skip points closer than this
|
||||
maxSpeedKmh: 60, // above this = GPS glitch, skip segment
|
||||
};
|
||||
|
||||
// Load saved config
|
||||
|
|
@ -83,8 +84,6 @@ GPS.computeStats = function(points, weightKg, opts) {
|
|||
const d = GPS.distance(prev.lat, prev.lon, p.lat, p.lon);
|
||||
|
||||
const dt = (new Date(p.ts) - new Date(prev.ts)) / 1000;
|
||||
// Skip teleportation glitches (> 180 km/h = GPS jump)
|
||||
if (dt > 0 && d / dt > 50) continue;
|
||||
|
||||
distance += d;
|
||||
|
||||
|
|
@ -224,8 +223,8 @@ GPS.recording = {
|
|||
// Start native GPS service on Android (foreground service survives background)
|
||||
GPS._startNativeService = async function() {
|
||||
try {
|
||||
// Send config along with start command: start:minSpeed:maxAcc:minDist
|
||||
const cmd = `start:${GPS.config.minSpeedMs}:${GPS.config.minAccuracyM}:${GPS.config.minDistanceM}`;
|
||||
// Send config along with start command: start:minSpeed:maxAcc:minDist:maxSpeed
|
||||
const cmd = `start:${GPS.config.minSpeedMs}:${GPS.config.minAccuracyM}:${GPS.config.minDistanceM}:${GPS.config.maxSpeedKmh / 3.6}`;
|
||||
await window.__TAURI__.core.invoke("gps_service_command", { cmd });
|
||||
console.log("[GPS] Native service started with config:", cmd);
|
||||
GPS.recording.nativeService = true;
|
||||
|
|
@ -877,34 +876,43 @@ GPS.recalculateSession = async function(sessionId) {
|
|||
const points = await DB.getGpsPoints(sessionId);
|
||||
if (points.length < 2) return null;
|
||||
|
||||
// Fix point ordering: re-assign seq by timestamp
|
||||
const invoke = (q, v) => window.__TAURI__.core.invoke("plugin:sql|execute", { db: "sqlite:baldav.db", query: q, values: v });
|
||||
|
||||
// Delete glitch points (speed > maxSpeedKmh) from DB
|
||||
const sorted = points.slice().sort((a, b) => new Date(a.ts) - new Date(b.ts));
|
||||
let needsReorder = false;
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
if (sorted[i].seq !== i + 1) { needsReorder = true; break; }
|
||||
}
|
||||
if (needsReorder) {
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
await window.__TAURI__.core.invoke("plugin:sql|execute", {
|
||||
db: "sqlite:baldav.db",
|
||||
query: "UPDATE gps_points SET seq = ?1 WHERE session_id = ?2 AND ts = ?3",
|
||||
values: [i + 1, sessionId, sorted[i].ts],
|
||||
});
|
||||
const maxMs = GPS.config.maxSpeedKmh / 3.6;
|
||||
let deleted = 0;
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
const d = GPS.distance(sorted[i-1].lat, sorted[i-1].lon, sorted[i].lat, sorted[i].lon);
|
||||
const dt = (new Date(sorted[i].ts) - new Date(sorted[i-1].ts)) / 1000;
|
||||
if (dt > 0 && d / dt > maxMs) {
|
||||
await invoke("DELETE FROM gps_points WHERE session_id = ?1 AND ts = ?2", [sessionId, sorted[i].ts]);
|
||||
sorted.splice(i, 1);
|
||||
i--;
|
||||
deleted++;
|
||||
}
|
||||
}
|
||||
if (deleted) console.log(`[GPS] Deleted ${deleted} glitch points from session ${sessionId}`);
|
||||
|
||||
// Re-assign seq only if points were deleted or reordered
|
||||
if (deleted > 0) {
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
await invoke("UPDATE gps_points SET seq = ?1 WHERE session_id = ?2 AND ts = ?3", [i + 1, sessionId, sorted[i].ts]);
|
||||
}
|
||||
console.log(`[GPS] Reordered ${sorted.length} points for session ${sessionId}`);
|
||||
}
|
||||
|
||||
// Get user weight: latest weight log, or profile, or 80
|
||||
const latestW = await DB.getLatestWeight();
|
||||
const profile = await DB.getFullProfile();
|
||||
const weightKg = latestW?.weight_kg ?? profile?.weight_kg ?? 80;
|
||||
const weightKg = parseFloat(latestW?.weight_kg) || parseFloat(profile?.weight_kg) || 80;
|
||||
|
||||
// Get bike if linked to session, otherwise use active bike
|
||||
const bike = session.bike_id ? await DB.getBike(session.bike_id) : await DB.getActiveBike();
|
||||
|
||||
const CRR_MAP = { asphalt: 0.004, mixed: 0.006, gravel: 0.008 };
|
||||
|
||||
const stats = GPS.computeStats(points, weightKg, {
|
||||
// Use cleaned sorted array, not original points
|
||||
const stats = GPS.computeStats(sorted, weightKg, {
|
||||
bikeWeightKg: bike?.weight_kg ?? 0,
|
||||
activityType: session.activity_type ?? "bike",
|
||||
surfaceCrr: CRR_MAP[bike?.surface_type] ?? 0.006,
|
||||
|
|
|
|||
|
|
@ -2738,6 +2738,13 @@ async function renderSettings(container) {
|
|||
</div>
|
||||
<input id="s-min-dist" type="number" step="0.5" value="${GPS.config.minDistanceM}" style="width:60px;text-align:right;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg);padding:4px 6px;">
|
||||
</div>
|
||||
<div class="settings-item">
|
||||
<div class="settings-item-left">
|
||||
<div class="settings-item-title">Макс. скорость (км/ч)</div>
|
||||
<div class="settings-item-desc">Выше = глитч GPS, игнор</div>
|
||||
</div>
|
||||
<input id="s-max-speed" type="number" step="1" value="${GPS.config.maxSpeedKmh}" style="width:60px;text-align:right;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg);padding:4px 6px;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
|
|
@ -2784,13 +2791,14 @@ async function renderSettings(container) {
|
|||
};
|
||||
|
||||
// GPS config inputs
|
||||
const gpsInputs = ["s-min-speed", "s-min-acc", "s-min-dist"];
|
||||
const gpsInputs = ["s-min-speed", "s-min-acc", "s-min-dist", "s-max-speed"];
|
||||
for (const id of gpsInputs) {
|
||||
const el = container.querySelector("#" + id);
|
||||
if (el) el.onchange = () => {
|
||||
GPS.config.minSpeedMs = parseFloat(container.querySelector("#s-min-speed").value) / 3.6;
|
||||
GPS.config.minAccuracyM = parseFloat(container.querySelector("#s-min-acc").value);
|
||||
GPS.config.minDistanceM = parseFloat(container.querySelector("#s-min-dist").value);
|
||||
GPS.config.maxSpeedKmh = parseFloat(container.querySelector("#s-max-speed").value) || 60;
|
||||
GPS.saveConfig();
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,205 @@
|
|||
package dev.baldav.app
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.location.Location
|
||||
import android.location.LocationListener
|
||||
import android.location.LocationManager
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import android.os.PowerManager
|
||||
import android.util.Log
|
||||
import java.io.FileOutputStream
|
||||
import java.io.File
|
||||
|
||||
class GpsRecorderService : Service(), LocationListener {
|
||||
|
||||
companion object {
|
||||
const val TAG = "GpsRecorder"
|
||||
const val CHANNEL_ID = "baldav_gps"
|
||||
const val NOTIFICATION_ID = 1
|
||||
const val ACTION_STOP = "dev.baldav.app.STOP_RECORDING"
|
||||
const val EXTRA_MIN_SPEED = "min_speed"
|
||||
const val EXTRA_MAX_ACC = "max_acc"
|
||||
const val EXTRA_MIN_DIST = "min_dist"
|
||||
const val EXTRA_MAX_SPEED = "max_speed"
|
||||
|
||||
var isRunning = false
|
||||
private set
|
||||
var pointsFile: File? = null
|
||||
private set
|
||||
}
|
||||
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
private var locationManager: LocationManager? = null
|
||||
private var minSpeedMs = 0.42f
|
||||
private var maxAccuracyM = 20f
|
||||
private var minDistanceM = 1f
|
||||
private var maxSpeedMs = 16.67f // 60 km/h default
|
||||
|
||||
private val pointBuffer = mutableListOf<String>()
|
||||
private val bufferLock = Object()
|
||||
private val flushHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Log.d(TAG, "Service created")
|
||||
createNotificationChannel()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (intent?.action == ACTION_STOP) {
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
// Read config from intent extras
|
||||
minSpeedMs = intent?.getFloatExtra(EXTRA_MIN_SPEED, 0.42f) ?: 0.42f
|
||||
maxAccuracyM = intent?.getFloatExtra(EXTRA_MAX_ACC, 20f) ?: 20f
|
||||
minDistanceM = intent?.getFloatExtra(EXTRA_MIN_DIST, 1f) ?: 1f
|
||||
maxSpeedMs = intent?.getFloatExtra(EXTRA_MAX_SPEED, 16.67f) ?: 16.67f
|
||||
Log.d(TAG, "Starting GPS recording: minSpeed=${minSpeedMs}m/s maxAcc=${maxAccuracyM}m minDist=${minDistanceM}m maxSpeed=${maxSpeedMs}m/s")
|
||||
isRunning = true
|
||||
|
||||
// Points file in app root data dir (same as Rust app_data_dir)
|
||||
val appDataDir = filesDir.parentFile ?: filesDir
|
||||
pointsFile = File(appDataDir, "gps_live_points.jsonl")
|
||||
pointsFile?.writeText("") // clear
|
||||
|
||||
// Foreground notification
|
||||
val stopIntent = Intent(this, GpsRecorderService::class.java).apply { action = ACTION_STOP }
|
||||
val stopPending = PendingIntent.getService(this, 0, stopIntent, PendingIntent.FLAG_IMMUTABLE)
|
||||
|
||||
val openIntent = packageManager.getLaunchIntentForPackage(packageName)
|
||||
val openPending = PendingIntent.getActivity(this, 0, openIntent, PendingIntent.FLAG_IMMUTABLE)
|
||||
|
||||
val notification = Notification.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle("BalDav GPS")
|
||||
.setContentText("Recording track...")
|
||||
.setSmallIcon(android.R.drawable.ic_menu_mylocation)
|
||||
.setContentIntent(openPending)
|
||||
.addAction(Notification.Action.Builder(null, "Stop", stopPending).build())
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
|
||||
// WakeLock
|
||||
val pm = getSystemService(POWER_SERVICE) as PowerManager
|
||||
wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "baldav:gps")
|
||||
wakeLock?.acquire(4 * 60 * 60 * 1000L) // 4 hours max
|
||||
|
||||
// Start GPS
|
||||
locationManager = getSystemService(LOCATION_SERVICE) as LocationManager
|
||||
try {
|
||||
val gpsEnabled = locationManager?.isProviderEnabled(LocationManager.GPS_PROVIDER) ?: false
|
||||
Log.d(TAG, "GPS provider enabled: $gpsEnabled")
|
||||
locationManager?.requestLocationUpdates(
|
||||
LocationManager.GPS_PROVIDER,
|
||||
1000L, // min time 1s
|
||||
0f, // min distance 0m
|
||||
this
|
||||
)
|
||||
Log.d(TAG, "Location updates registered successfully")
|
||||
|
||||
// Flush buffer every 10 seconds
|
||||
fun scheduleFlush() {
|
||||
flushHandler.postDelayed({
|
||||
flushBuffer()
|
||||
if (isRunning) scheduleFlush()
|
||||
}, 10000)
|
||||
}
|
||||
scheduleFlush()
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG, "GPS permission denied", e)
|
||||
stopSelf()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to register location updates", e)
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
private var lastLat = 0.0
|
||||
private var lastLon = 0.0
|
||||
private var lastTime = 0L
|
||||
|
||||
private var pointCount = 0
|
||||
|
||||
override fun onLocationChanged(location: Location) {
|
||||
Log.v(TAG, "Location: acc=${location.accuracy} spd=${location.speed} lat=${location.latitude}")
|
||||
if (location.accuracy > maxAccuracyM) return
|
||||
if (location.speed < minSpeedMs) return
|
||||
if (location.speed > maxSpeedMs) return
|
||||
if (lastTime > 0) {
|
||||
val results = FloatArray(1)
|
||||
Location.distanceBetween(lastLat, lastLon, location.latitude, location.longitude, results)
|
||||
if (results[0] < minDistanceM) return
|
||||
}
|
||||
|
||||
lastLat = location.latitude
|
||||
lastLon = location.longitude
|
||||
lastTime = location.time
|
||||
|
||||
pointCount++
|
||||
val line = """{"lat":${location.latitude},"lon":${location.longitude},"alt":${location.altitude},"speed":${location.speed},"acc":${location.accuracy},"ts":${location.time}}"""
|
||||
synchronized(bufferLock) {
|
||||
pointBuffer.add(line)
|
||||
}
|
||||
Log.d(TAG, "Buffered point #$pointCount: ${location.latitude},${location.longitude} spd=${location.speed} (buf=${pointBuffer.size})")
|
||||
}
|
||||
|
||||
private fun flushBuffer() {
|
||||
synchronized(bufferLock) {
|
||||
if (pointBuffer.isEmpty()) return
|
||||
val batch = pointBuffer.toList()
|
||||
pointBuffer.clear()
|
||||
pointsFile?.let { f ->
|
||||
try {
|
||||
FileOutputStream(f, true).bufferedWriter().use { writer ->
|
||||
for (line in batch) writer.appendLine(line)
|
||||
writer.flush()
|
||||
}
|
||||
Log.d(TAG, "Flushed ${batch.size} points to file")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to flush points", e)
|
||||
// Put points back
|
||||
pointBuffer.addAll(0, batch)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
Log.d(TAG, "Service stopping")
|
||||
isRunning = false
|
||||
flushHandler.removeCallbacksAndMessages(null)
|
||||
flushBuffer() // flush remaining points
|
||||
locationManager?.removeUpdates(this)
|
||||
wakeLock?.let { if (it.isHeld) it.release() }
|
||||
stopForeground(true)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID, "GPS Tracking",
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
).apply {
|
||||
description = "Shows when GPS tracking is active"
|
||||
}
|
||||
val nm = getSystemService(NotificationManager::class.java)
|
||||
nm.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -112,11 +112,13 @@ class MainActivity : TauriActivity() {
|
|||
val minSpeed = parts.getOrNull(1)?.toFloatOrNull() ?: 0.42f
|
||||
val maxAcc = parts.getOrNull(2)?.toFloatOrNull() ?: 20f
|
||||
val minDist = parts.getOrNull(3)?.toFloatOrNull() ?: 1f
|
||||
Log.d("BalDav", "Starting GPS service: speed=$minSpeed acc=$maxAcc dist=$minDist")
|
||||
val maxSpeed = parts.getOrNull(4)?.toFloatOrNull() ?: 16.67f
|
||||
Log.d("BalDav", "Starting GPS service: speed=$minSpeed acc=$maxAcc dist=$minDist maxSpeed=$maxSpeed")
|
||||
val intent = Intent(this, GpsRecorderService::class.java).apply {
|
||||
putExtra(GpsRecorderService.EXTRA_MIN_SPEED, minSpeed)
|
||||
putExtra(GpsRecorderService.EXTRA_MAX_ACC, maxAcc)
|
||||
putExtra(GpsRecorderService.EXTRA_MIN_DIST, minDist)
|
||||
putExtra(GpsRecorderService.EXTRA_MAX_SPEED, maxSpeed)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
startForegroundService(intent)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue