The firmware forwarded CAM only: gn_unwrap_cam accepted single-hop broadcast
(HT=5) and BTP port 2001, so every DENM was dropped before it reached the phone.
Real OBUs disseminate DENM by GeoBroadcast (HT=4), whose 44-byte extended header
also carries the hazard's relevance area - materially more useful on a map than
the sender's own position, since a sender may be relaying for someone else.
Firmware
- gn_unwrap_cam -> gn_unwrap_its: accepts GeoBroadcast alongside TSB/SHB, and
BTP ports 2001 and 2002, extracting the GeoBroadcast destination area. Both
extended-header lengths were measured against live air capture rather than
read off a spec table. Secured packets (Basic Header NextHeader=2) are
rejected rather than misparsed.
- SERIAL_MSG_CAM_RX (0x02) superseded by SERIAL_MSG_V2X_RX (0x04): a 14-byte
prefix carrying BTP port, RSSI and the destination area. Adding MAPEM later
needs a decoder on the phone but no protocol change. 0x02 stays reserved so
the numbering is not silently reused.
- Promiscuous RX capture buffer 400 -> 800 bytes. A real GeoBroadcast DENM is
around 500 bytes on air and was being truncated mid-payload, which no amount
of correct unwrapping downstream could have recovered from.
- geonet_wrap_shb, both firmwares: the SHB extended header is 28 bytes, not 24.
The Source Position Vector is followed by a 4-byte reserved field; without it
a standards-strict receiver reads the CAM payload's first two bytes as the BTP
destination port.
App
- DenmUperCodec: UPER decoder for the ManagementContainer and the
SituationContainer's eventType. ValidityDuration is 17 bits, not 16, and
ManagementContainer, SituationContainer and CauseCode each carry their own
extension bit - a single wrong bit made a real frame read causeCode 47
instead of 94.
- DenmEvent gains actionID (originatingStationID + sequenceNumber), stationType,
termination, detectionTime, relevance radius and RSSI. Dedup keys on actionID
where available, so a termination lands on the event it ends instead of
creating a second pin.
- denmEvents merges the MQTT and over-the-air sources and drops terminated
events. The V2X list view now shows hazards above the CAM stations; it
previously took no DENM parameter at all, so hazards reached the map but never
the list.
- DenmParser: the Use Case API sends causeCode as a string enum, so reading it
as an Int always yielded null.
Testing
- DenmAirReceiveTest covers the V2X_RX prefix and the decoder using real frames
from a live capture as fixtures. Expected values were cross-checked against
the ETSI ASN.1 modules via asn1tools, which agreed on all 1885 decodable
DENMs across the capture set, every field including detectionTime.
- Verified on hardware: a CiT One HLN-SV DENM decodes as cause 94/0 with a
1000 m relevance radius at 1 Hz alongside CAM, with no decode failures and no
unexpected BTP ports.
Also replaces em dashes with hyphens throughout the user-facing strings,
including the German translation.
UI:
- Top bar shows a USB glyph reflecting the serial link on the ESP32-C5 path,
instead of a Wi-Fi glyph driven by an MQTT state that is permanently
disconnected there.
- Recording screen's OBU stream row follows whichever transport the selected
hardware uses. It previously read as offline throughout a recording that was
actively beaconing CAMs.
- Live map uses distinct markers: a centred dot for own position (a fact about
the viewer, not a tracked object) and a teardrop pin for remote stations,
tinted by alert severity. Both were osmdroid's identical default pin before,
and severity required tapping a marker to read its label.
- Sensor Monitor moves out of the bottom nav to Settings > Developer. A live
phone-sensor feed is a bench tool; the Dashboard already reports GNSS/IMU
health. Screen and route are unchanged, just not in the rider's way.
Detection engine on the ESP32 path:
- Seed our own StationID from the persisted value CamTransmitLoop transmits.
It was null here (the CiT One learns it from v2x/rx/obu_gnss, which doesn't
exist on this path), so the self-heard-TX filter never fired: the ESP32 runs
promiscuous for raw TX to work at all, hears our own CAMs back off the air,
and they were tracked as a remote station - a ghost vehicle on top of the ego
position, fed to the engine as a collision partner for itself.
RSSI:
- The firmware has always sent per-frame RSSI in byte 0 of every CAM_RX frame;
the app discarded it. Now carried on Cam, persisted per V2X message, shown per
station in the received-CAM list, and exported. Null for own CAMs and the
whole CiT One path, neither of which has a measurement.
Trips, CSV and export (schema v3 -> v4):
- trips.sessionId links a trip to the CSV session recorded alongside it. The two
are written by independent subsystems that the Recording button happens to
start together; without the link, deleting a trip orphaned its CSV forever.
Timestamp matching was rejected - close recordings would delete the wrong file.
- Deleting a trip now removes the CSV file AND its sessions row, so it stops
appearing in the Session Log pointing at nothing.
- Per-trip combined CSV export: GPS track, detected events, V2X messages (with
RSSI) and the raw sensor samples in one file, keyed by a leading type column.
One file rather than a zip of tables because the point of the export is
correlating those streams, and splitting them pushes the join downstream.
- Export takes the Activity context. Sharing from the ViewModel's Application
context threw AndroidRuntimeException on startActivity - this crashed on the
first tap of the share button.
Trips recorded before v4 have a null sessionId, so their exports omit raw sensor
rows and their CSVs still need clearing by hand once.
CurvatureCalculationMode is the one extensible ENUMERATED in CAM:
ENUMERATED {yawRateUsed(0), yawRateNotUsed(1), unavailable(2), ...}
UPER encodes an extensible ENUMERATED as an extension bit followed by the root
index - 1 + 2 = 3 bits. All three of our encoders wrote only the 2-bit index,
shifting yawRate and the entire low-frequency container one bit early for any
standards-compliant receiver.
It went unnoticed because every end of this project shared the mistake: the
Kotlin codec was ported bit-for-bit from cam.c, so phone and ESP32 agreed
perfectly with each other and with nothing else. Confirmed against the ETSI
ASN.1 in the C-ITS-Parser checkout, where rasn marks this type - and only this
type - #[non_exhaustive].
Fixed in all three copies of the encoder (app CamUperCodec.kt,
obu-firmware/main/cam.c, obu-cam-transmistter/main/cam.c) plus the decoder,
which now rejects rather than misreads a set extension bit. Frame size is
unchanged at 43 bytes. Transmitter reflashed and the phone decodes its CAMs.
Also in this change:
- serial_link: skip send_frame entirely when no USB host is attached, and raise
the tx mutex timeout above the worst-case hold. With the phone unplugged every
write blocked its full timeout while holding the lock, so forwarded CAM_RX
traffic starved the 1 Hz heartbeat - observed as "tx mutex timeout, dropping
frame" on the console, and it would have tripped the phone's link watchdog.
Verified gone on hardware.
- Log decoded and failed CAMs in CamUseCaseRepository. "The app shows nothing"
had two indistinguishable causes; a silent `?: return` made this bug much
harder to find than it needed to be.
- Remove the ESP32 send-only/send-and-receive toggle. Reception can't be
disabled in firmware (raw TX only works while promiscuous), so it was an
app-side filter pretending to be a radio control.
- V2X monitor follows the serial link state on the ESP32 path instead of MQTT,
which is permanently disconnected there; CAM intake is gated on the link being
up, and engine state is cleared when it drops.
- About screen: 0.5.0, Phase 03.
- Track obu-cam-transmistter, the bench CAM transmitter. Its cam.c is compiled
(unlike obu-firmware's reference copy) and must stay bit-identical to the other
two - this commit is what that coupling costs when it's broken.
- Document the two-toolchain split: this project builds on IDF 5.5.4, obu-firmware
on the pinned 6.1. Exporting both in one shell fails confusingly.
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.
Enumeration:
- Merge the library's stock probe table instead of replacing it, so adding
Espressif 0x303A/0x1001 doesn't drop every other supported device
- Select the ESP32-C5 by VID/PID rather than list position
- Log USB interface descriptors to distinguish CDC data from the JTAG interface
Lifecycle:
- Don't close the shared port in MqttViewModel.onCleared() - the foreground
recording service outlives the ViewModel and would beacon into a dead port
- Handle ACTION_USB_DEVICE_DETACHED so the UI stops reporting a stale link
- Implement the STATUS heartbeat on both sides (1 Hz) plus a phone-side watchdog
- Surface write failures and firmware drop counters on the CAM Pinger card
Protocol:
- Assert DTR/RTS on open (unverified on hardware - see FLASHING.md step 5)
- Raise SERIAL_LINK_MAX_PAYLOAD 160 -> 512 on both sides; real third-party CAMs
exceed 160 and were being silently dropped at the resync branch
- Move the enlarged buffers off task stacks; serialize send_frame with a mutex
Firmware and app must be updated together - a 512/160 mismatch fails silently.
- Firmware: rewrite obu-firmware TX loop to be serial-driven (no on-chip timer), add promiscuous RX + GeoNetworking/BTP unwrap (gn_unwrap.c), add binary UART framing to the phone (serial_link.c/.h). Drop local cam_encode() - CAM is now built on the phone.
- Kotlin: byte-exact UPER CAM encoder/decoder ported from cam.c (BitWriter/BitReader/CamUperCodec), matching SerialFrame codec, real UsbSerialTransport (usb-serial-for-android), CamTransmitLoop (1Hz base rate, event/geofence boost, ESP32-C5-only), wired into CamUseCaseRepository for RX and TripRecordingService for TX.
- Add V2X message retention: persist all CAM (own+remote) to Room while recording, drop otherwise (DB v2 -> v3 migration).
- Add jitpack repo + usb-serial-for-android dependency.
Fixes: UsbSerialTransport now uses SerialInputOutputManager.start()/stop() (this lib version manages its own thread internally) instead of manual Runnable/Thread submission, which didn't compile.