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:
co-authored by
Claude Opus 5
parent
64fb78590e
commit
f3ae81a8fe
@@ -2,6 +2,7 @@ package com.hawhamburg.micr0bu.data.mqtt
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.datastore.preferences.core.edit
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.longPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import androidx.datastore.preferences.preferencesDataStore
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
import com.hawhamburg.micr0bu.data.transport.EspRxMode
|
import com.hawhamburg.micr0bu.data.transport.EspRxMode
|
||||||
@@ -11,6 +12,7 @@ import kotlinx.coroutines.flow.Flow
|
|||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
import kotlin.random.Random
|
||||||
|
|
||||||
private val Context.obuHardwareDataStore by preferencesDataStore(name = "obu_hardware_prefs")
|
private val Context.obuHardwareDataStore by preferencesDataStore(name = "obu_hardware_prefs")
|
||||||
|
|
||||||
@@ -26,6 +28,7 @@ class ObuHardwarePreferences @Inject constructor(
|
|||||||
private object Keys {
|
private object Keys {
|
||||||
val OBU_HARDWARE = stringPreferencesKey("obu_hardware")
|
val OBU_HARDWARE = stringPreferencesKey("obu_hardware")
|
||||||
val ESP_RX_MODE = stringPreferencesKey("esp_rx_mode")
|
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 ->
|
val obuHardwareFlow: Flow<ObuHardware> = context.obuHardwareDataStore.data.map { prefs ->
|
||||||
@@ -48,4 +51,32 @@ class ObuHardwarePreferences @Inject constructor(
|
|||||||
suspend fun setEspRxMode(mode: EspRxMode) {
|
suspend fun setEspRxMode(mode: EspRxMode) {
|
||||||
context.obuHardwareDataStore.edit { prefs -> prefs[Keys.ESP_RX_MODE] = mode.id }
|
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
|
* 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
|
* 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
|
* this).
|
||||||
* protocolVersion/messageID, a CamParameters/HighFrequencyContainer/LowFrequencyContainer
|
*
|
||||||
* shape we don't decode (extension in use, RSU container instead of vehicle, or a
|
* Accepts any CAM whose BasicContainer + BasicVehicleContainerHighFrequency are non-extended,
|
||||||
* specialVehicleContainer present — none of those are things this project transmits or
|
* regardless of which optional high-frequency fields the sender includes or whether it carries
|
||||||
* currently needs to receive), or a truncated frame.
|
* 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
|
* [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
|
* 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)
|
val camParamsExt = br.getBitsInt(1)
|
||||||
if (camParamsExt != 0) return null // extension in use - unsupported shape
|
if (camParamsExt != 0) return null // extension in use - unsupported shape
|
||||||
val lowFreqPresent = br.getBitsInt(1) == 1
|
// lowFrequencyContainer / specialVehicleContainer presence bits. Both containers are
|
||||||
val specialVehiclePresent = br.getBitsInt(1) == 1
|
// declared after highFrequencyContainer, so everything this decoder reads comes first and
|
||||||
if (specialVehiclePresent) return null // different container shape we don't parse
|
// 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)
|
val basicContainerExt = br.getBitsInt(1)
|
||||||
if (basicContainerExt != 0) return null
|
if (basicContainerExt != 0) return null
|
||||||
@@ -201,7 +211,17 @@ object CamUperCodec {
|
|||||||
val highFreqIndex = br.getBitsInt(1)
|
val highFreqIndex = br.getBitsInt(1)
|
||||||
if (highFreqExt != 0 || highFreqIndex != 0) return null // extension, or rsuContainerHighFrequency
|
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)
|
val headingRaw = br.getBitsInt(12)
|
||||||
br.getBits(7) // headingConfidence
|
br.getBits(7) // headingConfidence
|
||||||
@@ -233,14 +253,15 @@ object CamUperCodec {
|
|||||||
br.getBits(3) // yawRateConfidence
|
br.getBits(3) // yawRateConfidence
|
||||||
val yawRateDps = if (yawRateRaw == YAW_RATE_UNAVAILABLE) null else yawRateRaw / 100.0
|
val yawRateDps = if (yawRateRaw == YAW_RATE_UNAVAILABLE) null else yawRateRaw / 100.0
|
||||||
|
|
||||||
if (lowFreqPresent) {
|
// Everything after yawRate is deliberately left unread: the 7 optional high-frequency
|
||||||
val lowFreqExt = br.getBitsInt(1)
|
// fields, then lowFrequencyContainer (vehicleRole / exteriorLights / pathHistory), then
|
||||||
if (lowFreqExt != 0) return null
|
// specialVehicleContainer. None of it maps onto [Cam], and because it all follows the
|
||||||
br.getBits(4) // vehicleRole
|
// fields above, not parsing it cannot misalign anything already extracted.
|
||||||
br.getBits(8) // exteriorLights
|
//
|
||||||
br.getBits(6) // pathHistory count (0..40) - not decoded into path points, just consumed
|
// 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(
|
return Cam(
|
||||||
stationId = stationId,
|
stationId = stationId,
|
||||||
stationType = stationType,
|
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
|
* 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.
|
* 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
|
* This class only does the sensor-fusion-into-CAM-fields part, independent of the wire protocol
|
||||||
* (not yet defined) wire protocol to the ESP32-C5. It is **not yet wired into any transmit
|
* to the ESP32-C5. Live flow, driven by [com.hawhamburg.micr0bu.service.CamTransmitLoop]:
|
||||||
* 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:
|
|
||||||
*
|
*
|
||||||
* `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
|
* 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
|
* 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 {
|
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 gnss latest phone GNSS fix.
|
||||||
* @param gyroZRadPerSec latest gyroscope z-axis reading, rad/s (device frame). Positive per
|
* @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
|
* 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.
|
* 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
|
* @param stationId this device's own station ID, from
|
||||||
* defines how the phone learns/assigns an ID on the ESP32-C5 path (the CiT One path
|
* [com.hawhamburg.micr0bu.data.mqtt.ObuHardwarePreferences.getOrCreateOwnStationId] — a
|
||||||
* currently learns this from `v2x/rx/obu_gnss`'s own_info, which doesn't exist here).
|
* 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(
|
fun build(
|
||||||
gnss: GnssReading,
|
gnss: GnssReading,
|
||||||
gyroZRadPerSec: Float?,
|
gyroZRadPerSec: Float?,
|
||||||
stationId: Long = PLACEHOLDER_OWN_STATION_ID,
|
stationId: Long,
|
||||||
|
longitudinalAccelMps2: Double? = null,
|
||||||
): Cam {
|
): Cam {
|
||||||
val yawRateDps = gyroZRadPerSec?.let { -it * RAD_TO_DEG } // negate: CCW+ -> CW+ convention
|
val yawRateDps = gyroZRadPerSec?.let { -it * RAD_TO_DEG } // negate: CCW+ -> CW+ convention
|
||||||
|
|
||||||
@@ -49,6 +51,7 @@ object PhoneCamBuilder {
|
|||||||
speedMps = gnss.speedMs.toDouble(),
|
speedMps = gnss.speedMs.toDouble(),
|
||||||
headingDeg = normalizeHeading(gnss.bearingDeg.toDouble()),
|
headingDeg = normalizeHeading(gnss.bearingDeg.toDouble()),
|
||||||
yawRateDps = yawRateDps?.let { if (abs(it) < YAW_RATE_NOISE_FLOOR_DPS) 0.0 else it },
|
yawRateDps = yawRateDps?.let { if (abs(it) < YAW_RATE_NOISE_FLOOR_DPS) 0.0 else it },
|
||||||
|
accelerationMps2 = longitudinalAccelMps2,
|
||||||
timestamp = gnss.timestamp,
|
timestamp = gnss.timestamp,
|
||||||
isOwn = true,
|
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 latestGyroZ: Float? = null
|
||||||
@Volatile private var elevatedUntilMs: Long = 0L
|
@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
|
@Volatile var stationId: Long = 0L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -80,7 +88,9 @@ class CamTransmitLoop @Inject constructor(
|
|||||||
fun start() {
|
fun start() {
|
||||||
if (job?.isActive == true) return
|
if (job?.isActive == true) return
|
||||||
elevatedUntilMs = 0L
|
elevatedUntilMs = 0L
|
||||||
|
previousGnss = null
|
||||||
job = scope.launch {
|
job = scope.launch {
|
||||||
|
stationId = obuHardwarePrefs.getOrCreateOwnStationId()
|
||||||
obuHardwarePrefs.obuHardwareFlow.collectLatest { hardware ->
|
obuHardwarePrefs.obuHardwareFlow.collectLatest { hardware ->
|
||||||
if (hardware != ObuHardware.ESP32_C5) return@collectLatest
|
if (hardware != ObuHardware.ESP32_C5) return@collectLatest
|
||||||
runTransmitLoop()
|
runTransmitLoop()
|
||||||
@@ -101,7 +111,7 @@ class CamTransmitLoop @Inject constructor(
|
|||||||
while (true) {
|
while (true) {
|
||||||
val gnss = latestGnss
|
val gnss = latestGnss
|
||||||
if (gnss != null) {
|
if (gnss != null) {
|
||||||
val cam = PhoneCamBuilder.build(gnss, latestGyroZ, stationId)
|
val cam = PhoneCamBuilder.build(gnss, latestGyroZ, stationId, longitudinalAccel(gnss))
|
||||||
val bytes = codec.encodeCam(cam)
|
val bytes = codec.encodeCam(cam)
|
||||||
usbSerialTransport.sendCamTx(bytes)
|
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 {
|
private fun currentRateHz(gnss: GnssReading?): Double {
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
val inGeofence = gnss != null && config.geofences.any { fence ->
|
val inGeofence = gnss != null && config.geofences.any { fence ->
|
||||||
@@ -120,5 +156,11 @@ class CamTransmitLoop @Inject constructor(
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val ELEVATED_HOLD_MS = 5_000L
|
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.cam.CamParser
|
||||||
import com.hawhamburg.micr0bu.domain.denm.DenmUseCase
|
import com.hawhamburg.micr0bu.domain.denm.DenmUseCase
|
||||||
import com.hawhamburg.micr0bu.domain.usecase.AlertLevel
|
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.UseCaseAlert
|
||||||
import com.hawhamburg.micr0bu.domain.usecase.UseCaseType
|
import com.hawhamburg.micr0bu.domain.usecase.UseCaseType
|
||||||
import com.hawhamburg.micr0bu.viewmodel.MqttViewModel
|
import com.hawhamburg.micr0bu.viewmodel.MqttViewModel
|
||||||
@@ -123,8 +124,10 @@ fun MqttTopicViewerScreen(
|
|||||||
val usbSerialState by viewModel.usbSerialState.collectAsState()
|
val usbSerialState by viewModel.usbSerialState.collectAsState()
|
||||||
val camPingerActive by viewModel.camPingerActive.collectAsState()
|
val camPingerActive by viewModel.camPingerActive.collectAsState()
|
||||||
val camPingerSentCount by viewModel.camPingerSentCount.collectAsState()
|
val camPingerSentCount by viewModel.camPingerSentCount.collectAsState()
|
||||||
|
val camPingerHasFix by viewModel.camPingerHasFix.collectAsState()
|
||||||
val camSendFailures by viewModel.camSendFailures.collectAsState()
|
val camSendFailures by viewModel.camSendFailures.collectAsState()
|
||||||
val espLinkStatus by viewModel.espLinkStatus.collectAsState()
|
val espLinkStatus by viewModel.espLinkStatus.collectAsState()
|
||||||
|
val denmEvents by viewModel.denmEvents.collectAsState()
|
||||||
|
|
||||||
// Sort: sys/ topics first (heartbeat/health), then alphabetical
|
// Sort: sys/ topics first (heartbeat/health), then alphabetical
|
||||||
val sortedTopics = topicMessages.keys.sortedWith(
|
val sortedTopics = topicMessages.keys.sortedWith(
|
||||||
@@ -204,9 +207,12 @@ fun MqttTopicViewerScreen(
|
|||||||
useCaseAlerts = useCaseAlerts,
|
useCaseAlerts = useCaseAlerts,
|
||||||
showDenmTrigger = obuHardware == ObuHardware.CIT_ONE,
|
showDenmTrigger = obuHardware == ObuHardware.CIT_ONE,
|
||||||
showCamPinger = obuHardware == ObuHardware.ESP32_C5,
|
showCamPinger = obuHardware == ObuHardware.ESP32_C5,
|
||||||
|
isEsp32 = obuHardware == ObuHardware.ESP32_C5,
|
||||||
|
denmEvents = denmEvents,
|
||||||
usbSerialState = usbSerialState,
|
usbSerialState = usbSerialState,
|
||||||
camPingerActive = camPingerActive,
|
camPingerActive = camPingerActive,
|
||||||
camPingerSentCount = camPingerSentCount,
|
camPingerSentCount = camPingerSentCount,
|
||||||
|
camPingerHasFix = camPingerHasFix,
|
||||||
camSendFailures = camSendFailures,
|
camSendFailures = camSendFailures,
|
||||||
espLinkStatus = espLinkStatus,
|
espLinkStatus = espLinkStatus,
|
||||||
ownCamPosition = ownCamPosition,
|
ownCamPosition = ownCamPosition,
|
||||||
@@ -240,9 +246,12 @@ private fun TopicListPane(
|
|||||||
useCaseAlerts: List<UseCaseAlert>,
|
useCaseAlerts: List<UseCaseAlert>,
|
||||||
showDenmTrigger: Boolean = true,
|
showDenmTrigger: Boolean = true,
|
||||||
showCamPinger: Boolean = false,
|
showCamPinger: Boolean = false,
|
||||||
|
isEsp32: Boolean = false,
|
||||||
|
denmEvents: List<com.hawhamburg.micr0bu.domain.denm.DenmEvent> = emptyList(),
|
||||||
usbSerialState: UsbSerialState = UsbSerialState.DISCONNECTED,
|
usbSerialState: UsbSerialState = UsbSerialState.DISCONNECTED,
|
||||||
camPingerActive: Boolean = false,
|
camPingerActive: Boolean = false,
|
||||||
camPingerSentCount: Int = 0,
|
camPingerSentCount: Int = 0,
|
||||||
|
camPingerHasFix: Boolean = false,
|
||||||
camSendFailures: Int = 0,
|
camSendFailures: Int = 0,
|
||||||
espLinkStatus: EspLinkStatus? = null,
|
espLinkStatus: EspLinkStatus? = null,
|
||||||
ownCamPosition: com.hawhamburg.micr0bu.domain.cam.Cam? = null,
|
ownCamPosition: com.hawhamburg.micr0bu.domain.cam.Cam? = null,
|
||||||
@@ -285,6 +294,7 @@ private fun TopicListPane(
|
|||||||
usbConnected = usbSerialState == UsbSerialState.CONNECTED,
|
usbConnected = usbSerialState == UsbSerialState.CONNECTED,
|
||||||
pingerActive = camPingerActive,
|
pingerActive = camPingerActive,
|
||||||
sentCount = camPingerSentCount,
|
sentCount = camPingerSentCount,
|
||||||
|
hasFix = camPingerHasFix,
|
||||||
sendFailures = camSendFailures,
|
sendFailures = camSendFailures,
|
||||||
linkStatus = espLinkStatus,
|
linkStatus = espLinkStatus,
|
||||||
onStart = onStartCamPinger,
|
onStart = onStartCamPinger,
|
||||||
@@ -316,9 +326,20 @@ private fun TopicListPane(
|
|||||||
) { Text(stringResource(R.string.mqtt_view_map)) }
|
) { Text(stringResource(R.string.mqtt_view_map)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Topic rows / live map ─────────────────────────────────────────────
|
// ── Topic rows / received CAMs / live map ─────────────────────────────
|
||||||
if (viewMode == TopicViewMode.MAP) {
|
if (viewMode == TopicViewMode.MAP) {
|
||||||
V2xLiveMapView(
|
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,
|
own = ownCamPosition,
|
||||||
remotes = remoteCamPositions,
|
remotes = remoteCamPositions,
|
||||||
alerts = useCaseAlerts,
|
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
|
@Composable
|
||||||
private fun TopicRow(
|
private fun TopicRow(
|
||||||
topic: String,
|
topic: String,
|
||||||
@@ -698,6 +877,7 @@ private fun CamPingerCard(
|
|||||||
usbConnected: Boolean,
|
usbConnected: Boolean,
|
||||||
pingerActive: Boolean,
|
pingerActive: Boolean,
|
||||||
sentCount: Int,
|
sentCount: Int,
|
||||||
|
hasFix: Boolean,
|
||||||
sendFailures: Int,
|
sendFailures: Int,
|
||||||
linkStatus: EspLinkStatus?,
|
linkStatus: EspLinkStatus?,
|
||||||
onStart: () -> Unit,
|
onStart: () -> Unit,
|
||||||
@@ -752,6 +932,18 @@ private fun CamPingerCard(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
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 ──────────────────────────────────────────────
|
// ── Link diagnostics ──────────────────────────────────────────────
|
||||||
// "Sent: 240" is meaningless on its own if all 240 writes failed, or if the ESP32
|
// "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
|
// 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.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
import com.hawhamburg.micr0bu.R
|
import com.hawhamburg.micr0bu.R
|
||||||
import com.hawhamburg.micr0bu.domain.cam.Cam
|
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.AlertLevel
|
||||||
import com.hawhamburg.micr0bu.domain.usecase.UseCaseAlert
|
import com.hawhamburg.micr0bu.domain.usecase.UseCaseAlert
|
||||||
import org.osmdroid.config.Configuration
|
import org.osmdroid.config.Configuration
|
||||||
@@ -53,6 +55,7 @@ fun V2xLiveMapView(
|
|||||||
own: Cam?,
|
own: Cam?,
|
||||||
remotes: Map<Long, Cam>,
|
remotes: Map<Long, Cam>,
|
||||||
alerts: List<UseCaseAlert>,
|
alerts: List<UseCaseAlert>,
|
||||||
|
denms: List<DenmEvent> = emptyList(),
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
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.controller.animateTo(ownGeoPoint)
|
||||||
mv.invalidate()
|
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.UsbNetworkDetector
|
||||||
import com.hawhamburg.micr0bu.data.transport.UsbSerialState
|
import com.hawhamburg.micr0bu.data.transport.UsbSerialState
|
||||||
import com.hawhamburg.micr0bu.data.transport.UsbSerialTransport
|
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.denm.DenmUseCase
|
||||||
import com.hawhamburg.micr0bu.domain.usecase.UseCaseAlert
|
import com.hawhamburg.micr0bu.domain.usecase.UseCaseAlert
|
||||||
import com.hawhamburg.micr0bu.domain.usecase.UseCaseType
|
import com.hawhamburg.micr0bu.domain.usecase.UseCaseType
|
||||||
@@ -104,6 +106,9 @@ class MqttViewModel @Inject constructor(
|
|||||||
val camPingerActive: StateFlow<Boolean> = camPinger.isActive
|
val camPingerActive: StateFlow<Boolean> = camPinger.isActive
|
||||||
val camPingerSentCount: StateFlow<Int> = camPinger.sentCount
|
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 startCamPinger() = camPinger.start()
|
||||||
fun stopCamPinger() = camPinger.stop()
|
fun stopCamPinger() = camPinger.stop()
|
||||||
|
|
||||||
@@ -138,6 +143,34 @@ class MqttViewModel @Inject constructor(
|
|||||||
.map { it != null && it != 2 }
|
.map { it != null && it != 2 }
|
||||||
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
|
.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 ─────────────────────────────────────────────────────
|
// ── DENM transmission ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** True while a DENM use case is actively broadcasting on the OBU. */
|
/** 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>
|
||||||
@@ -204,7 +204,30 @@
|
|||||||
|
|
||||||
<!-- CAM-Pinger — nur ESP32-C5, manueller Bank-Test, Gegenstück zur DENM-TX-Karte oben -->
|
<!-- 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_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_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_active">Sendet — 1 CAM/s über die serielle Verbindung</string>
|
||||||
<string name="mqtt_cam_pinger_sent_count">Gesendet: %1$d</string>
|
<string name="mqtt_cam_pinger_sent_count">Gesendet: %1$d</string>
|
||||||
|
|||||||
@@ -205,7 +205,30 @@
|
|||||||
|
|
||||||
<!-- CAM Pinger — ESP32-C5-only manual bench test, equivalent to the DENM TX card above -->
|
<!-- 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_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_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_active">Pinging — 1 CAM/s over the serial link</string>
|
||||||
<string name="mqtt_cam_pinger_sent_count">Sent: %1$d</string>
|
<string name="mqtt_cam_pinger_sent_count">Sent: %1$d</string>
|
||||||
|
|||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user