Files
MicrOBU/app/src/test/java/com/hawhamburg/micr0bu/PseudonymTest.kt
T
Ashin Walpola 83153a0971 Send each CAM with its position vector, a rotating pseudonym and GNSS time
The app side of the firmware's CAM_TX_PV message. Until now the phone
handed the ESP32 bare CAM bytes, so the GeoNetworking header around them
could only carry the firmware's bench placeholders.

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

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

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

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

Leap seconds are an open question, recorded in ItsTime: TimestampIts may
be TAI-based, which would put it 5 s higher. 85 tests, 0 failures.
2026-09-10 14:47:30 +02:00

97 lines
3.7 KiB
Kotlin

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<Long>,
private val bytes: ArrayDeque<ByteArray>,
) : 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()
}
}