Skip to content

Commit 88eb581

Browse files
committed
Merge feature/multi-wheel-record into next-version
Brings the multi-wheel recording feature, trip details/map, TripCsv fixes, export-as-zip, and the metrics work (trip meter, phase current, dashboard metric pills, HUD GPS speed) plus the dashboard wrong-value audit fixes. Supersedes metrics-fixes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GADLyChheAoMX9dQbRgRnH # Conflicts: # app/src/main/java/com/eried/eucplanet/ui/recording/RecordingViewModel.kt
2 parents bba8b71 + bd8c908 commit 88eb581

55 files changed

Lines changed: 2748 additions & 151 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

app/src/main/java/com/eried/eucplanet/ble/InMotionV2Parser.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ import kotlin.math.roundToInt
1414
*/
1515
object InMotionV2Parser {
1616

17+
/** P6 motor torque constant (N.m per amp of phase current), recovered by
18+
* correlating the InMotion app's Phase Current vs Motor Torque readings
19+
* across a labelled ride. phase_A = torque_Nm / this. */
20+
private const val P6_KT_NM_PER_A = 0.586f
21+
1722
/**
1823
* Parse RealTimeInfo response (command 0x04) into WheelData.
1924
* V14 telemetry layout.
@@ -245,6 +250,15 @@ object InMotionV2Parser {
245250
// Earlier guess at 18-19 was zero across all idle frames.
246251
val torque = if (data.size >= 14) ByteUtils.getInt16LE(data, 12) / 100f else 0f
247252

253+
// Phase current is NOT transmitted by the P6; the InMotion app derives it
254+
// from torque and so do we. Verified against a same-ride video+btsnoop at
255+
// three labelled load points over a 75x range (torque 1.3 / 9.7 / 96.7 N.m
256+
// vs app phase 2.2 / 16.6 / 165.1 A): phase = torque / Kt with Kt ~ 0.586
257+
// N.m/A (i.e. torque x 1.706) reproduces every point within rounding, and
258+
// an exhaustive byte search found no independent phase-current field. Kept
259+
// signed (negative on regen) to match how we already show current/torque.
260+
val phaseCurrent = torque / P6_KT_NM_PER_A
261+
248262
// Battery and motor power (W, signed) at offsets 16 and 18 - the SAME
249263
// layout as the V14 RealTimeInfo packet, which reads batteryPower@16 and
250264
// motorPower@18. Confirmed against a labelled P6 capture: body[16] tracks
@@ -328,6 +342,7 @@ object InMotionV2Parser {
328342
current = current,
329343
pwm = pwm,
330344
torque = torque,
345+
phaseCurrent = phaseCurrent,
331346
batteryPower = batteryPower,
332347
motorPower = motorPower,
333348
pcMode = pcMode,

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,16 @@ object MetricCatalog {
136136
sparkline = SparklineStyle.NONE, // monotonic counter
137137
supportsStats = false
138138
),
139+
MetricSpec(
140+
// Car-odometer-style running distance, counts while connected and
141+
// persists across restarts. Tapping opens the distance-split detail
142+
// view, not the generic min/max/avg one, so it needs no stat buffer.
143+
key = "TRIP_METER",
144+
labelRes = R.string.metric_chip_trip_meter,
145+
accent = AccentPurple,
146+
sparkline = SparklineStyle.NONE, // monotonic counter
147+
supportsStats = false
148+
),
139149

140150
// ---- Pool (already-buffered) ----
141151
MetricSpec(
@@ -221,6 +231,13 @@ object MetricCatalog {
221231
sparkline = SparklineStyle.AREA_BIPOLAR,
222232
bipolarNegativeAccent = AccentGreen
223233
),
234+
MetricSpec(
235+
key = "PHASE_CURRENT",
236+
labelRes = R.string.metric_chip_phase_current,
237+
accent = AccentBlue,
238+
sparkline = SparklineStyle.AREA_BIPOLAR,
239+
bipolarNegativeAccent = AccentGreen
240+
),
224241
MetricSpec(
225242
key = "DYN_SPEED_LIMIT",
226243
labelRes = R.string.metric_chip_dyn_speed_limit,
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
package com.eried.eucplanet.data.model
2+
3+
/**
4+
* One 10 km / 10 mi split record in the trip meter's log. Distances are stored
5+
* in km (the app's canonical distance unit); the detail view converts to the
6+
* rider's chosen unit at render time. All fields are cheap running accumulators,
7+
* captured the moment a boundary is crossed.
8+
*/
9+
data class TripMeterSplit(
10+
/** 1, 2, 3 ... in the order the boundaries were crossed. */
11+
val index: Int,
12+
/** Boundary distance in km (10, 20, 30 ... for a km rider; 16.09, 32.19 ...
13+
* for a mi rider whose boundary is every 10 mi). */
14+
val markDistanceKm: Float,
15+
/** Active (moving) time elapsed when this mark was reached, in ms. */
16+
val cumulativeMs: Long,
17+
/** Time for this segment alone (cumulativeMs minus the previous mark's), in ms. */
18+
val segmentMs: Long,
19+
/** Average speed over this segment in km/h (one step / segmentMs). */
20+
val segmentAvgKmh: Float,
21+
/** Peak speed seen during this segment in km/h (running max). */
22+
val segmentMaxKmh: Float,
23+
/** Battery percent sampled at the mark. -1 when unknown. */
24+
val batteryPctAtMark: Int,
25+
)
26+
27+
/**
28+
* Persisted running state of the car-odometer-style trip meter. Counts distance
29+
* while a wheel is connected (independent of recording) and is cleared only by a
30+
* manual reset or by Stop All. Distances are km; the UI converts to the rider's
31+
* unit.
32+
*/
33+
data class TripMeterState(
34+
/** Total distance since the last reset, in km. */
35+
val distanceKm: Float = 0f,
36+
/** Active (moving) time since the last reset, in ms. */
37+
val activeMs: Long = 0L,
38+
/** Wall-clock (epoch ms) the meter first started counting after a reset. 0 = never. */
39+
val startedAtMs: Long = 0L,
40+
/** Full split log, oldest first. Never pruned by the dashboard stats window. */
41+
val splits: List<TripMeterSplit> = emptyList(),
42+
) {
43+
/** Overall average speed in km/h over the active time. 0 when no active time yet. */
44+
val overallAvgKmh: Float
45+
get() = if (activeMs > 0L) (distanceKm / (activeMs / 3_600_000.0)).toFloat() else 0f
46+
}
47+
48+
/**
49+
* Pure split-accumulation core for the trip meter, free of Android types so it is
50+
* unit-testable on the JVM. Feed it per-tick deltas; it maintains the running
51+
* total and appends a [TripMeterSplit] each time distance crosses the next
52+
* multiple of [intervalKm], resetting the segment accumulators at every boundary.
53+
*
54+
* The in-progress (partial) segment is not a split yet; it lives in the running
55+
* [distanceKm] / [activeMs] the [snapshot] exposes.
56+
*/
57+
class TripMeterAccumulator(
58+
/** The split step in km (10 for a km rider, 10 mi -> ~16.09 for a mi rider). */
59+
var intervalKm: Float = 10f,
60+
) {
61+
var distanceKm: Double = 0.0
62+
private set
63+
var activeMs: Long = 0L
64+
private set
65+
var startedAtMs: Long = 0L
66+
private set
67+
68+
private val splits = mutableListOf<TripMeterSplit>()
69+
// Peak speed within the current (in-progress) segment; reset at each boundary.
70+
private var segmentMaxKmh: Float = 0f
71+
72+
/**
73+
* Advance the meter by one telemetry tick.
74+
*
75+
* @param distanceDeltaKm distance covered since the previous tick, in km (>= 0).
76+
* @param dtActiveMs active (moving) time since the previous tick, in ms (>= 0).
77+
* @param speedKmh current speed in km/h, folded into the segment running max.
78+
* @param batteryPct battery percent sampled this tick (used when a mark lands here).
79+
* @param nowMs wall clock, used only to stamp [startedAtMs] on the first motion.
80+
*/
81+
fun onTick(
82+
distanceDeltaKm: Float,
83+
dtActiveMs: Long,
84+
speedKmh: Float,
85+
batteryPct: Int,
86+
nowMs: Long,
87+
) {
88+
if (startedAtMs == 0L && (distanceDeltaKm > 0f || dtActiveMs > 0L)) startedAtMs = nowMs
89+
if (dtActiveMs > 0L) activeMs += dtActiveMs
90+
if (speedKmh > segmentMaxKmh) segmentMaxKmh = speedKmh
91+
if (distanceDeltaKm > 0f) distanceKm += distanceDeltaKm.toDouble()
92+
93+
val step = intervalKm.toDouble().coerceAtLeast(0.0001)
94+
// Usually crosses 0 or 1 boundary per tick; the loop also handles a rare
95+
// multi-boundary jump (a big GPS delta) without losing a split.
96+
while (distanceKm + 1e-6 >= (splits.size + 1) * step) {
97+
val index = splits.size + 1
98+
val markKm = index * step
99+
val prevCumMs = splits.lastOrNull()?.cumulativeMs ?: 0L
100+
val cumulativeMs = activeMs
101+
val segMs = (cumulativeMs - prevCumMs).coerceAtLeast(0L)
102+
val segAvg = if (segMs > 0L) (intervalKm / (segMs / 3_600_000.0)).toFloat() else 0f
103+
splits.add(
104+
TripMeterSplit(
105+
index = index,
106+
markDistanceKm = markKm.toFloat(),
107+
cumulativeMs = cumulativeMs,
108+
segmentMs = segMs,
109+
segmentAvgKmh = segAvg,
110+
segmentMaxKmh = segmentMaxKmh,
111+
batteryPctAtMark = batteryPct,
112+
)
113+
)
114+
segmentMaxKmh = 0f
115+
}
116+
}
117+
118+
/** Current running state (total + full split log). */
119+
fun snapshot(): TripMeterState =
120+
TripMeterState(
121+
distanceKm = distanceKm.toFloat(),
122+
activeMs = activeMs,
123+
startedAtMs = startedAtMs,
124+
splits = splits.toList(),
125+
)
126+
127+
/** Zero the total and clear the split log. */
128+
fun reset() {
129+
distanceKm = 0.0
130+
activeMs = 0L
131+
startedAtMs = 0L
132+
splits.clear()
133+
segmentMaxKmh = 0f
134+
}
135+
136+
/**
137+
* Reload from a persisted state (app restart). The in-progress segment's
138+
* running max isn't persisted, so it restarts at 0; the completed splits and
139+
* the totals carry over exactly.
140+
*/
141+
fun restore(state: TripMeterState) {
142+
distanceKm = state.distanceKm.toDouble()
143+
activeMs = state.activeMs
144+
startedAtMs = state.startedAtMs
145+
splits.clear()
146+
splits.addAll(state.splits)
147+
segmentMaxKmh = 0f
148+
}
149+
}

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ data class WheelData(
99
val battery2Percent: Float = 0f,
1010
val pwm: Float = 0f,
1111
val torque: Float = 0f,
12+
/** Motor phase current in A (signed: negative on regen / braking, like
13+
* [current] and [torque]). Only some wheels report or expose it; on the P6
14+
* it is derived from torque (the wheel sends no phase-current field), so it
15+
* stays 0 on wheels that neither send nor derive it. */
16+
val phaseCurrent: Float = 0f,
1217
val temperatures: List<Float> = emptyList(),
1318
val maxTemperature: Float = 0f,
1419
val tripDistance: Float = 0f, // km
@@ -28,6 +33,15 @@ data class WheelData(
2833
* or -1 when none is paired / no fresh sample. Merged in like the battery /
2934
* lat / long above so an overlay or HUD element can show it. */
3035
val externalGpsSpeedKmh: Float = -1f,
36+
/** Ground speed in km/h from the PHONE's fused GPS, or -1 when no fix.
37+
* Distinct from externalGpsSpeedKmh (a paired box); merged in like lat/long
38+
* so an overlay / HUD element can show the phone GPS speed. */
39+
val gpsSpeedKmh: Float = -1f,
40+
/** Running trip-meter distance in km (the connect-scoped car odometer), or -1
41+
* when not merged in. Not wheel telemetry, so it stays -1 on the plain wheel
42+
* stream; the Overlay Studio / HUD merge it in like gpsSpeedKmh so an overlay
43+
* number can show it. */
44+
val tripMeterKm: Float = -1f,
3145
/** Phone IMU acceleration magnitude in g, 0 for trips recorded before this. */
3246
val gForce: Float = 0f,
3347
/** Phone IMU lateral acceleration in g (+right). 0 for trips recorded before this. */

0 commit comments

Comments
 (0)