Phase 03: CAM decode coverage, real sensor data in TX, V2X monitor for ESP32 path

CAM codec:
- Stop rejecting CAMs carrying a specialVehicleContainer. It is declared last in
  CamParameters, after everything this decoder reads, so buses / emergency
  vehicles / road-works vehicles now decode for position and kinematics instead
  of being dropped outright
- Drop the lowFrequencyContainer parse - it extracted nothing into Cam, and its
  reads were only correct when no high-frequency optionals were present
- Document why the 7 optional-presence bits are consumed but not acted on: UPER
  writes a SEQUENCE's presence bitmap up front but each field's value in
  declaration order, and all seven are declared after yawRate
- Field widths and container ordering verified against the ETSI ASN.1 sources in
  the C-ITS-Parser checkout, not from memory

Transmit path:
- Own StationID is now a persisted random 32-bit value instead of a hardcoded 0.
  Receivers key on StationID to track a station across CAMs, so every unit
  broadcasting 0 made two MicrOBUs indistinguishable - including to this app's
  own detection engine
- Populate longitudinalAcceleration from successive GNSS speed samples. Not from
  the accelerometer: CAM wants signed along-track acceleration, and the raw
  sensor is device-frame with gravity in it. Null outside a usable sample gap
  rather than a fabricated value
- CAM pinger builds from live GNSS/IMU via PhoneCamBuilder instead of beaconing a
  hardcoded bench coordinate with speed and heading pinned to zero, so it now
  exercises the sensor pipeline and not just the wire. Sends nothing without a
  fix, and reports that rather than sitting at "Sent: 0"

V2X monitor:
- Received-CAM pane for the ESP32-C5 path, replacing the MQTT topic list that is
  permanently empty there. One row per station rather than per message - CAMs
  arrive at 1-10 Hz per station, so the pane is bounded by road users nearby, not
  by traffic rate. Nearest first, tinted by active alert level
- DENM hazard pins on the live map as a warning triangle, drawn above vehicle
  markers. CiT One path only: the ESP32 firmware forwards BTP-B port 2001 (CAM)
  and drops port 2002 before it reaches the phone

