Files
MicrOBU/app/src/main/java/com/hawhamburg/micr0bu/data/GnssTimeSource.kt
T
Ashin Walpola 83153a0971 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.
2026-09-10 14:47:30 +02:00

64 lines
2.9 KiB
Kotlin

package com.hawhamburg.micr0bu.data
import android.os.SystemClock
import android.util.Log
import com.hawhamburg.micr0bu.domain.asn1.ItsTime
import java.time.DateTimeException
/**
* Puts the timestamps this phone transmits on GNSS time instead of its own wall clock.
*
* ## Why
* Every CAM carries a generationDeltaTime and every GeoNetworking header a TST, and receivers use
* them to judge how fresh a message is and in what order messages came. Both used to come straight
* from `System.currentTimeMillis()`, so they were only as good as the phone's clock setting. On
* 2026-09-10 the bench phone was 24 minutes fast: automatic time had no source (no SIM, and the
* lab Wi-Fi has no internet time), so it had not set the clock once in 69 hours, and every CAM
* went out stamped 24 minutes in the future. A bike-mounted phone on the road is in exactly that
* position. GNSS time depends on none of it.
*
* ## How
* [SystemClock.currentGnssTimeClock] (API 29, this app's minSdk) is a UTC clock the platform keeps
* synchronised from GNSS fixes. One reading of it taken alongside the wall clock gives the wall
* clock's error, which is then applied to the fix's own timestamp. When GNSS time is unavailable,
* typically indoors before any satellite fix since boot, the wall clock is used unchanged.
*
* Which clock is in use is logged whenever it changes, with the measured error, so a capture shows
* where a given run's timestamps came from.
*
* Only the transmit path uses this. Everything else in the app stays on the wall clock, because
* received messages, sensor samples and trip records are all stamped with it and must stay
* comparable with one another.
*/
object GnssTimeSource {
private const val TAG = "GnssTimeSource"
/** Whether the last correction used GNSS time; null before the first. For change-only logging. */
@Volatile private var lastUsedGnss: Boolean? = null
/** [systemMs], a wall-clock reading, moved onto GNSS time where GNSS time is available. */
fun correct(systemMs: Long): Long {
val systemNow = System.currentTimeMillis()
val gnssNow = try {
SystemClock.currentGnssTimeClock().millis()
} catch (e: DateTimeException) {
null
}
noteSource(gnssNow, systemNow)
return ItsTime.onGnssTime(systemMs, gnssNow, systemNow)
}
private fun noteSource(gnssNow: Long?, systemNow: Long) {
val usingGnss = gnssNow != null
if (lastUsedGnss == usingGnss) return
lastUsedGnss = usingGnss
if (gnssNow != null) {
Log.i(TAG, "transmit timestamps now on GNSS time; phone clock is " +
"${"%+.1f".format((systemNow - gnssNow) / 1000.0)} s off")
} else {
Log.w(TAG, "GNSS time unavailable, transmit timestamps fall back to the phone clock, " +
"which has no automatic time source without a SIM or internet")
}
}
}