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:
@@ -6,13 +6,18 @@ import com.hawhamburg.micr0bu.data.GnssTimeSource
|
||||
import com.hawhamburg.micr0bu.data.SensorRepository
|
||||
import com.hawhamburg.micr0bu.data.cam.PseudonymManager
|
||||
import com.hawhamburg.micr0bu.data.mqtt.ObuHardwarePreferences
|
||||
import com.hawhamburg.micr0bu.data.transport.Esp32Link
|
||||
import com.hawhamburg.micr0bu.data.transport.GnPositionVector
|
||||
import com.hawhamburg.micr0bu.data.transport.ObuHardware
|
||||
import com.hawhamburg.micr0bu.data.transport.UsbSerialTransport
|
||||
import com.hawhamburg.micr0bu.data.transport.OutgoingIts
|
||||
import com.hawhamburg.micr0bu.data.transport.OutgoingMessage
|
||||
import com.hawhamburg.micr0bu.domain.asn1.RealAsn1UperCodec
|
||||
import com.hawhamburg.micr0bu.domain.asn1.VamContent
|
||||
import com.hawhamburg.micr0bu.domain.asn1.VamUperCodec
|
||||
import com.hawhamburg.micr0bu.domain.cam.CamTransmitConfig
|
||||
import com.hawhamburg.micr0bu.domain.cam.PhoneCamBuilder
|
||||
import com.hawhamburg.micr0bu.domain.usecase.GeoMath
|
||||
import com.hawhamburg.micr0bu.domain.vam.VamGenerationRules
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -45,12 +50,19 @@ import javax.inject.Singleton
|
||||
* [com.hawhamburg.micr0bu.domain.detection.EventDetector] stream that already drives trip event
|
||||
* logging). Both rate figures are placeholders pending real-world tuning, per
|
||||
* [CamTransmitConfig]'s own disclaimer.
|
||||
*
|
||||
* ## CAM or VAM
|
||||
* Settings chooses what goes out ([OutgoingMessage]). CAM follows the rate policy above. VAM is
|
||||
* checked every [VAM_TICK_MS] against the generation rules of TS 103 300-3 clause 6.4
|
||||
* ([VamGenerationRules]) and sent when one fires, from the same GNSS fix, pseudonym and position
|
||||
* vector a CAM would use. Whether either is signed is the "Sign outgoing messages" setting; the
|
||||
* micrOBU does the signing ([Esp32Link]).
|
||||
*/
|
||||
@Singleton
|
||||
class CamTransmitLoop @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val obuHardwarePrefs: ObuHardwarePreferences,
|
||||
private val usbSerialTransport: UsbSerialTransport,
|
||||
private val esp32Link: Esp32Link,
|
||||
private val codec: RealAsn1UperCodec,
|
||||
private val pseudonymManager: PseudonymManager,
|
||||
) {
|
||||
@@ -67,6 +79,10 @@ class CamTransmitLoop @Inject constructor(
|
||||
/** Previous GNSS fix, kept only to derive along-track acceleration — see [longitudinalAccel]. */
|
||||
@Volatile private var previousGnss: GnssReading? = null
|
||||
|
||||
@Volatile private var outgoing: OutgoingMessage = OutgoingMessage.CAM
|
||||
@Volatile private var signOutgoing: Boolean = true
|
||||
private val vamRules = VamGenerationRules()
|
||||
|
||||
/**
|
||||
* Call when a braking/turning/stopping event fires during an active trip — bumps the CAM
|
||||
* rate to [CamTransmitConfig.elevatedRateHz] for [ELEVATED_HOLD_MS] so nearby stations get
|
||||
@@ -85,6 +101,7 @@ class CamTransmitLoop @Inject constructor(
|
||||
if (job?.isActive == true) return
|
||||
elevatedUntilMs = 0L
|
||||
previousGnss = null
|
||||
vamRules.reset()
|
||||
job = scope.launch {
|
||||
obuHardwarePrefs.obuHardwareFlow.collectLatest { hardware ->
|
||||
if (hardware != ObuHardware.ESP32_C5) return@collectLatest
|
||||
@@ -102,24 +119,66 @@ class CamTransmitLoop @Inject constructor(
|
||||
private suspend fun runTransmitLoop() = coroutineScope {
|
||||
launch { sensorRepository.gnssFlow().collect { latestGnss = it } }
|
||||
launch { sensorRepository.gyroscopeFlow().collect { latestGyroZ = it.z } }
|
||||
launch { obuHardwarePrefs.signOutgoingFlow.collect { signOutgoing = it } }
|
||||
launch {
|
||||
obuHardwarePrefs.outgoingMessageFlow.collect {
|
||||
if (it != outgoing) vamRules.reset()
|
||||
outgoing = it
|
||||
}
|
||||
}
|
||||
|
||||
while (true) {
|
||||
val gnss = latestGnss
|
||||
if (gnss != null) {
|
||||
// Asked for per CAM rather than once per trip: that is what lets a pseudonym
|
||||
// rotation fall cleanly between two frames instead of inside one.
|
||||
val pseudonym = pseudonymManager.current()
|
||||
// Stamped on GNSS time rather than the phone clock; see GnssTimeSource. Only the
|
||||
// outgoing CAM is: acceleration below still differences wall-clock samples.
|
||||
val fix = gnss.copy(timestamp = GnssTimeSource.correct(gnss.timestamp))
|
||||
val cam = PhoneCamBuilder.build(fix, latestGyroZ, pseudonym.stationId, longitudinalAccel(gnss))
|
||||
val bytes = codec.encodeCam(cam)
|
||||
usbSerialTransport.sendCamTx(bytes, GnPositionVector.fromCam(cam, gnss.accuracyM, pseudonym.mac))
|
||||
when (outgoing) {
|
||||
OutgoingMessage.CAM -> sendCam(gnss)
|
||||
OutgoingMessage.VAM -> sendVamIfDue(gnss)
|
||||
}
|
||||
}
|
||||
delay((1000.0 / currentRateHz(gnss)).toLong())
|
||||
delay(if (outgoing == OutgoingMessage.VAM) VAM_TICK_MS else (1000.0 / currentRateHz(gnss)).toLong())
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun sendCam(gnss: GnssReading) {
|
||||
// Asked for per CAM rather than once per trip: that is what lets a pseudonym
|
||||
// rotation fall cleanly between two frames instead of inside one.
|
||||
val pseudonym = pseudonymManager.current()
|
||||
// Stamped on GNSS time rather than the phone clock; see GnssTimeSource. Only the
|
||||
// outgoing CAM is: acceleration below still differences wall-clock samples.
|
||||
val fix = gnss.copy(timestamp = GnssTimeSource.correct(gnss.timestamp))
|
||||
val cam = PhoneCamBuilder.build(fix, latestGyroZ, pseudonym.stationId, longitudinalAccel(gnss))
|
||||
val bytes = codec.encodeCam(cam)
|
||||
esp32Link.send(OutgoingIts(OutgoingMessage.CAM, bytes,
|
||||
GnPositionVector.fromCam(cam, gnss.accuracyM, pseudonym.mac), gnss.accuracyM, signOutgoing))
|
||||
}
|
||||
|
||||
private suspend fun sendVamIfDue(gnss: GnssReading) {
|
||||
val now = System.currentTimeMillis()
|
||||
val kinematics = VamGenerationRules.Kinematics(gnss.latitude, gnss.longitude,
|
||||
gnss.speedMs.toDouble(), gnss.bearingDeg.toDouble())
|
||||
if (!vamRules.due(now, kinematics)) return
|
||||
val pseudonym = pseudonymManager.current()
|
||||
val fix = gnss.copy(timestamp = GnssTimeSource.correct(gnss.timestamp))
|
||||
// The CAM view of this fix is built only for its position vector, so the GN header of a VAM
|
||||
// follows exactly the rules a CAM's does. It is not transmitted.
|
||||
val cam = PhoneCamBuilder.build(fix, latestGyroZ, pseudonym.stationId, longitudinalAccel(gnss))
|
||||
val withLowFrequency = vamRules.includeLowFrequency(now)
|
||||
val bytes = VamUperCodec.encode(VamContent(
|
||||
stationId = pseudonym.stationId,
|
||||
timestamp = cam.timestamp,
|
||||
latitude = cam.latitude,
|
||||
longitude = cam.longitude,
|
||||
accuracyM = gnss.accuracyM,
|
||||
speedMps = cam.speedMps,
|
||||
headingDeg = cam.headingDeg,
|
||||
accelerationMps2 = cam.accelerationMps2,
|
||||
includeLowFrequency = withLowFrequency,
|
||||
))
|
||||
val handedOver = esp32Link.send(OutgoingIts(OutgoingMessage.VAM, bytes,
|
||||
GnPositionVector.fromCam(cam, gnss.accuracyM, pseudonym.mac), gnss.accuracyM, signOutgoing))
|
||||
if (handedOver) vamRules.onSent(now, kinematics, withLowFrequency)
|
||||
}
|
||||
|
||||
/**
|
||||
* Along-track acceleration in m/s², from the change in GNSS speed since the previous fix.
|
||||
*
|
||||
@@ -158,6 +217,9 @@ class CamTransmitLoop @Inject constructor(
|
||||
companion object {
|
||||
private const val ELEVATED_HOLD_MS = 5_000L
|
||||
|
||||
/** How often VAM generation rules are checked: T_GenVamMin, TS 103 300-3 Table 16. */
|
||||
private const val VAM_TICK_MS = VamGenerationRules.T_GEN_VAM_MIN_MS
|
||||
|
||||
/** Below this gap, GNSS speed noise divided by a tiny dt produces absurd accelerations. */
|
||||
private const val MIN_ACCEL_DT_SEC = 0.2
|
||||
|
||||
|
||||
Reference in New Issue
Block a user