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