DENM over-the-air receive on the ESP32-C5 path

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.
This commit is contained in:
Ashin Walpola
2026-08-17 18:42:48 +02:00
parent f1770e11dd
commit 0ccb867228
19 changed files with 989 additions and 188 deletions
+34 -9
View File
@@ -28,11 +28,25 @@
// built by the phone (position/speed/heading/yaw rate baked in). On receipt the ESP32
// immediately GeoNetworking-wraps and transmits it - this IS the transmit clock now, there
// is no independent on-chip timer. See main.c's rx-driven tx path.
// SERIAL_MSG_CAM_RX (0x02), ESP32 -> phone: payload is [rssi:1 signed][CAM UPER bytes...] - a
// CAM received over the air, already stripped of its 802.11/LLC-SNAP/GeoNetworking/BTP-B
// framing by gn_unwrap.c. The phone never sees raw 802.11 frames. No station id is carried
// separately - CAM's own ItsPduHeader.stationID (the first field inside the UPER bytes) is
// already the meaningful identifier; see gn_unwrap.h for why a second one isn't added here.
// SERIAL_MSG_CAM_RX (0x02), ESP32 -> phone: SUPERSEDED by SERIAL_MSG_V2X_RX, no longer sent.
// The constant is kept so the numbering is not silently reused by a future message type.
// SERIAL_MSG_V2X_RX (0x04), ESP32 -> phone: any ITS message received over the air, already
// stripped of its 802.11/LLC-SNAP/GeoNetworking/BTP-B framing by gn_unwrap.c - the phone
// never sees raw 802.11 frames. Payload is a fixed 14-byte prefix followed by the UPER bytes:
//
// [0..1] btp_dest_port uint16 LE 2001 = CAM, 2002 = DENM (ETSI TS 103 248)
// [2] rssi int8 dBm, from the promiscuous RX metadata
// [3] flags uint8 bit0: geo area fields below are valid
// [4..7] geo_area_lat int32 LE 1/10 microdegree, GeoBroadcast destination area
// [8..11] geo_area_lon int32 LE 1/10 microdegree
// [12..13] geo_area_dist uint16 LE Distance A, metres (relevance radius for a circle)
// [14..] UPER message bytes
//
// All prefix fields are LITTLE-endian, matching this framing's own length field - note the
// GeoNetworking wire format they came from is big-endian, so gn_unwrap.c converts.
// Generic on purpose: adding MAPEM/SPATEM later needs a decoder on the phone and one port in
// gn_unwrap.c, but no change to this protocol. No station id is carried separately - each
// message's own ItsPduHeader.stationID is the meaningful identifier.
// SERIAL_MSG_STATUS (0x03), ESP32 -> phone: heartbeat + counters, sent at 1 Hz so the phone can
// distinguish "link idle" from "link dead" independent of CAM traffic (the app's watchdog in
// UsbSerialTransport.kt declares the link dead after 3 missed beats). Payload is 7 bytes:
@@ -44,6 +58,10 @@
#define SERIAL_MSG_CAM_TX 0x01
#define SERIAL_MSG_CAM_RX 0x02
#define SERIAL_MSG_STATUS 0x03
#define SERIAL_MSG_V2X_RX 0x04
// Size of the V2X_RX prefix documented above. Must match the app's SerialFrame.kt.
#define SERIAL_V2X_RX_PREFIX_LEN 14
// USB Serial/JTAG has no baud rate or GPIO pins to configure - it's a fixed on-chip USB device
// controller wired directly to the native USB-C port's D+/D- lines in silicon. RX/TX buffer
@@ -76,10 +94,17 @@
typedef void (*serial_link_cam_tx_cb_t)(const uint8_t *cam_uper, int cam_len);
void serial_link_init(serial_link_cam_tx_cb_t on_cam_tx);
// Sends a SERIAL_MSG_CAM_RX frame to the phone: rssi + the CAM UPER bytes gn_unwrap.c extracted
// from an over-the-air frame. Returns true if the frame was written to the UART (not an
// end-to-end ack - the phone may still drop it, e.g. serial buffer overrun).
bool serial_link_send_cam_rx(int8_t rssi, const uint8_t *cam_uper, int cam_len);
// Sends a SERIAL_MSG_V2X_RX frame: the metadata prefix plus the UPER bytes gn_unwrap.c extracted
// from an over-the-air frame. Pass has_geo_area=false and zeroes for the area fields when the
// source frame carried no destination area (i.e. it was single-hop broadcast, not GeoBroadcast).
// Returns true if the frame was written to the USB endpoint - not an end-to-end ack, the phone
// may still drop it.
bool serial_link_send_v2x_rx(uint16_t btp_dest_port, int8_t rssi,
bool has_geo_area,
int32_t geo_area_lat_tenmicrodeg,
int32_t geo_area_lon_tenmicrodeg,
uint16_t geo_area_distance_a_m,
const uint8_t *uper, int uper_len);
// Sends one SERIAL_MSG_STATUS heartbeat frame immediately (status byte + the current counters).
// Normally unnecessary to call by hand - serial_link_init() starts a task that does this at 1 Hz.