From f3ae81a8fed2d5d29845fa2a5415a96996b43b17 Mon Sep 17 00:00:00 2001 From: Ashin Walpola Date: Mon, 10 Aug 2026 14:09:18 +0200 Subject: [PATCH] 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 --- .../data/mqtt/ObuHardwarePreferences.kt | 31 +++ .../micr0bu/data/transport/EspRxMode.kt | 24 +++ .../micr0bu/domain/asn1/CamUperCodec.kt | 55 +++-- .../micr0bu/domain/cam/PhoneCamBuilder.kt | 29 +-- .../micr0bu/domain/denm/DenmEvent.kt | 89 ++++++++ .../hawhamburg/micr0bu/service/CamPinger.kt | 141 +++++++++++++ .../micr0bu/service/CamTransmitLoop.kt | 46 ++++- .../ui/screens/MqttTopicViewerScreen.kt | 194 +++++++++++++++++- .../micr0bu/ui/screens/V2xLiveMapView.kt | 23 +++ .../micr0bu/viewmodel/MqttViewModel.kt | 33 +++ app/src/main/res/drawable/ic_denm_warning.xml | 31 +++ app/src/main/res/values-de/strings.xml | 25 ++- app/src/main/res/values/strings.xml | 25 ++- obu-firmware/FLASHING.md | 97 +++++++++ 14 files changed, 808 insertions(+), 35 deletions(-) create mode 100644 app/src/main/java/com/hawhamburg/micr0bu/data/transport/EspRxMode.kt create mode 100644 app/src/main/java/com/hawhamburg/micr0bu/domain/denm/DenmEvent.kt create mode 100644 app/src/main/java/com/hawhamburg/micr0bu/service/CamPinger.kt create mode 100644 app/src/main/res/drawable/ic_denm_warning.xml create mode 100644 obu-firmware/FLASHING.md diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/mqtt/ObuHardwarePreferences.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/mqtt/ObuHardwarePreferences.kt index ebea81e..58c1ca7 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/data/mqtt/ObuHardwarePreferences.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/data/mqtt/ObuHardwarePreferences.kt @@ -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 = 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 = 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]!! + } } diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/transport/EspRxMode.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/transport/EspRxMode.kt new file mode 100644 index 0000000..777eb58 --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/data/transport/EspRxMode.kt @@ -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"), +} diff --git a/app/src/main/java/com/hawhamburg/micr0bu/domain/asn1/CamUperCodec.kt b/app/src/main/java/com/hawhamburg/micr0bu/domain/asn1/CamUperCodec.kt index 9d6fefa..6f00fb7 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/domain/asn1/CamUperCodec.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/domain/asn1/CamUperCodec.kt @@ -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, diff --git a/app/src/main/java/com/hawhamburg/micr0bu/domain/cam/PhoneCamBuilder.kt b/app/src/main/java/com/hawhamburg/micr0bu/domain/cam/PhoneCamBuilder.kt index 9fc4bc8..dfffa4a 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/domain/cam/PhoneCamBuilder.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/domain/cam/PhoneCamBuilder.kt @@ -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, ) diff --git a/app/src/main/java/com/hawhamburg/micr0bu/domain/denm/DenmEvent.kt b/app/src/main/java/com/hawhamburg/micr0bu/domain/denm/DenmEvent.kt new file mode 100644 index 0000000..8d65022 --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/domain/denm/DenmEvent.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/com/hawhamburg/micr0bu/service/CamPinger.kt b/app/src/main/java/com/hawhamburg/micr0bu/service/CamPinger.kt new file mode 100644 index 0000000..3d9f5ec --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/service/CamPinger.kt @@ -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 = _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 = _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 = _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 + } +} diff --git a/app/src/main/java/com/hawhamburg/micr0bu/service/CamTransmitLoop.kt b/app/src/main/java/com/hawhamburg/micr0bu/service/CamTransmitLoop.kt index 2e21750..d4fb686 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/service/CamTransmitLoop.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/service/CamTransmitLoop.kt @@ -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 } } diff --git a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/MqttTopicViewerScreen.kt b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/MqttTopicViewerScreen.kt index c19d28b..bfb5c1f 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/MqttTopicViewerScreen.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/MqttTopicViewerScreen.kt @@ -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, showDenmTrigger: Boolean = true, showCamPinger: Boolean = false, + isEsp32: Boolean = false, + denmEvents: List = 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, + alerts: List, + 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 diff --git a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/V2xLiveMapView.kt b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/V2xLiveMapView.kt index 6d282a4..70e4cde 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/V2xLiveMapView.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/V2xLiveMapView.kt @@ -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, alerts: List, + denms: List = 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() }, diff --git a/app/src/main/java/com/hawhamburg/micr0bu/viewmodel/MqttViewModel.kt b/app/src/main/java/com/hawhamburg/micr0bu/viewmodel/MqttViewModel.kt index 9a974f0..8013617 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/viewmodel/MqttViewModel.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/viewmodel/MqttViewModel.kt @@ -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 = camPinger.isActive val camPingerSentCount: StateFlow = camPinger.sentCount + /** False while the pinger runs without a GNSS fix — it has no position to build a CAM from. */ + val camPingerHasFix: StateFlow = 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> = 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. */ diff --git a/app/src/main/res/drawable/ic_denm_warning.xml b/app/src/main/res/drawable/ic_denm_warning.xml new file mode 100644 index 0000000..2e59433 --- /dev/null +++ b/app/src/main/res/drawable/ic_denm_warning.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index f50748a..db5306a 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -204,7 +204,30 @@ CAM-Pinger (manueller Test) - Fester Standort, 1-Hz-CAM-Ping — prüft die serielle Verbindung und den ESP32-Funkpfad ohne GNSS-Bewegung oder Fahrtaufzeichnung. + 1-Hz-CAM-Ping aus Live-GNSS- und IMU-Daten — prüft die serielle Verbindung und den ESP32-Funkpfad ohne Fahrtaufzeichnung. + Warte auf GNSS-Fix — noch nichts gesendet + + + %1$d Station(en) in Reichweite — jeweils neueste CAM + Keine CAMs empfangen + Dekodierte CAMs benachbarter Stationen erscheinen hier, sobald sie über die serielle Verbindung eintreffen. + Station %1$d · %2$s + %1$.1f km/h · Kurs %2$.0f° + %1$.0f m + — m + + + Gefahr: Ursache %1$d/%2$d (Station %3$d) + Gefahr von Station %1$d + + + Fußgänger + Radfahrer + Pkw + Bus + Lkw + Straßenseiteneinheit + Typ %1$d ESP32-C5 verbinden, um den CAM-Pinger zu aktivieren Sendet — 1 CAM/s über die serielle Verbindung Gesendet: %1$d diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f688c8d..6ea7fa1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -205,7 +205,30 @@ CAM Pinger (Manual Test) - Fixed-location 1 Hz CAM ping — verifies the serial link and ESP32 radio path without needing GNSS movement or a trip recording. + 1 Hz CAM ping built from live GNSS and IMU data — verifies the serial link and ESP32 radio path without needing a trip recording. + Waiting for GNSS fix — nothing transmitted yet + + + %1$d station(s) in range — latest CAM per station + No CAMs received + Decoded CAMs from nearby stations appear here as they arrive over the serial link. + Station %1$d · %2$s + %1$.1f km/h · heading %2$.0f° + %1$.0f m + — m + + + Hazard: cause %1$d/%2$d (station %3$d) + Hazard from station %1$d + + + Pedestrian + Cyclist + Car + Bus + Truck + Roadside unit + Type %1$d Connect the ESP32-C5 to enable the CAM pinger Pinging — 1 CAM/s over the serial link Sent: %1$d diff --git a/obu-firmware/FLASHING.md b/obu-firmware/FLASHING.md new file mode 100644 index 0000000..e236465 --- /dev/null +++ b/obu-firmware/FLASHING.md @@ -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.