Files
MicrOBU/obu-firmware/main/gn_unwrap.c
T
Ashin Walpola a5ad3dcc5d SPATEM receive, RSU CAM decode, and two ASN.1 encoding fixes
SPATEM over the air
- gn_unwrap.c accepts BTP-B port 2004 alongside 2001/2002. The serial protocol
  already carries the port in its V2X_RX prefix, so nothing else changed there.
  Note the crossover that makes this easy to get wrong: SPATEM is port 2004 but
  messageID 4, while MAPEM is port 2003 and messageID 5.
- SpatemUperCodec decodes SPAT down to per-signal-group phase and timing. The
  bit layout was validated by replaying 79,042 real SPATEMs - the whole
  2026-03-18 drive across 7+ RSUs plus the bench trigger - against asn1tools
  using the ETSI modules. All 79,042 matched on every field, none hit an
  unsupported branch. Two traps are pinned by tests: TimeChangeDetails is the
  one SEQUENCE here that is NOT extensible (5 optional bits, no extension bit),
  and maneuverAssistList cannot be skipped when present - it is variable-length,
  so it has to be walked to find where the next movement starts.
- The V2X list shows one row per intersection with each signal group coloured by
  phase and a countdown where the RSU supplies timing. TimeMark wraps hourly, so
  the countdown corrects for it; without that it reads hugely negative once an
  hour, precisely when someone is watching it.
- Entries expire after 15 s, much shorter than DENM's window: a traffic light
  that stopped updating is not "still green".

  Size caveat, deliberately deferred: SERIAL_LINK_MAX_PAYLOAD is still 512, so a
  SPATEM over ~498 bytes is counted as an oversize drop. The bench RSU sends 58
  bytes and is unaffected, but real road RSUs measured 555 median / 1243 max, so
  roughly 70% would not arrive. Raising the cap also requires enlarging
  RX_FRAME_MAX_LEN and moving rx_item_t off the WiFi driver's callback stack,
  where it would otherwise overflow.

RSU CAM decode
- HighFrequencyContainer is a CHOICE, and a roadside unit picks
  rsuContainerHighFrequency, which carries no kinematics at all. The decoder
  bailed on that branch, so every RSU CAM was dropped - including the bench RSU,
  which sends CAM and SPATEM from the same station id. It now decodes for
  position and stationType.
- RSU CAMs are kept out of UseCaseDetectionEngine. They arrive as a permanently
  stationary station at a fixed point, which is exactly the shape the
  stopped-vehicle and intersection-movement use cases match, and would raise a
  standing false alert for as long as the RSU was in range.

CAM transmit: yawRateConfidence
- YawRateConfidence has nine enumerands (0..8), so UPER needs 4 bits and
  "unavailable" is 8. The encoder wrote 3 bits with value 7 - one bit short and
  the wrong symbol - shifting every field after yawRate for any standards-strict
  receiver. The decoder read 3 bits too, so phone and ESP32 agreed with each
  other and with nothing else.
- This is the third instance of that exact failure mode in this project, after
  CurvatureCalculationMode and the GeoNetworking reserved bytes. A round-trip
  test through our own decoder structurally cannot catch it, so CamEncodeGolden
  Test asserts the bytes asn1tools produces instead: it decoded this encoder's
  output and re-encoded it byte-identically. Confirmed on air afterwards - 26 of
  our own CAMs captured back off the OBU's receiver, all 26 accepted, where the
  same decoder rejected them before.

DENM
- Hazards now expire 60 s after their last repetition. This needs a clock, not
  just a filter: both source flows only emit when a DENM arrives, so a sender
  that drives away or loses power would never trigger a recompute and its hazard
  would stay on screen indefinitely.
- The MQTT path was dropping every DENM for two independent reasons, both found
  by checking the payload against CI-CiT-MQTT_API_Documentation-v6 listing 2.6
  rather than guessing: the station id key is originatingStationId, and
  eventPosition IS a GeoJSON Point rather than an object containing one. Also
  parses termination (presence is the signal), sequenceNumber, stationType and
  the RFC3339 detectionTime. Note roadSideUnit is 15, not 12 - the enumeration
  has a gap after tram(11).

