Skip to content

Commit b0179bd

Browse files
eriedclaude
andcommitted
Data sources sheet: phone IMU + wheel + RaceBox + Compare
Tap the GPS indicator at the top-right of the dashboard to open a bottom sheet with live readouts from every source the app currently knows about. Each source has its own tab and a Compare tab lets the rider pick two sources and see deltas between their shared metrics. Sources today - Phone: GPS speed + lat/lon (TripRepository.currentLocation) plus a brand-new TYPE_LINEAR_ACCELERATION listener exposing accel X/Y/Z as g. - Wheel: BLE telemetry speed only (no GPS or IMU on the wheel). - RaceBox: existing speed/pos plus new accel parsing for the device's 0xFF/0x01 extended-frame (accel int16 mg at offsets 68/70/72). Standard 0x01/0x07 NAV-PVT still works for basic GPS-only streams. UI - Source pills at the top show live / stale state per source. - Per-source tab renders only the metrics that source provides; unavailable rows show "—" so the user understands what's missing. - G-force crosshair (± 1.5 g) with a fading 75-sample trail. Same visual treatment for Phone and RaceBox sources so the rider can compare patterns directly. - Compare tab: two source pickers, speed delta, position-pair distance (haversine, m) and a 2-dot mini-map when both sources have GPS, plus |G| delta when both have IMU. - Multi-source ring: when 2+ sources are live a tiny segmented ring paints around the GPS icon in the dashboard, one segment per source coloured by its palette colour. Doesn't appear for single- source case so the icon stays clean by default. Consistent palette across the screens — Phone blue, Wheel green, RaceBox purple — to match the existing dial overlay dot. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 833cab8 commit b0179bd

7 files changed

Lines changed: 963 additions & 11 deletions

File tree

app/src/main/java/com/eried/eucplanet/ble/gps/RaceBoxAdapter.kt

Lines changed: 62 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -54,14 +54,19 @@ class RaceBoxAdapter @Inject constructor() : ExternalGpsAdapter {
5454
// Pull the candidate frame out of the buffer.
5555
val frame = ByteArray(totalFrameSize) { buffer.removeFirst() }
5656

57-
// Only NAV-PVT carries position+speed; ignore other frames.
58-
if (cls != 0x01 || id != 0x07 || len != 92) return null
59-
60-
// Fletcher-8 checksum over class, id, length, payload (everything after the
61-
// header bytes, before the trailing checksum bytes).
57+
// Accept either:
58+
// - Standard UBX NAV-PVT (cls=0x01, id=0x07, len=92): position+speed only.
59+
// - RaceBox extended (cls=0xFF, id=0x01, len=80): position+speed PLUS
60+
// accelerometer X/Y/Z and gyro at the tail. Devices stream this when
61+
// the user enables data recording on the RaceBox companion.
62+
val isStandardPvt = cls == 0x01 && id == 0x07 && len == 92
63+
val isExtendedPvt = cls == 0xFF && id == 0x01 && len == 80
64+
if (!isStandardPvt && !isExtendedPvt) return null
65+
66+
// Fletcher-8 checksum over class, id, length, payload.
6267
if (!checksumValid(frame)) return null
6368

64-
return parsePvt(frame)
69+
return if (isExtendedPvt) parseExtendedPvt(frame) else parsePvt(frame)
6570
}
6671

6772
private fun checksumValid(frame: ByteArray): Boolean {
@@ -113,12 +118,63 @@ class RaceBoxAdapter @Inject constructor() : ExternalGpsAdapter {
113118
)
114119
}
115120

