Files
MicrOBU/app/src/test/java/com/hawhamburg/micr0bu/CamTxPvSerialTest.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

178 lines
7.3 KiB
Kotlin

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("<IiihH", ...)` from the
* layout documented at SERIAL_MSG_CAM_TX_PV in `serial_link.h`, independently of this encoder, so
* an agreement here is not an encoder agreeing with itself.
*
* That same `struct.pack` call is what the bench harness used on 2026-09-10 to drive an
* ESP32-C5 over its native USB port with this message. The CiT One OBU, an independent
* GeoNetworking stack, decoded every Source Position Vector field of the resulting
* transmissions (station type, PAI, latitude, longitude, speed, heading and timestamp) back to
* the values sent. These are bytes a third-party receiver has accepted on air, not only bytes
* this app agrees with.
*/
class CamTxPvSerialTest {
private fun String.hexToBytes(): ByteArray =
chunked(2).map { it.toInt(16).toByte() }.toByteArray()
private fun ByteArray.u32le(at: Int): Long =
(0 until 4).fold(0L) { acc, i -> 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) }
}