diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/GnssTimeSource.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/GnssTimeSource.kt new file mode 100644 index 0000000..6a04804 --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/data/GnssTimeSource.kt @@ -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") + } + } +} diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/cam/CamUseCaseRepository.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/cam/CamUseCaseRepository.kt index cb17660..b7c8585 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/data/cam/CamUseCaseRepository.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/data/cam/CamUseCaseRepository.kt @@ -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 diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/cam/PseudonymManager.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/cam/PseudonymManager.kt new file mode 100644 index 0000000..6071d74 --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/data/cam/PseudonymManager.kt @@ -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(null) + + /** The identity in use, or null before the first call to [current] has loaded one. */ + val currentFlow: StateFlow = _current.asStateFlow() + + /** Station IDs replaced most recently, newest first. See [ownStationIds]. */ + @Volatile private var recentlyRetired: List = 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 = 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 + } +} diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/mqtt/ObuHardwarePreferences.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/mqtt/ObuHardwarePreferences.kt index 8c4fb52..2373e78 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/data/mqtt/ObuHardwarePreferences.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/data/mqtt/ObuHardwarePreferences.kt @@ -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 = 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 = 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() } diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/transport/SerialFrame.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/transport/SerialFrame.kt index cdd68ec..483c54a 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/data/transport/SerialFrame.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/data/transport/SerialFrame.kt @@ -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 { diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/transport/UsbSerialTransport.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/transport/UsbSerialTransport.kt index 042b3bc..dce7efc 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/data/transport/UsbSerialTransport.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/data/transport/UsbSerialTransport.kt @@ -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 diff --git a/app/src/main/java/com/hawhamburg/micr0bu/domain/asn1/CamUperCodec.kt b/app/src/main/java/com/hawhamburg/micr0bu/domain/asn1/CamUperCodec.kt index f5175ed..a97b66e 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/domain/asn1/CamUperCodec.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/domain/asn1/CamUperCodec.kt @@ -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() diff --git a/app/src/main/java/com/hawhamburg/micr0bu/domain/asn1/ItsTime.kt b/app/src/main/java/com/hawhamburg/micr0bu/domain/asn1/ItsTime.kt new file mode 100644 index 0000000..14b29ac --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/domain/asn1/ItsTime.kt @@ -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) +} diff --git a/app/src/main/java/com/hawhamburg/micr0bu/domain/cam/OwnStationIds.kt b/app/src/main/java/com/hawhamburg/micr0bu/domain/cam/OwnStationIds.kt index f63efbc..cfe3b7e 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/domain/cam/OwnStationIds.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/domain/cam/OwnStationIds.kt @@ -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, benchPingIsOurs: Boolean): Boolean { if (stationId == 0L) return false - return stationId == persistedOwnId || stationId == BENCH_PING + if (stationId == BENCH_PING) return benchPingIsOurs + return stationId in ownIds } } diff --git a/app/src/main/java/com/hawhamburg/micr0bu/domain/cam/PhoneCamBuilder.kt b/app/src/main/java/com/hawhamburg/micr0bu/domain/cam/PhoneCamBuilder.kt index dfffa4a..6f70c23 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/domain/cam/PhoneCamBuilder.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/domain/cam/PhoneCamBuilder.kt @@ -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, diff --git a/app/src/main/java/com/hawhamburg/micr0bu/domain/cam/Pseudonym.kt b/app/src/main/java/com/hawhamburg/micr0bu/domain/cam/Pseudonym.kt new file mode 100644 index 0000000..85f6264 --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/domain/cam/Pseudonym.kt @@ -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) + } + } +} diff --git a/app/src/main/java/com/hawhamburg/micr0bu/service/CamPinger.kt b/app/src/main/java/com/hawhamburg/micr0bu/service/CamPinger.kt index 94b934e..58d6a1d 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/service/CamPinger.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/service/CamPinger.kt @@ -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 = _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 diff --git a/app/src/main/java/com/hawhamburg/micr0bu/service/CamTransmitLoop.kt b/app/src/main/java/com/hawhamburg/micr0bu/service/CamTransmitLoop.kt index d4fb686..15b5d49 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/service/CamTransmitLoop.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/service/CamTransmitLoop.kt @@ -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()) } diff --git a/app/src/test/java/com/hawhamburg/micr0bu/CamTxPvSerialTest.kt b/app/src/test/java/com/hawhamburg/micr0bu/CamTxPvSerialTest.kt new file mode 100644 index 0000000..e2b0222 --- /dev/null +++ b/app/src/test/java/com/hawhamburg/micr0bu/CamTxPvSerialTest.kt @@ -0,0 +1,177 @@ +package com.hawhamburg.micr0bu + +import com.hawhamburg.micr0bu.data.transport.EspLinkStatus +import com.hawhamburg.micr0bu.data.transport.GnPositionVector +import com.hawhamburg.micr0bu.domain.asn1.ItsTime +import com.hawhamburg.micr0bu.domain.cam.Cam +import com.hawhamburg.micr0bu.domain.cam.StationType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Pins the phone side of SERIAL_MSG_CAM_TX_PV: the 24-byte prefix the ESP32 turns into the + * GeoNetworking Source Position Vector, and the heartbeat capability bit that decides whether the + * phone may send that message at all. + * + * ## Where the expected bytes come from + * Not from this code. They were produced with Python's `struct.pack(" acc or ((this[at + i].toLong() and 0xFF) shl (8 * i)) } + + // ---- the wire layout ------------------------------------------------------------------- + + @Test + fun `encodes the prefix byte for byte`() { + val pv = GnPositionVector( + mac = "024d49435230".hexToBytes(), + stationType = 2, + pai = true, + tstMs = 0x12345678L, + latTenMicroDeg = 535_543_026, + lonTenMicroDeg = 100_226_476, + speedCms = 543, + headingDeciDeg = 1234, + ) + // 024d49435230 | 02 | 01 | 78563412 | f2bceb1f | ac55f905 | 1f02 | d204 + assertEquals("024d49435230020178563412f2bceb1fac55f9051f02d204", pv.toSerialPrefix().toHex()) + } + + @Test + fun `encodes negative, extreme and flag-clear values`() { + // Southern and western hemisphere, full reverse speed, heading at its maximum, PAI clear: + // the sign handling that a northern-hemisphere bench test never exercises. + val pv = GnPositionVector( + mac = "020000000001".hexToBytes(), + stationType = 2, + pai = false, + tstMs = 0xFFFF_FFFFL, + latTenMicroDeg = -335_543_026, + lonTenMicroDeg = -100_226_476, + speedCms = -16384, + headingDeciDeg = 3599, + ) + assertEquals("0200000000010200ffffffff0e0500ec54aa06fa00c00f0e", pv.toSerialPrefix().toHex()) + } + + @Test + fun `the timestamp is reduced modulo 2^32 on the wire`() { + // TimestampIts passed 2^32 ms about 49.7 days after its 2004 epoch, so every real value + // today is wider than 32 bits and the reduction is the normal case, not an edge case. + val pv = vectorAt(tstMs = 716_121_572_779L) + assertEquals(3_157_001_643L, pv.toSerialPrefix().u32le(8)) + } + + // ---- building it from a CAM ------------------------------------------------------------ + + private val cam = Cam( + stationId = 1_234_567_890L, + stationType = StationType.CYCLIST, + latitude = 53.5543026, + longitude = 10.0226476, + speedMps = 5.43, + headingDeg = 123.4, + yawRateDps = null, + accelerationMps2 = null, + timestamp = 1_789_036_772_779L, + isOwn = true, + ) + + @Test + fun `fromCam takes the same values the CAM payload carries`() { + val pv = GnPositionVector.fromCam(cam, accuracyM = 5f, mac = "024d49435230".hexToBytes()) + assertEquals(2, pv.stationType) + assertEquals(535_543_026, pv.latTenMicroDeg) + assertEquals(100_226_476, pv.lonTenMicroDeg) + assertEquals(543, pv.speedCms) + assertEquals(1234, pv.headingDeciDeg) + assertTrue(pv.pai) + // The GN TST and the CAM's generationDeltaTime must follow one time rule. + assertEquals(ItsTime.timestampIts(cam.timestamp), pv.tstMs) + assertEquals(716_121_572_779L, pv.tstMs) + } + + @Test + fun `speed is clamped to the 15-bit field, never wrapped`() { + // A wrapped 15-bit speed flips its sign bit and reads as reversing at speed. + assertEquals(16383, GnPositionVector.fromCam(cam.copy(speedMps = 400.0), 5f, mac).speedCms) + assertEquals(-16384, GnPositionVector.fromCam(cam.copy(speedMps = -400.0), 5f, mac).speedCms) + } + + @Test + fun `heading wraps into 0 to 3599`() { + assertEquals(0, GnPositionVector.fromCam(cam.copy(headingDeg = 360.0), 5f, mac).headingDeciDeg) + assertEquals(50, GnPositionVector.fromCam(cam.copy(headingDeg = 725.0), 5f, mac).headingDeciDeg) + assertEquals(3590, GnPositionVector.fromCam(cam.copy(headingDeg = -1.0), 5f, mac).headingDeciDeg) + } + + @Test + fun `non-finite speed or heading does not throw`() { + val pv = GnPositionVector.fromCam( + cam.copy(speedMps = Double.NaN, headingDeg = Double.POSITIVE_INFINITY), 5f, mac, + ) + assertEquals(0, pv.speedCms) + assertEquals(0, pv.headingDeciDeg) + } + + @Test + fun `PAI follows the horizontal accuracy`() { + assertTrue(GnPositionVector.fromCam(cam, GnPositionVector.PAI_MAX_ACCURACY_M, mac).pai) + assertFalse(GnPositionVector.fromCam(cam, 25f, mac).pai) + // Android reports 0 when it has no accuracy estimate: unknown is not accurate. + assertFalse(GnPositionVector.fromCam(cam, 0f, mac).pai) + assertFalse(GnPositionVector.fromCam(cam, null, mac).pai) + } + + @Test(expected = IllegalArgumentException::class) + fun `an address that is not six bytes is rejected`() { + GnPositionVector.fromCam(cam, 5f, ByteArray(5)) + } + + // ---- capability negotiation ------------------------------------------------------------ + + @Test + fun `firmware that predates the capability byte advertises nothing`() { + // Old firmware sends a 7-byte heartbeat. Reading that as "no CAM_TX_PV" is what keeps a + // new app on the legacy message, which that firmware still understands. + val status = EspLinkStatus.parse("00000000000000".hexToBytes())!! + assertEquals(0, status.capabilities) + assertFalse(status.supportsCamTxPv) + } + + @Test + fun `firmware that advertises CAM_TX_PV is recognised`() { + val status = EspLinkStatus.parse("0000000000000001".hexToBytes())!! + assertTrue(status.supportsCamTxPv) + } + + @Test + fun `a capability byte without the CAM_TX_PV bit does not enable it`() { + assertFalse(EspLinkStatus.parse("0000000000000002".hexToBytes())!!.supportsCamTxPv) + } + + private val mac = "024d49435230".hexToBytes() + + private fun vectorAt(tstMs: Long) = GnPositionVector( + mac = mac, stationType = 2, pai = false, tstMs = tstMs, + latTenMicroDeg = 0, lonTenMicroDeg = 0, speedCms = 0, headingDeciDeg = 0, + ) + + private fun ByteArray.toHex() = joinToString("") { "%02x".format(it) } +} diff --git a/app/src/test/java/com/hawhamburg/micr0bu/ItsTimeTest.kt b/app/src/test/java/com/hawhamburg/micr0bu/ItsTimeTest.kt new file mode 100644 index 0000000..4698899 --- /dev/null +++ b/app/src/test/java/com/hawhamburg/micr0bu/ItsTimeTest.kt @@ -0,0 +1,41 @@ +package com.hawhamburg.micr0bu + +import com.hawhamburg.micr0bu.domain.asn1.ItsTime +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Pins the arithmetic that moves a transmit timestamp from the phone's wall clock onto GNSS time. + * + * The cases come from the 2026-09-10 bench session. The sending phone's clock was 1456 s fast + * because it had no automatic time source, and every CAM it sent was stamped 24 minutes in the + * future. After a manual correction it was 6 s slow. Both have to come out on GNSS time. + */ +class ItsTimeTest { + + private val gnssNow = 1_789_038_922_000L + + @Test + fun `without a GNSS reading the wall-clock time is used unchanged`() { + assertEquals(1_000L, ItsTime.onGnssTime(systemMs = 1_000L, gnssNowMs = null, systemNowMs = 5_000L)) + } + + @Test + fun `a phone clock running fast is pulled back onto GNSS time`() { + val systemNow = gnssNow + 1_456_000L + // A fix the wall clock stamped 0.8 s ago. It must still be 0.8 s old afterwards. + val fix = systemNow - 800L + assertEquals(gnssNow - 800L, ItsTime.onGnssTime(fix, gnssNow, systemNow)) + } + + @Test + fun `a phone clock running slow is pushed forward onto GNSS time`() { + val systemNow = gnssNow - 6_000L + assertEquals(gnssNow - 250L, ItsTime.onGnssTime(systemNow - 250L, gnssNow, systemNow)) + } + + @Test + fun `an accurate phone clock is left where it is`() { + assertEquals(gnssNow - 40L, ItsTime.onGnssTime(gnssNow - 40L, gnssNow, gnssNow)) + } +} diff --git a/app/src/test/java/com/hawhamburg/micr0bu/OwnStationIdsTest.kt b/app/src/test/java/com/hawhamburg/micr0bu/OwnStationIdsTest.kt index b0ec764..e9a2431 100644 --- a/app/src/test/java/com/hawhamburg/micr0bu/OwnStationIdsTest.kt +++ b/app/src/test/java/com/hawhamburg/micr0bu/OwnStationIdsTest.kt @@ -1,66 +1,95 @@ package com.hawhamburg.micr0bu import com.hawhamburg.micr0bu.domain.cam.OwnStationIds +import com.hawhamburg.micr0bu.domain.cam.OwnStationIds.BENCH_PING +import com.hawhamburg.micr0bu.domain.cam.OwnStationIds.BENCH_PING_GRACE_MS +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotEquals import org.junit.Assert.assertTrue import org.junit.Test /** * Pins the rule that decides whether a received CAM is one this phone sent. * - * ## The bug this exists to prevent - * The phone transmits under two station IDs: the persisted per-install one used by - * `CamTransmitLoop`, and a fixed bench ID used by `CamPinger` so pings stay identifiable in - * captures. The ESP32-C5 receives promiscuously, so both come straight back off the air. + * ## The bugs this exists to prevent + * Getting it wrong fails in two opposite directions, and each has happened: * - * The filter originally checked only the persisted ID. Every bench ping therefore returned as a - * remote road user sitting exactly on top of the ego position, moving at the ego's own speed and - * heading, and was fed to the detection engine as a collision partner for itself. Nothing failed - * loudly: the app simply raised use case alerts against itself for as long as the pinger ran. - * - * These tests are what should fail if a third transmit path is ever added without teaching this - * rule about it. + * - **Too narrow.** An own frame that is not recognised comes back as a remote road user sitting + * exactly on the ego position, and is fed to the detection engine as a collision partner for + * itself. That happened with the bench pinger's separate ID, and pseudonym rotation creates the + * same risk for an ID that has just been retired. + * - **Too wide.** On 2026-09-10 the bench ID counted as ours on every phone, so a phone watching + * through the CiT One silently discarded another phone's pings as its own, although it had sent + * none. Nothing appeared on its V2X screen while the broker was full of them. */ class OwnStationIdsTest { - private val persisted = 1_691_338_363L + private val current = 1_691_338_363L + private val retired = 2_222_222_222L + private val ours = setOf(current, retired) @Test - fun `recognises the persisted transmit id`() { - assertTrue(OwnStationIds.isOwn(persisted, persisted)) + fun `recognises the current transmit id`() { + assertTrue(OwnStationIds.isOwn(current, ours, benchPingIsOurs = false)) } @Test - fun `recognises the bench ping id even though it is not the persisted one`() { - // The regression. The pinger's id is deliberately different, which is exactly why a - // filter written around the persisted id alone let every ping through. - assertNotEquals( - "the bench id is meant to be distinct, or this test proves nothing", - persisted, - OwnStationIds.BENCH_PING, - ) - assertTrue(OwnStationIds.isOwn(OwnStationIds.BENCH_PING, persisted)) + fun `recognises a recently retired id, so a frame sent just before a rotation is still ours`() { + assertTrue(OwnStationIds.isOwn(retired, ours, benchPingIsOurs = false)) } @Test - fun `recognises the bench ping id before the persisted id has loaded`() { - // The persisted id is read asynchronously, so it can still be null while the pinger is - // already transmitting. The ping must be recognised as ours regardless. - assertTrue(OwnStationIds.isOwn(OwnStationIds.BENCH_PING, null)) + fun `another phone's bench ping is shown, not swallowed as our own`() { + // The 2026-09-10 regression: this phone is not pinging, so 999999 is someone else. + assertFalse(OwnStationIds.isOwn(BENCH_PING, ours, benchPingIsOurs = false)) + assertFalse(OwnStationIds.isOwn(BENCH_PING, emptySet(), benchPingIsOurs = false)) + } + + @Test + fun `our own bench ping is recognised while we are pinging, even before any transmit id loads`() { + assertTrue(OwnStationIds.isOwn(BENCH_PING, emptySet(), benchPingIsOurs = true)) } @Test fun `treats a genuine remote station as remote`() { - assertFalse(OwnStationIds.isOwn(2_741_041_966L, persisted)) - assertFalse(OwnStationIds.isOwn(2_741_041_966L, null)) + assertFalse(OwnStationIds.isOwn(2_741_041_966L, ours, benchPingIsOurs = true)) + assertFalse(OwnStationIds.isOwn(2_741_041_966L, emptySet(), benchPingIsOurs = false)) } @Test fun `station id zero is never ours`() { // 0 is the "not resolved yet" placeholder for the ego identity. Matching on it would // swallow real traffic from any station that reported 0. - assertFalse(OwnStationIds.isOwn(0L, null)) - assertFalse(OwnStationIds.isOwn(0L, 0L)) + assertFalse(OwnStationIds.isOwn(0L, setOf(0L), benchPingIsOurs = true)) + } + + // ---- when the bench id is ours --------------------------------------------------------- + + @Test + fun `the bench id is ours while the pinger runs`() { + assertTrue(OwnStationIds.benchPingIsOurs(pingerActive = true, pingerStoppedAtMs = null, nowMs = 0L)) + } + + @Test + fun `the bench id is not ours on a phone that never pinged`() { + assertFalse(OwnStationIds.benchPingIsOurs(pingerActive = false, pingerStoppedAtMs = null, nowMs = 50_000L)) + } + + @Test + fun `the bench id stays ours for the grace window after Stop, and not a moment longer`() { + val stop = 100_000L + assertTrue(OwnStationIds.benchPingIsOurs(false, stop, stop + BENCH_PING_GRACE_MS)) + assertFalse(OwnStationIds.benchPingIsOurs(false, stop, stop + BENCH_PING_GRACE_MS + 1)) + } + + @Test + fun `a clock reading before the stop time does not claim the bench id`() { + assertFalse(OwnStationIds.benchPingIsOurs(false, pingerStoppedAtMs = 100_000L, nowMs = 99_000L)) + } + + @Test + fun `the bench MAC is a locally administered unicast address`() { + // Bit 1 set, bit 0 clear. A source address must never be a group address. + assertEquals(0x02, OwnStationIds.BENCH_PING_MAC[0].toInt() and 0x03) } } diff --git a/app/src/test/java/com/hawhamburg/micr0bu/PseudonymTest.kt b/app/src/test/java/com/hawhamburg/micr0bu/PseudonymTest.kt new file mode 100644 index 0000000..9134494 --- /dev/null +++ b/app/src/test/java/com/hawhamburg/micr0bu/PseudonymTest.kt @@ -0,0 +1,96 @@ +package com.hawhamburg.micr0bu + +import com.hawhamburg.micr0bu.domain.cam.OwnStationIds +import com.hawhamburg.micr0bu.domain.cam.Pseudonym +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import kotlin.random.Random + +/** + * Pins what a transmit pseudonym is allowed to look like, and when it rotates. + * + * The address rules matter on air, not just in the app: the ESP32 writes this MAC straight into + * the 802.11 source address. A group (multicast) source address is invalid, and a random address + * without the locally-administered bit claims to belong to a real hardware vendor. + */ +class PseudonymTest { + + @Test + fun `rotates every ten minutes`() { + assertEquals(10 * 60_000L, Pseudonym.ROTATION_INTERVAL_MS) + } + + @Test + fun `expires exactly at the rotation interval, not a millisecond before`() { + val p = Pseudonym(stationId = 42L, mac = mac(0x02), createdAtMs = 1_000L) + assertFalse(p.isExpired(1_000L + Pseudonym.ROTATION_INTERVAL_MS - 1)) + assertTrue(p.isExpired(1_000L + Pseudonym.ROTATION_INTERVAL_MS)) + } + + @Test + fun `a clock that moved back past the creation time forces a rotation`() { + // Otherwise a creation time now lying in the future would pin one identity until the + // clock caught up, which after a large correction could be hours. + val p = Pseudonym(stationId = 42L, mac = mac(0x02), createdAtMs = 1_000L) + assertTrue(p.isExpired(999L)) + } + + @Test + fun `generated addresses are locally administered unicast, whatever the random bytes`() { + repeat(500) { seed -> + val first = Pseudonym.generate(0L, Random(seed)).mac[0].toInt() + assertEquals("seed $seed: bit 1 set, bit 0 clear", 0x02, first and 0x03) + } + } + + @Test + fun `generated station ids stay in range`() { + repeat(500) { seed -> + val id = Pseudonym.generate(0L, Random(seed)).stationId + assertTrue("seed $seed: $id", id in 1L until 0xFFFF_FFFEL) + } + } + + @Test + fun `never generates the bench pinger's identity`() { + // Scripted so the exclusion loops actually run: the first draw of each is the bench + // value, which must be rejected in favour of the second. + val random = ScriptedRandom( + longs = ArrayDeque(listOf(OwnStationIds.BENCH_PING, 42L)), + bytes = ArrayDeque(listOf(OwnStationIds.BENCH_PING_MAC, byteArrayOf(0x13, 1, 2, 3, 4, 5))), + ) + val p = Pseudonym.generate(0L, random) + assertEquals(42L, p.stationId) + assertEquals("0x13 with the group bit cleared and the local bit set", 0x12, p.mac[0].toInt() and 0xFF) + } + + @Test + fun `a rotation replaces the station id and the address together`() { + val a = Pseudonym.generate(0L, Random(1)) + val b = Pseudonym.generate(Pseudonym.ROTATION_INTERVAL_MS, Random(2)) + assertNotEquals(a.stationId, b.stationId) + assertFalse(a.mac.contentEquals(b.mac)) + } + + @Test + fun `equality compares the address bytes, not the array instance`() { + assertEquals( + Pseudonym(7L, mac(0x02), 5L), + Pseudonym(7L, mac(0x02), 5L), + ) + } + + private fun mac(first: Int) = byteArrayOf(first.toByte(), 0x11, 0x22, 0x33, 0x44, 0x55) + + private class ScriptedRandom( + private val longs: ArrayDeque, + private val bytes: ArrayDeque, + ) : Random() { + override fun nextBits(bitCount: Int): Int = error("not used by Pseudonym.generate") + override fun nextLong(from: Long, until: Long): Long = longs.removeFirst() + override fun nextBytes(size: Int): ByteArray = bytes.removeFirst().copyOf() + } +}