Files
MicrOBU/obu-firmware/main/serial_link.h
T
Ashin Walpola 75d6d3b85c Receive signed ITS messages and forward each at its declared length
Signed packets. A GeoNetworking Basic Header NextHeader of 2 means a
TS 103 097 (IEEE 1609.2) envelope follows, with the Common Header
inside it. gn_unwrap_its rejected all of these, and most real traffic is
signed: the 2026-08-17 capture holds 157 signed frames from 15 source
MACs against 2 unsecured stations. It now opens a COER-encoded
signedData, or a bare unsecuredData, and parses the inner packet as
before. The inner packet comes first inside tbsData, so the certificate
and signature are never parsed, and the signature is not verified - the
firmware has no trust store. Such messages reach the phone with the new
V2X_RX flags bit1, signed but not verified. The app reads only bit0 and
is unaffected until it learns the flag. Encrypted payloads, nested
signing and the legacy v1.2.1 envelope are still rejected. All 157
recorded signed frames have the layout this reads, in all three COER
length forms, and asn1tools decodes every envelope to the same inner
packet.

Payload bounds. Every frame recorded through the ESP32-C5's promiscuous
RX, about 15 000 of them, ends in 8 bytes that are not part of the
802.11 frame and not a valid FCS. obu-firmware reads frames through the
same API and took the rest of the frame as the message, so it forwarded
those 8 bytes to the phone after every message. UPER decoders stop where
the message ends, so nothing visibly broke, but the bytes cost serial
bandwidth and 8 bytes of the DENM's headroom, and they stayed attached
wherever raw payloads were stored or passed on. The payload is now
exactly what the Common Header's payload-length field declares, which is
also what separates a signed message from its signature.

A frame longer than main.c's 800-byte capture buffer is now reported as
truncated instead of being forwarded cut off, and counted as an oversize
drop through the new serial_link_note_oversize_drop, as it was when the
cut-off frame failed serial_link's size check.

Host tests in obu-firmware/test/host build the firmware sources
unmodified with MSYS2 gcc; `make` runs all three.
- test_chain: frames from the firmware's TX code checked byte by byte
  against EN 302 636-4-1 and parsed back, including hand-built signed
  frames, the payload-length rule, the RX trailer, and every truncation
  length against a no-access guard page. 1731 checks, 0 failures.
- test_replay and check_replay.py: all 15 145 recorded frames through
  gn_unwrap_its, cut to 800 bytes as on the board, and re-derived
  independently in Python with the envelope decoded by asn1tools. They
  agree on every record; 15 131 accepted, 157 of them signed. 11 043 of
  the 11 106 distinct messages re-encode byte-identically. The other 63
  fail the same way with the old 8 bytes put back, so the boundary is
  not the cause: 5 are our own CAMs from before the 2026-08-20
  yawRateConfidence fix, and the rest, from other stations, are a
  follow-up in TODO.md.
- fuzz_gn_unwrap: random edits of every recorded frame, each run against
  the guard page. 50 000 000 iterations, no crash.

obu-firmware/test/pcap_gn_tally.py tallies GeoNetworking header fields
per station over captures; it is how the other stations' lifetimes were
measured. TODO.md collects what is still open, including the on-air
check for this change: it builds on IDF 6.1 but has not been flashed.
2026-09-11 20:19:40 +02:00

169 lines
11 KiB
C