121+
/**
122+
* RaceBox extended PVT (cls=0xFF id=0x01, 80-byte payload). The first 60
123+
* bytes mirror UBX-NAV-PVT closely so we re-use the same offsets for
124+
* fix/position/speed, then read the trailing accelerometer triple.
125+
*
126+
* Per the RaceBox public protocol notes the tail of the payload carries
127+
* accel as int16 milligees (LE) at offsets 68/70/72 — i.e. the last
128+
* 6 bytes before checksum start. Gyro words follow but we don't
129+
* surface them yet.
130+
*/
131+
private fun parseExtendedPvt(frame: ByteArray): ExternalGpsSample? {
132+
val payloadStart = 6
133+
if (frame.size < payloadStart + 80) return null
134+
135+
val fixType = frame[payloadStart + 20].toInt() and 0xFF
136+
if (fixType != 2 && fixType != 3 && fixType != 4) return null
137+
138+
val lonRaw = readInt32LE(frame, payloadStart + 24)
139+
val latRaw = readInt32LE(frame, payloadStart + 28)
140+
val hMslRaw = readInt32LE(frame, payloadStart + 36)
141+
val hAccRaw = readUInt32LE(frame, payloadStart + 40)
142+
// Note: in the extended frame the speed field sits at offset 60 too,
143+
// same as standard PVT — RaceBox kept the layout compatible for the
144+
// shared fields.
145+
val gSpeedRaw = readInt32LE(frame, payloadStart + 60)
146+
147+
// Accel: int16 LE milligees on each axis (positive Y = forward).
148+
// Divide by 1000 to get g.
149+
val ax = readInt16LE(frame, payloadStart + 68) / 1000f
150+
val ay = readInt16LE(frame, payloadStart + 70) / 1000f
151+
val az = readInt16LE(frame, payloadStart + 72) / 1000f
152+
153+
val speedMmS = gSpeedRaw.coerceAtLeast(0)
154+
return ExternalGpsSample(
155+
source = ExternalGpsSource.RACEBOX,
156+
speedKmh = speedMmS * 0.0036f,
157+
latitude = latRaw * 1e-7,
158+
longitude = lonRaw * 1e-7,
159+
altitudeMeters = hMslRaw / 1000f,
160+
accuracyMeters = hAccRaw / 1000f,
161+
accelXG = ax,
162+
accelYG = ay,
163+
accelZG = az
164+
)
165+
}
166+
116167
private fun readInt32LE(b: ByteArray, off: Int): Int =
117168
(b[off].toInt() and 0xFF) or
118169
((b[off + 1].toInt() and 0xFF) shl 8) or
119170
((b[off + 2].toInt() and 0xFF) shl 16) or
120171
((b[off + 3].toInt() and 0xFF) shl 24)
121172

173+
private fun readInt16LE(b: ByteArray, off: Int): Int {
174+
val v = (b[off].toInt() and 0xFF) or ((b[off + 1].toInt() and 0xFF) shl 8)
175+
return if (v and 0x8000 != 0) v or 0x7FFF.inv() else v
176+
}
177+
122178
private fun readUInt32LE(b: ByteArray, off: Int): Long =
123179
readInt32LE(b, off).toLong() and 0xFFFFFFFFL
124180
}

app/src/main/java/com/eried/eucplanet/data/model/ExternalGpsSample.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@ data class ExternalGpsSample(
1414
val altitudeMeters: Float,
1515
/** Horizontal accuracy in metres, or 0 if the device doesn't report it. */
1616
val accuracyMeters: Float = 0f,
17+
/** Linear acceleration in g, or null if the device doesn't report it.
18+
* RaceBox Mini/S/Pro stream accel + gyro alongside the PVT message
19+
* on their custom 0xFF/0x01 frame; basic GPS receivers (Garmin etc.)
20+
* won't fill these in. */
21+
val accelXG: Float? = null,
22+
val accelYG: Float? = null,
23+
val accelZG: Float? = null,
1724
val timestamp: Long = System.currentTimeMillis()
1825
)
1926

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package com.eried.eucplanet.data.repository
2+
3+
import android.content.Context
4+
import android.hardware.Sensor
5+
import android.hardware.SensorEvent
6+
import android.hardware.SensorEventListener
7+
import android.hardware.SensorManager
8+
import dagger.hilt.android.qualifiers.ApplicationContext
9+
import kotlinx.coroutines.flow.MutableStateFlow
10+
import kotlinx.coroutines.flow.StateFlow
11+
import kotlinx.coroutines.flow.asStateFlow
12+
import javax.inject.Inject
13+
import javax.inject.Singleton
14+
15+
/**
16+
* One sample from the phone's IMU. Linear acceleration is gravity-subtracted
17+
* via Android's TYPE_LINEAR_ACCELERATION sensor, then converted from m/s² to
18+
* g so the values line up with what RaceBox reports.
19+
*
20+
* x = lateral (right positive)
21+
* y = vertical (up positive — usually ~0 in linear-accel mode)
22+
* z = forward (forward positive when the phone is screen-up on the wheel)
23+
*
24+
* On a phone held by the rider in a pocket the axes don't perfectly line up
25+
* with the wheel's motion, so the values are useful as a relative reading
26+
* (spikes, magnitudes) but shouldn't be treated as absolute lateral / longitudinal
27+
* G the way a RaceBox mounted to the stem would.
28+
*/
29+
data class PhoneImuSample(
30+
val xG: Float,
31+
val yG: Float,
32+
val zG: Float,
33+
val timestamp: Long = System.currentTimeMillis()
34+
)
35+
36+
/**
37+
* Singleton listener over [SensorManager] that re-emits linear acceleration as
38+
* a [PhoneImuSample] StateFlow. Registers lazily on first observer (start) and
39+
* unregisters when no longer needed (stop). The repo retains the last sample
40+
* so consumers can read a snapshot via [latest].
41+
*/
42+
@Singleton
43+
class PhoneSensorRepository @Inject constructor(
44+
@ApplicationContext private val context: Context
45+
) {
46+
private val sensorManager: SensorManager? =
47+
context.getSystemService(Context.SENSOR_SERVICE) as? SensorManager
48+
private val linearAccel: Sensor? =
49+
sensorManager?.getDefaultSensor(Sensor.TYPE_LINEAR_ACCELERATION)
50+
51+
private val _imu = MutableStateFlow<PhoneImuSample?>(null)
52+
val imu: StateFlow<PhoneImuSample?> = _imu.asStateFlow()
53+
54+
val latest: PhoneImuSample? get() = _imu.value
55+
val isAvailable: Boolean get() = linearAccel != null
56+
57+
private var listening = false
58+
private val listener = object : SensorEventListener {
59+
override fun onSensorChanged(event: SensorEvent) {
60+
if (event.sensor.type != Sensor.TYPE_LINEAR_ACCELERATION) return
61+
// m/s² → g (9.80665). x/y/z follow the SensorManager axis convention:
62+
// x = device right, y = device up, z = out of screen toward user.
63+
_imu.value = PhoneImuSample(
64+
xG = event.values[0] / 9.80665f,
65+
yG = event.values[1] / 9.80665f,
66+
zG = event.values[2] / 9.80665f
67+
)
68+
}
69+
70+
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) { /* unused */ }
71+
}
72+
73+
/** Idempotent — second call while listening is a no-op. Safe to invoke
74+
* every time the dialog opens. */
75+
fun start() {
76+
if (listening) return
77+
val sm = sensorManager ?: return
78+
val s = linearAccel ?: return
79+
// SENSOR_DELAY_GAME (~20 ms) gives a smooth trail in the crosshair
80+
// without pegging the CPU. SENSOR_DELAY_UI (~60 ms) felt choppy on
81+
// a Pixel 6 during a tilt test.
82+
sm.registerListener(listener, s, SensorManager.SENSOR_DELAY_GAME)
83+
listening = true
84+
}
85+
86+
/** Stop callback registration. Sample stays in [latest] until the next
87+
* start, so the UI can still show the last reading after dismissing. */
88+
fun stop() {
89+
if (!listening) return
90+
sensorManager?.unregisterListener(listener)
91+
listening = false
92+
}
93+
}

