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.
97 lines
3.7 KiB
Kotlin
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()
|
|
}
|
|
}
|