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:
Ashin Walpola
2026-09-23 17:28:05 +02:00
parent d2fd222a62
commit a08494b56a
27 changed files with 2092 additions and 134 deletions
@@ -0,0 +1,60 @@
package com.hawhamburg.micr0bu
import com.hawhamburg.micr0bu.domain.vam.VamGenerationRules
import com.hawhamburg.micr0bu.domain.vam.VamGenerationRules.Kinematics
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/** TS 103 300-3 clause 6.4 items 1 to 4, with the Table 16/17 values. */
class VamGenerationRulesTest {
private val here = Kinematics(53.5546667, 10.0223889, speedMps = 3.0, headingDeg = 90.0)
private fun sentAt(ms: Long, k: Kinematics = here) = VamGenerationRules().apply { onSent(ms, k, withLowFrequency = true) }
@Test
fun `the first VAM is always due`() {
assertTrue(VamGenerationRules().due(0, here))
}
@Test
fun `nothing is due within T_GenVamMin even after a big jump`() {
assertFalse(sentAt(1_000).due(1_050, here.copy(latitude = here.latitude + 0.001)))
}
@Test
fun `a stationary VRU gets one VAM every T_GenVamMax`() {
val rules = sentAt(1_000)
assertFalse(rules.due(5_900, here))
assertTrue(rules.due(6_001, here))
}
@Test
fun `position, speed and heading changes trigger past their thresholds only`() {
val rules = sentAt(1_000)
// ~3.3 m north: under 4 m. ~5.6 m: over.
assertFalse(rules.due(2_000, here.copy(latitude = here.latitude + 0.00003)))
assertTrue(rules.due(2_000, here.copy(latitude = here.latitude + 0.00005)))
assertFalse(rules.due(2_000, here.copy(speedMps = 3.4)))
assertTrue(rules.due(2_000, here.copy(speedMps = 3.6)))
assertFalse(rules.due(2_000, here.copy(headingDeg = 93.0)))
assertTrue(rules.due(2_000, here.copy(headingDeg = 95.0)))
}
@Test
fun `heading change is measured the short way round north`() {
val rules = sentAt(1_000, here.copy(headingDeg = 358.0))
assertFalse(rules.due(2_000, here.copy(headingDeg = 1.0))) // 3 degrees across north
assertTrue(rules.due(2_000, here.copy(headingDeg = 3.0))) // 5 degrees
}
@Test
fun `the low-frequency container goes with the first VAM, then every T_GenVamLFMin`() {
val rules = VamGenerationRules()
assertTrue(rules.includeLowFrequency(0))
rules.onSent(0, here, withLowFrequency = true)
assertFalse(rules.includeLowFrequency(1_999))
assertTrue(rules.includeLowFrequency(2_000))
}
}