Send each CAM with its position vector, a rotating pseudonym and GNSS time

The app side of the firmware's CAM_TX_PV message. Until now the phone
handed the ESP32 bare CAM bytes, so the GeoNetworking header around them
could only carry the firmware's bench placeholders.

GnPositionVector.fromCam builds the Source Position Vector from the same
Cam the UPER is encoded from, so the two layers cannot disagree about
where the rider is. Position is rounded exactly as CamUperCodec rounds
it, heading wraps into 0..3599, and non-finite values become 0. PAI is
set when Android's horizontal accuracy is at most 24.7 m, the 40 m
itsGnPaiInterval/2 threshold converted from a 95% to a 68% confidence
radius. UsbSerialTransport.sendCamTx sends 0x05 once the heartbeat
advertises the capability and 0x01 otherwise, so this build still
transmits against older firmware, and logs which path it is on.

Pseudonyms. The station ID used to be created once per install and never
changed, under a MAC that never changed either, so every CAM this phone
ever sent was linkable to every other. PseudonymManager now owns the
station ID and the MAC as one identity and replaces both together every
10 minutes, or immediately if the clock goes backwards. Both are
persisted in a single edit, so a crash cannot leave them mismatched.
MACs are locally administered unicast and can never equal the bench
ping's. CamTransmitLoop takes the current pseudonym per CAM, and the two
most recently retired IDs still count as ours, so a frame sent just
before a rotation is not taken for a stranger.

GNSS time. On 2026-09-10 the bench phone's clock was 24 minutes fast:
with no SIM and no internet time it had no automatic time source, and
every CAM went out stamped in the future. GnssTimeSource moves transmit
timestamps onto SystemClock.currentGnssTimeClock() and falls back to the
wall clock without a fix, logging which one is in use and the measured
error. ItsTime is now the single rule for both the CAM's
generationDeltaTime and the GN TST. Receive paths stay on the wall clock
so everything they stamp remains comparable.

The bench pinger keeps its fixed station 999999 and a fixed MAC, so a
ping stays recognisable in a capture. 999999 now counts as ours only
while this phone's pinger runs and for 5 s after it stops. The previous
rule treated it as ours unconditionally, which hid another phone's pings
on the same bench.

Leap seconds are an open question, recorded in ItsTime: TimestampIts may
be TAI-based, which would put it 5 s higher. 85 tests, 0 failures.
This commit is contained in:
Ashin Walpola
2026-09-10 14:47:30 +02:00
parent 3eeccfb268
commit 83153a0971
17 changed files with 988 additions and 120 deletions
@@ -2,8 +2,11 @@ package com.hawhamburg.micr0bu.service
import android.content.Context
import com.hawhamburg.micr0bu.data.GnssReading
import com.hawhamburg.micr0bu.data.GnssTimeSource
import com.hawhamburg.micr0bu.data.SensorRepository
import com.hawhamburg.micr0bu.data.cam.PseudonymManager
import com.hawhamburg.micr0bu.data.mqtt.ObuHardwarePreferences
import com.hawhamburg.micr0bu.data.transport.GnPositionVector
import com.hawhamburg.micr0bu.data.transport.ObuHardware
import com.hawhamburg.micr0bu.data.transport.UsbSerialTransport
import com.hawhamburg.micr0bu.domain.asn1.RealAsn1UperCodec
@@ -49,6 +52,7 @@ class CamTransmitLoop @Inject constructor(
private val obuHardwarePrefs: ObuHardwarePreferences,
private val usbSerialTransport: UsbSerialTransport,
private val codec: RealAsn1UperCodec,
private val pseudonymManager: PseudonymManager,
) {
private val config = CamTransmitConfig()
private val sensorRepository = SensorRepository(context)
@@ -63,14 +67,6 @@ class CamTransmitLoop @Inject constructor(
/** Previous GNSS fix, kept only to derive along-track acceleration — see [longitudinalAccel]. */
@Volatile private var previousGnss: GnssReading? = null
/**
* Own station id for the ESP32-C5 path, loaded once per [start] from
* [ObuHardwarePreferences.getOrCreateOwnStationId]. 0 means "not loaded yet" — the loop waits
* for the real value rather than beaconing as station 0, which would be indistinguishable
* from every other MicrOBU to any receiver.
*/
@Volatile var stationId: Long = 0L
/**
* Call when a braking/turning/stopping event fires during an active trip — bumps the CAM
* rate to [CamTransmitConfig.elevatedRateHz] for [ELEVATED_HOLD_MS] so nearby stations get
@@ -90,7 +86,6 @@ class CamTransmitLoop @Inject constructor(
elevatedUntilMs = 0L
previousGnss = null
job = scope.launch {
stationId = obuHardwarePrefs.getOrCreateOwnStationId()
obuHardwarePrefs.obuHardwareFlow.collectLatest { hardware ->
if (hardware != ObuHardware.ESP32_C5) return@collectLatest
runTransmitLoop()
@@ -111,9 +106,15 @@ class CamTransmitLoop @Inject constructor(
while (true) {
val gnss = latestGnss
if (gnss != null) {
val cam = PhoneCamBuilder.build(gnss, latestGyroZ, stationId, longitudinalAccel(gnss))
// Asked for per CAM rather than once per trip: that is what lets a pseudonym
// rotation fall cleanly between two frames instead of inside one.
val pseudonym = pseudonymManager.current()
// Stamped on GNSS time rather than the phone clock; see GnssTimeSource. Only the
// outgoing CAM is: acceleration below still differences wall-clock samples.
val fix = gnss.copy(timestamp = GnssTimeSource.correct(gnss.timestamp))
val cam = PhoneCamBuilder.build(fix, latestGyroZ, pseudonym.stationId, longitudinalAccel(gnss))
val bytes = codec.encodeCam(cam)
usbSerialTransport.sendCamTx(bytes)
usbSerialTransport.sendCamTx(bytes, GnPositionVector.fromCam(cam, gnss.accuracyM, pseudonym.mac))
}
delay((1000.0 / currentRateHz(gnss)).toLong())
}