#ifndef SERIAL_LINK_H
#define SERIAL_LINK_H
#include <stdint.h>
#include <stddef.h>
#include <stdbool.h>
// Binary framing for the phone <-> ESP32-C5 link (Phase 03). This runs over the ESP32-C5's
// native USB Serial/JTAG peripheral (driver/usb_serial_jtag.h) - the same physical USB-C port
// used for JTAG, exposed to the host as a fixed-VID/PID (0x303A/0x1001) USB CDC-ACM device.
// Deliberately NOT the same wire as the ESP-IDF console/ESP_LOG output, which stays on the
// OTHER USB-C port (the UART-bridge one, UART0, see sdkconfig CONFIG_ESP_CONSOLE_UART_NUM=0) -
// mixing binary frames with human-readable log text on one wire would corrupt both, and this
// way they're physically separate ports so there's no risk of that regardless.
//
// On the Android side, connect the phone (via USB-OTG) to the board's NATIVE USB-C port, not
// the UART-bridge/flashing port. The usb-serial-for-android library's default prober doesn't
// know Espressif's 0x303A/0x1001 VID/PID, so UsbSerialTransport.kt registers it manually via a
// custom ProbeTable pointed at CdcAcmSerialDriver - see that file's KDoc.
//
// Frame format (both directions, symmetric):
// [0xAA][0x55][type:1][length:2 LE][payload: length bytes][crc16:2 LE]
// CRC16 is CRC-16/CCITT-FALSE (poly 0x1021, init 0xFFFF, no reflect, no xorout), computed over
// type + length + payload only (not the two sync bytes). Same algorithm must be used on the
// Kotlin side (see app SerialFrame.kt) - frames that don't checksum are silently dropped.
//
// Direction / types:
// SERIAL_MSG_CAM_TX (0x01), phone -> ESP32: payload is a raw CAM UPER byte string, already
// 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: 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
// bit1: arrived signed (TS 103 097), signature NOT
// verified. An app that tests only bit0 ignores it.
// [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 - exactly the message. Before 2026-09-11 they were followed by
// the 8 bytes the chip's promiscuous RX appends (gn_unwrap.h, "Payload bounds").
//
// 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 8 bytes:
// [status:1][oversize_drops:2 LE][tx_failures:2 LE][rx_crc_errors:2 LE][capabilities:1]
// status 0 = ok. The counters are free-running totals since boot, saturating at 0xFFFF.
// capabilities is a bitmask of the SERIAL_CAP_* flags below. It was appended as byte 7 rather
// than inserted, so an app that predates it, and reads only the first 7 bytes, is unaffected.
// They exist because the alternative - ESP_LOGW on the flashing port - is invisible to the
// phone, which is the only thing watching during a bench session. Mirrored by EspLinkStatus
// in the app's SerialFrame.kt.
#define SERIAL_MSG_CAM_TX 0x01
#define SERIAL_MSG_CAM_RX 0x02
#define SERIAL_MSG_STATUS 0x03
#define SERIAL_MSG_V2X_RX 0x04
#define SERIAL_MSG_CAM_TX_PV 0x05
// Size of the V2X_RX prefix documented above. Must match the app's SerialFrame.kt.
#define SERIAL_V2X_RX_PREFIX_LEN 14
// SERIAL_MSG_CAM_TX_PV (0x05), phone -> ESP32: a CAM together with the GeoNetworking Source
// Position Vector to transmit it under. Payload is a fixed 24-byte prefix, then the CAM UPER:
//
// [0..5] mac 6 bytes pseudonym: the 802.11 source address AND the GN_ADDR MID
// [6] station_type uint8 TS 102 894-2 StationType (2 = cyclist)
// [7] flags uint8 bit0: PAI, position accuracy indicator
// [8..11] tst uint32 LE ms at which lat/lon were acquired, TimestampIts mod 2^32
// [12..15] lat int32 LE 1/10 microdegree
// [16..19] lon int32 LE 1/10 microdegree
// [20..21] speed int16 LE 0.01 m/s
// [22..23] heading uint16 LE 0.1 degree from north, clockwise, 0..3599
// [24..] CAM UPER bytes
//
// Little-endian like the rest of this framing; geonet.c converts to GeoNetworking's big-endian.
// Every prefix field is something the phone already has when it builds the CAM, and none of it
// can be known on this chip, which has no GNSS and no clock source on the OCB channel. Before
// this message existed the GN header carried fixed placeholders instead (see main.c).
//
// A new type rather than a redefined CAM_TX, so app and firmware can be updated independently:
// - old app, new firmware: the app sends CAM_TX, which is handled exactly as before.
// - new app, old firmware: the app sends CAM_TX_PV only once the heartbeat advertises
// SERIAL_CAP_CAM_TX_PV, and an old heartbeat carries no such bit, so it stays on CAM_TX.
// Redefining CAM_TX would instead have double-wrapped every frame in one of those combinations
// and sent one with no GN header in the other, silently, since neither side checks versions.
#define SERIAL_CAM_TX_PV_PREFIX_LEN 24
// Capability bits, carried in byte 7 of the SERIAL_MSG_STATUS payload.
#define SERIAL_CAP_CAM_TX_PV 0x01
// 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
// sizes for usb_serial_jtag_driver_install() (see serial_link.c) are sized generously relative
// to SERIAL_LINK_MAX_PAYLOAD below.
#define SERIAL_LINK_USB_BUF_SIZE 1024
// Per-write block ceiling, and the mutex acquire timeout that must comfortably exceed the
// worst case of one frame (4 writes: sync, head, payload, crc). Keep that relationship if you
// change either number - a lock timeout below the max hold turns normal contention into
// dropped frames, which is how the heartbeat was being starved by forwarded CAM_RX traffic.
#define SERIAL_LINK_WRITE_TIMEOUT_MS 100
#define SERIAL_LINK_TX_LOCK_TIMEOUT_MS 600
// Max CAM payload this link will carry. MUST match SERIAL_LINK_MAX_PAYLOAD in the app's
// SerialFrame.kt - a mismatch means every frame above the smaller of the two is rejected by that
// side's "length exceeds max, resync" branch, silently.
//
// Raised from 160 to 512: 160 was reasoned from cam.c's 96-byte encode buffer, which only ever
// described OUR OWN minimal CAM. A third-party CAM off the air carrying a path-history or
// special-vehicle container comfortably exceeds it, and those stations would then never reach the
// phone at all. 512 clears any realistic CAM. Our own CAM is 43 bytes of UPER.
//
// This, not the radio side, is the ceiling on the RX path. main.c captures up to RX_FRAME_MAX_LEN
// (800) bytes per frame, sized for the CiT One's 528-byte DENM, so a larger ITS payload
// does arrive here. serial_link_send_v2x_rx() then drops anything above this minus its 14-byte
// prefix and counts it in the heartbeat's oversize-drop counter.
#define SERIAL_LINK_MAX_PAYLOAD 512
// Initializes the USB Serial/JTAG driver and its background RX-framing and 1 Hz heartbeat tasks.
// Call once from app_main, after nvs/event loop init. Both callbacks run in the RX task's context,
// so keep them fast: they block the next frame's parsing.
// on_cam_tx a complete, checksummed SERIAL_MSG_CAM_TX frame: bare CAM UPER.
// on_cam_tx_pv a complete, checksummed SERIAL_MSG_CAM_TX_PV frame, already checked to carry at
// least one CAM byte after its prefix: the 24-byte prefix, then the CAM UPER.
typedef void (*serial_link_cam_tx_cb_t)(const uint8_t *cam_uper, int cam_len);
typedef void (*serial_link_cam_tx_pv_cb_t)(const uint8_t *prefix,
const uint8_t *cam_uper, int cam_len);
void serial_link_init(serial_link_cam_tx_cb_t on_cam_tx,
serial_link_cam_tx_pv_cb_t on_cam_tx_pv);
// 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).
// signed_unverified is gn_rx_t's flag of the same name; it sets bit1 of the prefix flags.
// 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, bool signed_unverified,
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.
bool serial_link_send_status(uint8_t status);
// Records a failed esp_wifi_80211_tx() so it shows up in the next heartbeat's tx_failures
// counter. Called from main.c's tx_radio_task - a CAM that reached the radio but didn't go out is
// otherwise indistinguishable, from the phone's side, from one that transmitted fine.
void serial_link_note_tx_failure(void);
// Counts an ITS message that cannot be forwarded because it is too large, in the same heartbeat
// counter serial_link_send_v2x_rx() uses for its own size check. For main.c's rx_forward_task,
// whose capture buffer is smaller than the largest frames on air.
void serial_link_note_oversize_drop(uint16_t btp_dest_port);
#endif