app/src/main/java/com/eried/eucplanet/ui/dashboard/DashboardScreen.kt

Lines changed: 58 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,7 @@ fun DashboardScreen(
193193
var showQuitDialog by remember { mutableStateOf(false) }
194194
var showDisconnectDialog by remember { mutableStateOf(false) }
195195
var showNoTripsDialog by remember { mutableStateOf(false) }
196+
var showSourcesSheet by remember { mutableStateOf(false) }
196197
var showSettingsMenu by remember { mutableStateOf(false) }
197198
var showRestoreConfirmDialog by remember { mutableStateOf(false) }
198199
val hasSyncFolder by viewModel.hasSyncFolder.collectAsState()
@@ -486,20 +487,65 @@ fun DashboardScreen(
486487
// no permission -> GpsOff (dim)
487488
// no fix yet -> GpsNotFixed (dim)
488489
// locked -> GpsFixed (green)
490+
// Tapping the icon opens the multi-source live data sheet
491+
// (Phone / Wheel / RaceBox / Compare). When more than one
492+
// source is live we paint a small colored ring around the
493+
// icon as a hint that there's combined data to inspect.
489494
val gpsIcon = when {
490495
!locationGranted -> Icons.Default.GpsOff
491496
gpsFix -> Icons.Default.GpsFixed
492497
else -> Icons.Default.GpsNotFixed
493498
}
494-
DashIndicatorIcon(
495-
icon = gpsIcon,
496-
active = gpsFix && locationGranted,
497-
activeColor = if (useAccent) primary else AccentGreen,
499+
val liveSources = buildList {
500+
if (gpsFix && locationGranted)
501+
add(com.eried.eucplanet.ui.dashboard.sources.DataSource.PHONE)
502+
if (connectionState == ConnectionState.CONNECTED)
503+
add(com.eried.eucplanet.ui.dashboard.sources.DataSource.WHEEL)
504+
if (externalGpsSpeed != null)
505+
add(com.eried.eucplanet.ui.dashboard.sources.DataSource.RACEBOX)
506+
}
507+
Box(
498508
modifier = Modifier
499509
.align(Alignment.TopEnd)
500510
.offset(x = 4.dp)
501511
.padding(top = 8.dp)
502-
)
512+
.clickable { showSourcesSheet = true }
513+
) {
514+
DashIndicatorIcon(
515+
icon = gpsIcon,
516+
active = gpsFix && locationGranted,
517+
activeColor = if (useAccent) primary else AccentGreen,
518+
modifier = Modifier
519+
)
520+
// Multi-source ring overlay — one short arc per live
521+
// source, equally divided around the icon. Single-source
522+
// case skips the ring entirely so the icon doesn't grow
523+
// a useless decoration when only Phone is providing data.
524+
if (liveSources.size >= 2) {
525+
Canvas(
526+
modifier = Modifier
527+
.matchParentSize()
528+
.padding(2.dp)
529+
) {
530+
val r = size.minDimension / 2f - 1f
531+
val center = androidx.compose.ui.geometry.Offset(size.width / 2f, size.height / 2f)
532+
val sweep = 360f / liveSources.size
533+
val gap = 8f // degrees between segments
534+
liveSources.forEachIndexed { idx, src ->
535+
val start = idx * sweep + gap / 2f
536+
drawArc(
537+
color = src.color,
538+
startAngle = start - 90f,
539+
sweepAngle = sweep - gap,
540+
useCenter = false,
541+
topLeft = androidx.compose.ui.geometry.Offset(center.x - r, center.y - r),
542+
size = androidx.compose.ui.geometry.Size(r * 2, r * 2),
543+
style = androidx.compose.ui.graphics.drawscope.Stroke(width = 2.5f)
544+
)
545+
}
546+
}
547+
}
548+
}
503549
}
504550

505551
Spacer(Modifier.height(16.dp))
@@ -819,6 +865,13 @@ fun DashboardScreen(
819865
)
820866
}
821867