V2X screen
- The decoded CAM/DENM list now renders on the CiT One path too; it was gated to
  the ESP32-C5 path and CiT One fell through to the raw MQTT topic list. Those
  topics move to their own tab, hidden on the ESP32-C5 path where there is no
  broker.

Testing
- Adds org.json as a test-only dependency: the android.jar stub throws
  "not mocked" on every JSONObject call, which made the MQTT payload parsers
  untestable off-device.
- 23 V2X tests pass. EventDetectorTest's 4 failures are pre-existing and
  untouched by this change.
2026-08-20 15:47:14 +02:00

162 lines
6.0 KiB
C

#include "gn_unwrap.h"
#include <string.h>
// Mirrors dot11p.c / geonet.c's constants and layout, in reverse. Keep these two files in sync
// if either the TX-side frame shape or these constants change.
#define GN_ETHERTYPE (0x8947)
#define LLC_SNAP_HEADER_LEN (8)
#define IEEE80211_HEADER_LEN (24) // non-QoS Data
#define IEEE80211_QOS_CTRL_LEN (2) // extra field QoS Data frames add
#define IEEE80211_FC_TYPE_DATA (2)
#define IEEE80211_FC_QOS_SUBTYPE_BIT (0x08)
#define GN_BASIC_HEADER_LEN (4)
#define GN_COMMON_HEADER_LEN (8)
#define BTP_B_HEADER_LEN (4)
// Extended-header lengths per GeoNetworking header type - see gn_unwrap.h for why these exact
// numbers, and why they must not be assumed equal.
#define GN_SHB_EXT_HEADER_LEN (28) // SO PV (24) + Reserved (4)
#define GN_GBC_EXT_HEADER_LEN (44) // SN(2) + Rsvd(2) + SO PV(24) + area(12) + Rsvd(4)
// Offsets of the destination-area fields within the GBC extended header.
#define GBC_AREA_LAT_OFFSET (28)
#define GBC_AREA_LON_OFFSET (32)
#define GBC_AREA_DIST_A_OFFSET (36)
#define GN_HEADER_TYPE_GBC (4) // GeoBroadcast
#define GN_HEADER_TYPE_TSB (5) // Topologically-Scoped Broadcast
#define GN_HEADER_SUBTYPE_SINGLE_HOP (0)
#define GN_NEXT_HEADER_COMMON (1) // unsecured; 2 would be a secured packet
#define GN_COMMON_NEXT_HEADER_BTP_B (2)
#define BTP_DEST_PORT_CAM (2001) // ETSI TS 103 248
#define BTP_DEST_PORT_DENM (2002)
// NOTE the crossover: SPATEM is BTP port 2004 but ItsPduHeader messageID 4, while MAPEM is port
// 2003 and messageID 5. Port and messageID are NOT the same number - mixing them up routes every
// message to the wrong decoder on the phone.
#define BTP_DEST_PORT_SPATEM (2004)
static const uint8_t s_llc_snap_prefix[6] = {0xAA, 0xAA, 0x03, 0x00, 0x00, 0x00};
static int32_t be32(const uint8_t *p)
{
return (int32_t)(((uint32_t)p[0] << 24) | ((uint32_t)p[1] << 16) |
((uint32_t)p[2] << 8) | (uint32_t)p[3]);
}
static uint16_t be16(const uint8_t *p)
{
return (uint16_t)(((uint16_t)p[0] << 8) | (uint16_t)p[1]);
}
bool gn_unwrap_its(const uint8_t *frame, int frame_len, gn_rx_t *out)
{
if (!frame || !out || frame_len < IEEE80211_HEADER_LEN) {
return false;
}
memset(out, 0, sizeof(*out));
uint8_t fc0 = frame[0];
uint8_t fc1 = frame[1];
uint8_t type = (fc0 >> 2) & 0x03;
uint8_t subtype = (fc0 >> 4) & 0x0F;
bool to_ds = fc1 & 0x01;
bool from_ds = fc1 & 0x02;
// Only plain broadcast Data frames, no WDS. Both QoS Data (what real ITS-G5 hardware sends,
// 26-byte header) and non-QoS Data (24-byte, what our own TX currently builds) are accepted.
if (type != IEEE80211_FC_TYPE_DATA || (to_ds && from_ds)) {
return false;
}
int offset = IEEE80211_HEADER_LEN;
if (subtype & IEEE80211_FC_QOS_SUBTYPE_BIT) {
offset += IEEE80211_QOS_CTRL_LEN;
}
if (frame_len < offset + LLC_SNAP_HEADER_LEN) {
return false;
}
if (memcmp(frame + offset, s_llc_snap_prefix, sizeof(s_llc_snap_prefix)) != 0) {
return false;
}
if (be16(frame + offset + 6) != GN_ETHERTYPE) {
return false;
}
offset += LLC_SNAP_HEADER_LEN;
// ---- GN Basic Header (4 bytes) ----
if (frame_len < offset + GN_BASIC_HEADER_LEN) {
return false;
}
// NextHeader distinguishes an unsecured packet (1 = Common Header follows) from a secured one
// (2 = a TS 103 097 SecuredMessage follows, with the Common Header buried inside it at a
// variable offset). Checking this rather than blindly skipping means a secured packet is
// rejected cleanly instead of having its security envelope misread as a Common Header.
if ((frame[offset] & 0x0F) != GN_NEXT_HEADER_COMMON) {
return false;
}
offset += GN_BASIC_HEADER_LEN;
// ---- GN Common Header (8 bytes) ----
if (frame_len < offset + GN_COMMON_HEADER_LEN) {
return false;
}
uint8_t next_header = (frame[offset + 0] >> 4) & 0x0F;
uint8_t header_type = (frame[offset + 1] >> 4) & 0x0F;
uint8_t header_subtype = frame[offset + 1] & 0x0F;
if (next_header != GN_COMMON_NEXT_HEADER_BTP_B) {
return false;
}
offset += GN_COMMON_HEADER_LEN;
// ---- Extended header: length depends on the header type ----
int ext_len;
bool is_gbc = false;
if (header_type == GN_HEADER_TYPE_TSB && header_subtype == GN_HEADER_SUBTYPE_SINGLE_HOP) {
ext_len = GN_SHB_EXT_HEADER_LEN;
} else if (header_type == GN_HEADER_TYPE_GBC) {
// Subtype selects the area shape (0 circle, 1 rectangle, 2 ellipse). All three carry the
// same field layout - DistanceB and Angle are simply unused for a circle - so the length
// is the same and we don't need to branch on it.
ext_len = GN_GBC_EXT_HEADER_LEN;
is_gbc = true;
} else {
return false; // Beacon / GeoUnicast / GeoAnycast / multi-hop TSB - see header comment
}
if (frame_len < offset + ext_len) {
return false;
}
if (is_gbc) {
out->has_geo_area = true;
out->geo_area_lat_tenmicrodeg = be32(frame + offset + GBC_AREA_LAT_OFFSET);
out->geo_area_lon_tenmicrodeg = be32(frame + offset + GBC_AREA_LON_OFFSET);
out->geo_area_distance_a_m = be16(frame + offset + GBC_AREA_DIST_A_OFFSET);
}
offset += ext_len;
// ---- BTP-B header (4 bytes) ----
if (frame_len < offset + BTP_B_HEADER_LEN) {
return false;
}
uint16_t dest_port = be16(frame + offset);
if (dest_port != BTP_DEST_PORT_CAM && dest_port != BTP_DEST_PORT_DENM &&
dest_port != BTP_DEST_PORT_SPATEM) {
return false;
}
offset += BTP_B_HEADER_LEN;
// ---- Whatever's left is the ITS UPER payload ----
int payload_len = frame_len - offset;
if (payload_len <= 0) {
return false;
}
out->btp_dest_port = dest_port;
out->payload = frame + offset;
out->payload_len = payload_len;
return true;
}