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) } }