Drive the ESP32-C5 station over USB or BLE, send CAM or VAM, signed or not
The app now speaks the station-link protocol of the new obu-firmware. Esp32Link picks the transport from Settings (UsbSerialTransport or the new BleLinkTransport), tells the previous firmware from the new one by its heartbeat, and runs the session: STATION_CONFIGURE with the current pseudonym MAC (which also starts the board's radio), CREDENTIALS_PROVISION of the bundled demo chain when the board has no ticket, then per message a POTI_UPDATE and a BTP_DATA_REQUEST. Received messages still arrive as V2X_RX frames, so the receive side is unchanged. A board on the previous firmware keeps working for CAM over USB. Settings > Connection > ESP32-C5: link USB-C or Bluetooth, transmit CAM or VAM, "Sign outgoing messages" (on by default). The connection card, top bar and dashboard show the link in use, the pairing passkey and signing counters. - VAM: VamUperCodec (TS 103 300-3 V2.3.1, bytes checked against asn1tools) and VamGenerationRules (clause 6.4, Tables 16/17). - BLE: the firmware's GATT layout (service 0000C175-...), MTU 517, pairing and encryption settled before any other operation (short timeouts during pairing made it loop), backoff between attempts, reasons on the card. - Clock: a PoTi goes to the board once per new fix and never moves the board's clock backwards except for a real correction (>= 60 s); stale and wobbling fix times made the board answer time_regression and restart its stack every few seconds. GnssTimeSource keeps the last measured phone-clock error while GNSS time drops out indoors: the bench phone is 14 minutes fast, and falling back to it made every transmitted timestamp jump by that much. - assets/demo-chain.vcr: throwaway, not EU-registered demo chain generated 2026-09-23 (AT B80B49387A4C12EB, psid 36 and 638). Its private key ships with the app on purpose; receivers verifying against the EU trust list drop what it signs. - Bluetooth permissions requested at start-up on Android 12+. StationLinkTest pins the codec to bytes from the colleague's Python implementation (microbu_link/messages.py). 103 unit tests pass.
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
package com.hawhamburg.micr0bu
|
||||
|
||||
import com.hawhamburg.micr0bu.data.transport.BtpDataRequest
|
||||
import com.hawhamburg.micr0bu.data.transport.LinkMessage
|
||||
import com.hawhamburg.micr0bu.data.transport.LinkOpcode
|
||||
import com.hawhamburg.micr0bu.data.transport.LinkResult
|
||||
import com.hawhamburg.micr0bu.data.transport.LinkSecurityProfile
|
||||
import com.hawhamburg.micr0bu.data.transport.PotiUpdate
|
||||
import com.hawhamburg.micr0bu.data.transport.StationConfigure
|
||||
import com.hawhamburg.micr0bu.data.transport.StationInfo
|
||||
import com.hawhamburg.micr0bu.data.transport.StationStatus
|
||||
import com.hawhamburg.micr0bu.data.transport.credentialsSegment
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pins the phone side of the station-link message layer.
|
||||
*
|
||||
* ## Where the expected bytes come from
|
||||
* The colleague's Python implementation of the same protocol, microbu-esp32c5
|
||||
* station-link/python/microbu_link/messages.py (`encode_message` over each body's `encode`),
|
||||
* run with the same field values. That module is what their phone emulator drives the firmware
|
||||
* with, so an agreement here is agreement with a second, independent implementation.
|
||||
*/
|
||||
class StationLinkTest {
|
||||
|
||||
private fun String.hexToBytes(): ByteArray = chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
private fun ByteArray.hex(): String = joinToString("") { "%02x".format(it) }
|
||||
|
||||
@Test
|
||||
fun `STATION_CONFIGURE matches the Python encoder`() {
|
||||
val body = StationConfigure(stationType = 2, mid = "021122334455".hexToBytes()).encode()
|
||||
assertEquals(
|
||||
"0100070002010002112233445500b40014020205",
|
||||
LinkMessage(LinkOpcode.STATION_CONFIGURE, 7, body).encode().hex(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `POTI_UPDATE matches the Python encoder`() {
|
||||
val body = PotiUpdate(
|
||||
timestampMs = 717_254_800_123L, latTenMicroDeg = 535_546_667, lonTenMicroDeg = 100_223_889,
|
||||
semiMajorCm = 486, semiMinorCm = 486, speedCms = 543, headingDeciDeg = 1234, pai = true,
|
||||
).encode()
|
||||
assertEquals(
|
||||
"02000800fbb2b7ffa60000002bcbeb1f914bf905e601e60100000e000000001f02d204",
|
||||
LinkMessage(LinkOpcode.POTI_UPDATE, 8, body).encode().hex(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `BTP_DATA_REQUEST for a signed CAM matches the Python encoder`() {
|
||||
val body = BtpDataRequest(
|
||||
destinationPort = 2001, itsAid = 36, securityProfile = LinkSecurityProfile.SECURED,
|
||||
permissions = "010000".hexToBytes(), flSdu = "0102030405".hexToBytes(),
|
||||
).encode()
|
||||
assertEquals(
|
||||
"0300090001d1070000010102ffff000000000024000000030100000005000102030405",
|
||||
LinkMessage(LinkOpcode.BTP_DATA_REQUEST, 9, body).encode().hex(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CREDENTIALS_PROVISION segment matches the Python encoder`() {
|
||||
val body = credentialsSegment(totalLength = 695, offset = 240, segment = ByteArray(3) { 0xAB.toByte() })
|
||||
assertEquals("04000a00b702f00003ababab", LinkMessage(LinkOpcode.CREDENTIALS_PROVISION, 10, body).encode().hex())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `STATUS from the Python encoder decodes field by field`() {
|
||||
val message = LinkMessage.decode(
|
||||
("8400341240e20100010800021122334455b80b49387a4c12eb00010b0000000c0000000d0000000e000000" +
|
||||
"0f000000100000001100000012000000130000001400000015000000160000001700000018000000" +
|
||||
"190000001a0000001b0000001c000000fbb2b7ffa6000000").hexToBytes(),
|
||||
)
|
||||
assertNotNull(message)
|
||||
assertEquals(LinkOpcode.STATUS, message!!.opcode)
|
||||
assertEquals(0x1234, message.sequence)
|
||||
val status = StationStatus.decode(message.body)!!
|
||||
assertEquals(123_456L, status.uptimeMs)
|
||||
assertTrue(status.configured)
|
||||
assertArrayEquals("b80b49387a4c12eb".hexToBytes(), status.identifier)
|
||||
assertEquals(1, status.tickets)
|
||||
assertEquals(11L, status.signedMessages)
|
||||
assertEquals(12L, status.refusedNoTicket)
|
||||
assertEquals(13L, status.refusedChangePending)
|
||||
assertEquals(14L, status.refusedPermission)
|
||||
assertEquals(15L, status.signFailed)
|
||||
assertEquals(16L, status.verified)
|
||||
assertEquals(17L, status.rejected)
|
||||
assertEquals(18L, status.requestsAccepted)
|
||||
assertEquals(19L, status.requestsRefused)
|
||||
assertEquals(21L, status.radioSubmitted)
|
||||
assertEquals(22L, status.radioFailed)
|
||||
assertEquals(23L, status.radioReceived)
|
||||
assertEquals(24L, status.radioDropped)
|
||||
assertEquals(26L, status.linkCrcErrors)
|
||||
assertEquals(27L, status.linkMalformed)
|
||||
assertEquals(28L, status.potiUpdates)
|
||||
assertEquals(717_254_800_123L, status.itsTimeMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a STATUS of the wrong length is refused, as the Python decoder does`() {
|
||||
assertNull(StationStatus.decode(ByteArray(StationStatus.SIZE + 1)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `RESULT of STATION_CONFIGURE carries the credential state`() {
|
||||
val message = LinkMessage.decode("8000070000120800021122334455b80b49387a4c12eb0101".hexToBytes())!!
|
||||
val result = LinkResult.decode(message.body)!!
|
||||
assertTrue(result.accepted)
|
||||
assertEquals(StationInfo(credentialsLoaded = true, tickets = 1), StationInfo.decode(result.detail))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user