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:
@@ -0,0 +1,63 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import com.hawhamburg.micr0bu.domain.spat.SpatEvent
|
||||
import com.hawhamburg.micr0bu.domain.usecase.UseCaseAlert
|
||||
import com.hawhamburg.micr0bu.domain.usecase.UseCaseDetectionEngine
|
||||
import com.hawhamburg.micr0bu.domain.usecase.UseCaseType
|
||||
import com.hawhamburg.micr0bu.service.CamPinger
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -104,6 +105,8 @@ class CamUseCaseRepository @Inject constructor(
|
||||
private val usbSerialTransport: UsbSerialTransport,
|
||||
private val camCodec: RealAsn1UperCodec,
|
||||
private val obuHardwarePrefs: ObuHardwarePreferences,
|
||||
private val pseudonymManager: PseudonymManager,
|
||||
private val camPinger: CamPinger,
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
@@ -312,8 +315,8 @@ class CamUseCaseRepository @Inject constructor(
|
||||
}
|
||||
|
||||
// Our own station ID. On the CiT One path it's learned from v2x/rx/obu_gnss; the ESP32-C5
|
||||
// path has no such topic, so it comes from the same persisted value CamTransmitLoop puts
|
||||
// in outgoing CAMs.
|
||||
// path has no such topic, so it follows the current transmit pseudonym, the same one
|
||||
// CamTransmitLoop puts in outgoing CAMs, across every rotation.
|
||||
//
|
||||
// Without this the ID stayed null on the ESP32 path and the self-heard-TX filter in
|
||||
// [handleCamFromSerial] never fired - so the phone's own CAMs, which the ESP32 hears back
|
||||
@@ -321,10 +324,13 @@ class CamUseCaseRepository @Inject constructor(
|
||||
// sitting exactly on top of the ego position, fed into the detection engine as a
|
||||
// collision partner for itself.
|
||||
scope.launch {
|
||||
obuHardwarePrefs.obuHardwareFlow.collect { hardware ->
|
||||
combine(obuHardwarePrefs.obuHardwareFlow, pseudonymManager.currentFlow) { hardware, pseudonym ->
|
||||
hardware to pseudonym
|
||||
}.collect { (hardware, pseudonym) ->
|
||||
currentHardware = hardware
|
||||
if (hardware == ObuHardware.ESP32_C5) {
|
||||
_ownStationId.value = obuHardwarePrefs.getOrCreateOwnStationId()
|
||||
// currentFlow re-emits on every rotation, so this tracks the live identity.
|
||||
_ownStationId.value = (pseudonym ?: pseudonymManager.current()).stationId
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -339,11 +345,17 @@ class CamUseCaseRepository @Inject constructor(
|
||||
* recognised as our own rather than tracked as another road user. Also drives the OWN/REMOTE
|
||||
* badges in the raw message list.
|
||||
*
|
||||
* The rule lives in [OwnStationIds], which explains why there are two such ids and what goes
|
||||
* wrong when only one of them is checked.
|
||||
* The rule lives in [OwnStationIds], which explains which ids count and what goes wrong when
|
||||
* one is missed. The set passed in holds the current transmit pseudonym and the ones it most
|
||||
* recently replaced, plus, on the CiT One path, the OBU's own id from obu_gnss. The bench
|
||||
* ping id counts only while this phone's own pinger is running.
|
||||
*/
|
||||
fun isOwnStationId(stationId: Long): Boolean =
|
||||
OwnStationIds.isOwn(stationId, _ownStationId.value)
|
||||
OwnStationIds.isOwn(
|
||||
stationId,
|
||||
ownIds = pseudonymManager.ownStationIds() + setOfNotNull(_ownStationId.value),
|
||||
benchPingIsOurs = camPinger.benchPingIsOurs(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Primary ego state source: `v2x/rx/obu_gnss`, ~4 Hz, carries position/speed/heading/yaw
|
||||
@@ -392,9 +404,11 @@ class CamUseCaseRepository @Inject constructor(
|
||||
|
||||
private fun handleCam(payload: String, timestamp: Long) {
|
||||
val cam = CamParser.parse(payload, _ownStationId.value, timestamp) ?: return
|
||||
// A bench ping the CiT One's radio picked up and relayed here. Not a road user, and not
|
||||
// ego state either: the ping is built from the same phone GNSS the engine already has.
|
||||
if (cam.stationId == OwnStationIds.BENCH_PING) return
|
||||
// This phone's own bench ping, relayed back by the CiT One's radio: not a road user, and not
|
||||
// ego state either, since it is built from the same phone GNSS the engine already has.
|
||||
// Only while this phone is the one pinging, though. Another phone's pings carry the same
|
||||
// fixed id and are genuine remote traffic to this one.
|
||||
if (cam.stationId == OwnStationIds.BENCH_PING && camPinger.benchPingIsOurs()) return
|
||||
if (cam.isOwn) {
|
||||
// Third fallback - the CAM topic's own low-rate entry. onOwnCam() keeps whichever
|
||||
// update is freshest, so this only actually wins when both obu_gnss and phone GNSS
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.hawhamburg.micr0bu.data.cam
|
||||
|
||||
import android.util.Log
|
||||
import com.hawhamburg.micr0bu.data.mqtt.ObuHardwarePreferences
|
||||
import com.hawhamburg.micr0bu.domain.cam.Pseudonym
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Owns the phone's transmit identity on the ESP32-C5 path and rotates it every
|
||||
* [Pseudonym.ROTATION_INTERVAL_MS].
|
||||
*
|
||||
* A singleton because there must be exactly one of these. [com.hawhamburg.micr0bu.service.CamTransmitLoop]
|
||||
* runs inside the foreground recording service and [CamUseCaseRepository] filters received frames;
|
||||
* if each held its own identity, the phone could transmit under one pseudonym while its receive
|
||||
* path recognised another, which brings back the ghost road user sitting on the ego position.
|
||||
* The bench pinger deliberately does not use this: it keeps a fixed identity so pings stay
|
||||
* recognisable in a capture.
|
||||
*/
|
||||
@Singleton
|
||||
class PseudonymManager @Inject constructor(
|
||||
private val prefs: ObuHardwarePreferences,
|
||||
) {
|
||||
private val mutex = Mutex()
|
||||
|
||||
private val _current = MutableStateFlow<Pseudonym?>(null)
|
||||
|
||||
/** The identity in use, or null before the first call to [current] has loaded one. */
|
||||
val currentFlow: StateFlow<Pseudonym?> = _current.asStateFlow()
|
||||
|
||||
/** Station IDs replaced most recently, newest first. See [ownStationIds]. */
|
||||
@Volatile private var recentlyRetired: List<Long> = emptyList()
|
||||
|
||||
/**
|
||||
* Every station ID one of our own frames could still be carrying: the current pseudonym's and
|
||||
* the ones it replaced most recently.
|
||||
*
|
||||
* The previous IDs matter because the ESP32 hears our own transmissions back. A frame sent just
|
||||
* before a rotation can come back just after it, and if its ID no longer counted as ours it
|
||||
* would be tracked as another road user sitting exactly on the ego position.
|
||||
*/
|
||||
fun ownStationIds(): Set<Long> = buildSet {
|
||||
_current.value?.let { add(it.stationId) }
|
||||
addAll(recentlyRetired)
|
||||
}
|
||||
|
||||
/**
|
||||
* The pseudonym to transmit under right now, rotating first if the current one has expired.
|
||||
*
|
||||
* Rotation happens here, at the moment an identity is about to be used, rather than on a
|
||||
* timer. Each frame therefore carries one complete identity chosen in a single step, so a
|
||||
* rotation can never land between the CAM being built and its position vector being attached.
|
||||
*
|
||||
* Persisted, so an app restart inside the interval keeps the same identity. Only elapsed time
|
||||
* rotates it, never a crash or a relaunch.
|
||||
*/
|
||||
suspend fun current(nowMs: Long = System.currentTimeMillis()): Pseudonym = mutex.withLock {
|
||||
val existing = _current.value ?: prefs.loadPseudonym()
|
||||
if (existing != null && !existing.isExpired(nowMs)) {
|
||||
_current.value = existing
|
||||
existing
|
||||
} else {
|
||||
val next = Pseudonym.generate(nowMs)
|
||||
prefs.savePseudonym(next)
|
||||
if (existing != null) {
|
||||
recentlyRetired = (listOf(existing.stationId) + recentlyRetired).take(RETIRED_TO_KEEP)
|
||||
}
|
||||
_current.update { next }
|
||||
Log.i(TAG, "pseudonym rotated: station ${existing?.stationId} -> ${next.stationId}")
|
||||
next
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "PseudonymManager"
|
||||
|
||||
/**
|
||||
* A loopback arrives within milliseconds, so one previous ID would already be ample. Two
|
||||
* costs nothing and covers a rotation that fires twice in quick succession after a clock
|
||||
* correction.
|
||||
*/
|
||||
const val RETIRED_TO_KEEP = 2
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,13 @@ import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.hawhamburg.micr0bu.data.transport.ObuHardware
|
||||
import com.hawhamburg.micr0bu.domain.cam.Pseudonym
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.random.Random
|
||||
|
||||
private val Context.obuHardwareDataStore by preferencesDataStore(name = "obu_hardware_prefs")
|
||||
|
||||
@@ -26,7 +27,11 @@ class ObuHardwarePreferences @Inject constructor(
|
||||
) {
|
||||
private object Keys {
|
||||
val OBU_HARDWARE = stringPreferencesKey("obu_hardware")
|
||||
// The current transmit pseudonym. Three keys, but only ever read or written together;
|
||||
// see loadPseudonym.
|
||||
val OWN_STATION_ID = longPreferencesKey("own_station_id")
|
||||
val OWN_MAC = stringPreferencesKey("own_mac")
|
||||
val OWN_PSEUDONYM_CREATED_MS = longPreferencesKey("own_pseudonym_created_ms")
|
||||
}
|
||||
|
||||
val obuHardwareFlow: Flow<ObuHardware> = context.obuHardwareDataStore.data.map { prefs ->
|
||||
@@ -37,31 +42,36 @@ class ObuHardwarePreferences @Inject constructor(
|
||||
context.obuHardwareDataStore.edit { prefs -> prefs[Keys.OBU_HARDWARE] = hardware.id }
|
||||
}
|
||||
|
||||
/** This device's own CAM StationID, or null if one hasn't been assigned yet. */
|
||||
val ownStationIdFlow: Flow<Long?> = context.obuHardwareDataStore.data.map { prefs ->
|
||||
prefs[Keys.OWN_STATION_ID]
|
||||
/**
|
||||
* The transmit pseudonym last saved by [savePseudonym], or null if there is none.
|
||||
*
|
||||
* All three parts must be present. An install from before pseudonym rotation has a station ID
|
||||
* but no MAC or creation time, and loads as null so that a complete new pseudonym is
|
||||
* generated. Keeping the old ID alongside a fresh MAC would be exactly the partial rotation
|
||||
* [Pseudonym] exists to rule out.
|
||||
*
|
||||
* Only [com.hawhamburg.micr0bu.data.cam.PseudonymManager] should call this: it is the one
|
||||
* owner of the phone's transmit identity.
|
||||
*/
|
||||
suspend fun loadPseudonym(): Pseudonym? {
|
||||
val prefs = context.obuHardwareDataStore.data.first()
|
||||
val stationId = prefs[Keys.OWN_STATION_ID] ?: return null
|
||||
val mac = prefs[Keys.OWN_MAC]?.let(::macFromHex) ?: return null
|
||||
val createdAtMs = prefs[Keys.OWN_PSEUDONYM_CREATED_MS] ?: return null
|
||||
return Pseudonym(stationId, mac, createdAtMs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns this device's own CAM StationID, generating and persisting a random one on first
|
||||
* call.
|
||||
*
|
||||
* Replaces the previous hardcoded 0: receivers key on StationID to track a station across
|
||||
* successive CAMs, so every MicrOBU broadcasting 0 makes two units in the same area
|
||||
* indistinguishable to any receiver — including this app's own detection engine, which
|
||||
* dedupes remote stations by ID. Random rather than derived from a hardware identifier both
|
||||
* because ETSI expects station IDs to be pseudonymous and because Android hardware IDs aren't
|
||||
* readable without privileged permissions on modern versions.
|
||||
*
|
||||
* Range is 1..2^32-2: StationID is INTEGER(0..4294967295), and 0 is avoided so leftover
|
||||
* placeholder traffic stays distinguishable from a real assignment.
|
||||
*/
|
||||
suspend fun getOrCreateOwnStationId(): Long {
|
||||
val prefs = context.obuHardwareDataStore.edit { p ->
|
||||
if (p[Keys.OWN_STATION_ID] == null) {
|
||||
p[Keys.OWN_STATION_ID] = Random.nextLong(1L, 0xFFFF_FFFEL)
|
||||
}
|
||||
/** Persists [pseudonym] in a single edit, so a crash can never leave half an identity stored. */
|
||||
suspend fun savePseudonym(pseudonym: Pseudonym) {
|
||||
context.obuHardwareDataStore.edit { p ->
|
||||
p[Keys.OWN_STATION_ID] = pseudonym.stationId
|
||||
p[Keys.OWN_MAC] = pseudonym.mac.joinToString("") { "%02x".format(it) }
|
||||
p[Keys.OWN_PSEUDONYM_CREATED_MS] = pseudonym.createdAtMs
|
||||
}
|
||||
return prefs[Keys.OWN_STATION_ID]!!
|
||||
}
|
||||
|
||||
private fun macFromHex(hex: String): ByteArray? =
|
||||
if (hex.length != 12) null
|
||||
else runCatching { ByteArray(6) { i -> hex.substring(2 * i, 2 * i + 2).toInt(16).toByte() } }
|
||||
.getOrNull()
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
package com.hawhamburg.micr0bu.data.transport
|
||||
|
||||
import com.hawhamburg.micr0bu.domain.asn1.ItsTime
|
||||
import com.hawhamburg.micr0bu.domain.cam.Cam
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.roundToLong
|
||||
|
||||
/**
|
||||
* Binary framing for the phone <-> ESP32-C5 link (Phase 03). Kotlin counterpart of the
|
||||
* firmware's `obu-firmware/main/serial_link.c`/`.h` — frame shape and CRC algorithm MUST stay
|
||||
@@ -23,6 +28,15 @@ object SerialFrameType {
|
||||
/** ESP32 -> phone: periodic heartbeat + drop counters, independent of CAM traffic.
|
||||
* Payload layout is [EspLinkStatus] — see its KDoc. */
|
||||
const val STATUS: Int = 0x03
|
||||
|
||||
/**
|
||||
* Phone -> ESP32: a CAM together with the GeoNetworking Source Position Vector to transmit it
|
||||
* under. Payload is the [GnPositionVector.PREFIX_SIZE]-byte [GnPositionVector] prefix, then
|
||||
* the CAM UPER. Sent only to firmware whose heartbeat advertises
|
||||
* [EspLinkStatus.supportsCamTxPv]; `serial_link.h` explains why this is a new type rather
|
||||
* than a changed [CAM_TX].
|
||||
*/
|
||||
const val CAM_TX_PV: Int = 0x05
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,10 +73,22 @@ data class EspLinkStatus(
|
||||
val txFailures: Int,
|
||||
/** Frames from the phone the firmware dropped on CRC mismatch. */
|
||||
val rxCrcErrors: Int,
|
||||
/**
|
||||
* What the firmware accepts, as `SERIAL_CAP_*` bits from `serial_link.h`. Byte 7 of the
|
||||
* payload; 0 for firmware that predates it and sends only 7 bytes, which is exactly the answer
|
||||
* the phone needs from such firmware: it accepts nothing beyond the original messages.
|
||||
*/
|
||||
val capabilities: Int = 0,
|
||||
) {
|
||||
/** True when the firmware accepts [SerialFrameType.CAM_TX_PV]. */
|
||||
val supportsCamTxPv: Boolean get() = capabilities and CAP_CAM_TX_PV != 0
|
||||
|
||||
companion object {
|
||||
const val PAYLOAD_SIZE = 7
|
||||
|
||||
/** Mirrors `SERIAL_CAP_CAM_TX_PV` in `serial_link.h`. */
|
||||
const val CAP_CAM_TX_PV = 0x01
|
||||
|
||||
/** Returns null if [payload] isn't a well-formed status payload (e.g. older firmware). */
|
||||
fun parse(payload: ByteArray): EspLinkStatus? {
|
||||
if (payload.size < PAYLOAD_SIZE) return null
|
||||
@@ -72,6 +98,7 @@ data class EspLinkStatus(
|
||||
oversizeDrops = u16(1),
|
||||
txFailures = u16(3),
|
||||
rxCrcErrors = u16(5),
|
||||
capabilities = if (payload.size > PAYLOAD_SIZE) payload[7].toInt() and 0xFF else 0,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -161,6 +188,116 @@ data class V2xRxFrame(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The GeoNetworking Source Position Vector content sent with each CAM: the 24-byte little-endian
|
||||
* prefix of a [SerialFrameType.CAM_TX_PV] payload. Must stay in lockstep with the layout at
|
||||
* `SERIAL_MSG_CAM_TX_PV` in `serial_link.h`, which the firmware decodes into `gn_lpv_t`.
|
||||
*
|
||||
* Every field is something the ESP32-C5 cannot know by itself, since it has no GNSS and no clock
|
||||
* on the OCB channel. That is why its GN header used to carry fixed bench placeholders instead,
|
||||
* describing a stationary car at the bench while the CAM inside described the moving rider.
|
||||
*/
|
||||
data class GnPositionVector(
|
||||
/** Pseudonym, 6 bytes: both the 802.11 source address and the GN_ADDR MID. */
|
||||
val mac: ByteArray,
|
||||
/** TS 102 894-2 StationType. */
|
||||
val stationType: Int,
|
||||
/** Position Accuracy Indicator. */
|
||||
val pai: Boolean,
|
||||
/** TimestampIts at which the position was acquired; reduced modulo 2^32 on the wire. */
|
||||
val tstMs: Long,
|
||||
/** 1/10 microdegree. */
|
||||
val latTenMicroDeg: Int,
|
||||
/** 1/10 microdegree. */
|
||||
val lonTenMicroDeg: Int,
|
||||
/** 0.01 m/s, within the GN field's 15-bit signed range. */
|
||||
val speedCms: Int,
|
||||
/** 0.1 degree from north, clockwise, 0..3599. */
|
||||
val headingDeciDeg: Int,
|
||||
) {
|
||||
init {
|
||||
require(mac.size == 6) { "a MAC is 6 bytes, got ${mac.size}" }
|
||||
}
|
||||
|
||||
/** The 24-byte prefix, little-endian like the rest of this framing. */
|
||||
fun toSerialPrefix(): ByteArray {
|
||||
val out = ByteArray(PREFIX_SIZE)
|
||||
mac.copyInto(out, destinationOffset = 0)
|
||||
out[6] = stationType.toByte()
|
||||
out[7] = (if (pai) 0x01 else 0x00).toByte()
|
||||
putLe(out, 8, tstMs, 4)
|
||||
putLe(out, 12, latTenMicroDeg.toLong(), 4)
|
||||
putLe(out, 16, lonTenMicroDeg.toLong(), 4)
|
||||
putLe(out, 20, speedCms.toLong(), 2)
|
||||
putLe(out, 22, headingDeciDeg.toLong(), 2)
|
||||
return out
|
||||
}
|
||||
|
||||
// Generated equals/hashCode would compare the MAC array by identity.
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is GnPositionVector) return false
|
||||
return mac.contentEquals(other.mac) && stationType == other.stationType &&
|
||||
pai == other.pai && tstMs == other.tstMs && latTenMicroDeg == other.latTenMicroDeg &&
|
||||
lonTenMicroDeg == other.lonTenMicroDeg && speedCms == other.speedCms &&
|
||||
headingDeciDeg == other.headingDeciDeg
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var h = mac.contentHashCode()
|
||||
for (v in listOf(stationType, pai.hashCode(), tstMs.hashCode(), latTenMicroDeg,
|
||||
lonTenMicroDeg, speedCms, headingDeciDeg)) h = 31 * h + v
|
||||
return h
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val PREFIX_SIZE = 24
|
||||
|
||||
/** The GN speed field is 15-bit signed, in 0.01 m/s. */
|
||||
const val SPEED_MIN_CMS = -16384
|
||||
const val SPEED_MAX_CMS = 16383
|
||||
|
||||
/**
|
||||
* Largest Android horizontal accuracy, in metres, that still sets the Position Accuracy
|
||||
* Indicator.
|
||||
*
|
||||
* EN 302 636-4-1 sets PAI when the 95% semi-major confidence is below itsGnPaiInterval / 2,
|
||||
* and itsGnPaiInterval defaults to 80 m, so the bound is 40 m at 95%. Android reports a 68%
|
||||
* radius instead, and for a circular 2-D error the 95% radius is about 1.62 times the 68%
|
||||
* one, so 40 m becomes about 24.7 m on Android's scale.
|
||||
*/
|
||||
const val PAI_MAX_ACCURACY_M = 24.7f
|
||||
|
||||
/**
|
||||
* The position vector for [cam], built from the same values the CAM payload carries, so
|
||||
* the two layers of one frame describe the same station at the same moment. [accuracyM] is
|
||||
* Android's horizontal accuracy; null or 0 means unknown and leaves PAI clear.
|
||||
*/
|
||||
fun fromCam(cam: Cam, accuracyM: Float?, mac: ByteArray): GnPositionVector =
|
||||
GnPositionVector(
|
||||
mac = mac,
|
||||
stationType = cam.stationType,
|
||||
pai = accuracyM != null && accuracyM > 0f && accuracyM <= PAI_MAX_ACCURACY_M,
|
||||
tstMs = ItsTime.timestampIts(cam.timestamp),
|
||||
// Same rounding as CamUperCodec's referencePosition, so the GN position and the
|
||||
// CAM's own position agree to the last digit.
|
||||
latTenMicroDeg = (cam.latitude * 1e7).roundToLong().toInt(),
|
||||
lonTenMicroDeg = (cam.longitude * 1e7).roundToLong().toInt(),
|
||||
// Clamped, never wrapped: a wrapped 15-bit speed flips sign and reads as reversing.
|
||||
speedCms = if (cam.speedMps.isFinite()) {
|
||||
(cam.speedMps * 100).roundToInt().coerceIn(SPEED_MIN_CMS, SPEED_MAX_CMS)
|
||||
} else 0,
|
||||
headingDeciDeg = if (cam.headingDeg.isFinite()) {
|
||||
Math.floorMod((cam.headingDeg * 10).roundToInt(), 3600)
|
||||
} else 0,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun putLe(out: ByteArray, offset: Int, value: Long, bytes: Int) {
|
||||
for (i in 0 until bytes) out[offset + i] = ((value ushr (8 * i)) and 0xFF).toByte()
|
||||
}
|
||||
|
||||
data class DecodedFrame(val type: Int, val payload: ByteArray)
|
||||
|
||||
object SerialFrameEncoder {
|
||||
|
||||
@@ -329,12 +329,14 @@ class UsbSerialTransport @Inject constructor(
|
||||
prev.oversizeDrops != status.oversizeDrops ||
|
||||
prev.txFailures != status.txFailures ||
|
||||
prev.rxCrcErrors != status.rxCrcErrors ||
|
||||
prev.status != status.status
|
||||
prev.status != status.status ||
|
||||
prev.capabilities != status.capabilities
|
||||
) {
|
||||
Log.i(TAG, "ESP32 counters: status=${status.status} " +
|
||||
"oversizeDrops=${status.oversizeDrops} " +
|
||||
"txFailures=${status.txFailures} " +
|
||||
"rxCrcErrors=${status.rxCrcErrors}")
|
||||
"rxCrcErrors=${status.rxCrcErrors} " +
|
||||
"capabilities=${status.capabilities}")
|
||||
}
|
||||
_linkStatus.value = status
|
||||
}
|
||||
@@ -386,6 +388,25 @@ class UsbSerialTransport @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/** Which frame type the last CAM went out as, so a change of path is logged once, not per CAM. */
|
||||
@Volatile private var lastTxWithPositionVector: Boolean? = null
|
||||
|
||||
/**
|
||||
* Logs whenever CAMs switch between [SerialFrameType.CAM_TX_PV] and legacy
|
||||
* [SerialFrameType.CAM_TX]. Without it, "the GN header still says bench" has no visible cause
|
||||
* in a logcat capture: it looks identical whether the firmware is old or the phone is.
|
||||
*/
|
||||
private fun noteTxPath(withPositionVector: Boolean, requested: Boolean) {
|
||||
if (lastTxWithPositionVector == withPositionVector) return
|
||||
lastTxWithPositionVector = withPositionVector
|
||||
Log.i(TAG, when {
|
||||
withPositionVector -> "CAM TX path: CAM_TX_PV, GN position vector supplied by the phone"
|
||||
requested -> "CAM TX path: legacy CAM_TX, firmware has not advertised CAM_TX_PV yet; " +
|
||||
"GN position vector is the firmware's bench placeholder"
|
||||
else -> "CAM TX path: legacy CAM_TX, no position vector supplied"
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes [camUperBytes] as a [SerialFrameType.CAM_TX] frame and writes it to the port.
|
||||
* No-op (returns false) if not currently connected — callers (the CAM transmit loop) should
|
||||
@@ -394,15 +415,28 @@ class UsbSerialTransport @Inject constructor(
|
||||
* [consecutiveWriteFailures] so they can't stay invisible.
|
||||
*
|
||||
* Blocking: writes with a 200 ms timeout, so call from a background dispatcher.
|
||||
*
|
||||
* [positionVector], when given, travels with the CAM as a [SerialFrameType.CAM_TX_PV] frame so
|
||||
* the ESP32 builds the GeoNetworking Source Position Vector from real values. It is used only
|
||||
* once the heartbeat advertises [EspLinkStatus.supportsCamTxPv]. Until then, and against
|
||||
* firmware that predates it, the CAM goes out as a plain [SerialFrameType.CAM_TX] exactly as
|
||||
* before and the GN header carries the firmware's bench placeholders. Neither mixed-version
|
||||
* combination breaks transmission; `serial_link.h` explains why.
|
||||
*/
|
||||
fun sendCamTx(camUperBytes: ByteArray): Boolean {
|
||||
fun sendCamTx(camUperBytes: ByteArray, positionVector: GnPositionVector? = null): Boolean {
|
||||
val p = port
|
||||
if (p == null) {
|
||||
_consecutiveWriteFailures.update { it + 1 }
|
||||
return false
|
||||
}
|
||||
return try {
|
||||
val frame = SerialFrameEncoder.encode(SerialFrameType.CAM_TX, camUperBytes)
|
||||
val pv = positionVector?.takeIf { _linkStatus.value?.supportsCamTxPv == true }
|
||||
noteTxPath(withPositionVector = pv != null, requested = positionVector != null)
|
||||
val frame = if (pv != null) {
|
||||
SerialFrameEncoder.encode(SerialFrameType.CAM_TX_PV, pv.toSerialPrefix() + camUperBytes)
|
||||
} else {
|
||||
SerialFrameEncoder.encode(SerialFrameType.CAM_TX, camUperBytes)
|
||||
}
|
||||
p.write(frame, /* timeout ms */ 200)
|
||||
_consecutiveWriteFailures.value = 0
|
||||
true
|
||||
|
||||
@@ -34,9 +34,6 @@ object CamUperCodec {
|
||||
/** Encode buffer size — matches `cam.c`'s `cam_payload[96]`, the known-sufficient size. */
|
||||
private const val ENCODE_BUFFER_BYTES = 96
|
||||
|
||||
// TimestampIts epoch: 2004-01-01T00:00:00Z, in Unix epoch milliseconds.
|
||||
private const val TS_ITS_EPOCH_MS = 1_072_915_200_000L
|
||||
|
||||
// ASN.1 "unavailable" sentinel values, straight from the CAM/ITS-Container modules (also
|
||||
// documented inline in cam.c against each field).
|
||||
private const val HEADING_UNAVAILABLE = 3601
|
||||
@@ -47,9 +44,13 @@ object CamUperCodec {
|
||||
private const val ACCEL_UNAVAILABLE = 161
|
||||
private const val YAW_RATE_UNAVAILABLE = 32767
|
||||
|
||||
/** Converts a wall-clock epoch-ms timestamp to a UPER GenerationDeltaTime (TimestampIts mod 65536). */
|
||||
/**
|
||||
* Converts a wall-clock epoch-ms timestamp to a UPER GenerationDeltaTime (TimestampIts mod
|
||||
* 65536). Goes through [ItsTime], the same rule the GeoNetworking TST uses, so the two
|
||||
* timestamps in one transmitted frame cannot disagree.
|
||||
*/
|
||||
fun generationDeltaTime(epochMs: Long): Int {
|
||||
val itsMs = epochMs - TS_ITS_EPOCH_MS
|
||||
val itsMs = ItsTime.timestampIts(epochMs)
|
||||
// floorMod so this stays well-defined even for epochMs before the ITS epoch (shouldn't
|
||||
// happen with a real clock, but avoids a negative/UB result if it ever does).
|
||||
return Math.floorMod(itsMs, 65536L).toInt()
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.hawhamburg.micr0bu.domain.asn1
|
||||
|
||||
/**
|
||||
* ITS time, as used by every timestamp this app puts on the air.
|
||||
*
|
||||
* TimestampIts (ETSI TS 102 894-2) counts milliseconds from 2004-01-01T00:00:00Z. Two fields in a
|
||||
* single transmitted frame come from it: the CAM's generationDeltaTime (modulo 65536) and the
|
||||
* GeoNetworking Source Position Vector's TST (modulo 2^32). A receiver can compare the two, so
|
||||
* they must follow one rule. Both go through here so they cannot drift apart.
|
||||
*
|
||||
* **Which clock.** The input should be GNSS time, not the phone's wall clock. A phone with no SIM
|
||||
* and no internet time has no automatic time source at all, and the bench phone was found 24
|
||||
* minutes fast that way. `GnssTimeSource` moves a timestamp onto GNSS time, using [onGnssTime],
|
||||
* before it gets here.
|
||||
*
|
||||
* **Open question: leap seconds.** This is Unix time minus the 2004 epoch, with no leap-second
|
||||
* term. If TimestampIts is read as TAI-based, the correct value is currently 5 s higher, for the
|
||||
* five leap seconds inserted since 2004. Whichever reading turns out right, it is changed here and
|
||||
* nowhere else. Settling it needs a frame from a third-party stack with a trusted clock, such as
|
||||
* the RSU's CAM compared against GNSS time, and no such traffic was on air when this was written.
|
||||
*/
|
||||
object ItsTime {
|
||||
/** 2004-01-01T00:00:00Z in Unix epoch milliseconds. */
|
||||
const val EPOCH_MS = 1_072_915_200_000L
|
||||
|
||||
/** TimestampIts for wall-clock [epochMs], before any modulo is applied. */
|
||||
fun timestampIts(epochMs: Long): Long = epochMs - EPOCH_MS
|
||||
|
||||
/**
|
||||
* Moves [systemMs], a reading of this phone's wall clock, onto GNSS time, using one pair of
|
||||
* simultaneous readings of both clocks: [gnssNowMs] and [systemNowMs]. Their difference is the
|
||||
* wall clock's error, whatever caused it, and the age of [systemMs] is preserved. Returns
|
||||
* [systemMs] unchanged when there is no GNSS reading.
|
||||
*
|
||||
* Pure so the arithmetic can be tested apart from the Android clock API, which is where the
|
||||
* readings come from (see `GnssTimeSource`).
|
||||
*/
|
||||
fun onGnssTime(systemMs: Long, gnssNowMs: Long?, systemNowMs: Long): Long =
|
||||
if (gnssNowMs == null) systemMs else systemMs + (gnssNowMs - systemNowMs)
|
||||
}
|
||||
@@ -5,26 +5,27 @@ package com.hawhamburg.micr0bu.domain.cam
|
||||
* user when a frame comes back off the air.
|
||||
*
|
||||
* ## Why this exists
|
||||
* On the ESP32-C5 path the radio receives promiscuously, so it hears the phone's own
|
||||
* transmissions. Anything that decodes received CAMs has to recognise them, or the phone tracks
|
||||
* itself: a station sitting exactly on top of the ego position, moving at the ego's own speed and
|
||||
* heading, handed to [com.hawhamburg.micr0bu.domain.usecase.UseCaseDetectionEngine] as a
|
||||
* collision partner for itself.
|
||||
* A receiver that fails to recognise its own transmissions tracks itself: a station sitting exactly
|
||||
* on top of the ego position, moving at the ego's own speed and heading, handed to
|
||||
* [com.hawhamburg.micr0bu.domain.usecase.UseCaseDetectionEngine] as a collision partner for itself.
|
||||
* The phone's own frames can come back to it off the air, for example relayed by the CiT One's
|
||||
* radio when a phone is connected to both OBUs at once.
|
||||
*
|
||||
* ## Why two IDs
|
||||
* The phone transmits under two different station IDs by design:
|
||||
* ## Which IDs count
|
||||
* - The current transmit pseudonym used by [com.hawhamburg.micr0bu.service.CamTransmitLoop], and
|
||||
* the one or two it most recently replaced. The pseudonym rotates every ten minutes (see
|
||||
* [Pseudonym]), and a frame sent just before a rotation can come back just after it, so a
|
||||
* retired ID has to stay ours for a while. `PseudonymManager.ownStationIds()` supplies these.
|
||||
* - On the CiT One path, the OBU's own ID learned from obu_gnss.
|
||||
* - [BENCH_PING], but only while this phone's own pinger is running or has just stopped. See
|
||||
* [benchPingIsOurs].
|
||||
*
|
||||
* - [com.hawhamburg.micr0bu.service.CamTransmitLoop] uses the persisted per-install ID from
|
||||
* `ObuHardwarePreferences.getOrCreateOwnStationId()`, which is the real identity this station
|
||||
* presents to the world.
|
||||
* - [com.hawhamburg.micr0bu.service.CamPinger] uses [BENCH_PING], a fixed and recognisable value,
|
||||
* so manual bench pings stay identifiable in captures and cannot be confused with the
|
||||
* recording-driven stream when both run at once.
|
||||
*
|
||||
* That second ID is the whole reason this object exists. A filter that knew only the persisted ID
|
||||
* let every bench ping return as a ghost road user, which is the bug this centralises the fix
|
||||
* for. Keeping the rule in one place, in a layer with no Android dependencies, is what makes it
|
||||
* testable and what stops the next transmit path from reintroducing the same gap.
|
||||
* ## Why the bench ID is conditional
|
||||
* It used to count as ours unconditionally, on every phone, and that hid other phones' pings. On
|
||||
* the 2026-09-10 bench one phone pinged through an ESP32 while a second phone watched through the
|
||||
* CiT One, and the watcher silently discarded every ping as its own frame heard back, although it
|
||||
* had sent none. A fixed ID shared by every MicrOBU is only ours on the phone actually using it.
|
||||
* The one case this cannot resolve is two phones pinging at the same time: each hides the other.
|
||||
*/
|
||||
object OwnStationIds {
|
||||
|
||||
@@ -34,21 +35,47 @@ object OwnStationIds {
|
||||
*/
|
||||
const val BENCH_PING = 999_999L
|
||||
|
||||
/**
|
||||
* The bench pinger's link-layer address, which the ESP32 writes into both the 802.11 source
|
||||
* address and the GN_ADDR MID. It is the address the firmware always used for its fixed
|
||||
* pseudonym, so bench traffic looks the same in a capture before and after the phone took
|
||||
* over the GeoNetworking identity. A fresh copy each time, so no caller can alter it for all.
|
||||
*/
|
||||
val BENCH_PING_MAC: ByteArray get() = byteArrayOf(0x02, 0x00, 0x00, 0x00, 0x00, 0x01)
|
||||
|
||||
/**
|
||||
* How long after this phone's pinger stops its pings still count as ours. A frame sent just
|
||||
* before Stop can arrive just after it, relayed through another radio. A relay takes a
|
||||
* fraction of a second, so five seconds leaves ample margin without hiding a genuine sender
|
||||
* for long.
|
||||
*/
|
||||
const val BENCH_PING_GRACE_MS = 5_000L
|
||||
|
||||
/**
|
||||
* True when station [BENCH_PING] on air is this phone's own ping: while [pingerActive], or
|
||||
* within [BENCH_PING_GRACE_MS] of [pingerStoppedAtMs]. Both times must come from one monotonic
|
||||
* clock. A [nowMs] earlier than the stop time means that clock is not monotonic after all, and
|
||||
* the ping is then not claimed.
|
||||
*/
|
||||
fun benchPingIsOurs(pingerActive: Boolean, pingerStoppedAtMs: Long?, nowMs: Long): Boolean {
|
||||
if (pingerActive) return true
|
||||
val stoppedAt = pingerStoppedAtMs ?: return false
|
||||
return nowMs - stoppedAt in 0..BENCH_PING_GRACE_MS
|
||||
}
|
||||
|
||||
/**
|
||||
* True when [stationId] is one this phone transmits under.
|
||||
*
|
||||
* [persistedOwnId] is the per-install station ID, or null before it has been loaded. Station
|
||||
* ID 0 is never ours: it is the "not known yet" placeholder used while the ego identity is
|
||||
* still being resolved, and matching on it would swallow real traffic.
|
||||
* [ownIds] is every non-bench ID currently counted as ours: the current and recently retired
|
||||
* transmit pseudonyms, plus the CiT One's own ID on that path. [benchPingIsOurs] says whether
|
||||
* [BENCH_PING] is ours right now; see the function of the same name.
|
||||
*
|
||||
* [BENCH_PING] counts as ours unconditionally, not merely while the pinger is running. A
|
||||
* time-windowed check would still let a frame transmitted moments before Stop arrive
|
||||
* afterwards and be tracked as a stranger. The cost is that a genuine remote station using
|
||||
* this ID would be ignored, which is not a real risk at a lab site and is the bargain that
|
||||
* reserving a fixed ID already implies.
|
||||
* Station ID 0 is never ours: it is the "not known yet" placeholder used while the ego
|
||||
* identity is still being resolved, and matching on it would swallow real traffic.
|
||||
*/
|
||||
fun isOwn(stationId: Long, persistedOwnId: Long?): Boolean {
|
||||
fun isOwn(stationId: Long, ownIds: Set<Long>, benchPingIsOurs: Boolean): Boolean {
|
||||
if (stationId == 0L) return false
|
||||
return stationId == persistedOwnId || stationId == BENCH_PING
|
||||
if (stationId == BENCH_PING) return benchPingIsOurs
|
||||
return stationId in ownIds
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,10 +24,10 @@ object PhoneCamBuilder {
|
||||
* @param gyroZRadPerSec latest gyroscope z-axis reading, rad/s (device frame). Positive per
|
||||
* Android's convention is counter-clockwise around +Z; converted to the clockwise-positive
|
||||
* yaw rate convention already used by [Cam.yawRateDps] to match OBU/remote CAM data.
|
||||
* @param stationId this device's own station ID, from
|
||||
* [com.hawhamburg.micr0bu.data.mqtt.ObuHardwarePreferences.getOrCreateOwnStationId] — a
|
||||
* persisted random value, not a placeholder. Receivers use it to track this station across
|
||||
* successive CAMs, so it must be stable for the life of the install and distinct per device.
|
||||
* @param stationId the station ID to transmit under: the current pseudonym from
|
||||
* [com.hawhamburg.micr0bu.data.cam.PseudonymManager], or the bench pinger's fixed ID.
|
||||
* Receivers track a station across successive CAMs by this ID, which is why it only ever
|
||||
* changes in a coordinated rotation together with the link-layer address.
|
||||
* @param longitudinalAccelMps2 along-track acceleration, signed (positive = accelerating).
|
||||
* Derived from successive GNSS speed samples by [com.hawhamburg.micr0bu.service.CamTransmitLoop]
|
||||
* rather than from the accelerometer: CAM wants acceleration along the direction of travel,
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.hawhamburg.micr0bu.domain.cam
|
||||
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* The identity this phone transmits under on the ESP32-C5 path: the CAM stationID, and the
|
||||
* link-layer address the firmware writes into both the GeoNetworking GN_ADDR and the 802.11
|
||||
* source address.
|
||||
*
|
||||
* ## Why the two change together
|
||||
* A pseudonym only makes a station harder to follow if every identifier on the frame changes at
|
||||
* the same moment. Rotating the address while keeping the stationID, or the reverse, leaves the
|
||||
* unchanged one as a stable handle, so a receiver loses nothing and the rotation buys nothing.
|
||||
* Holding both in one value that is only ever replaced whole makes a partial rotation impossible
|
||||
* to express.
|
||||
*
|
||||
* ## Why every [ROTATION_INTERVAL_MS]
|
||||
* Real ITS stacks change pseudonym every few minutes, 5 to 15 being typical, and the CiT One was
|
||||
* seen rotating its station ID twice within one bench session. Ten minutes sits in that range.
|
||||
*
|
||||
* ## A limit worth stating
|
||||
* Nothing this app transmits is signed (there is no ETSI TS 103 097 security), so rotation gives
|
||||
* nominal unlinkability at best: an unsigned frame's content can still be correlated across a
|
||||
* change. This is the correct behaviour to build on, not a privacy guarantee.
|
||||
*/
|
||||
data class Pseudonym(
|
||||
val stationId: Long,
|
||||
/** Six bytes, locally administered and unicast. See [generate]. */
|
||||
val mac: ByteArray,
|
||||
/** Wall-clock ms this pseudonym was created, for [isExpired]. */
|
||||
val createdAtMs: Long,
|
||||
) {
|
||||
init {
|
||||
require(mac.size == 6) { "a MAC is 6 bytes, got ${mac.size}" }
|
||||
}
|
||||
|
||||
/**
|
||||
* True once this pseudonym has been in use for [intervalMs], or if the clock has moved back
|
||||
* past its creation time. The second case rotates rather than trusting a creation time that
|
||||
* now lies in the future, which would otherwise pin one identity until the clock caught up.
|
||||
*/
|
||||
fun isExpired(nowMs: Long, intervalMs: Long = ROTATION_INTERVAL_MS): Boolean =
|
||||
nowMs < createdAtMs || nowMs - createdAtMs >= intervalMs
|
||||
|
||||
// Generated equals/hashCode would compare the MAC array by identity, so two pseudonyms with
|
||||
// the same bytes would compare unequal.
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is Pseudonym) return false
|
||||
return stationId == other.stationId && createdAtMs == other.createdAtMs &&
|
||||
mac.contentEquals(other.mac)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int =
|
||||
31 * (31 * stationId.hashCode() + mac.contentHashCode()) + createdAtMs.hashCode()
|
||||
|
||||
companion object {
|
||||
const val ROTATION_INTERVAL_MS = 10 * 60_000L
|
||||
|
||||
/**
|
||||
* A fresh identity. StationID is INTEGER(0..4294967295); 0 is avoided because it is the
|
||||
* "not yet known" placeholder elsewhere in this app, and [OwnStationIds.BENCH_PING] is
|
||||
* avoided so a rider can never be mistaken for the bench pinger.
|
||||
*
|
||||
* The MAC is random with the locally-administered bit set and the group bit clear. A
|
||||
* source address must never be a group address, and a random one must not claim a real
|
||||
* vendor's OUI. [OwnStationIds.BENCH_PING_MAC] is excluded for the same reason as the ID.
|
||||
*/
|
||||
fun generate(nowMs: Long, random: Random = Random.Default): Pseudonym {
|
||||
var stationId: Long
|
||||
do {
|
||||
stationId = random.nextLong(1L, 0xFFFF_FFFEL)
|
||||
} while (stationId == OwnStationIds.BENCH_PING)
|
||||
|
||||
var mac: ByteArray
|
||||
do {
|
||||
mac = random.nextBytes(6)
|
||||
mac[0] = ((mac[0].toInt() and 0xFC) or 0x02).toByte()
|
||||
} while (mac.contentEquals(OwnStationIds.BENCH_PING_MAC))
|
||||
|
||||
return Pseudonym(stationId, mac, nowMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
package com.hawhamburg.micr0bu.service
|
||||
|
||||
import android.content.Context
|
||||
import android.os.SystemClock
|
||||
import com.hawhamburg.micr0bu.data.GnssReading
|
||||
import com.hawhamburg.micr0bu.data.GnssTimeSource
|
||||
import com.hawhamburg.micr0bu.data.SensorRepository
|
||||
import com.hawhamburg.micr0bu.data.transport.GnPositionVector
|
||||
import com.hawhamburg.micr0bu.data.transport.UsbSerialTransport
|
||||
import com.hawhamburg.micr0bu.domain.asn1.RealAsn1UperCodec
|
||||
import com.hawhamburg.micr0bu.domain.cam.OwnStationIds
|
||||
@@ -68,6 +71,20 @@ class CamPinger @Inject constructor(
|
||||
/** False while the pinger is running but has no GNSS fix yet — nothing is being transmitted. */
|
||||
val hasFix: StateFlow<Boolean> = _hasFix.asStateFlow()
|
||||
|
||||
/** [SystemClock.elapsedRealtime] when the pinger last stopped, or null if it never ran. */
|
||||
@Volatile private var stoppedAtElapsedMs: Long? = null
|
||||
|
||||
/**
|
||||
* True while station [OwnStationIds.BENCH_PING] on air is this phone's own ping: while the
|
||||
* pinger runs, and briefly after it stops, so a frame sent just before Stop is not taken for a
|
||||
* stranger. Uses elapsed realtime, so changing the wall clock cannot move the window.
|
||||
*
|
||||
* Otherwise that ID belongs to someone else, typically another MicrOBU phone pinging on the
|
||||
* same bench, and must be shown like any remote station. See [OwnStationIds.benchPingIsOurs].
|
||||
*/
|
||||
fun benchPingIsOurs(): Boolean =
|
||||
OwnStationIds.benchPingIsOurs(_isActive.value, stoppedAtElapsedMs, SystemClock.elapsedRealtime())
|
||||
|
||||
fun start() {
|
||||
if (job?.isActive == true) return
|
||||
_sentCount.value = 0
|
||||
@@ -87,13 +104,17 @@ class CamPinger @Inject constructor(
|
||||
_hasFix.value = gnss != null
|
||||
if (gnss != null) {
|
||||
val cam = PhoneCamBuilder.build(
|
||||
gnss = gnss,
|
||||
// Stamped on GNSS time rather than the phone clock; see GnssTimeSource.
|
||||
gnss = gnss.copy(timestamp = GnssTimeSource.correct(gnss.timestamp)),
|
||||
gyroZRadPerSec = latestGyroZ,
|
||||
stationId = OwnStationIds.BENCH_PING,
|
||||
longitudinalAccelMps2 = longitudinalAccel(gnss),
|
||||
)
|
||||
val bytes = codec.encodeCam(cam)
|
||||
if (usbSerialTransport.sendCamTx(bytes)) {
|
||||
// Fixed bench identity on every layer, the link-layer address included, so a ping
|
||||
// stays recognisable in a capture and never rotates.
|
||||
val pv = GnPositionVector.fromCam(cam, gnss.accuracyM, OwnStationIds.BENCH_PING_MAC)
|
||||
if (usbSerialTransport.sendCamTx(bytes, pv)) {
|
||||
_sentCount.update { it + 1 }
|
||||
}
|
||||
}
|
||||
@@ -120,6 +141,9 @@ class CamPinger @Inject constructor(
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
// Only a real stop opens the grace window. stop() is also called unconditionally on
|
||||
// teardown, and that must not make a phone that never pinged claim 999999 for a while.
|
||||
if (_isActive.value) stoppedAtElapsedMs = SystemClock.elapsedRealtime()
|
||||
job?.cancel()
|
||||
job = null
|
||||
_isActive.value = false
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user