868+
if (showSourcesSheet) {
869+
com.eried.eucplanet.ui.dashboard.sources.DataSourcesSheet(
870+
imperial = imperial,
871+
onDismiss = { showSourcesSheet = false }
872+
)
873+
}
874+
822875
if (showAboutDialog) {
823876
val crashes = remember { com.eried.eucplanet.util.CrashHandler.listCrashes(context) }
824877
val licenseText = remember {
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package com.eried.eucplanet.ui.dashboard.sources
2+
3+
import androidx.compose.ui.graphics.Color
4+
import com.eried.eucplanet.ui.theme.AccentBlue
5+
import com.eried.eucplanet.ui.theme.AccentGreen
6+
import com.eried.eucplanet.ui.theme.AccentPurple
7+
8+
/**
9+
* One of the three live data sources the dashboard knows about. The enum is
10+
* stable across the UI: same icon, same colour, same string label everywhere
11+
* the dialog shows it, so users build a mental map of "blue = phone, green =
12+
* wheel, purple = RaceBox" that carries through to the overlay dot on the
13+
* speed dial and the lines on the trip-detail chart.
14+
*/
15+
enum class DataSource(val displayName: String, val color: Color) {
16+
PHONE("Phone", AccentBlue),
17+
WHEEL("Wheel", AccentGreen),
18+
RACEBOX("RaceBox", AccentPurple);
19+
20+
/**
21+
* Capability flags so each tab can render the right rows without
22+
* branching deep inside the view code. Update these alongside the
23+
* underlying repository when a new metric becomes available.
24+
*/
25+
val hasSpeed: Boolean get() = true // all three expose speed
26+
val hasPosition: Boolean get() = this != WHEEL // wheel has no GPS
27+
val hasImu: Boolean get() = this == PHONE || this == RACEBOX
28+
}
29+
30+
/**
31+
* Snapshot fed to the source dialog. Each field is nullable so the UI can
32+
* dash out rows the source doesn't currently provide (e.g. RaceBox accel
33+
* stays null until the device sends an extended-frame, even though the
34+
* source claims hasImu = true at the enum level).
35+
*/
36+
data class SourceSnapshot(
37+
val speedKmh: Float? = null,
38+
val latitude: Double? = null,
39+
val longitude: Double? = null,
40+
/** True when speed/position values are flowing live; UI dims when false. */
41+
val isLive: Boolean = false,
42+
/** Accel in g, axes aligned to the source's reference frame. */
43+
val accelXG: Float? = null,
44+
val accelYG: Float? = null,
45+
val accelZG: Float? = null
46+
) {
47+
/** Magnitude of horizontal G-force, useful as a single safety number. */
48+
val horizGMagnitude: Float?
49+
get() {
50+
val x = accelXG ?: return null
51+
val z = accelZG ?: return null
52+
return kotlin.math.sqrt(x * x + z * z)
53+
}
54+
}

0 commit comments

Comments
 (0)