DenmParser uses tolerant field-name matching - the Use Case API's DENM JSON
schema is not yet confirmed against real payloads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Ashin Walpola
2026-08-10 14:09:18 +02:00
co-authored by Claude Opus 5
parent 64fb78590e
commit f3ae81a8fe
14 changed files with 808 additions and 35 deletions
@@ -2,6 +2,7 @@ package com.hawhamburg.micr0bu.data.mqtt
import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.hawhamburg.micr0bu.data.transport.EspRxMode
@@ -11,6 +12,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.random.Random
private val Context.obuHardwareDataStore by preferencesDataStore(name = "obu_hardware_prefs")
@@ -26,6 +28,7 @@ class ObuHardwarePreferences @Inject constructor(
private object Keys {
val OBU_HARDWARE = stringPreferencesKey("obu_hardware")
val ESP_RX_MODE = stringPreferencesKey("esp_rx_mode")
val OWN_STATION_ID = longPreferencesKey("own_station_id")
}
val obuHardwareFlow: Flow<ObuHardware> = context.obuHardwareDataStore.data.map { prefs ->
@@ -48,4 +51,32 @@ class ObuHardwarePreferences @Inject constructor(
suspend fun setEspRxMode(mode: EspRxMode) {
context.obuHardwareDataStore.edit { prefs -> prefs[Keys.ESP_RX_MODE] = mode.id }
}
/** This device's own CAM StationID, or null if one hasn't been assigned yet. */
val ownStationIdFlow: Flow<Long?> = context.obuHardwareDataStore.data.map { prefs ->
prefs[Keys.OWN_STATION_ID]
}
/**
* Returns this device's own CAM StationID, generating and persisting a random one on first
* call.
*
* Replaces the previous hardcoded 0: receivers key on StationID to track a station across
* successive CAMs, so every MicrOBU broadcasting 0 makes two units in the same area
* indistinguishable to any receiver — including this app's own detection engine, which
* dedupes remote stations by ID. Random rather than derived from a hardware identifier both
* because ETSI expects station IDs to be pseudonymous and because Android hardware IDs aren't
* readable without privileged permissions on modern versions.
*
* Range is 1..2^32-2: StationID is INTEGER(0..4294967295), and 0 is avoided so leftover
* placeholder traffic stays distinguishable from a real assignment.
*/
suspend fun getOrCreateOwnStationId(): Long {
val prefs = context.obuHardwareDataStore.edit { p ->
if (p[Keys.OWN_STATION_ID] == null) {
p[Keys.OWN_STATION_ID] = Random.nextLong(1L, 0xFFFF_FFFEL)
}
}
return prefs[Keys.OWN_STATION_ID]!!
}
}
@@ -0,0 +1,24 @@
package com.hawhamburg.micr0bu.data.transport
/**
* Whether the ESP32-C5 path (Phase 03) processes remote CAM traffic it receives, or only ever
* transmits the phone's own CAM.
*
* **Important nuance:** this does NOT physically disable the ESP32's radio receiver. The
* firmware's own promiscuous-mode setup (`main.c`, see comments there) is required for its raw
* 802.11p TX path to work at all — ESP-IDF only allows `esp_wifi_80211_tx()` to emit frames when
* the MAC is promiscuous or associated to an AP. So the ESP32 always physically receives and
* forwards CAM_RX frames over the serial link regardless of this setting; what this setting
* actually controls is purely on the phone side — whether [SEND_ONLY] mode ignores those
* incoming frames (see [com.hawhamburg.micr0bu.data.cam.CamUseCaseRepository]) instead of
* feeding them into the detection engine / UI. Useful for isolating the TX path during bench
* testing (e.g. with the [com.hawhamburg.micr0bu.service.CamPinger]) without nearby test traffic
* cluttering the use-case alerts or live map.
*/
enum class EspRxMode(val id: String) {
/** Transmit the phone's own CAM only; incoming CAM_RX frames from the ESP32 are discarded. */
SEND_ONLY("send_only"),
/** Normal full-duplex operation: transmit own CAM and process received CAM traffic. */
SEND_AND_RECEIVE("send_and_receive"),
}
@@ -148,11 +148,18 @@ object CamUperCodec {
/**
* Decodes a UPER CAM byte string into a domain [Cam] (always `isOwn = false` — this is only
* used for CAMs received from other stations; the ego's own CAM never round-trips through
* this). Returns null if the bytes aren't a CAM this codec understands: wrong
* protocolVersion/messageID, a CamParameters/HighFrequencyContainer/LowFrequencyContainer
* shape we don't decode (extension in use, RSU container instead of vehicle, or a
* specialVehicleContainer present — none of those are things this project transmits or
* currently needs to receive), or a truncated frame.
* this).
*
* Accepts any CAM whose BasicContainer + BasicVehicleContainerHighFrequency are non-extended,
* regardless of which optional high-frequency fields the sender includes or whether it carries
* a lowFrequencyContainer or a specialVehicleContainer — all of that is declared after the
* fields read here, so it neither shifts them nor needs parsing. A bus, an emergency vehicle,
* or a car sending lanePosition and steering-wheel angle all decode normally.
*
* Returns null only when the bytes genuinely can't be read as vehicle kinematics: wrong
* protocolVersion/messageID, an extension marker in use on a container this parses,
* rsuContainerHighFrequency (roadside infrastructure — carries no heading/speed/yaw at all,
* so there is nothing for the detection engine to consume), or a truncated frame.
*
* [receivedAtEpochMs] becomes [Cam.timestamp] (wall-clock receipt time) — GenerationDeltaTime
* alone (a value mod 65536 ms) isn't enough on its own to reconstruct an absolute timestamp
@@ -179,9 +186,12 @@ object CamUperCodec {
val camParamsExt = br.getBitsInt(1)
if (camParamsExt != 0) return null // extension in use - unsupported shape
val lowFreqPresent = br.getBitsInt(1) == 1
val specialVehiclePresent = br.getBitsInt(1) == 1
if (specialVehiclePresent) return null // different container shape we don't parse
// lowFrequencyContainer / specialVehicleContainer presence bits. Both containers are
// declared after highFrequencyContainer, so everything this decoder reads comes first and
// neither needs parsing - a public-transport bus or an emergency vehicle now decodes for
// its position and kinematics like any other station, instead of being dropped.
br.getBits(1)
br.getBits(1)
val basicContainerExt = br.getBitsInt(1)
if (basicContainerExt != 0) return null
@@ -201,7 +211,17 @@ object CamUperCodec {
val highFreqIndex = br.getBitsInt(1)
if (highFreqExt != 0 || highFreqIndex != 0) return null // extension, or rsuContainerHighFrequency
br.getBits(7) // 7 optional-presence bits
// Optional-presence bitmap for BasicVehicleContainerHighFrequency's 7 trailing OPTIONAL
// fields: accelerationControl, lanePosition, steeringWheelAngle, lateralAcceleration,
// verticalAcceleration, performanceClass, cenDsrcTollingZone (see
// C-ITS-Parser/autogen/asn.1/cam_1_4_1.asn).
//
// Consumed but not acted on, and that is correct: UPER writes a SEQUENCE's presence
// bitmap up front but each field's VALUE in declaration order, and all seven of these are
// declared AFTER yawRate. Everything this decoder extracts (heading..yawRate) therefore
// sits between the bitmap and the optionals, at a fixed offset no matter which optionals
// a sender includes. Do not "skip" the optional values here - they are not here.
br.getBits(7)
val headingRaw = br.getBitsInt(12)
br.getBits(7) // headingConfidence
@@ -233,14 +253,15 @@ object CamUperCodec {
br.getBits(3) // yawRateConfidence
val yawRateDps = if (yawRateRaw == YAW_RATE_UNAVAILABLE) null else yawRateRaw / 100.0
if (lowFreqPresent) {
val lowFreqExt = br.getBitsInt(1)
if (lowFreqExt != 0) return null
br.getBits(4) // vehicleRole
br.getBits(8) // exteriorLights
br.getBits(6) // pathHistory count (0..40) - not decoded into path points, just consumed
}
// Everything after yawRate is deliberately left unread: the 7 optional high-frequency
// fields, then lowFrequencyContainer (vehicleRole / exteriorLights / pathHistory), then
// specialVehicleContainer. None of it maps onto [Cam], and because it all follows the
// fields above, not parsing it cannot misalign anything already extracted.
//
// IMPORTANT: if a future change needs any of those - path history is the likely one - the
// 7 optionals must be parsed and consumed first, in declaration order, or every read after
// them lands at the wrong bit offset. At that point this hand-written decoder stops being
// the right tool; use the generated codec (see C-ITS-Parser) instead.
return Cam(
stationId = stationId,
stationType = stationType,
@@ -7,13 +7,10 @@ import kotlin.math.abs
* Builds an outgoing [Cam] from the phone's own GNSS + gyroscope, for the ESP32-C5 hardware
* path (Phase 03, Section 13) where the OBU itself generates no CAM at all — the phone must.
*
* This class only does the sensor-fusion-into-CAM-fields part, which is independent of the
* (not yet defined) wire protocol to the ESP32-C5. It is **not yet wired into any transmit
* pipeline** — nothing calls this today. Once the ESP32 firmware protocol is translated to
* Kotlin and [com.hawhamburg.micr0bu.domain.asn1.Asn1UperCodec] has a real implementation, the
* intended flow is:
* This class only does the sensor-fusion-into-CAM-fields part, independent of the wire protocol
* to the ESP32-C5. Live flow, driven by [com.hawhamburg.micr0bu.service.CamTransmitLoop]:
*
* `PhoneCamBuilder.build(...)` → `Asn1UperCodec.encodeCam(...)` → `UsbSerialTransport` (write).
* `PhoneCamBuilder.build(...)` → `RealAsn1UperCodec.encodeCam(...)` → `UsbSerialTransport` (write).
*
* Position/speed/heading come straight from GNSS. Yaw rate is derived from the gyroscope's
* z-axis reading (rotation about the vertical axis while the phone is roughly flat/mounted
@@ -22,22 +19,27 @@ import kotlin.math.abs
*/
object PhoneCamBuilder {
/** Placeholder station ID until real station-ID assignment/config exists for this path. */
private const val PLACEHOLDER_OWN_STATION_ID = 0L
/**
* @param gnss latest phone GNSS fix.
* @param gyroZRadPerSec latest gyroscope z-axis reading, rad/s (device frame). Positive per
* Android's convention is counter-clockwise around +Z; converted to the clockwise-positive
* yaw rate convention already used by [Cam.yawRateDps] to match OBU/remote CAM data.
* @param stationId this device's own station ID. Defaults to a placeholder until Phase 03
* defines how the phone learns/assigns an ID on the ESP32-C5 path (the CiT One path
* currently learns this from `v2x/rx/obu_gnss`'s own_info, which doesn't exist here).
* @param stationId this device's own station ID, from
* [com.hawhamburg.micr0bu.data.mqtt.ObuHardwarePreferences.getOrCreateOwnStationId] — a
* persisted random value, not a placeholder. Receivers use it to track this station across
* successive CAMs, so it must be stable for the life of the install and distinct per device.
* @param longitudinalAccelMps2 along-track acceleration, signed (positive = accelerating).
* Derived from successive GNSS speed samples by [com.hawhamburg.micr0bu.service.CamTransmitLoop]
* rather than from the accelerometer: CAM wants acceleration along the direction of travel,
* and the raw accelerometer is in the device frame with gravity mixed in, so it can't supply
* that without full orientation estimation. Null when it can't be computed (no previous fix,
* stale sample), which encodes as the ASN.1 `unavailable` sentinel.
*/
fun build(
gnss: GnssReading,
gyroZRadPerSec: Float?,
stationId: Long = PLACEHOLDER_OWN_STATION_ID,
stationId: Long,
longitudinalAccelMps2: Double? = null,
): Cam {
val yawRateDps = gyroZRadPerSec?.let { -it * RAD_TO_DEG } // negate: CCW+ -> CW+ convention
@@ -49,6 +51,7 @@ object PhoneCamBuilder {
speedMps = gnss.speedMs.toDouble(),
headingDeg = normalizeHeading(gnss.bearingDeg.toDouble()),
yawRateDps = yawRateDps?.let { if (abs(it) < YAW_RATE_NOISE_FLOOR_DPS) 0.0 else it },
accelerationMps2 = longitudinalAccelMps2,
timestamp = gnss.timestamp,
isOwn = true,
)
@@ -0,0 +1,89 @@
package com.hawhamburg.micr0bu.domain.denm
import com.hawhamburg.micr0bu.domain.cam.JsonFieldReader
import org.json.JSONObject
/**
* A decentralized environmental notification received from another station — a hazard at a fixed
* place, as opposed to [com.hawhamburg.micr0bu.domain.cam.Cam]'s "here I am, moving" beacon.
*
* Only the fields needed to put a pin on the live map are modelled. DENM carries a great deal
* more (validity duration, relevance area, traffic direction, trace paths); none of it is used
* yet, and inventing a fuller model before there's a consumer for it would just be guesswork.
*
* **Availability:** DENM reaches the app only on the CiT One path, via the Use Case API's
* `v2x-uca/output/json/denm` topic. The ESP32-C5 path receives none — the firmware's
* `gn_unwrap.c` accepts BTP-B destination port 2001 (CAM) only and drops port 2002 (DENM) before
* anything is forwarded over the serial link. See that file's header comment.
*/
data class DenmEvent(
/** Originating station ID. */
val stationId: Long,
/** Event position (WGS84 degrees) — where the hazard is, not where the sender is. */
val latitude: Double,
val longitude: Double,
/** ETSI TS 102 894-2 CauseCode, or null if the payload didn't carry one. */
val causeCode: Int?,
/** SubCauseCode qualifying [causeCode], or null. */
val subCauseCode: Int?,
/** Wall-clock ms this DENM was received. */
val timestamp: Long,
) {
/**
* Stable identity for map/list dedup: successive DENMs about the same hazard from the same
* station should replace each other rather than pile up as separate pins. ETSI's real identity
* is actionID (stationID + sequenceNumber); this approximates it with the cause, since the
* Use Case API's JSON doesn't reliably expose a sequence number.
*/
val dedupKey: String get() = "$stationId/${causeCode ?: -1}/${subCauseCode ?: -1}"
}
/**
* Parses the processed DENM JSON published by the consider it Use Case API on
* `v2x-uca/output/json/denm`.
*
* Same field-name tolerance approach as [com.hawhamburg.micr0bu.domain.cam.CamParser] — confirmed
* spellings first, plausible alternatives as fallbacks via [JsonFieldReader] — because the exact
* schema hasn't been pinned against real OBU payloads yet. Returns null rather than a
* half-populated event when position is missing: a DENM with no position is useless to a map and
* worse than absent on a hazard display.
*/
object DenmParser {
fun parse(json: String, timestamp: Long = System.currentTimeMillis()): DenmEvent? {
val obj = runCatching { JSONObject(json) }.getOrNull() ?: return null
// Event position may sit at the top level or nested under an eventPosition/
// situation-style object, depending on how the API flattens the ASN.1.
val (lat, lon) = JsonFieldReader.firstLatLon(obj)
?: obj.optJSONObject("eventPosition")?.let { JsonFieldReader.firstLatLon(it) }
?: obj.optJSONObject("management")?.optJSONObject("eventPosition")
?.let { JsonFieldReader.firstLatLon(it) }
?: return null
val stationId = JsonFieldReader.firstLong(obj, "stationId", "stationID", "station_id")
?: obj.optJSONObject("management")?.let {
JsonFieldReader.firstLong(it, "stationId", "stationID", "station_id")
}
?: return null
val situation = obj.optJSONObject("situation")
val causeCode = JsonFieldReader.firstInt(obj, "causeCode", "cause_code", "cause")
?: situation?.let { JsonFieldReader.firstInt(it, "causeCode", "cause_code", "cause") }
val subCauseCode = JsonFieldReader.firstInt(obj, "subCauseCode", "sub_cause_code", "subCause")
?: situation?.let { JsonFieldReader.firstInt(it, "subCauseCode", "sub_cause_code", "subCause") }
return DenmEvent(
stationId = stationId,
latitude = lat,
longitude = lon,
causeCode = causeCode,
subCauseCode = subCauseCode,
timestamp = timestamp,
)
}
}
@@ -0,0 +1,141 @@
package com.hawhamburg.micr0bu.service
import android.content.Context
import com.hawhamburg.micr0bu.data.GnssReading
import com.hawhamburg.micr0bu.data.SensorRepository
import com.hawhamburg.micr0bu.data.transport.UsbSerialTransport
import com.hawhamburg.micr0bu.domain.asn1.RealAsn1UperCodec
import com.hawhamburg.micr0bu.domain.cam.PhoneCamBuilder
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
/**
* Manual bench-test CAM transmitter for the ESP32-C5 path (Phase 03) — a fixed-rate (1 Hz) CAM
* ping built from the phone's real GNSS and gyroscope, independent of [CamTransmitLoop] and not
* tied to an active trip recording. Purpose: verify the phone <-> ESP32-C5 serial link and the
* ESP32's TX/RX radio path end-to-end without needing a full recording session — the CAM
* equivalent of the CiT One path's manual DENM trigger
* ([com.hawhamburg.micr0bu.data.mqtt.MqttRepository.activateDenm]).
*
* Uses the same [PhoneCamBuilder] as the real transmit path, so what goes on air here is a
* properly populated CAM — real position, speed, heading, yaw rate and along-track acceleration —
* not a synthetic frame. Previously this beaconed a hardcoded bench coordinate with speed and
* heading pinned to zero, which exercised the link but told you nothing about whether the sensor
* pipeline produced sane CAM content.
*
* Requires a GNSS fix: with no fix there is no position to put in a CAM, so the loop sends
* nothing and reports that via [hasFix] rather than transmitting a placeholder.
*
* Entirely user-triggered (Start/Stop in the V2X Monitor screen) — never started automatically,
* and does not interact with [CamTransmitLoop]'s recording-gated loop. Both could in theory run at
* once (nothing prevents it); they use distinct station IDs so the two streams stay separable.
*/
@Singleton
class CamPinger @Inject constructor(
@ApplicationContext private val context: Context,
private val usbSerialTransport: UsbSerialTransport,
private val codec: RealAsn1UperCodec,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val sensorRepository = SensorRepository(context)
private var job: Job? = null
@Volatile private var latestGnss: GnssReading? = null
@Volatile private var latestGyroZ: Float? = null
@Volatile private var previousGnss: GnssReading? = null
private val _isActive = MutableStateFlow(false)
val isActive: StateFlow<Boolean> = _isActive.asStateFlow()
private val _sentCount = MutableStateFlow(0)
/** Number of CAM pings sent since [start] was last called. Reset to 0 on each [start]. */
val sentCount: StateFlow<Int> = _sentCount.asStateFlow()
private val _hasFix = MutableStateFlow(false)
/** False while the pinger is running but has no GNSS fix yet — nothing is being transmitted. */
val hasFix: StateFlow<Boolean> = _hasFix.asStateFlow()
fun start() {
if (job?.isActive == true) return
_sentCount.value = 0
_hasFix.value = false
previousGnss = null
latestGnss = null
_isActive.value = true
job = scope.launch { runPingLoop() }
}
private suspend fun runPingLoop() = coroutineScope {
launch { sensorRepository.gnssFlow().collect { latestGnss = it } }
launch { sensorRepository.gyroscopeFlow().collect { latestGyroZ = it.z } }
while (true) {
val gnss = latestGnss
_hasFix.value = gnss != null
if (gnss != null) {
val cam = PhoneCamBuilder.build(
gnss = gnss,
gyroZRadPerSec = latestGyroZ,
stationId = PING_STATION_ID,
longitudinalAccelMps2 = longitudinalAccel(gnss),
)
val bytes = codec.encodeCam(cam)
if (usbSerialTransport.sendCamTx(bytes)) {
_sentCount.update { it + 1 }
}
}
delay(PING_INTERVAL_MS)
}
}
/**
* Along-track acceleration from successive GNSS speed samples — same derivation and same
* reasoning as [CamTransmitLoop.longitudinalAccel] (the accelerometer reads in the device
* frame with gravity mixed in, so it can't give signed along-track acceleration without a
* full orientation estimate). Null outside a usable sample gap, which encodes as the ASN.1
* `unavailable` sentinel.
*/
private fun longitudinalAccel(gnss: GnssReading): Double? {
val prev = previousGnss
previousGnss = gnss
if (prev == null) return null
val dtSec = (gnss.timestamp - prev.timestamp) / 1000.0
if (dtSec < MIN_ACCEL_DT_SEC || dtSec > MAX_ACCEL_DT_SEC) return null
return (gnss.speedMs.toDouble() - prev.speedMs.toDouble()) / dtSec
}
fun stop() {
job?.cancel()
job = null
_isActive.value = false
_hasFix.value = false
}
companion object {
private const val PING_INTERVAL_MS = 1_000L
private const val MIN_ACCEL_DT_SEC = 0.2
private const val MAX_ACCEL_DT_SEC = 3.0
/**
* Recognizable station id, deliberately distinct from the persisted real one
* [CamTransmitLoop] uses, so manual bench pings stay identifiable in captures and can't be
* confused with the recording-driven stream if both happen to run at once.
*/
private const val PING_STATION_ID = 999_999L
}
}
@@ -60,7 +60,15 @@ class CamTransmitLoop @Inject constructor(
@Volatile private var latestGyroZ: Float? = null
@Volatile private var elevatedUntilMs: Long = 0L
/** Own station id for the ESP32-C5 path — see [PhoneCamBuilder]'s KDoc on why this is a placeholder. */
/** Previous GNSS fix, kept only to derive along-track acceleration — see [longitudinalAccel]. */
@Volatile private var previousGnss: GnssReading? = null
/**
* Own station id for the ESP32-C5 path, loaded once per [start] from
* [ObuHardwarePreferences.getOrCreateOwnStationId]. 0 means "not loaded yet" — the loop waits
* for the real value rather than beaconing as station 0, which would be indistinguishable
* from every other MicrOBU to any receiver.
*/
@Volatile var stationId: Long = 0L
/**
@@ -80,7 +88,9 @@ class CamTransmitLoop @Inject constructor(
fun start() {
if (job?.isActive == true) return
elevatedUntilMs = 0L
previousGnss = null
job = scope.launch {
stationId = obuHardwarePrefs.getOrCreateOwnStationId()
obuHardwarePrefs.obuHardwareFlow.collectLatest { hardware ->
if (hardware != ObuHardware.ESP32_C5) return@collectLatest
runTransmitLoop()
@@ -101,7 +111,7 @@ class CamTransmitLoop @Inject constructor(
while (true) {
val gnss = latestGnss
if (gnss != null) {
val cam = PhoneCamBuilder.build(gnss, latestGyroZ, stationId)
val cam = PhoneCamBuilder.build(gnss, latestGyroZ, stationId, longitudinalAccel(gnss))
val bytes = codec.encodeCam(cam)
usbSerialTransport.sendCamTx(bytes)
}
@@ -109,6 +119,32 @@ class CamTransmitLoop @Inject constructor(
}
}
/**
* Along-track acceleration in m/s², from the change in GNSS speed since the previous fix.
*
* Deliberately not from the accelerometer: CAM's `longitudinalAcceleration` is acceleration
* along the direction of travel, while the raw accelerometer reads in the device frame with
* gravity included — extracting the along-track component from it needs a full orientation
* estimate, which this path doesn't have (the detection engine sidesteps the same problem by
* working on orientation-independent magnitudes, which is not what CAM wants here).
*
* Returns null — encoded as ASN.1 `unavailable` — when there's no usable previous fix, when
* the gap is too short to divide by safely, or when it's long enough that the two samples
* aren't really consecutive. Better an honest "unavailable" than a fabricated number a
* receiving vehicle might brake on.
*/
private fun longitudinalAccel(gnss: GnssReading): Double? {
val prev = previousGnss
previousGnss = gnss
if (prev == null) return null
val dtSec = (gnss.timestamp - prev.timestamp) / 1000.0
if (dtSec < MIN_ACCEL_DT_SEC || dtSec > MAX_ACCEL_DT_SEC) return null
val dv = gnss.speedMs.toDouble() - prev.speedMs.toDouble()
return dv / dtSec
}
private fun currentRateHz(gnss: GnssReading?): Double {
val now = System.currentTimeMillis()
val inGeofence = gnss != null && config.geofences.any { fence ->
@@ -120,5 +156,11 @@ class CamTransmitLoop @Inject constructor(
companion object {
private const val ELEVATED_HOLD_MS = 5_000L
/** Below this gap, GNSS speed noise divided by a tiny dt produces absurd accelerations. */
private const val MIN_ACCEL_DT_SEC = 0.2
/** Above this gap the two fixes aren't consecutive enough to call the result acceleration. */
private const val MAX_ACCEL_DT_SEC = 3.0
}
}
@@ -71,6 +71,7 @@ import com.hawhamburg.micr0bu.data.transport.UsbSerialState
import com.hawhamburg.micr0bu.domain.cam.CamParser
import com.hawhamburg.micr0bu.domain.denm.DenmUseCase
import com.hawhamburg.micr0bu.domain.usecase.AlertLevel
import com.hawhamburg.micr0bu.domain.usecase.GeoMath
import com.hawhamburg.micr0bu.domain.usecase.UseCaseAlert
import com.hawhamburg.micr0bu.domain.usecase.UseCaseType
import com.hawhamburg.micr0bu.viewmodel.MqttViewModel
@@ -123,8 +124,10 @@ fun MqttTopicViewerScreen(
val usbSerialState by viewModel.usbSerialState.collectAsState()
val camPingerActive by viewModel.camPingerActive.collectAsState()
val camPingerSentCount by viewModel.camPingerSentCount.collectAsState()
val camPingerHasFix by viewModel.camPingerHasFix.collectAsState()
val camSendFailures by viewModel.camSendFailures.collectAsState()
val espLinkStatus by viewModel.espLinkStatus.collectAsState()
val denmEvents by viewModel.denmEvents.collectAsState()
// Sort: sys/ topics first (heartbeat/health), then alphabetical
val sortedTopics = topicMessages.keys.sortedWith(
@@ -204,9 +207,12 @@ fun MqttTopicViewerScreen(
useCaseAlerts = useCaseAlerts,
showDenmTrigger = obuHardware == ObuHardware.CIT_ONE,
showCamPinger = obuHardware == ObuHardware.ESP32_C5,
isEsp32 = obuHardware == ObuHardware.ESP32_C5,
denmEvents = denmEvents,
usbSerialState = usbSerialState,
camPingerActive = camPingerActive,
camPingerSentCount = camPingerSentCount,
camPingerHasFix = camPingerHasFix,
camSendFailures = camSendFailures,
espLinkStatus = espLinkStatus,
ownCamPosition = ownCamPosition,
@@ -240,9 +246,12 @@ private fun TopicListPane(
useCaseAlerts: List<UseCaseAlert>,
showDenmTrigger: Boolean = true,
showCamPinger: Boolean = false,
isEsp32: Boolean = false,
denmEvents: List<com.hawhamburg.micr0bu.domain.denm.DenmEvent> = emptyList(),
usbSerialState: UsbSerialState = UsbSerialState.DISCONNECTED,
camPingerActive: Boolean = false,
camPingerSentCount: Int = 0,
camPingerHasFix: Boolean = false,
camSendFailures: Int = 0,
espLinkStatus: EspLinkStatus? = null,
ownCamPosition: com.hawhamburg.micr0bu.domain.cam.Cam? = null,
@@ -285,6 +294,7 @@ private fun TopicListPane(
usbConnected = usbSerialState == UsbSerialState.CONNECTED,
pingerActive = camPingerActive,
sentCount = camPingerSentCount,
hasFix = camPingerHasFix,
sendFailures = camSendFailures,
linkStatus = espLinkStatus,
onStart = onStartCamPinger,
@@ -316,9 +326,20 @@ private fun TopicListPane(
) { Text(stringResource(R.string.mqtt_view_map)) }
}
// ── Topic rows / live map ─────────────────────────────────────────────
// ── Topic rows / received CAMs / live map ─────────────────────────────
if (viewMode == TopicViewMode.MAP) {
V2xLiveMapView(
own = ownCamPosition,
remotes = remoteCamPositions,
alerts = useCaseAlerts,
denms = denmEvents,
modifier = Modifier.fillMaxSize(),
)
} else if (isEsp32) {
// The MQTT topic list is meaningless on this path - there is no broker, so `topics`
// is permanently empty and the list would read as "nothing is happening" even while
// CAMs stream in over the serial link. Show the decoded traffic instead.
ReceivedCamPane(
own = ownCamPosition,
remotes = remoteCamPositions,
alerts = useCaseAlerts,
@@ -358,6 +379,164 @@ private fun TopicListPane(
}
}
/**
* Received-CAM list for the ESP32-C5 path — one row per remote station, showing that station's
* latest decoded CAM.
*
* **Deliberately one row per station, not one per message.** CAMs arrive at 1-10 Hz *per
* station*; rendering a scrolling log of individual messages would repaint constantly, bury the
* useful information, and tell you nothing a per-station summary doesn't. The underlying
* [com.hawhamburg.micr0bu.data.cam.CamUseCaseRepository] already keeps only the latest CAM per
* station, so this pane is bounded by the number of road users nearby, not by traffic rate or
* session length.
*
* Sorted nearest-first: on a bike, the closest station is the one that matters. Rows are tinted
* by that station's most severe active alert, matching [UseCaseAlertPanel] and the map markers.
*/
@Composable
private fun ReceivedCamPane(
own: com.hawhamburg.micr0bu.domain.cam.Cam?,
remotes: Map<Long, com.hawhamburg.micr0bu.domain.cam.Cam>,
alerts: List<UseCaseAlert>,
modifier: Modifier = Modifier,
) {
if (remotes.isEmpty()) {
Box(modifier = modifier, contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
stringResource(R.string.v2x_cam_rx_none),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(6.dp))
Text(
stringResource(R.string.v2x_cam_rx_none_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
)
}
}
return
}
val alertByStation = remember(alerts) {
alerts.groupBy { it.remoteStationId }
.mapValues { (_, a) -> a.maxByOrNull { it.alertLevel.ordinal }?.alertLevel }
}
// Distance is computed once per recomposition per station rather than inside each row, so
// sorting and display agree and the haversine isn't run twice per station.
val rows = remember(remotes, own) {
remotes.values
.map { cam ->
val distance = own?.let {
GeoMath.haversineMeters(it.latitude, it.longitude, cam.latitude, cam.longitude)
}
cam to distance
}
.sortedBy { (_, d) -> d ?: Double.MAX_VALUE }
}
Column(modifier = modifier) {
Text(
text = stringResource(R.string.v2x_cam_rx_count, rows.size),
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f))
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(rows, key = { (cam, _) -> cam.stationId }) { (cam, distance) ->
ReceivedCamRow(cam, distance, alertByStation[cam.stationId])
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f))
}
}
}
}
@Composable
private fun ReceivedCamRow(
cam: com.hawhamburg.micr0bu.domain.cam.Cam,
distanceMeters: Double?,
alertLevel: AlertLevel?,
) {
val accent = when (alertLevel) {
AlertLevel.WARNING -> WarningRed
AlertLevel.AWARENESS -> AwarenessAmber
AlertLevel.INFO -> InfoBlue
null -> MaterialTheme.colorScheme.primary
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Default.Circle,
contentDescription = null,
tint = accent,
modifier = Modifier.size(8.dp),
)
Spacer(Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(
R.string.v2x_cam_rx_station,
cam.stationId,
stationTypeLabel(cam.stationType),
),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
color = accent,
)
Spacer(Modifier.height(2.dp))
Text(
text = stringResource(
R.string.v2x_cam_rx_kinematics,
cam.speedMps * 3.6,
cam.headingDeg,
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace,
)
}
Column(horizontalAlignment = Alignment.End) {
Text(
text = distanceMeters
?.let { stringResource(R.string.v2x_cam_rx_distance, it) }
?: stringResource(R.string.v2x_cam_rx_distance_unknown),
style = MaterialTheme.typography.bodyMedium,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = timeFormat.format(Date(cam.timestamp)),
style = MaterialTheme.typography.labelSmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
/** Human label for the ETSI stationType values this project is likely to actually see. */
@Composable
private fun stationTypeLabel(stationType: Int): String = when (stationType) {
1 -> stringResource(R.string.station_type_pedestrian)
2 -> stringResource(R.string.station_type_cyclist)
5 -> stringResource(R.string.station_type_car)
6 -> stringResource(R.string.station_type_bus)
8 -> stringResource(R.string.station_type_truck)
15 -> stringResource(R.string.station_type_rsu)
else -> stringResource(R.string.station_type_other, stationType)
}
@Composable
private fun TopicRow(
topic: String,
@@ -698,6 +877,7 @@ private fun CamPingerCard(
usbConnected: Boolean,
pingerActive: Boolean,
sentCount: Int,
hasFix: Boolean,
sendFailures: Int,
linkStatus: EspLinkStatus?,
onStart: () -> Unit,
@@ -752,6 +932,18 @@ private fun CamPingerCard(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// No GNSS fix means the loop is running but has no position to build a CAM from, so
// nothing is going out - without this the card would just sit at "Sent: 0".
if (pingerActive && !hasFix) {
Spacer(Modifier.height(6.dp))
Text(
stringResource(R.string.mqtt_cam_pinger_no_fix),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace,
)
}
// ── Link diagnostics ──────────────────────────────────────────────
// "Sent: 240" is meaningless on its own if all 240 writes failed, or if the ESP32
// accepted them and the radio rejected every one. These two lines are the difference
@@ -23,11 +23,13 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import com.hawhamburg.micr0bu.R
import com.hawhamburg.micr0bu.domain.cam.Cam
import com.hawhamburg.micr0bu.domain.denm.DenmEvent
import com.hawhamburg.micr0bu.domain.usecase.AlertLevel
import com.hawhamburg.micr0bu.domain.usecase.UseCaseAlert
import org.osmdroid.config.Configuration
@@ -53,6 +55,7 @@ fun V2xLiveMapView(
own: Cam?,
remotes: Map<Long, Cam>,
alerts: List<UseCaseAlert>,
denms: List<DenmEvent> = emptyList(),
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
@@ -137,6 +140,26 @@ fun V2xLiveMapView(
)
}
// DENM hazard pins, added last so they draw on top of vehicle markers - a hazard
// hidden behind a CAM pin defeats the point of showing it.
denms.forEach { denm ->
mv.overlays.add(
Marker(mv).apply {
position = GeoPoint(denm.latitude, denm.longitude)
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
icon = ContextCompat.getDrawable(context, R.drawable.ic_denm_warning)
title = denm.causeCode?.let {
context.getString(
R.string.v2x_map_denm_labeled,
it,
denm.subCauseCode ?: 0,
denm.stationId,
)
} ?: context.getString(R.string.v2x_map_denm_plain, denm.stationId)
}
)
}
mv.controller.animateTo(ownGeoPoint)
mv.invalidate()
},
@@ -16,6 +16,8 @@ import com.hawhamburg.micr0bu.data.transport.TransportType
import com.hawhamburg.micr0bu.data.transport.UsbNetworkDetector
import com.hawhamburg.micr0bu.data.transport.UsbSerialState
import com.hawhamburg.micr0bu.data.transport.UsbSerialTransport
import com.hawhamburg.micr0bu.domain.denm.DenmEvent
import com.hawhamburg.micr0bu.domain.denm.DenmParser
import com.hawhamburg.micr0bu.domain.denm.DenmUseCase
import com.hawhamburg.micr0bu.domain.usecase.UseCaseAlert
import com.hawhamburg.micr0bu.domain.usecase.UseCaseType
@@ -104,6 +106,9 @@ class MqttViewModel @Inject constructor(
val camPingerActive: StateFlow<Boolean> = camPinger.isActive
val camPingerSentCount: StateFlow<Int> = camPinger.sentCount
/** False while the pinger runs without a GNSS fix — it has no position to build a CAM from. */
val camPingerHasFix: StateFlow<Boolean> = camPinger.hasFix
fun startCamPinger() = camPinger.start()
fun stopCamPinger() = camPinger.stop()
@@ -138,6 +143,34 @@ class MqttViewModel @Inject constructor(
.map { it != null && it != 2 }
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
// ── DENM reception (live map hazard pins) ─────────────────────────────────
/**
* Hazards received from other stations, newest first, deduped by [DenmEvent.dedupKey] so a
* repeating DENM about the same hazard stays one pin instead of stacking up.
*
* Derived from the raw `v2x-uca/output/json/denm` messages the repository already buffers,
* rather than a second subscription — the repository caps each topic's history, so this is
* bounded by construction.
*
* Always empty on the ESP32-C5 path: that firmware forwards BTP-B port 2001 (CAM) only and
* drops DENM before it reaches the phone. See [DenmEvent]'s KDoc.
*/
val denmEvents: StateFlow<List<DenmEvent>> = repo.topicMessages
.map { byTopic ->
(byTopic[DENM_RX_TOPIC] ?: emptyList())
.mapNotNull { DenmParser.parse(it.payload, it.timestamp) }
.associateBy { it.dedupKey } // last write wins = most recent per hazard
.values
.sortedByDescending { it.timestamp }
}
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
private companion object {
/** Use Case API topic carrying received DENMs (CiT One path only). */
const val DENM_RX_TOPIC = "v2x-uca/output/json/denm"
}
// ── DENM transmission ─────────────────────────────────────────────────────
/** True while a DENM use case is actively broadcasting on the OBU. */
@@ -0,0 +1,31 @@
<!--
DENM map pin: the standard hazard warning triangle (! in a triangle).
Drawn rather than reused from Material's Icons.Filled.Warning because osmdroid Markers take a
Drawable, not a Compose ImageVector, and a filled triangle with an opaque outline reads far
better against arbitrary map tiles than a single-colour glyph does.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="36dp"
android:height="36dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- White outline first, so the pin stays legible over dark map features. -->
<path
android:fillColor="#FFFFFFFF"
android:pathData="M12,1.2L0.6,21.4h22.8L12,1.2z" />
<!-- Amber triangle body. -->
<path
android:fillColor="#FFFFC107"
android:pathData="M12,3.6L2.9,20.0h18.2L12,3.6z" />
<!-- Exclamation mark. -->
<path
android:fillColor="#FF1A1A1A"
android:pathData="M11.1,8.4h1.8v5.4h-1.8z" />
<path
android:fillColor="#FF1A1A1A"
android:pathData="M11.1,15.2h1.8v1.8h-1.8z" />
</vector>
+24 -1
View File
@@ -204,7 +204,30 @@
<!-- CAM-Pinger — nur ESP32-C5, manueller Bank-Test, Gegenstück zur DENM-TX-Karte oben -->
<string name="mqtt_cam_pinger_title">CAM-Pinger (manueller Test)</string>
<string name="mqtt_cam_pinger_desc">Fester Standort, 1-Hz-CAM-Ping — prüft die serielle Verbindung und den ESP32-Funkpfad ohne GNSS-Bewegung oder Fahrtaufzeichnung.</string>
<string name="mqtt_cam_pinger_desc">1-Hz-CAM-Ping aus Live-GNSS- und IMU-Daten — prüft die serielle Verbindung und den ESP32-Funkpfad ohne Fahrtaufzeichnung.</string>
<string name="mqtt_cam_pinger_no_fix">Warte auf GNSS-Fix — noch nichts gesendet</string>
<!-- Empfangene CAMs (ESP32-C5-Pfad) -->
<string name="v2x_cam_rx_count">%1$d Station(en) in Reichweite — jeweils neueste CAM</string>
<string name="v2x_cam_rx_none">Keine CAMs empfangen</string>
<string name="v2x_cam_rx_none_hint">Dekodierte CAMs benachbarter Stationen erscheinen hier, sobald sie über die serielle Verbindung eintreffen.</string>
<string name="v2x_cam_rx_station">Station %1$d · %2$s</string>
<string name="v2x_cam_rx_kinematics">%1$.1f km/h · Kurs %2$.0f°</string>
<string name="v2x_cam_rx_distance">%1$.0f m</string>
<string name="v2x_cam_rx_distance_unknown">— m</string>
<!-- DENM-Kartenmarker -->
<string name="v2x_map_denm_labeled">Gefahr: Ursache %1$d/%2$d (Station %3$d)</string>
<string name="v2x_map_denm_plain">Gefahr von Station %1$d</string>
<!-- ETSI-Stationstypen -->
<string name="station_type_pedestrian">Fußgänger</string>
<string name="station_type_cyclist">Radfahrer</string>
<string name="station_type_car">Pkw</string>
<string name="station_type_bus">Bus</string>
<string name="station_type_truck">Lkw</string>
<string name="station_type_rsu">Straßenseiteneinheit</string>
<string name="station_type_other">Typ %1$d</string>
<string name="mqtt_cam_pinger_not_connected">ESP32-C5 verbinden, um den CAM-Pinger zu aktivieren</string>
<string name="mqtt_cam_pinger_active">Sendet — 1 CAM/s über die serielle Verbindung</string>
<string name="mqtt_cam_pinger_sent_count">Gesendet: %1$d</string>
+24 -1
View File
@@ -205,7 +205,30 @@
<!-- CAM Pinger — ESP32-C5-only manual bench test, equivalent to the DENM TX card above -->
<string name="mqtt_cam_pinger_title">CAM Pinger (Manual Test)</string>
<string name="mqtt_cam_pinger_desc">Fixed-location 1 Hz CAM ping — verifies the serial link and ESP32 radio path without needing GNSS movement or a trip recording.</string>
<string name="mqtt_cam_pinger_desc">1 Hz CAM ping built from live GNSS and IMU data — verifies the serial link and ESP32 radio path without needing a trip recording.</string>
<string name="mqtt_cam_pinger_no_fix">Waiting for GNSS fix — nothing transmitted yet</string>
<!-- Received-CAM list (ESP32-C5 path) -->
<string name="v2x_cam_rx_count">%1$d station(s) in range — latest CAM per station</string>
<string name="v2x_cam_rx_none">No CAMs received</string>
<string name="v2x_cam_rx_none_hint">Decoded CAMs from nearby stations appear here as they arrive over the serial link.</string>
<string name="v2x_cam_rx_station">Station %1$d · %2$s</string>
<string name="v2x_cam_rx_kinematics">%1$.1f km/h · heading %2$.0f°</string>
<string name="v2x_cam_rx_distance">%1$.0f m</string>
<string name="v2x_cam_rx_distance_unknown">— m</string>
<!-- DENM map pins -->
<string name="v2x_map_denm_labeled">Hazard: cause %1$d/%2$d (station %3$d)</string>
<string name="v2x_map_denm_plain">Hazard from station %1$d</string>
<!-- ETSI station types -->
<string name="station_type_pedestrian">Pedestrian</string>
<string name="station_type_cyclist">Cyclist</string>
<string name="station_type_car">Car</string>
<string name="station_type_bus">Bus</string>
<string name="station_type_truck">Truck</string>
<string name="station_type_rsu">Roadside unit</string>
<string name="station_type_other">Type %1$d</string>
<string name="mqtt_cam_pinger_not_connected">Connect the ESP32-C5 to enable the CAM pinger</string>
<string name="mqtt_cam_pinger_active">Pinging — 1 CAM/s over the serial link</string>
<string name="mqtt_cam_pinger_sent_count">Sent: %1$d</string>
+97
View File
@@ -0,0 +1,97 @@
# obu-firmware — setup & flashing notes
## Every new PowerShell session
Activate the toolchain (obu-firmware has no esp-idf of its own — reuse the
receiver firmware's already-installed checkout):
```powershell
Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass
C:\Users\Ashin\Documents\micrOBU_workspace\its-g5-receiver-firmware\esp-idf\export.ps1
idf.py --version
```
## Build & flash
```powershell
cd C:\Users\Ashin\AndroidStudioProjects\MicrOBU\obu-firmware
idf.py set-target esp32c5 # only needed once per clean build folder
idf.py build
idf.py -p COM5 -b 921600 flash monitor
```
Swap `COM5` for whatever port the ESP32-C5 enumerates as (Device Manager →
Ports). `monitor` opens the serial console after flashing — `Ctrl+]` to exit.
## If the build fails
- **"includes X.h, provided by Y component(s)... not in the requirements
list"** — IDF 5.x split the old monolithic `driver` component apart
(`esp_driver_gpio`, `esp_driver_uart`, etc.). Add the named component to
`REQUIRES` in `main/CMakeLists.txt` and rebuild. Already fixed once for
`esp_driver_gpio` + `esp_driver_uart` — if a new header comes up, same fix.
- Otherwise, start clean before re-building:
```powershell
idf.py fullclean
idf.py build
```
## Connecting the phone (ESP32-C5-WIFI6-KIT)
The board has two USB-C ports — use the right one:
- **Native USB-C port** (labeled for JTAG/native USB, up to 12 Mbps) — this
is where the phone plugs in via USB-OTG. The CAM serial link
(`serial_link.c`) runs over the ESP32-C5's native USB Serial/JTAG
peripheral on this port, enumerating as a CDC-ACM device under Espressif's
VID/PID (0x303A/0x1001).
- **UART-bridge port** (labeled for flashing) — this is what you use for
`idf.py flash monitor` from your PC. Leave the phone unplugged from this
one; it only carries `idf.py`'s flashing protocol and the ESP_LOG console.
The app recognizes the ESP32-C5's VID/PID via a custom probe table in
`UsbSerialTransport.kt` (the default `usb-serial-for-android` prober doesn't
know Espressif's device IDs). If the phone doesn't detect anything when
plugged into the native port, first confirm with a tool like "USB Device
Info" (or `adb shell dumpsys usb` from a PC) that Android sees a USB device
at all — that isolates a bad/charge-only OTG cable from an app-side issue.
## Bring-up checklist (phone <-> ESP32-C5 link)
Work down this list — each step isolates the layer below it.
1. **Flash and install together.** `SERIAL_LINK_MAX_PAYLOAD` is 512 on both sides.
A phone at 512 talking to firmware still at 160 (or vice versa) silently
rejects every large frame at the `length exceeds max, resync` branch. Never
update one side alone.
2. **Does Android see the device at all?** Plug the phone into the **native**
USB-C port, hit Connect, and read logcat for `UsbSerialTransport`. It logs
every attached device *and* each device's interfaces. Empty list = cable /
OTG / wrong port, below the app entirely.
3. **Did the right interface get claimed?** The C5's USB Serial/JTAG is a
composite device — expect CDC control (class 2) + CDC data (class 10) +
vendor-specific JTAG (class 255) in that dump. Compare against the `ports=`
count on the `matched device` line.
4. **Is the link alive?** The firmware sends a STATUS heartbeat at 1 Hz
regardless of radio traffic, and the app marks the link ERROR after ~3.5 s of
silence. Connected-and-staying-connected means device→host actually works.
5. **If it connects but no CAM_RX ever arrives** — suspect DTR. The app now
asserts DTR/RTS on open (`openDevice()` in `UsbSerialTransport.kt`), because
`CdcAcmSerialDriver` doesn't do it by default and the ESP32's USB Serial/JTAG
endpoint may gate TX on the host opening the CDC line. **This is still
unverified on real hardware** — test it both ways (with the `setDTR(true)`
call and with it commented out) and record the answer in `serial_link.h`
next to the VID/PID note, so nobody has to guess again.
6. **Watch the counters, not just "Sent: N".** The CAM Pinger card shows
consecutive write failures (phone side) and the firmware's tx-failure /
oversize-drop / CRC-error totals from the heartbeat. A rising `tx fail` means
CAMs reach the ESP32 but `esp_wifi_80211_tx` rejects them — a radio problem,
not a link problem.
## Notes
- No `git submodule update` needed here — obu-firmware has no pinned
submodule of its own, unlike its-g5-receiver-firmware.
- Don't use the global "ESP-IDF 5.5 PowerShell" shortcut — always export from
the receiver-firmware's pinned checkout, since this firmware's undocumented
PHY/driver internals were verified against that specific build.