Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73d4477f1e | ||
|
|
277c6b20f4 | ||
|
|
0f06cdb339 | ||
|
|
75d6d3b85c | ||
|
|
1baae2c5f6 | ||
|
|
8871708a98 | ||
|
|
d7043bb04d | ||
|
|
e908f7fae1 | ||
|
|
8f397eea20 | ||
|
|
cc395771ea | ||
|
|
83153a0971 | ||
|
|
3eeccfb268 | ||
|
|
5ec3619cbe | ||
|
|
1ad123a6f8 | ||
|
|
83ccf335bb | ||
|
|
034ef22336 | ||
|
|
ebe1c9edfd | ||
|
|
312f094909 | ||
|
|
b6a687982d |
@@ -27,6 +27,9 @@ secrets.properties
|
||||
/.idea/appInsightsSettings.xml
|
||||
/.idea/studiobot.xml
|
||||
|
||||
# Python bytecode caches, e.g. from importing obu-firmware/test/host/check_replay.py.
|
||||
__pycache__/
|
||||
|
||||
# ESP-IDF rewrites sdkconfig on every build and keeps the previous one here.
|
||||
# sdkconfig.defaults is the real, intentional configuration; these two are output.
|
||||
sdkconfig.old
|
||||
@@ -38,7 +41,11 @@ sdkconfig.old
|
||||
# Third-party working copies kept beside the project, not part of it. The ASN.1
|
||||
# modules this project actually needs are vendored under asn1/ instead.
|
||||
/C-ITS-Parser/
|
||||
/vanetza/
|
||||
|
||||
# Office lock files. Word/Excel create these beside a document while it is open
|
||||
# and remove them on close, so they are transient and machine-local.
|
||||
~$*
|
||||
|
||||
# Captures are large data files, not source (see capture/README.md).
|
||||
/capture/recordings/
|
||||
|
||||
@@ -2,78 +2,156 @@
|
||||
|
||||
Android companion app for the micrOBU; a compact V2X on-board unit developed by HAW Hamburg and consider it GmbH for vulnerable road users (cyclists, e-bike riders, pedestrians).
|
||||
|
||||
The app serves as the HMI for the micrOBU hardware, handling V2X message display, sensor data collection, trip recording, and OBU communication over USB-C, Wi-Fi (dev), and Bluetooth (upcoming).
|
||||
The app is the HMI for the OBU hardware and, on one of the two supported hardware paths, the entire V2X protocol stack. It handles V2X message display, use case detection, sensor collection, trip recording, and OBU communication over USB-C.
|
||||
|
||||
**Platform:** Android (Kotlin) · **Min SDK:** 29 (Android 10) · **Target SDK:** 36
|
||||
**Platform:** Android (Kotlin) / **Min SDK:** 29 (Android 10) / **Target SDK:** 36 / **Version:** 0.5.0 (Phase 03)
|
||||
|
||||
## Project goal
|
||||
|
||||
Demonstrate V2X communication with at least one C2C-CC bicycle safety use case working on the ESP32-C5, specifically intersection movement assist: a car approaching an intersection on a path that conflicts with the rider's.
|
||||
|
||||
That use case is driven entirely from the periodic position and kinematics vehicles broadcast. It needs no traffic light state and no intersection lane geometry, which is why several scope decisions in this repository look deliberately narrow.
|
||||
|
||||
## Two hardware paths
|
||||
|
||||
The project began against the consider it CiT One and later added the ESP32-C5 as a second option. Both paths are supported at runtime and selected by the rider in Settings > OBU Hardware.
|
||||
|
||||
| | consider it CiT One | ESP32-C5 |
|
||||
|---|---|---|
|
||||
| What it is | Complete V2X on-board unit | Development board acting as a plain radio |
|
||||
| Transport | IP over USB tethering, MQTT | Framed binary protocol over USB CDC serial |
|
||||
| Reaches the phone as | Processed JSON | Raw ASN.1 UPER bytes |
|
||||
| V2X stack lives | On the OBU | On the phone, except the radio and GeoNetworking |
|
||||
| Own CAM generated by | The OBU, autonomously | The phone, transmitted on the phone's clock |
|
||||
| DENM trigger | Available (manual test tool) | Not available |
|
||||
| Needs a network | Yes, internally | No |
|
||||
|
||||
Both paths converge at `CamUseCaseRepository`, which normalises whatever arrived into the domain `Cam` type. Everything above that point, including the entire use case detection engine and all UI, is shared and transport-agnostic. That is what makes the ESP32-C5 a drop-in second OBU rather than a fork of the application.
|
||||
|
||||
## What it does
|
||||
|
||||
**Real-time V2X monitoring**; subscribes to the OBU's MQTT broker and displays live CAM, DENM, SPAT, MAP, and CPM messages grouped by topic with pretty-printed JSON and TX/RX badges.
|
||||
**Real-time V2X monitoring**; live CAM, DENM and SPATEM with a station list, active hazards, live signal phase, and a map view. On the CiT One path the topic viewer additionally shows whatever the broker publishes, grouped by topic with pretty-printed JSON and TX/RX badges.
|
||||
|
||||
**DENM transmission**; triggers DENM use cases (e.g. stationary vehicle warning `hln-sv`) on the OBU via the consider it Use Case API (`v2x-uca/input/denmtrg`) with a single tap.
|
||||
**CAM-based use case detection**; correlates the rider's own state with a short per-station history of received CAMs to evaluate five C2C-CC bicycle safety use cases (IMA-B, IMA-S, RTW-B, LTW-B, SMVA/BCW-B) and raises alerts under the three-tier Info / Awareness / Warning model. None of these use cases generates a DENM.
|
||||
|
||||
**Sensor monitoring**; live readout of phone GNSS, accelerometer, gyroscope, magnetometer, and barometer alongside OBU GNSS for cross-reference.
|
||||
**Phone-generated CAM**; on the ESP32-C5 path the app builds a CAM from live GNSS and IMU, UPER-encodes it, and pushes it down the serial link for the board to broadcast over ITS-G5.
|
||||
|
||||
**Trip recording**; foreground service records all sensor streams and detects cycling events (braking, turning, stopping) using orientation-independent signal processing. Works fully offline with no OBU connected.
|
||||
**DENM transmission**; CiT One path only. Triggers the stationary vehicle profile (`hln-sv`, causeCode 94) via the consider it Use Case API. This is a manual antenna and range test tool. It is never triggered by a detected event or a use case alert, and the control is hidden entirely on the ESP32-C5 path.
|
||||
|
||||
**Trip review**; past trips displayed on an OpenStreetMap layer with detected events overlaid as coloured pins. Tap any pin for event details.
|
||||
**Trip recording**; foreground service records all sensor streams and detects cycling manoeuvres (braking, turning, stopping) using orientation-independent signal processing. Works fully offline with no OBU connected. The detected manoeuvres are neither shown nor stored - their only effect is to raise the CAM transmit rate through the manoeuvre on the ESP32-C5 path.
|
||||
|
||||
**CSV export**; every sensor sample written to a timestamped CSV in real time during a session. Shareable via the standard Android share sheet.
|
||||
**Trip review**; past trips displayed as a route on an OpenStreetMap layer, with duration and distance.
|
||||
|
||||
**CSV export**; every sensor sample written to a timestamped CSV in real time. Trip exports additionally include the V2X messages received and their RSSI. Shareable via the standard Android share sheet.
|
||||
|
||||
## Architecture
|
||||
|
||||
MVVM with Repository pattern throughout. Jetpack Compose for all UI (no XML layouts). Hilt for dependency injection.
|
||||
|
||||
```
|
||||
ui/screens/ Compose screens (Dashboard, V2X Monitor, Sensors, Recording, Trip History, Settings…)
|
||||
ui/screens/ Compose screens (Dashboard, Record, Trips, V2X Monitor, Settings...)
|
||||
ui/navigation/ Navigation graph and bottom nav bar
|
||||
viewmodel/ MqttViewModel, SensorViewModel, TripRecordingViewModel
|
||||
data/mqtt/ MQTT repository, Paho client, exponential-backoff reconnection
|
||||
data/transport/ USB tethering detection and gateway IP resolution
|
||||
data/db/ Room database (sessions, trips, detected events)
|
||||
data/ SensorRepository, TripRepository, CsvExporter
|
||||
data/transport/ UsbSerialTransport, SerialFrame, UsbNetworkDetector, ObuHardware
|
||||
data/cam/ CamUseCaseRepository; where both hardware paths converge
|
||||
data/db/ Room database (sessions, trips, detected events, V2X messages)
|
||||
data/ SensorRepository, TripRepository, CsvExporter, TripExporter
|
||||
domain/asn1/ BitReader/BitWriter and the CAM, DENM and SPATEM UPER codecs
|
||||
domain/usecase/ UseCaseDetectionEngine, UseCaseDetectionConfig, AlertLevel, GeoMath
|
||||
domain/detection/ EventDetector, RunningStats sliding window (orientation-independent)
|
||||
service/ TripRecordingService (foreground service)
|
||||
domain/cam/ Cam, CamParser, PhoneCamBuilder, CamTransmitConfig
|
||||
service/ TripRecordingService, CamTransmitLoop, CamPinger
|
||||
obu-firmware/ ESP32-C5 firmware (serial link, GeoNetworking, 802.11 OCB, raw TX)
|
||||
asn1/ Vendored ETSI ASN.1 modules the codecs are verified against
|
||||
```
|
||||
|
||||
The `domain/` packages contain no Android imports. That is what makes the 41-test JVM suite possible without an emulator or instrumentation.
|
||||
|
||||
## Connectivity
|
||||
|
||||
The app uses a phased transport strategy. The MQTT client, topic subscriptions, and all UI are identical across transports; only the underlying network path changes.
|
||||
USB-C on both hardware paths. Bluetooth is **not implemented** and remains an open question in the requirements.
|
||||
|
||||
| Phase | Transport | Status |
|
||||
|---|---|----------|
|
||||
| Phase 01 | Wi-Fi | Complete |
|
||||
| Phase 02 | USB-C tethering | Active |
|
||||
| Phase 03 | Bluetooth BLE | Future |
|
||||
| Transport | Path | Status |
|
||||
|---|---|---|
|
||||
| USB-C tethering (IP + MQTT) | CiT One | Active |
|
||||
| USB-C serial (framed binary) | ESP32-C5 | Active |
|
||||
| Wi-Fi | CiT One | Developer builds only |
|
||||
| Bluetooth | Either | Not implemented |
|
||||
|
||||
The MQTT broker runs on the OBU hardware (Mosquitto 2.0.11, port 1883). In Phase 02, Android USB tethering exposes the OBU as a virtual Ethernet interface at `192.168.42.x`. The app auto-detects the gateway IP on plug-in.
|
||||
On the CiT One path the MQTT broker runs on the OBU (Mosquitto 2.0.11, port 1883); Android USB tethering exposes it as a virtual Ethernet interface at `192.168.42.x` and the app auto-detects the gateway IP on plug-in. On the ESP32-C5 path there is no network layer at all: a private framed protocol runs over the board's native USB-C port as a CDC-ACM device.
|
||||
|
||||
## Verification
|
||||
|
||||
The app hand-encodes and decodes ETSI messages bit by bit on the ESP32-C5 path, which is the highest-risk code in the project. Round-trip tests through the project's own codecs structurally cannot catch a shared mistake about a field's bit width, and this project shipped exactly that bug three times (`CurvatureCalculationMode`, the GeoNetworking reserved bytes, `yawRateConfidence`). Phone and ESP32 agreed with each other and with nothing else.
|
||||
|
||||
Verification therefore uses an independent oracle: `asn1tools` compiled from the ETSI modules vendored in `asn1/`.
|
||||
|
||||
- **Golden-byte fixtures** assert exact encoder output, with expected values produced by the oracle rather than by this encoder.
|
||||
- **Bulk replay** compares every field over real captures: 79,042 SPATEMs and 1,885 DENMs, zero mismatches.
|
||||
- **Off-air confirmation**: 26 of this project's own CAMs captured back by an independent receiver, all accepted.
|
||||
|
||||
Never regenerate a golden fixture from this project's own encoder output. See `asn1/README.md`.
|
||||
|
||||
## Documentation
|
||||
|
||||
| Document | Audience |
|
||||
|---|---|
|
||||
| [docs/MicrOBU-User-Guide.docx](docs/MicrOBU-User-Guide.docx) | Riders. Setup, screens, what the alerts mean, troubleshooting |
|
||||
| [docs/MicrOBU-Technical-Documentation.docx](docs/MicrOBU-Technical-Documentation.docx) | Supervisors and stakeholders. Architecture, message path, verification, results, decisions |
|
||||
| [docs/01-requirements-traceability.md](docs/01-requirements-traceability.md) | Requirements chapters 0 to 13 mapped to implementation and evidence |
|
||||
| [05-obu-bench-test-2026-08-25.md](05-obu-bench-test-2026-08-25.md) | Bench campaign T1 to T9, measured results |
|
||||
| [04-transmit-setup.md](04-transmit-setup.md) | Transmitter bring-up, radio configuration diagnosis, the TX bypass |
|
||||
| [obu-firmware/FLASHING.md](obu-firmware/FLASHING.md) | Toolchain setup, flashing, phone-to-board bring-up checklist |
|
||||
| [asn1/README.md](asn1/README.md) | ASN.1 module provenance and the fixture regeneration rule |
|
||||
| [docs/references.bib](docs/references.bib) | Standards references as BibTeX |
|
||||
|
||||
## Key dependencies
|
||||
|
||||
| Library | Purpose |
|
||||
|---|---|
|
||||
| Jetpack Compose + Material3 | UI |
|
||||
| Eclipse Paho MQTT | OBU communication |
|
||||
| Eclipse Paho MQTT | CiT One path communication |
|
||||
| usb-serial-for-android | ESP32-C5 path communication (custom probe table for Espressif VID/PID) |
|
||||
| Room | Local database |
|
||||
| Hilt | Dependency injection |
|
||||
| OSMDroid | Trip review map |
|
||||
| OSMDroid | Trip review and live V2X map |
|
||||
| DataStore | Settings persistence |
|
||||
| FusedLocationProviderClient | GNSS |
|
||||
|
||||
## Getting started
|
||||
|
||||
1. Open in Android Studio (Hedgehog or newer).
|
||||
1. Open in Android Studio and build the `app` module. Gradle 8.10.2.
|
||||
2. Connect a device running Android 10+ (API 29).
|
||||
3. Build and run the `app` module.
|
||||
4. For Phase 02 testing: plug the phone into the OBU via USB-C, enable USB tethering on the phone, and the app will detect the interface and connect automatically. Broker IP can be overridden manually in Settings → Connection.
|
||||
5. For standalone trip recording: no OBU required. Go to the Record tab and tap Record.
|
||||
3. Choose your hardware in Settings > OBU Hardware.
|
||||
|
||||
The Wi-Fi transport (Phase 01 broker at `192.168.3.202`) remains available in developer builds and can be toggled in Settings → Developer.
|
||||
**CiT One path.** Plug the phone into the OBU via USB-C, enable USB tethering on the phone, and the app detects the interface and connects automatically. Broker IP can be overridden in Settings > Connection.
|
||||
|
||||
**ESP32-C5 path.** Flash `obu-firmware/` (see `obu-firmware/FLASHING.md`), then plug the phone into the board's **native** USB-C port, not the UART bridge port used for flashing. Tap Connect and grant the USB permission. Use the CAM Pinger on the V2X screen to verify the link and radio without starting a trip.
|
||||
|
||||
**Standalone trip recording.** No OBU required. Go to the Record tab and tap REC.
|
||||
|
||||
The Wi-Fi transport (broker at `192.168.3.202`) remains available in developer builds via Settings > Developer.
|
||||
|
||||
Firmware and app must be flashed and installed together: `SERIAL_LINK_MAX_PAYLOAD` is 512 on both sides and a mismatch silently rejects every large frame.
|
||||
|
||||
## Status and known limitations
|
||||
|
||||
Bench verified against live ITS-G5 traffic on 2026-08-25: 2868 frames over 305 seconds, zero decode failures, zero USB errors, zero crashes. Not yet road validated.
|
||||
|
||||
- **Requirement 11.6, Phase A success criteria, is not met.** The bench proves reception. It cannot prove the use case behaves correctly with two genuinely moving stations, because nothing on the bench moves. This is the main open evidence gap for the project's central claim.
|
||||
- **No message signing.** ETSI TS 103 097 is out of scope. Secured frames are rejected rather than mis-parsed.
|
||||
- **Detection thresholds are untuned engineering estimates**, not calibrated against real intersection data.
|
||||
- **512-byte serial payload cap.** Roughly 70% of real road RSU SPATEMs would be dropped as oversize. Accepted deliberately: the intersection use case is CAM-driven and needs none of it.
|
||||
- **No automatic reconnect** after USB re-enumeration; requires a manual Connect.
|
||||
- **Station IDs rotate**, so they cannot identify a physical unit over time.
|
||||
- **No backend, no login.** Everything is on-device.
|
||||
- **No MAPEM decoder**, so signal groups cannot yet be associated with the rider's lane.
|
||||
|
||||
## Project context
|
||||
|
||||
The micrOBU project is funded under the ZIM program (BMWK) and targets micromobility users in Hamburg. The companion app offloads processing from the compact OBU hardware to the smartphone; GNSS fusion, event detection, and future antenna coordination all run on the phone to keep the OBU lightweight and power-efficient.
|
||||
The micrOBU project is funded under the ZIM program (BMWK) and targets micromobility users in Hamburg. The companion app offloads processing from the compact OBU hardware to the smartphone; GNSS fusion, event detection, and on the ESP32-C5 path the full ASN.1 encoding and decoding all run on the phone to keep the OBU lightweight and power-efficient.
|
||||
|
||||
V2X communication uses ITS-G5 (IEEE 802.11p / DSRC) at 5.9 GHz. The app communicates with the OBU exclusively via the consider it MQTT API v6 (processed JSON messages); no ASN.1 encoding in the app.
|
||||
V2X communication uses ITS-G5 (IEEE 802.11p) at 5.9 GHz. On the CiT One path the app communicates via the consider it MQTT API v6 (processed JSON). On the ESP32-C5 path the app performs its own ASN.1 UPER encoding and decoding against the ETSI modules vendored in `asn1/`.
|
||||
|
||||
**Owner:** HAW Hamburg
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
# TODO
|
||||
|
||||
Engineering to-do list. The reviewer-facing open items live in
|
||||
`docs/01-requirements-traceability.md` ("Open items"); this file is the working list behind them.
|
||||
|
||||
## Waiting on hardware
|
||||
|
||||
### Over-the-air check of the GN lifetime fix (added 2026-09-11)
|
||||
|
||||
`geonet.c` now writes GN lifetime `0x05` (1 s) instead of `0x83`, which decoded to 3200 s. Changed
|
||||
in both `obu-firmware` and `obu-cam-transmistter`. Both still build (IDF 6.1 / 5.5.4), and the
|
||||
compiled `geonet_wrap_shb` stores the new byte. Confirmed on air 2026-09-14. Nothing else
|
||||
reads this byte (`gn_unwrap.c` ignores it, the app never sees GN headers), so the app does not
|
||||
need updating alongside the firmware.
|
||||
|
||||
Needs: the phone with the app, the OBU ESP32-C5, and a **second** ESP32-C5 running
|
||||
`its-g5-receiver-firmware` to capture with.
|
||||
|
||||
- [x] Flash `obu-firmware` (done 2026-09-14 on COM3; flash backed up first to
|
||||
`Documents/micrOBU_workspace/firmware-backups/COM3-2026-09-14-before-secured-rx.bin`).
|
||||
- [x] Capture with the receiver (COM8) into `its-g5-receiver-firmware/recordings/`.
|
||||
- [x] `pcap_gn_tally.py` on capture_20260914_132126.pcap: our station sends SHB, port 2001,
|
||||
lifetime `0x05`, same as both bench stations. It was `0x83` in the August captures.
|
||||
- [x] Real-station CAMs/DENMs/SPATEM still reach the app (logcat: `handleCamUper`,
|
||||
`handleDenmUper`, `handleSpatUper` all decoding, 2026-09-14).
|
||||
- [x] Our own CAMs decode on air: 397 frames from station 999999 decode with asn1tools and
|
||||
re-encode byte-identically.
|
||||
- [ ] Confirm the CAM Pinger card's `tx fail` / oversize / CRC counters are 0 (needs a look at the
|
||||
phone; not readable from the PC).
|
||||
|
||||
Partial check possible with one board and no phone: flash it, `idf.py -p COMx monitor`, and look
|
||||
for `OCB @ 5900 MHz - TX/RX armed`. That proves the new build boots and brings the radio up, not
|
||||
that it transmits correctly.
|
||||
|
||||
### obu-cam-transmistter yawRateConfidence fix (added 2026-09-11)
|
||||
|
||||
Its `cam.c` (compiled into that firmware) wrote `yawRateConfidence` as 3 bits / 7 instead of
|
||||
4 bits / unavailable(8), the bug the app fixed on 2026-08-20. Fixed in it and in obu-firmware's
|
||||
reference copy; asn1tools now decodes the CAM and re-encodes it byte-identically, and it builds on
|
||||
IDF 5.5.4. Since 2026-09-14 the spare board on COM10 runs it as a bench beacon:
|
||||
|
||||
- [x] Done 2026-09-14: flashed on COM10 and captured on COM8. All 72 CAMs from station
|
||||
195936478 (0x0BADC0DE) decode with asn1tools and re-encode byte-identically, so the
|
||||
4-bit yawRateConfidence is right on air. COM10 now runs this beacon rather than
|
||||
obu-firmware - reflash it if the spare is needed as an OBU again.
|
||||
|
||||
### Signed-message reception and exact payloads (added 2026-09-11)
|
||||
|
||||
obu-firmware's `gn_unwrap.c` now unwraps TS 103 097 signed packets (signature not verified,
|
||||
reported as V2X_RX flags bit1) and cuts every message to the length its header declares, dropping
|
||||
the 8 bytes the chip's RX appends to each frame, which were forwarded to the phone until now.
|
||||
Verified on the host (`obu-firmware/test/host`: chain, replay of all recordings against asn1tools,
|
||||
50M-iteration fuzz) and flashed to the production OBU on 2026-09-14. The remaining gap is signed
|
||||
traffic to receive: real vehicles or
|
||||
RSUs, since the bench CiT One sends unsigned. A second ESP32 running the receiver firmware is
|
||||
optional, but shows what was on air at the time.
|
||||
|
||||
- [x] Flash obu-firmware (done 2026-09-14, COM3).
|
||||
- [x] Unsigned bench traffic still decodes in the app, with messages now cut to their declared
|
||||
length (CAM, DENM and SPATEM all decoding in logcat after the flash).
|
||||
- [ ] Near signed traffic: signed CAMs/DENMs appear in the app, and a simultaneous capture shows
|
||||
them on air (`pcap_gn_tally.py` lists them as `secured`). NOT possible at this bench: the
|
||||
CiT One transmits unsigned (`ItsGnSecurity = 0`) and nothing else here signs. Needs a drive
|
||||
past real RSUs, the CiT One switched to signed mode if its API allows, or a replay firmware
|
||||
on a spare board that re-transmits the recorded signed frames.
|
||||
- [ ] The heartbeat's oversize counter still counts over-long messages. Not exercised at the
|
||||
bench: the SPATEMs here are ~340 bytes on air, far below the cap.
|
||||
|
||||
## Set up host testing
|
||||
|
||||
- [x] Install MSYS2 UCRT64 gcc (done 2026-09-11: gcc 16.2.0, GNU Make 4.4.1; chosen over WSL,
|
||||
vanetza is not going to be built). Setup and the PATH gotcha: `obu-firmware/test/host/README.md`.
|
||||
- [x] Host round-trip test `obu-firmware/test/host/test_chain.c` (`geonet_wrap_shb` ->
|
||||
`dot11p_build_frame` -> `gn_unwrap_its`, byte-checked against the standard). Done
|
||||
2026-09-11: 491 checks, 0 failed. Run `make` in that folder before flashing any firmware fix.
|
||||
- [x] Replay of the recorded captures (`test_replay.c` + `check_replay.py`, independent asn1tools
|
||||
check). Done 2026-09-11: C and Python agree on all 15 145 records.
|
||||
- [x] Mutation fuzzer `fuzz_gn_unwrap.c`, inputs against a no-access guard page. Done 2026-09-11:
|
||||
50 000 000 iterations, no crash. `make` runs a 2 000 000-iteration pass every time.
|
||||
|
||||
## Firmware ideas from the vanetza review (2026-09-11, not started)
|
||||
|
||||
Suggested order after the host tests exist:
|
||||
|
||||
- [x] **Read secured packets (GN NextHeader=2) without verifying them.** Done 2026-09-11 in
|
||||
`gn_unwrap.c`, host-verified; flagged to the phone as V2X_RX flags bit1. On-air check under
|
||||
"Waiting on hardware".
|
||||
- [ ] **Forward the full GeoBroadcast area**: shape (circle/rectangle/ellipse), DistanceB, angle,
|
||||
appended to the V2X_RX prefix behind a capability bit. Port vanetza's `geonet/areas.cpp`
|
||||
`inside_or_at_border` to the app, which currently treats every area as a circle.
|
||||
- [ ] **RX filtering before the serial link**: duplicate detection for GBC (last 8 sequence numbers
|
||||
per source, as vanetza does), drop our own frames, reject GN version != 1.
|
||||
- [ ] **Read the DCC-MCO field** (the 4 "reserved" bytes of an SHB header): neighbours' channel
|
||||
busy ratio for free.
|
||||
- [ ] **Minimum TX gap in firmware** as a DCC safety net (vanetza reactive table: 60 ms relaxed ...
|
||||
460 ms restrictive), with a CBR estimate in the heartbeat.
|
||||
- [ ] **Generic V2X_TX message** (BTP port, SHB/GBC, traffic class, lifetime, area) so the phone can
|
||||
send DENM and VAM without reflashing. Consider QoS Data frames: vanetza's Cohda receive path
|
||||
drops non-QoS ones.
|
||||
|
||||
Dropped: building vanetza as a GN/BTP oracle. Real captures (`pcap_gn_tally.py`), the host
|
||||
round-trip test and `asn1tools` for UPER cover what it would have checked.
|
||||
|
||||
## Follow-ups found 2026-09-14
|
||||
|
||||
- [x] **Capture tooling moved into this repo** (`capture/`), with the CR-insertion fix. The
|
||||
sniffer's console inserts a CR before every LF, which also hits every 0x0a byte of the binary
|
||||
pcap stream, shifting pcap record headers and frames. A 787 KB capture parsed cleanly for
|
||||
only 82 of ~2000 records, and DENMs showed up on nonsense BTP ports. `undo_crlf()` reverses
|
||||
it on the raw stream before framing; afterwards a capture parsed to EOF and DENMs read as
|
||||
port 2002. **Every capture taken before 2026-09-14 is truncated at its first corrupted
|
||||
record** - re-measure anything derived from them.
|
||||
- [ ] `capture/dump_pcap.py` reads the same console and still needs the same treatment.
|
||||
- [ ] **Do not open COM3's console while the phone is attached.** Opening it toggles DTR/RTS on the
|
||||
CH343 and resets the OBU, which drops the phone's USB link and needs a manual Connect.
|
||||
|
||||
## Follow-ups found 2026-09-11
|
||||
|
||||
- [ ] **App: show the signed flag.** `V2xRxFrame.parse` in `SerialFrame.kt` only reads bit0 of
|
||||
the flags byte; read bit1 (signed, not verified) and show it where messages are listed.
|
||||
- [ ] **Messages that do not decode with asn1tools.** In the recordings, 56 from the CiT One
|
||||
(`aa:f8:76:7d:bd:ad`: 54 CAMs of 245 bytes, 2 DENMs of 402 bytes) and one 218-byte CAM from
|
||||
`6e:94:03:1b:05:26` fail against `cam_1_4_1`/`denm_1_3_1` + `cdd_1_3_1_1`, with or without the
|
||||
old trailing bytes. A newer module version on the sender, or a sender bug; check what the
|
||||
app's decoders make of them (`check_replay.py` lists the records).
|
||||
@@ -87,6 +87,12 @@ class MainActivity : AppCompatActivity() {
|
||||
val useCaseEnabledMap by mqttViewModel.useCaseEnabledMap.collectAsState()
|
||||
val obuHardware by mqttViewModel.obuHardware.collectAsState()
|
||||
val usbSerialState by mqttViewModel.usbSerialState.collectAsState()
|
||||
// Received hazards and live signal state, for the Dashboard's V2X summary cards.
|
||||
// Both flows already expire their own entries on a clock, so nothing here has to
|
||||
// decide when a hazard or a traffic light has gone stale.
|
||||
val denmEvents by mqttViewModel.denmEvents.collectAsState()
|
||||
val spatIntersections by mqttViewModel.spatIntersections.collectAsState()
|
||||
val ownCamPosition by mqttViewModel.ownCamPosition.collectAsState()
|
||||
|
||||
MicrOBUTheme(darkTheme = state.darkTheme) {
|
||||
val view = LocalView.current
|
||||
@@ -150,20 +156,29 @@ class MainActivity : AppCompatActivity() {
|
||||
usbCableConnected = usbConnected,
|
||||
obuStationTypeWarning = obuStationTypeWarning,
|
||||
obuStationType = obuStationType,
|
||||
hazards = denmEvents,
|
||||
signals = spatIntersections,
|
||||
ownPosition = ownCamPosition,
|
||||
onNavigateToConnection = { navController.navigate(Screen.Connection.route) },
|
||||
onNavigateToSensors = {
|
||||
navController.navigate(Screen.Sensors.route) {
|
||||
popUpTo(Screen.Dashboard.route) { saveState = true }
|
||||
popUpTo(Screen.Dashboard.route)
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
},
|
||||
onNavigateToMap = { navController.navigate(Screen.Map.route) },
|
||||
onNavigateToRecord = {
|
||||
navController.navigate(Screen.Record.route) {
|
||||
popUpTo(Screen.Dashboard.route) { saveState = true }
|
||||
popUpTo(Screen.Dashboard.route)
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
// Same options the bottom bar uses, so arriving at V2X from a
|
||||
// Dashboard card leaves the same back stack as tapping the tab.
|
||||
onNavigateToV2x = {
|
||||
navController.navigate(Screen.MqttViewer.route) {
|
||||
popUpTo(Screen.Dashboard.route)
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -233,10 +248,7 @@ class MainActivity : AppCompatActivity() {
|
||||
val trip = trips.firstOrNull { it.id == tripId }
|
||||
|
||||
if (trip != null) {
|
||||
TripReviewScreen(
|
||||
trip = trip,
|
||||
viewModel = tripViewModel,
|
||||
)
|
||||
TripReviewScreen(trip = trip)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.hawhamburg.micr0bu.data
|
||||
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import com.hawhamburg.micr0bu.domain.asn1.ItsTime
|
||||
import java.time.DateTimeException
|
||||
|
||||
/**
|
||||
* Puts the timestamps this phone transmits on GNSS time instead of its own wall clock.
|
||||
*
|
||||
* ## Why
|
||||
* Every CAM carries a generationDeltaTime and every GeoNetworking header a TST, and receivers use
|
||||
* them to judge how fresh a message is and in what order messages came. Both used to come straight
|
||||
* from `System.currentTimeMillis()`, so they were only as good as the phone's clock setting. On
|
||||
* 2026-09-10 the bench phone was 24 minutes fast: automatic time had no source (no SIM, and the
|
||||
* lab Wi-Fi has no internet time), so it had not set the clock once in 69 hours, and every CAM
|
||||
* went out stamped 24 minutes in the future. A bike-mounted phone on the road is in exactly that
|
||||
* position. GNSS time depends on none of it.
|
||||
*
|
||||
* ## How
|
||||
* [SystemClock.currentGnssTimeClock] (API 29, this app's minSdk) is a UTC clock the platform keeps
|
||||
* synchronised from GNSS fixes. One reading of it taken alongside the wall clock gives the wall
|
||||
* clock's error, which is then applied to the fix's own timestamp. When GNSS time is unavailable,
|
||||
* typically indoors before any satellite fix since boot, the wall clock is used unchanged.
|
||||
*
|
||||
* Which clock is in use is logged whenever it changes, with the measured error, so a capture shows
|
||||
* where a given run's timestamps came from.
|
||||
*
|
||||
* Only the transmit path uses this. Everything else in the app stays on the wall clock, because
|
||||
* received messages, sensor samples and trip records are all stamped with it and must stay
|
||||
* comparable with one another.
|
||||
*/
|
||||
object GnssTimeSource {
|
||||
private const val TAG = "GnssTimeSource"
|
||||
|
||||
/** Whether the last correction used GNSS time; null before the first. For change-only logging. */
|
||||
@Volatile private var lastUsedGnss: Boolean? = null
|
||||
|
||||
/** [systemMs], a wall-clock reading, moved onto GNSS time where GNSS time is available. */
|
||||
fun correct(systemMs: Long): Long {
|
||||
val systemNow = System.currentTimeMillis()
|
||||
val gnssNow = try {
|
||||
SystemClock.currentGnssTimeClock().millis()
|
||||
} catch (e: DateTimeException) {
|
||||
null
|
||||
}
|
||||
noteSource(gnssNow, systemNow)
|
||||
return ItsTime.onGnssTime(systemMs, gnssNow, systemNow)
|
||||
}
|
||||
|
||||
private fun noteSource(gnssNow: Long?, systemNow: Long) {
|
||||
val usingGnss = gnssNow != null
|
||||
if (lastUsedGnss == usingGnss) return
|
||||
lastUsedGnss = usingGnss
|
||||
if (gnssNow != null) {
|
||||
Log.i(TAG, "transmit timestamps now on GNSS time; phone clock is " +
|
||||
"${"%+.1f".format((systemNow - gnssNow) / 1000.0)} s off")
|
||||
} else {
|
||||
Log.w(TAG, "GNSS time unavailable, transmit timestamps fall back to the phone clock, " +
|
||||
"which has no automatic time source without a SIM or internet")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package com.hawhamburg.micr0bu.data
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.content.FileProvider
|
||||
import com.hawhamburg.micr0bu.data.db.DetectedEventEntity
|
||||
import com.hawhamburg.micr0bu.data.db.RecordedTripEntity
|
||||
import com.hawhamburg.micr0bu.data.db.V2xMessageEntity
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -23,12 +22,15 @@ fun tripFileName(trip: RecordedTripEntity): String =
|
||||
|
||||
/**
|
||||
* Builds a single combined CSV for one trip: the raw sensor samples recorded alongside it, the
|
||||
* events the detector fired, the GPS track, and every V2X message seen during the ride — all in
|
||||
* one file, ordered by time.
|
||||
* GPS track, and every V2X message seen during the ride — all in one file, ordered by time.
|
||||
*
|
||||
* Detected manoeuvres are deliberately absent. The detector exists to raise the CAM transmit
|
||||
* rate (see EventDetector's KDoc); its output is not retained, so there is nothing to export
|
||||
* beyond the per-trip count in the header.
|
||||
*
|
||||
* **Why one file rather than a zip of tables.** The point of the export is correlation: what was
|
||||
* the bike doing when that CAM arrived, what did the detector make of it. Splitting those into
|
||||
* separate files pushes the join onto whoever opens it. A leading `type` column keeps the rows
|
||||
* the bike doing when that CAM arrived. Splitting those into separate files pushes the join
|
||||
* onto whoever opens it. A leading `type` column keeps the rows
|
||||
* distinguishable, which is the same shape the existing session CSV already uses, so the two
|
||||
* remain readable by the same tooling.
|
||||
*
|
||||
@@ -44,7 +46,6 @@ fun tripFileName(trip: RecordedTripEntity): String =
|
||||
suspend fun buildTripCsv(
|
||||
context: Context,
|
||||
trip: RecordedTripEntity,
|
||||
events: List<DetectedEventEntity>,
|
||||
v2xMessages: List<V2xMessageEntity>,
|
||||
): String = withContext(Dispatchers.IO) {
|
||||
buildString {
|
||||
@@ -59,7 +60,6 @@ suspend fun buildTripCsv(
|
||||
appendLine()
|
||||
appendLine(
|
||||
"type,timestamp_ms,timestamp_iso,lat,lon,speed_ms,heading_deg," +
|
||||
"event_type,confidence,peak_accel,peak_gyro,duration_ms," +
|
||||
"station_id,station_type,is_own,yaw_rate_dps,rssi_dbm"
|
||||
)
|
||||
|
||||
@@ -68,16 +68,6 @@ suspend fun buildTripCsv(
|
||||
appendLine(
|
||||
"gps,${point.timestamp},${isoUtc.format(Date(point.timestamp))}," +
|
||||
"${point.lat},${point.lon},,," +
|
||||
",,,,," +
|
||||
",,,"
|
||||
)
|
||||
}
|
||||
|
||||
for (e in events) {
|
||||
appendLine(
|
||||
"event,${e.timestamp},${isoUtc.format(Date(e.timestamp))}," +
|
||||
"${e.latitude},${e.longitude},${e.speedMps},," +
|
||||
"${e.type},${e.confidence},${e.peakAccelMagnitude},${e.peakGyroMagnitude},${e.durationMs}," +
|
||||
",,,,"
|
||||
)
|
||||
}
|
||||
@@ -86,14 +76,13 @@ suspend fun buildTripCsv(
|
||||
appendLine(
|
||||
"v2x,${m.timestamp},${isoUtc.format(Date(m.timestamp))}," +
|
||||
"${m.latitude},${m.longitude},${m.speedMps},${m.headingDeg}," +
|
||||
",,,,," +
|
||||
"${m.stationId},${m.stationType},${m.isOwn},${m.yawRateDps ?: ""},${m.rssiDbm ?: ""}"
|
||||
)
|
||||
}
|
||||
|
||||
// Raw sensor samples, copied verbatim from the session CSV. Appended last rather than
|
||||
// merge-sorted in: a long ride is hundreds of thousands of rows, and sorting them against
|
||||
// the (comparatively tiny) event/V2X sets in memory would defeat the streaming that
|
||||
// the (comparatively tiny) V2X set in memory would defeat the streaming that
|
||||
// CsvExporter deliberately does. Each row carries its own timestamp, so sort on load.
|
||||
val sessionCsv = trip.sessionId?.let { File(File(context.filesDir, "sessions"), "$it.csv") }
|
||||
if (sessionCsv != null && sessionCsv.exists()) {
|
||||
@@ -110,12 +99,11 @@ suspend fun buildTripCsv(
|
||||
suspend fun shareTripCsv(
|
||||
context: Context,
|
||||
trip: RecordedTripEntity,
|
||||
events: List<DetectedEventEntity>,
|
||||
v2xMessages: List<V2xMessageEntity>,
|
||||
) {
|
||||
val fileName = tripFileName(trip)
|
||||
val cacheFile = File(context.cacheDir, fileName)
|
||||
val csv = buildTripCsv(context, trip, events, v2xMessages)
|
||||
val csv = buildTripCsv(context, trip, v2xMessages)
|
||||
|
||||
withContext(Dispatchers.IO) { cacheFile.writeText(csv) }
|
||||
|
||||
|
||||
@@ -3,11 +3,9 @@ package com.hawhamburg.micr0bu.data
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.hawhamburg.micr0bu.data.db.AppDatabase
|
||||
import com.hawhamburg.micr0bu.data.db.DetectedEventEntity
|
||||
import com.hawhamburg.micr0bu.data.db.RecordedTripEntity
|
||||
import com.hawhamburg.micr0bu.data.db.V2xMessageEntity
|
||||
import com.hawhamburg.micr0bu.domain.cam.Cam
|
||||
import com.hawhamburg.micr0bu.domain.detection.DetectedEvent
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import java.io.File
|
||||
@@ -15,7 +13,7 @@ import java.io.File
|
||||
private const val TAG = "TripRepository"
|
||||
|
||||
/**
|
||||
* Repository that abstracts Room access for trips and detected events.
|
||||
* Repository that abstracts Room access for trips and V2X messages.
|
||||
*
|
||||
* All suspend functions are safe to call from a coroutine running on any
|
||||
* dispatcher; Room executes the actual SQL on its own I/O thread pool.
|
||||
@@ -81,14 +79,11 @@ class TripRepository(db: AppDatabase, private val context: Context) {
|
||||
* One-shot snapshots for export. The Flow-returning variants above stay observable for the UI;
|
||||
* an export wants a value it can write out, not a stream it has to unsubscribe from.
|
||||
*/
|
||||
suspend fun getEventsForTripOnce(tripId: Long): List<DetectedEventEntity> =
|
||||
dao.getEventsForTrip(tripId).first()
|
||||
|
||||
suspend fun getV2xMessagesForTripOnce(tripId: Long): List<V2xMessageEntity> =
|
||||
dao.getV2xMessagesForTrip(tripId).first()
|
||||
|
||||
/**
|
||||
* Deletes a trip and everything belonging to it: detected events and V2X messages go via the
|
||||
* Deletes a trip and everything belonging to it: V2X messages go via the
|
||||
* schema's CASCADE foreign keys, and the CSV recorded alongside it is removed here.
|
||||
*
|
||||
* The CSV is a plain file outside the database, so nothing deletes it implicitly - before
|
||||
@@ -109,32 +104,6 @@ class TripRepository(db: AppDatabase, private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Events ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Persists a domain [DetectedEvent] for the given [tripId].
|
||||
* Converts the domain model to the Room entity.
|
||||
*/
|
||||
suspend fun insertEvent(tripId: Long, event: DetectedEvent) =
|
||||
dao.insertEvent(
|
||||
DetectedEventEntity(
|
||||
tripId = tripId,
|
||||
timestamp = event.timestamp,
|
||||
type = event.type.name,
|
||||
confidence = event.confidence.name,
|
||||
latitude = event.latitude,
|
||||
longitude = event.longitude,
|
||||
speedMps = event.speedMps.toFloat(),
|
||||
peakAccelMagnitude = event.peakAccelMagnitude.toFloat(),
|
||||
peakGyroMagnitude = event.peakGyroMagnitude.toFloat(),
|
||||
durationMs = event.durationMs,
|
||||
)
|
||||
)
|
||||
|
||||
/** Emits events for [tripId] ordered by timestamp, updating whenever the DB changes. */
|
||||
fun getEventsForTrip(tripId: Long): Flow<List<DetectedEventEntity>> =
|
||||
dao.getEventsForTrip(tripId)
|
||||
|
||||
// ── V2X messages (Phase 03) ──────────────────────────────────────────────────
|
||||
// Retention policy: only ever called while a trip is actively recording — see
|
||||
// V2xMessageEntity's KDoc and CamUseCaseRepository.processedCam's collector in
|
||||
|
||||
@@ -7,6 +7,10 @@ import com.hawhamburg.micr0bu.data.SensorRepository
|
||||
import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
|
||||
import com.hawhamburg.micr0bu.data.mqtt.MqttRepository
|
||||
import com.hawhamburg.micr0bu.data.mqtt.ObuHardwarePreferences
|
||||
import com.hawhamburg.micr0bu.data.mqtt.RAW_CAM_TOPIC
|
||||
import com.hawhamburg.micr0bu.data.mqtt.RAW_DENM_TOPIC
|
||||
import com.hawhamburg.micr0bu.data.mqtt.RAW_SPATEM_TOPIC
|
||||
import com.hawhamburg.micr0bu.data.mqtt.RecvV2xMessage
|
||||
import com.hawhamburg.micr0bu.data.mqtt.UseCaseAlertPreferences
|
||||
import com.hawhamburg.micr0bu.data.transport.ObuHardware
|
||||
import com.hawhamburg.micr0bu.data.transport.BtpPort
|
||||
@@ -20,12 +24,15 @@ import com.hawhamburg.micr0bu.domain.asn1.SpatemUperCodec
|
||||
import com.hawhamburg.micr0bu.domain.cam.Cam
|
||||
import com.hawhamburg.micr0bu.domain.cam.CamParser
|
||||
import com.hawhamburg.micr0bu.domain.cam.ObuGnssParser
|
||||
import com.hawhamburg.micr0bu.domain.cam.OwnStationIds
|
||||
import com.hawhamburg.micr0bu.domain.cam.OwnTxLoopback
|
||||
import com.hawhamburg.micr0bu.domain.cam.StationType
|
||||
import com.hawhamburg.micr0bu.domain.denm.DenmEvent
|
||||
import com.hawhamburg.micr0bu.domain.spat.SpatEvent
|
||||
import com.hawhamburg.micr0bu.domain.usecase.UseCaseAlert
|
||||
import com.hawhamburg.micr0bu.domain.usecase.UseCaseDetectionEngine
|
||||
import com.hawhamburg.micr0bu.domain.usecase.UseCaseType
|
||||
import com.hawhamburg.micr0bu.service.CamPinger
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -40,6 +47,7 @@ import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
@@ -55,6 +63,16 @@ private const val PRUNE_INTERVAL_MS = 1_000L
|
||||
// missed updates, not just normal jitter between samples.
|
||||
private const val OBU_GNSS_STALE_MS = 2_500L
|
||||
|
||||
/**
|
||||
* How long a raw `v2x/rx/cam` message keeps the Use Case app's CAM topic suppressed.
|
||||
*
|
||||
* The two topics carry the same traffic, but `v2x-uca/output/json/cam` is rate-limited and drops
|
||||
* messages, so while the raw topic is arriving there is nothing the processed one can add. A few
|
||||
* seconds is many missed repetitions at CAM rates, so this only lapses if the raw topic really
|
||||
* has stopped, which is what makes the fallback automatic on an OBU that does not publish it.
|
||||
*/
|
||||
private const val RAW_PREFERRED_WINDOW_MS = 5_000L
|
||||
|
||||
/**
|
||||
* Bridges the raw MQTT CAM stream (plus the ego's own obu_gnss/phone GNSS state) to
|
||||
* [UseCaseDetectionEngine] and exposes the resulting CAM-based Use Case Alerts to the UI
|
||||
@@ -72,7 +90,12 @@ private const val OBU_GNSS_STALE_MS = 2_500L
|
||||
* A singleton so detection keeps running (and alert state survives) even while no screen is
|
||||
* collecting it — same rationale as [MqttRepository]'s per-topic message log.
|
||||
*
|
||||
* DENM is decoded from the ESP32-C5 serial path (see [airDenm]) but deliberately kept out of
|
||||
* **Two decode sources, one funnel.** UPER arrives either from the ESP32-C5 serial link or, on
|
||||
* the CiT One path, from the raw `v2x/rx` protobuf topics ([RecvV2xMessage]). Both end up in
|
||||
* the same handlers, so everything downstream is transport-agnostic. The CiT One's processed
|
||||
* `v2x-uca/output/json` topics remain a fallback for an OBU that does not publish the raw ones.
|
||||
*
|
||||
* DENM is decoded from both (see [decodedDenm]) but deliberately kept out of
|
||||
* [UseCaseDetectionEngine] — that engine reasons about moving road users from CAM kinematics.
|
||||
*/
|
||||
@Singleton
|
||||
@@ -82,6 +105,8 @@ class CamUseCaseRepository @Inject constructor(
|
||||
private val usbSerialTransport: UsbSerialTransport,
|
||||
private val camCodec: RealAsn1UperCodec,
|
||||
private val obuHardwarePrefs: ObuHardwarePreferences,
|
||||
private val pseudonymManager: PseudonymManager,
|
||||
private val camPinger: CamPinger,
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
@@ -98,6 +123,9 @@ class CamUseCaseRepository @Inject constructor(
|
||||
@Volatile private var lastOwnStationType: Int = StationType.CYCLIST
|
||||
@Volatile private var lastObuGnssTimestamp: Long = 0L
|
||||
|
||||
/** When a raw `v2x/rx/cam` message last arrived, for [rawCamPreferred]. */
|
||||
@Volatile private var lastRawCamMs: Long = 0L
|
||||
|
||||
/** Per-use-case enable/disable toggles (Settings > Use Case Alerts). */
|
||||
val enabledMap: StateFlow<Map<UseCaseType, Boolean>> = prefs.enabledMapFlow.stateIn(
|
||||
scope, SharingStarted.Eagerly, UseCaseType.entries.associateWith { true },
|
||||
@@ -140,21 +168,42 @@ class CamUseCaseRepository @Inject constructor(
|
||||
*/
|
||||
val rsuStations: StateFlow<Map<Long, Cam>> = _rsuStations.asStateFlow()
|
||||
|
||||
private val _airSpat = MutableSharedFlow<SpatEvent>(replay = 16, extraBufferCapacity = 32)
|
||||
private val _ownTxLoopback = MutableStateFlow<OwnTxLoopback?>(null)
|
||||
/**
|
||||
* SPATEMs decoded from over-the-air traffic on the ESP32-C5 path. Replayed so a screen opened
|
||||
* mid-stream sees the current signal state immediately rather than waiting up to half a second
|
||||
* for the next repetition.
|
||||
* Our own transmissions heard back off the air, or null until one is.
|
||||
*
|
||||
* These frames are dropped from the detection engine, correctly, since the phone is not a
|
||||
* road user to itself. But dropping them silently threw away the one thing that proves the
|
||||
* whole radio loop works: the frame went out over serial, the ESP32 transmitted it, and the
|
||||
* ESP32 received it again. That is precisely what the bench pinger exists to demonstrate, so
|
||||
* it is counted here and reported rather than discarded.
|
||||
*
|
||||
* ESP32-C5 path in practice. The CiT One does not normally hear its own transmissions.
|
||||
*/
|
||||
val airSpat: SharedFlow<SpatEvent> = _airSpat.asSharedFlow()
|
||||
val ownTxLoopback: StateFlow<OwnTxLoopback?> = _ownTxLoopback.asStateFlow()
|
||||
|
||||
private val _airDenm = MutableSharedFlow<DenmEvent>(replay = 32, extraBufferCapacity = 32)
|
||||
/** Clears the loopback tally. Called when a fresh pinger run starts, so the count is per run. */
|
||||
fun resetOwnTxLoopback() { _ownTxLoopback.value = null }
|
||||
|
||||
private val _decodedSpat = MutableSharedFlow<SpatEvent>(replay = 16, extraBufferCapacity = 32)
|
||||
/**
|
||||
* DENMs decoded from over-the-air traffic on the ESP32-C5 path. `replay` so a screen opened
|
||||
* after a hazard was first heard still sees it - DENMs repeat at ~1 Hz but a subscriber that
|
||||
* missed the last repetition shouldn't have to wait for the next.
|
||||
* SPATEMs decoded from UPER, from either hardware path: the ESP32-C5 serial link or the CiT
|
||||
* One's `v2x/rx/spatem` topic. Replayed so a screen opened mid-stream sees the current signal
|
||||
* state immediately rather than waiting up to half a second for the next repetition.
|
||||
*
|
||||
* The CiT One's own `v2x-uca/output/json/spat` topic is not a source here. It was never
|
||||
* parsed, so before the raw topic was wired up this path produced no signal state at all.
|
||||
*/
|
||||
val airDenm: SharedFlow<DenmEvent> = _airDenm.asSharedFlow()
|
||||
val decodedSpat: SharedFlow<SpatEvent> = _decodedSpat.asSharedFlow()
|
||||
|
||||
private val _decodedDenm = MutableSharedFlow<DenmEvent>(replay = 32, extraBufferCapacity = 32)
|
||||
/**
|
||||
* DENMs decoded from UPER, from either hardware path: the ESP32-C5 serial link or the CiT
|
||||
* One's `v2x/rx/denm` topic. `replay` so a screen opened after a hazard was first heard still
|
||||
* sees it - DENMs repeat at ~1 Hz but a subscriber that missed the last repetition shouldn't
|
||||
* have to wait for the next.
|
||||
*/
|
||||
val decodedDenm: SharedFlow<DenmEvent> = _decodedDenm.asSharedFlow()
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
@@ -166,6 +215,37 @@ class CamUseCaseRepository @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
// CiT One raw path: every message the OBU's radio heard, as protobuf, decoded here with
|
||||
// the same codecs the serial path uses. This is what makes the CiT One see traffic the
|
||||
// Use Case app filtered out, the ESP32-C5's CAM pinger among it, and it is the only
|
||||
// source of SPATEM on this hardware.
|
||||
scope.launch {
|
||||
mqttRepository.rawV2x.collect { raw ->
|
||||
val envelope = RecvV2xMessage.parse(raw.bytes)
|
||||
if (envelope == null) {
|
||||
Log.w(TAG, "rawV2x: unparseable RecvV2XMessage on ${raw.topic}, " +
|
||||
"${raw.bytes.size} bytes - first bytes: ${raw.bytes.toHexPreview()}")
|
||||
return@collect
|
||||
}
|
||||
when (raw.topic) {
|
||||
RAW_CAM_TOPIC -> {
|
||||
lastRawCamMs = raw.timestamp
|
||||
handleCamUper(envelope.payload, rssiDbm = null, source = "mqtt")
|
||||
}
|
||||
// The GeoBroadcast radius comes off the GeoNetworking header the same way it
|
||||
// does on the serial path, so a hazard's relevance area survives here too.
|
||||
RAW_DENM_TOPIC -> handleDenmUper(
|
||||
uper = envelope.payload,
|
||||
rssiDbm = null,
|
||||
relevanceRadiusM = envelope.destAreaRadiusM,
|
||||
source = "mqtt",
|
||||
)
|
||||
RAW_SPATEM_TOPIC -> handleSpatUper(envelope.payload, rssiDbm = null, source = "mqtt")
|
||||
else -> Log.w(TAG, "rawV2x: unexpected topic ${raw.topic}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phone GNSS fallback — only applied when obu_gnss has gone stale (see class KDoc).
|
||||
// Retries in a loop: this singleton can be created before the user grants location
|
||||
// permission (requested at app startup), so a single subscription attempt isn't
|
||||
@@ -235,8 +315,8 @@ class CamUseCaseRepository @Inject constructor(
|
||||
}
|
||||
|
||||
// Our own station ID. On the CiT One path it's learned from v2x/rx/obu_gnss; the ESP32-C5
|
||||
// path has no such topic, so it comes from the same persisted value CamTransmitLoop puts
|
||||
// in outgoing CAMs.
|
||||
// path has no such topic, so it follows the current transmit pseudonym, the same one
|
||||
// CamTransmitLoop puts in outgoing CAMs, across every rotation.
|
||||
//
|
||||
// Without this the ID stayed null on the ESP32 path and the self-heard-TX filter in
|
||||
// [handleCamFromSerial] never fired - so the phone's own CAMs, which the ESP32 hears back
|
||||
@@ -244,10 +324,13 @@ class CamUseCaseRepository @Inject constructor(
|
||||
// sitting exactly on top of the ego position, fed into the detection engine as a
|
||||
// collision partner for itself.
|
||||
scope.launch {
|
||||
obuHardwarePrefs.obuHardwareFlow.collect { hardware ->
|
||||
combine(obuHardwarePrefs.obuHardwareFlow, pseudonymManager.currentFlow) { hardware, pseudonym ->
|
||||
hardware to pseudonym
|
||||
}.collect { (hardware, pseudonym) ->
|
||||
currentHardware = hardware
|
||||
if (hardware == ObuHardware.ESP32_C5) {
|
||||
_ownStationId.value = obuHardwarePrefs.getOrCreateOwnStationId()
|
||||
// currentFlow re-emits on every rotation, so this tracks the live identity.
|
||||
_ownStationId.value = (pseudonym ?: pseudonymManager.current()).stationId
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -257,8 +340,22 @@ class CamUseCaseRepository @Inject constructor(
|
||||
scope.launch { prefs.setEnabled(type, enabled) }
|
||||
}
|
||||
|
||||
/** True if [stationId] matches the ego OBU's own station ID (for OWN/REMOTE UI badges). */
|
||||
fun isOwnStationId(stationId: Long): Boolean = stationId != 0L && stationId == _ownStationId.value
|
||||
/**
|
||||
* True if [stationId] is one this phone transmits under, so a frame heard back off the air is
|
||||
* recognised as our own rather than tracked as another road user. Also drives the OWN/REMOTE
|
||||
* badges in the raw message list.
|
||||
*
|
||||
* The rule lives in [OwnStationIds], which explains which ids count and what goes wrong when
|
||||
* one is missed. The set passed in holds the current transmit pseudonym and the ones it most
|
||||
* recently replaced, plus, on the CiT One path, the OBU's own id from obu_gnss. The bench
|
||||
* ping id counts only while this phone's own pinger is running.
|
||||
*/
|
||||
fun isOwnStationId(stationId: Long): Boolean =
|
||||
OwnStationIds.isOwn(
|
||||
stationId,
|
||||
ownIds = pseudonymManager.ownStationIds() + setOfNotNull(_ownStationId.value),
|
||||
benchPingIsOurs = camPinger.benchPingIsOurs(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Primary ego state source: `v2x/rx/obu_gnss`, ~4 Hz, carries position/speed/heading/yaw
|
||||
@@ -301,16 +398,32 @@ class CamUseCaseRepository @Inject constructor(
|
||||
_processedCam.tryEmit(ego)
|
||||
}
|
||||
|
||||
/** True while `v2x/rx/cam` is arriving, in which case the processed CAM topic adds nothing. */
|
||||
private fun rawCamPreferred(now: Long): Boolean =
|
||||
lastRawCamMs != 0L && now - lastRawCamMs <= RAW_PREFERRED_WINDOW_MS
|
||||
|
||||
private fun handleCam(payload: String, timestamp: Long) {
|
||||
val cam = CamParser.parse(payload, _ownStationId.value, timestamp) ?: return
|
||||
// This phone's own bench ping, relayed back by the CiT One's radio: not a road user, and not
|
||||
// ego state either, since it is built from the same phone GNSS the engine already has.
|
||||
// Only while this phone is the one pinging, though. Another phone's pings carry the same
|
||||
// fixed id and are genuine remote traffic to this one.
|
||||
if (cam.stationId == OwnStationIds.BENCH_PING && camPinger.benchPingIsOurs()) return
|
||||
if (cam.isOwn) {
|
||||
// Third fallback — the CAM topic's own low-rate entry. onOwnCam() keeps whichever
|
||||
// Third fallback - the CAM topic's own low-rate entry. onOwnCam() keeps whichever
|
||||
// update is freshest, so this only actually wins when both obu_gnss and phone GNSS
|
||||
// are unavailable/stale.
|
||||
// are unavailable/stale. Deliberately still processed while the raw topic is live:
|
||||
// v2x/rx/cam is a receive topic and never carries the ego station's own CAM, so
|
||||
// suppressing this would remove the fallback without anything replacing it.
|
||||
engine.onOwnCam(cam)
|
||||
} else {
|
||||
engine.onRemoteCam(cam)
|
||||
_processedCam.tryEmit(cam)
|
||||
return
|
||||
}
|
||||
// A remote CAM the raw topic has already delivered, in fuller form and without the Use
|
||||
// Case app's rate limiting. Dropping it here rather than letting both reach the engine
|
||||
// keeps one station from being fed by two sources at two different rates.
|
||||
if (rawCamPreferred(timestamp)) return
|
||||
engine.onRemoteCam(cam)
|
||||
_processedCam.tryEmit(cam)
|
||||
}
|
||||
|
||||
@@ -325,7 +438,12 @@ class CamUseCaseRepository @Inject constructor(
|
||||
* its own just-transmitted frame (promiscuous capture of a local TX). Guarded the same way
|
||||
* the MQTT path guards against reprocessing "own" CAM: compare against [_ownStationId].
|
||||
*/
|
||||
private fun handleCamFromSerial(v2x: V2xRxFrame) {
|
||||
private fun handleCamFromSerial(v2x: V2xRxFrame) =
|
||||
handleCamUper(v2x.uper, v2x.rssiDbm, source = "serial")
|
||||
|
||||
/** Shared by both transports: [rssiDbm] is null on the MQTT path, which does not report it. */
|
||||
private fun handleCamUper(uper: ByteArray, rssiDbm: Int?, source: String) {
|
||||
val v2x = UperSource(uper, rssiDbm, source)
|
||||
val cam = camCodec.decodeCam(v2x.uper, System.currentTimeMillis())?.copy(rssiDbm = v2x.rssiDbm)
|
||||
if (cam == null) {
|
||||
// Logged, not silently dropped: "the app shows nothing" has two completely different
|
||||
@@ -333,14 +451,27 @@ class CamUseCaseRepository @Inject constructor(
|
||||
// without this line they're indistinguishable from the outside.
|
||||
Log.w(
|
||||
TAG,
|
||||
"handleCamFromSerial: decode FAILED for ${v2x.uper.size}-byte CAM " +
|
||||
"handleCamUper[${v2x.source}]: decode FAILED for ${v2x.uper.size}-byte CAM " +
|
||||
"(rssi=${v2x.rssiDbm} dBm) - first bytes: ${v2x.uper.toHexPreview()}",
|
||||
)
|
||||
return
|
||||
}
|
||||
Log.d(TAG, "handleCamFromSerial: decoded station=${cam.stationId} " +
|
||||
Log.d(TAG, "handleCamUper[${v2x.source}]: decoded station=${cam.stationId} " +
|
||||
"lat=${cam.latitude} lon=${cam.longitude} speed=${cam.speedMps} rssi=${v2x.rssiDbm} dBm")
|
||||
if (_ownStationId.value != null && cam.stationId == _ownStationId.value) return // self-heard TX
|
||||
if (isOwnStationId(cam.stationId)) {
|
||||
// Ours, on either station id. Kept out of the engine, but counted: this is the
|
||||
// round trip completing, and it is the only direct evidence the radio path works.
|
||||
_ownTxLoopback.update { prev ->
|
||||
OwnTxLoopback(
|
||||
frames = (prev?.frames ?: 0) + 1,
|
||||
// Hold the last known reading rather than overwriting it with null on a
|
||||
// transport that does not report RSSI, so the figure does not blink away.
|
||||
lastRssiDbm = v2x.rssiDbm ?: prev?.lastRssiDbm,
|
||||
lastHeardMs = System.currentTimeMillis(),
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Roadside units are infrastructure, not road users. Their CAM carries no kinematics (see
|
||||
// CamUperCodec's rsuContainerHighFrequency branch), so it reaches here as a permanently
|
||||
@@ -367,25 +498,38 @@ class CamUseCaseRepository @Inject constructor(
|
||||
* reasons about moving road users from CAM kinematics, and a static hazard is a different kind
|
||||
* of thing. DENMs go to the map and the message list only.
|
||||
*/
|
||||
private fun handleDenmFromSerial(v2x: V2xRxFrame) {
|
||||
private fun handleDenmFromSerial(v2x: V2xRxFrame) = handleDenmUper(
|
||||
uper = v2x.uper,
|
||||
rssiDbm = v2x.rssiDbm,
|
||||
relevanceRadiusM = v2x.geoArea?.radiusMeters,
|
||||
source = "serial",
|
||||
)
|
||||
|
||||
private fun handleDenmUper(
|
||||
uper: ByteArray,
|
||||
rssiDbm: Int?,
|
||||
relevanceRadiusM: Int?,
|
||||
source: String,
|
||||
) {
|
||||
val v2x = UperSource(uper, rssiDbm, source)
|
||||
val denm = DenmUperCodec.decode(
|
||||
bytes = v2x.uper,
|
||||
receivedAtEpochMs = System.currentTimeMillis(),
|
||||
rssiDbm = v2x.rssiDbm,
|
||||
relevanceRadiusM = v2x.geoArea?.radiusMeters,
|
||||
relevanceRadiusM = relevanceRadiusM,
|
||||
)
|
||||
if (denm == null) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"handleDenmFromSerial: decode FAILED for ${v2x.uper.size}-byte DENM " +
|
||||
"handleDenmUper[${v2x.source}]: decode FAILED for ${v2x.uper.size}-byte DENM " +
|
||||
"(rssi=${v2x.rssiDbm} dBm) - first bytes: ${v2x.uper.toHexPreview()}",
|
||||
)
|
||||
return
|
||||
}
|
||||
Log.d(TAG, "handleDenmFromSerial: decoded station=${denm.stationId}/${denm.sequenceNumber} " +
|
||||
Log.d(TAG, "handleDenmUper[${v2x.source}]: decoded station=${denm.stationId}/${denm.sequenceNumber} " +
|
||||
"cause=${denm.causeCode}/${denm.subCauseCode} lat=${denm.latitude} lon=${denm.longitude} " +
|
||||
"radius=${denm.relevanceRadiusM}m termination=${denm.isTermination} rssi=${v2x.rssiDbm} dBm")
|
||||
_airDenm.tryEmit(denm)
|
||||
_decodedDenm.tryEmit(denm)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -398,7 +542,11 @@ class CamUseCaseRepository @Inject constructor(
|
||||
* counts it as an oversize drop, so on real road RSUs (median 555 bytes) most will not arrive
|
||||
* until that cap is raised. The bench trigger's ~58-byte messages are unaffected.
|
||||
*/
|
||||
private fun handleSpatFromSerial(v2x: V2xRxFrame) {
|
||||
private fun handleSpatFromSerial(v2x: V2xRxFrame) =
|
||||
handleSpatUper(v2x.uper, v2x.rssiDbm, source = "serial")
|
||||
|
||||
private fun handleSpatUper(uper: ByteArray, rssiDbm: Int?, source: String) {
|
||||
val v2x = UperSource(uper, rssiDbm, source)
|
||||
val spat = SpatemUperCodec.decode(
|
||||
bytes = v2x.uper,
|
||||
receivedAtEpochMs = System.currentTimeMillis(),
|
||||
@@ -407,17 +555,24 @@ class CamUseCaseRepository @Inject constructor(
|
||||
if (spat == null) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"handleSpatFromSerial: decode FAILED for ${v2x.uper.size}-byte SPATEM " +
|
||||
"handleSpatUper[${v2x.source}]: decode FAILED for ${v2x.uper.size}-byte SPATEM " +
|
||||
"(rssi=${v2x.rssiDbm} dBm) - first bytes: ${v2x.uper.toHexPreview()}",
|
||||
)
|
||||
return
|
||||
}
|
||||
Log.d(TAG, "handleSpatFromSerial: decoded station=${spat.stationId} " +
|
||||
Log.d(TAG, "handleSpatUper[${v2x.source}]: decoded station=${spat.stationId} " +
|
||||
"intersections=${spat.intersections.joinToString { it.key }} " +
|
||||
"movements=${spat.intersections.sumOf { it.movements.size }} rssi=${v2x.rssiDbm} dBm")
|
||||
_airSpat.tryEmit(spat)
|
||||
_decodedSpat.tryEmit(spat)
|
||||
}
|
||||
|
||||
/**
|
||||
* The bits of a received frame the decoders and their log lines need, independent of whether
|
||||
* it came off the serial link or an MQTT topic. [rssiDbm] is null on the MQTT path: the
|
||||
* RecvV2XMessage envelope does not carry signal strength.
|
||||
*/
|
||||
private data class UperSource(val uper: ByteArray, val rssiDbm: Int?, val source: String)
|
||||
|
||||
private fun ByteArray.toHexPreview(limit: Int = 16): String =
|
||||
take(limit).joinToString(" ") { "%02x".format(it) } + if (size > limit) " ..." else ""
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.hawhamburg.micr0bu.data.cam
|
||||
|
||||
import android.util.Log
|
||||
import com.hawhamburg.micr0bu.data.mqtt.ObuHardwarePreferences
|
||||
import com.hawhamburg.micr0bu.domain.cam.Pseudonym
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Owns the phone's transmit identity on the ESP32-C5 path and rotates it every
|
||||
* [Pseudonym.ROTATION_INTERVAL_MS].
|
||||
*
|
||||
* A singleton because there must be exactly one of these. [com.hawhamburg.micr0bu.service.CamTransmitLoop]
|
||||
* runs inside the foreground recording service and [CamUseCaseRepository] filters received frames;
|
||||
* if each held its own identity, the phone could transmit under one pseudonym while its receive
|
||||
* path recognised another, which brings back the ghost road user sitting on the ego position.
|
||||
* The bench pinger deliberately does not use this: it keeps a fixed identity so pings stay
|
||||
* recognisable in a capture.
|
||||
*/
|
||||
@Singleton
|
||||
class PseudonymManager @Inject constructor(
|
||||
private val prefs: ObuHardwarePreferences,
|
||||
) {
|
||||
private val mutex = Mutex()
|
||||
|
||||
private val _current = MutableStateFlow<Pseudonym?>(null)
|
||||
|
||||
/** The identity in use, or null before the first call to [current] has loaded one. */
|
||||
val currentFlow: StateFlow<Pseudonym?> = _current.asStateFlow()
|
||||
|
||||
/** Station IDs replaced most recently, newest first. See [ownStationIds]. */
|
||||
@Volatile private var recentlyRetired: List<Long> = emptyList()
|
||||
|
||||
/**
|
||||
* Every station ID one of our own frames could still be carrying: the current pseudonym's and
|
||||
* the ones it replaced most recently.
|
||||
*
|
||||
* The previous IDs matter because the ESP32 hears our own transmissions back. A frame sent just
|
||||
* before a rotation can come back just after it, and if its ID no longer counted as ours it
|
||||
* would be tracked as another road user sitting exactly on the ego position.
|
||||
*/
|
||||
fun ownStationIds(): Set<Long> = buildSet {
|
||||
_current.value?.let { add(it.stationId) }
|
||||
addAll(recentlyRetired)
|
||||
}
|
||||
|
||||
/**
|
||||
* The pseudonym to transmit under right now, rotating first if the current one has expired.
|
||||
*
|
||||
* Rotation happens here, at the moment an identity is about to be used, rather than on a
|
||||
* timer. Each frame therefore carries one complete identity chosen in a single step, so a
|
||||
* rotation can never land between the CAM being built and its position vector being attached.
|
||||
*
|
||||
* Persisted, so an app restart inside the interval keeps the same identity. Only elapsed time
|
||||
* rotates it, never a crash or a relaunch.
|
||||
*/
|
||||
suspend fun current(nowMs: Long = System.currentTimeMillis()): Pseudonym = mutex.withLock {
|
||||
val existing = _current.value ?: prefs.loadPseudonym()
|
||||
if (existing != null && !existing.isExpired(nowMs)) {
|
||||
_current.value = existing
|
||||
existing
|
||||
} else {
|
||||
val next = Pseudonym.generate(nowMs)
|
||||
prefs.savePseudonym(next)
|
||||
if (existing != null) {
|
||||
recentlyRetired = (listOf(existing.stationId) + recentlyRetired).take(RETIRED_TO_KEEP)
|
||||
}
|
||||
_current.update { next }
|
||||
Log.i(TAG, "pseudonym rotated: station ${existing?.stationId} -> ${next.stationId}")
|
||||
next
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "PseudonymManager"
|
||||
|
||||
/**
|
||||
* A loopback arrives within milliseconds, so one previous ID would already be ample. Two
|
||||
* costs nothing and covers a rotation that fires twice in quick succession after a clock
|
||||
* correction.
|
||||
*/
|
||||
const val RETIRED_TO_KEEP = 2
|
||||
}
|
||||
}
|
||||
@@ -11,10 +11,9 @@ import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
entities = [
|
||||
SessionEntity::class,
|
||||
RecordedTripEntity::class,
|
||||
DetectedEventEntity::class,
|
||||
V2xMessageEntity::class,
|
||||
],
|
||||
version = 4,
|
||||
version = 5,
|
||||
exportSchema = false,
|
||||
)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
@@ -34,13 +33,29 @@ abstract class AppDatabase : RoomDatabase() {
|
||||
AppDatabase::class.java,
|
||||
"micr0bu.db",
|
||||
)
|
||||
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4)
|
||||
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5)
|
||||
.build()
|
||||
.also { INSTANCE = it }
|
||||
}
|
||||
|
||||
// ── Migrations ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Drops `detected_events`. The cyclist event detector still runs, but its output is now
|
||||
* consumed only by the CAM transmit-rate policy (see EventDetector's KDoc) and is no
|
||||
* longer persisted, displayed, or exported, so the table had no reader left.
|
||||
*
|
||||
* `trips.eventCount` is deliberately kept. Dropping a column means recreating `trips`
|
||||
* and copying every recorded ride across, which is real risk for one unused integer;
|
||||
* the service still writes an accurate count into it and the CSV header still reports it.
|
||||
*/
|
||||
private val MIGRATION_4_5 = object : Migration(4, 5) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
database.execSQL("DROP INDEX IF EXISTS `index_detected_events_tripId`")
|
||||
database.execSQL("DROP TABLE IF EXISTS `detected_events`")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Two additions:
|
||||
* - `trips.sessionId` links a trip to the CSV recording session captured alongside it, so
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
package com.hawhamburg.micr0bu.data.db
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
/**
|
||||
* One detected cyclist event (braking / turning / stopping) linked to a
|
||||
* [RecordedTripEntity] via the [tripId] foreign key.
|
||||
*
|
||||
* [type] and [confidence] are stored as the enum name strings so that the
|
||||
* database remains human-readable.
|
||||
*/
|
||||
@Entity(
|
||||
tableName = "detected_events",
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = RecordedTripEntity::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["tripId"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
)
|
||||
],
|
||||
)
|
||||
data class DetectedEventEntity(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
val id: Long = 0,
|
||||
|
||||
@ColumnInfo(index = true)
|
||||
val tripId: Long,
|
||||
|
||||
/** Wall-clock epoch ms of the first qualifying sensor frame. */
|
||||
val timestamp: Long,
|
||||
|
||||
/** EventType.name — one of BRAKING, TURNING, STOPPING. */
|
||||
val type: String,
|
||||
|
||||
/** Confidence.name — one of HIGH, MEDIUM, LOW. */
|
||||
val confidence: String,
|
||||
|
||||
val latitude: Double,
|
||||
val longitude: Double,
|
||||
val speedMps: Float,
|
||||
val peakAccelMagnitude: Float,
|
||||
val peakGyroMagnitude: Float,
|
||||
|
||||
/** Duration from first qualifying frame to emission (ms). */
|
||||
val durationMs: Long,
|
||||
)
|
||||
@@ -27,17 +27,6 @@ interface TripDao {
|
||||
@Query("DELETE FROM trips WHERE id = :id")
|
||||
suspend fun deleteTripById(id: Long)
|
||||
|
||||
// ── Events ────────────────────────────────────────────────────────────────
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertEvent(event: DetectedEventEntity)
|
||||
|
||||
@Query("SELECT * FROM detected_events WHERE tripId = :tripId ORDER BY timestamp ASC")
|
||||
fun getEventsForTrip(tripId: Long): Flow<List<DetectedEventEntity>>
|
||||
|
||||
@Query("SELECT COUNT(*) FROM detected_events WHERE tripId = :tripId")
|
||||
suspend fun getEventCountForTrip(tripId: Long): Int
|
||||
|
||||
// ── V2X messages (Phase 03) ──────────────────────────────────────────────────
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
|
||||
@@ -43,6 +43,13 @@ private val SUBSCRIBED_TOPICS = listOf(
|
||||
"sys/state/heartbeat",
|
||||
"sys/state/cellular",
|
||||
"v2x/rx/obu_gnss",
|
||||
// Everything the radio heard, as RecvV2XMessage protobuf (API section 2.4). Preferred over
|
||||
// the v2x-uca topics below, which are a rate-limited and lossy view of the same traffic.
|
||||
RAW_CAM_TOPIC,
|
||||
RAW_DENM_TOPIC,
|
||||
RAW_SPATEM_TOPIC,
|
||||
// Kept subscribed as a fallback for an OBU whose product configuration does not publish the
|
||||
// raw topics, and because the Use Case app is still the only source of its own alert output.
|
||||
"v2x-uca/output/json/cam",
|
||||
"v2x-uca/output/json/denm",
|
||||
"v2x-uca/output/json/spat",
|
||||
@@ -50,6 +57,31 @@ private val SUBSCRIBED_TOPICS = listOf(
|
||||
"v2x-uca/output/json/cpm",
|
||||
)
|
||||
|
||||
/** Raw received-V2X topics, carrying protobuf rather than JSON. See [RecvV2xMessage]. */
|
||||
const val RAW_CAM_TOPIC = "v2x/rx/cam"
|
||||
const val RAW_DENM_TOPIC = "v2x/rx/denm"
|
||||
const val RAW_SPATEM_TOPIC = "v2x/rx/spatem"
|
||||
|
||||
private val RAW_V2X_TOPICS = setOf(RAW_CAM_TOPIC, RAW_DENM_TOPIC, RAW_SPATEM_TOPIC)
|
||||
|
||||
/**
|
||||
* A message straight off a `v2x/rx` topic, before the protobuf envelope is opened.
|
||||
*
|
||||
* Carried as bytes, not [MqttMessage]: that type holds a String, and putting protobuf through
|
||||
* a UTF-8 round trip replaces every byte that is not valid UTF-8 with U+FFFD. The payload
|
||||
* survives looking plausible in a log and decodes to nothing.
|
||||
*/
|
||||
data class RawV2xMqttMessage(val topic: String, val bytes: ByteArray, val timestamp: Long) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is RawV2xMqttMessage) return false
|
||||
return topic == other.topic && timestamp == other.timestamp && bytes.contentEquals(other.bytes)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int =
|
||||
31 * (31 * topic.hashCode() + timestamp.hashCode()) + bytes.contentHashCode()
|
||||
}
|
||||
|
||||
@Singleton
|
||||
class MqttRepository @Inject constructor(
|
||||
private val prefs: MqttPreferences,
|
||||
@@ -69,6 +101,21 @@ class MqttRepository @Inject constructor(
|
||||
)
|
||||
val messages: SharedFlow<MqttMessage> = _messages.asSharedFlow()
|
||||
|
||||
// Same buffering rationale as [_messages], with more headroom: this stream carries every CAM
|
||||
// the radio hears rather than the Use Case app's thinned-out selection, which at a busy
|
||||
// intersection is a considerably higher rate.
|
||||
private val _rawV2x = MutableSharedFlow<RawV2xMqttMessage>(
|
||||
replay = 0,
|
||||
extraBufferCapacity = 512,
|
||||
)
|
||||
|
||||
/**
|
||||
* Undecoded `v2x/rx` protobuf messages. Consumed by
|
||||
* [com.hawhamburg.micr0bu.data.cam.CamUseCaseRepository], which opens the envelope and runs
|
||||
* the UPER decoders over the payload, exactly as it does for the ESP32-C5 serial path.
|
||||
*/
|
||||
val rawV2x: SharedFlow<RawV2xMqttMessage> = _rawV2x.asSharedFlow()
|
||||
|
||||
// Per-topic message log, kept here (singleton) so it survives even when no screen is
|
||||
// collecting — e.g. DENM TX messages emitted by TripRecordingService while the V2X
|
||||
// Monitor screen isn't open.
|
||||
@@ -194,6 +241,12 @@ class MqttRepository @Inject constructor(
|
||||
)
|
||||
}
|
||||
|
||||
/** A one-line, printable stand-in for a binary payload, for the raw topic log. */
|
||||
private fun describeBinary(bytes: ByteArray, limit: Int = 24): String {
|
||||
val hex = bytes.take(limit).joinToString(" ") { "%02x".format(it) }
|
||||
return "${bytes.size} bytes protobuf: $hex" + if (bytes.size > limit) " ..." else ""
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a message into both the live [messages] stream (for screens currently open)
|
||||
* and the persistent [topicMessages] log (survives even when no screen is collecting).
|
||||
@@ -301,11 +354,26 @@ class MqttRepository @Inject constructor(
|
||||
override fun connectionLost(cause: Throwable?) { lostSignal.complete(cause) }
|
||||
|
||||
override fun messageArrived(topic: String, message: PahoMqttMessage) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (topic in RAW_V2X_TOPICS) {
|
||||
// Binary. The bytes go to the decoders untouched; the topic log gets a hex
|
||||
// preview instead, because decoding these to a String would show the operator
|
||||
// a screenful of replacement characters and imply the data was corrupt.
|
||||
_rawV2x.tryEmit(RawV2xMqttMessage(topic, message.payload, now))
|
||||
recordMessage(
|
||||
MqttMessage(
|
||||
topic = topic,
|
||||
payload = describeBinary(message.payload),
|
||||
timestamp = now,
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
recordMessage(
|
||||
MqttMessage(
|
||||
topic = topic,
|
||||
payload = message.payload.toString(Charsets.UTF_8),
|
||||
timestamp = System.currentTimeMillis(),
|
||||
timestamp = now,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,12 +6,13 @@ import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.hawhamburg.micr0bu.data.transport.ObuHardware
|
||||
import com.hawhamburg.micr0bu.domain.cam.Pseudonym
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
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,7 +27,11 @@ class ObuHardwarePreferences @Inject constructor(
|
||||
) {
|
||||
private object Keys {
|
||||
val OBU_HARDWARE = stringPreferencesKey("obu_hardware")
|
||||
// The current transmit pseudonym. Three keys, but only ever read or written together;
|
||||
// see loadPseudonym.
|
||||
val OWN_STATION_ID = longPreferencesKey("own_station_id")
|
||||
val OWN_MAC = stringPreferencesKey("own_mac")
|
||||
val OWN_PSEUDONYM_CREATED_MS = longPreferencesKey("own_pseudonym_created_ms")
|
||||
}
|
||||
|
||||
val obuHardwareFlow: Flow<ObuHardware> = context.obuHardwareDataStore.data.map { prefs ->
|
||||
@@ -37,31 +42,36 @@ class ObuHardwarePreferences @Inject constructor(
|
||||
context.obuHardwareDataStore.edit { prefs -> prefs[Keys.OBU_HARDWARE] = hardware.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]
|
||||
/**
|
||||
* The transmit pseudonym last saved by [savePseudonym], or null if there is none.
|
||||
*
|
||||
* All three parts must be present. An install from before pseudonym rotation has a station ID
|
||||
* but no MAC or creation time, and loads as null so that a complete new pseudonym is
|
||||
* generated. Keeping the old ID alongside a fresh MAC would be exactly the partial rotation
|
||||
* [Pseudonym] exists to rule out.
|
||||
*
|
||||
* Only [com.hawhamburg.micr0bu.data.cam.PseudonymManager] should call this: it is the one
|
||||
* owner of the phone's transmit identity.
|
||||
*/
|
||||
suspend fun loadPseudonym(): Pseudonym? {
|
||||
val prefs = context.obuHardwareDataStore.data.first()
|
||||
val stationId = prefs[Keys.OWN_STATION_ID] ?: return null
|
||||
val mac = prefs[Keys.OWN_MAC]?.let(::macFromHex) ?: return null
|
||||
val createdAtMs = prefs[Keys.OWN_PSEUDONYM_CREATED_MS] ?: return null
|
||||
return Pseudonym(stationId, mac, createdAtMs)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
/** Persists [pseudonym] in a single edit, so a crash can never leave half an identity stored. */
|
||||
suspend fun savePseudonym(pseudonym: Pseudonym) {
|
||||
context.obuHardwareDataStore.edit { p ->
|
||||
p[Keys.OWN_STATION_ID] = pseudonym.stationId
|
||||
p[Keys.OWN_MAC] = pseudonym.mac.joinToString("") { "%02x".format(it) }
|
||||
p[Keys.OWN_PSEUDONYM_CREATED_MS] = pseudonym.createdAtMs
|
||||
}
|
||||
}
|
||||
return prefs[Keys.OWN_STATION_ID]!!
|
||||
}
|
||||
|
||||
private fun macFromHex(hex: String): ByteArray? =
|
||||
if (hex.length != 12) null
|
||||
else runCatching { ByteArray(6) { i -> hex.substring(2 * i, 2 * i + 2).toInt(16).toByte() } }
|
||||
.getOrNull()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
package com.hawhamburg.micr0bu.data.mqtt
|
||||
|
||||
/**
|
||||
* The CiT One's raw received-V2X envelope, as published on the `v2x/rx` MQTT topics.
|
||||
*
|
||||
* These topics carry a `RecvV2XMessage` protobuf (CI-CiT MQTT API section 2.4), not JSON: the
|
||||
* ITS-G5 PDU sits in one bytes field, and the GeoNetworking and BTP headers the stack stripped
|
||||
* off travel alongside it. That is the CiT One's counterpart to the ESP32-C5 path's
|
||||
* [com.hawhamburg.micr0bu.data.transport.V2xRxFrame], and it exists for the same reason: the
|
||||
* app decodes the UPER itself instead of accepting somebody else's summary.
|
||||
*
|
||||
* **Why this rather than the Use Case app's JSON.** `v2x-uca/output/json` is a processed,
|
||||
* rate-limited view. It drops messages, and what it does publish has already been reduced to
|
||||
* the fields the Use Case app cared about. `v2x/rx` is everything the radio actually heard.
|
||||
*
|
||||
* **Why a hand-written reader.** Only three of this envelope's fields are used, protobuf's wire
|
||||
* format is trivial to walk, and the alternative is adding protoc and the protobuf Gradle plugin
|
||||
* to an Android build plus vendoring a third-party `.proto` into this repository. The same
|
||||
* argument the ASN.1 codecs in `domain/asn1/` are built on applies here.
|
||||
*
|
||||
* Field numbers below come from consider it's `v2x_interface.proto`, V2X RX protocol v2.4.2.
|
||||
* They are wire-format constants: changing them silently mis-parses every message, so they are
|
||||
* pinned by `RecvV2xMessageTest` against a byte fixture rather than left to inspection.
|
||||
*/
|
||||
data class RecvV2xMessage(
|
||||
/**
|
||||
* `btpHeader.type`, the stack's own idea of which PDU this is: DENM 1, CAM 2, SPATEM 4,
|
||||
* MAPEM 5. Null when the sender omitted the header. Advisory only, since every decoder
|
||||
* re-checks the messageID in the ItsPduHeader itself.
|
||||
*/
|
||||
val pduType: Int?,
|
||||
|
||||
/** `btpHeader.destinationPort`: 2001 CAM, 2002 DENM, 2003 MAPEM, 2004 SPATEM. */
|
||||
val destinationPort: Int?,
|
||||
|
||||
/**
|
||||
* `gnHeader.dest.area.distA`, metres: the radius of the GeoBroadcast destination area, so
|
||||
* how far the sender meant its message to apply. Only DENM normally carries one. This is the
|
||||
* MQTT path's equivalent of the serial prefix's
|
||||
* [com.hawhamburg.micr0bu.data.transport.V2xRxFrame.GeoArea.radiusMeters].
|
||||
*/
|
||||
val destAreaRadiusM: Int?,
|
||||
|
||||
/** The ITS-G5 PDU as UPER, ItsPduHeader included. Empty when the field was absent. */
|
||||
val payload: ByteArray,
|
||||
) {
|
||||
// Generated equals/hashCode would compare the payload array by identity, which makes two
|
||||
// decodes of the same bytes unequal and quietly breaks any test or set that holds these.
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is RecvV2xMessage) return false
|
||||
return pduType == other.pduType &&
|
||||
destinationPort == other.destinationPort &&
|
||||
destAreaRadiusM == other.destAreaRadiusM &&
|
||||
payload.contentEquals(other.payload)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = pduType ?: 0
|
||||
result = 31 * result + (destinationPort ?: 0)
|
||||
result = 31 * result + (destAreaRadiusM ?: 0)
|
||||
result = 31 * result + payload.contentHashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
companion object {
|
||||
// RecvV2XMessage
|
||||
private const val F_BTP_HEADER = 1
|
||||
private const val F_GN_HEADER = 2
|
||||
private const val F_PAYLOAD = 3
|
||||
|
||||
// BasicTransportProtocolHeader
|
||||
private const val F_BTP_TYPE = 1
|
||||
private const val F_BTP_DEST_PORT = 2
|
||||
|
||||
// GeoNetworkingHeader
|
||||
private const val F_GN_DEST = 8
|
||||
|
||||
// GNDestination
|
||||
private const val F_DEST_AREA = 1
|
||||
|
||||
// GeoNetworkingArea
|
||||
private const val F_AREA_DIST_A = 3
|
||||
|
||||
/**
|
||||
* Parses an MQTT payload from a `v2x/rx` topic, or null if it is not a readable
|
||||
* `RecvV2XMessage` or carries no PDU.
|
||||
*
|
||||
* Unknown fields are skipped rather than treated as errors, which is what protobuf
|
||||
* requires and what keeps this working if consider it adds fields in a later revision.
|
||||
*/
|
||||
fun parse(bytes: ByteArray): RecvV2xMessage? {
|
||||
var pduType: Int? = null
|
||||
var destPort: Int? = null
|
||||
var radius: Int? = null
|
||||
var payload: ByteArray? = null
|
||||
|
||||
val reader = ProtoReader(bytes)
|
||||
while (reader.hasNext()) {
|
||||
val tag = reader.readTag() ?: return null
|
||||
when {
|
||||
tag.field == F_PAYLOAD && tag.wireType == WIRE_LENGTH_DELIMITED ->
|
||||
payload = reader.readBytes() ?: return null
|
||||
|
||||
tag.field == F_BTP_HEADER && tag.wireType == WIRE_LENGTH_DELIMITED -> {
|
||||
val sub = reader.readBytes() ?: return null
|
||||
val btp = ProtoReader(sub)
|
||||
while (btp.hasNext()) {
|
||||
val t = btp.readTag() ?: return null
|
||||
when {
|
||||
t.field == F_BTP_TYPE && t.wireType == WIRE_VARINT ->
|
||||
pduType = btp.readVarint()?.toInt() ?: return null
|
||||
t.field == F_BTP_DEST_PORT && t.wireType == WIRE_VARINT ->
|
||||
destPort = btp.readVarint()?.toInt() ?: return null
|
||||
else -> if (!btp.skip(t.wireType)) return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tag.field == F_GN_HEADER && tag.wireType == WIRE_LENGTH_DELIMITED -> {
|
||||
val sub = reader.readBytes() ?: return null
|
||||
radius = readDestAreaRadius(sub)
|
||||
}
|
||||
|
||||
else -> if (!reader.skip(tag.wireType)) return null
|
||||
}
|
||||
}
|
||||
|
||||
// A message with no payload has nothing to decode. Returning it anyway would push an
|
||||
// empty byte array into the ASN.1 decoders for them to reject one layer later.
|
||||
val pdu = payload ?: return null
|
||||
if (pdu.isEmpty()) return null
|
||||
|
||||
return RecvV2xMessage(
|
||||
pduType = pduType,
|
||||
destinationPort = destPort,
|
||||
destAreaRadiusM = radius,
|
||||
payload = pdu,
|
||||
)
|
||||
}
|
||||
|
||||
/** GeoNetworkingHeader.dest.area.distA, walking two levels down. Null at any break. */
|
||||
private fun readDestAreaRadius(gnHeader: ByteArray): Int? {
|
||||
val dest = nestedField(gnHeader, F_GN_DEST) ?: return null
|
||||
val area = nestedField(dest, F_DEST_AREA) ?: return null
|
||||
|
||||
val reader = ProtoReader(area)
|
||||
while (reader.hasNext()) {
|
||||
val tag = reader.readTag() ?: return null
|
||||
if (tag.field == F_AREA_DIST_A && tag.wireType == WIRE_VARINT) {
|
||||
return reader.readVarint()?.toInt()
|
||||
}
|
||||
if (!reader.skip(tag.wireType)) return null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** The bytes of the first length-delimited field numbered [field], or null. */
|
||||
private fun nestedField(bytes: ByteArray, field: Int): ByteArray? {
|
||||
val reader = ProtoReader(bytes)
|
||||
while (reader.hasNext()) {
|
||||
val tag = reader.readTag() ?: return null
|
||||
if (tag.field == field && tag.wireType == WIRE_LENGTH_DELIMITED) {
|
||||
return reader.readBytes()
|
||||
}
|
||||
if (!reader.skip(tag.wireType)) return null
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val WIRE_VARINT = 0
|
||||
private const val WIRE_FIXED64 = 1
|
||||
private const val WIRE_LENGTH_DELIMITED = 2
|
||||
private const val WIRE_FIXED32 = 5
|
||||
|
||||
private data class ProtoTag(val field: Int, val wireType: Int)
|
||||
|
||||
/**
|
||||
* A minimal protobuf wire-format reader: enough to walk a message, read varints and
|
||||
* length-delimited fields, and skip everything else.
|
||||
*
|
||||
* Every read returns null instead of throwing on a malformed or truncated buffer. These bytes
|
||||
* arrive off a network topic and a decoder that throws on bad input is a decoder that takes the
|
||||
* MQTT callback thread down with it.
|
||||
*/
|
||||
private class ProtoReader(private val buf: ByteArray) {
|
||||
private var pos = 0
|
||||
|
||||
fun hasNext(): Boolean = pos < buf.size
|
||||
|
||||
fun readTag(): ProtoTag? {
|
||||
val raw = readVarint() ?: return null
|
||||
val field = (raw ushr 3).toInt()
|
||||
val wireType = (raw and 0x7L).toInt()
|
||||
if (field <= 0) return null
|
||||
return ProtoTag(field, wireType)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a base-128 varint. Capped at ten bytes: that is the longest a 64-bit value can be,
|
||||
* and without the cap a run of 0x80 bytes would walk the reader off the end of the buffer.
|
||||
*/
|
||||
fun readVarint(): Long? {
|
||||
var result = 0L
|
||||
var shift = 0
|
||||
while (shift < 64) {
|
||||
if (pos >= buf.size) return null
|
||||
val b = buf[pos++].toInt()
|
||||
result = result or ((b and 0x7F).toLong() shl shift)
|
||||
if (b and 0x80 == 0) return result
|
||||
shift += 7
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun readBytes(): ByteArray? {
|
||||
val len = readVarint()?.toInt() ?: return null
|
||||
if (len < 0 || pos + len > buf.size) return null
|
||||
val out = buf.copyOfRange(pos, pos + len)
|
||||
pos += len
|
||||
return out
|
||||
}
|
||||
|
||||
/** Advances past a field of [wireType]. False if the type is unknown or the buffer is short. */
|
||||
fun skip(wireType: Int): Boolean = when (wireType) {
|
||||
WIRE_VARINT -> readVarint() != null
|
||||
WIRE_FIXED64 -> advance(8)
|
||||
WIRE_LENGTH_DELIMITED -> readBytes() != null
|
||||
WIRE_FIXED32 -> advance(4)
|
||||
else -> false // groups (3, 4) are not used by this schema
|
||||
}
|
||||
|
||||
private fun advance(n: Int): Boolean {
|
||||
if (pos + n > buf.size) return false
|
||||
pos += n
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
package com.hawhamburg.micr0bu.data.transport
|
||||
|
||||
import com.hawhamburg.micr0bu.domain.asn1.ItsTime
|
||||
import com.hawhamburg.micr0bu.domain.cam.Cam
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.roundToLong
|
||||
|
||||
/**
|
||||
* Binary framing for the phone <-> ESP32-C5 link (Phase 03). Kotlin counterpart of the
|
||||
* firmware's `obu-firmware/main/serial_link.c`/`.h` — frame shape and CRC algorithm MUST stay
|
||||
@@ -23,6 +28,15 @@ object SerialFrameType {
|
||||
/** ESP32 -> phone: periodic heartbeat + drop counters, independent of CAM traffic.
|
||||
* Payload layout is [EspLinkStatus] — see its KDoc. */
|
||||
const val STATUS: Int = 0x03
|
||||
|
||||
/**
|
||||
* Phone -> ESP32: a CAM together with the GeoNetworking Source Position Vector to transmit it
|
||||
* under. Payload is the [GnPositionVector.PREFIX_SIZE]-byte [GnPositionVector] prefix, then
|
||||
* the CAM UPER. Sent only to firmware whose heartbeat advertises
|
||||
* [EspLinkStatus.supportsCamTxPv]; `serial_link.h` explains why this is a new type rather
|
||||
* than a changed [CAM_TX].
|
||||
*/
|
||||
const val CAM_TX_PV: Int = 0x05
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,10 +73,22 @@ data class EspLinkStatus(
|
||||
val txFailures: Int,
|
||||
/** Frames from the phone the firmware dropped on CRC mismatch. */
|
||||
val rxCrcErrors: Int,
|
||||
/**
|
||||
* What the firmware accepts, as `SERIAL_CAP_*` bits from `serial_link.h`. Byte 7 of the
|
||||
* payload; 0 for firmware that predates it and sends only 7 bytes, which is exactly the answer
|
||||
* the phone needs from such firmware: it accepts nothing beyond the original messages.
|
||||
*/
|
||||
val capabilities: Int = 0,
|
||||
) {
|
||||
/** True when the firmware accepts [SerialFrameType.CAM_TX_PV]. */
|
||||
val supportsCamTxPv: Boolean get() = capabilities and CAP_CAM_TX_PV != 0
|
||||
|
||||
companion object {
|
||||
const val PAYLOAD_SIZE = 7
|
||||
|
||||
/** Mirrors `SERIAL_CAP_CAM_TX_PV` in `serial_link.h`. */
|
||||
const val CAP_CAM_TX_PV = 0x01
|
||||
|
||||
/** Returns null if [payload] isn't a well-formed status payload (e.g. older firmware). */
|
||||
fun parse(payload: ByteArray): EspLinkStatus? {
|
||||
if (payload.size < PAYLOAD_SIZE) return null
|
||||
@@ -72,6 +98,7 @@ data class EspLinkStatus(
|
||||
oversizeDrops = u16(1),
|
||||
txFailures = u16(3),
|
||||
rxCrcErrors = u16(5),
|
||||
capabilities = if (payload.size > PAYLOAD_SIZE) payload[7].toInt() and 0xFF else 0,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -161,6 +188,116 @@ data class V2xRxFrame(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The GeoNetworking Source Position Vector content sent with each CAM: the 24-byte little-endian
|
||||
* prefix of a [SerialFrameType.CAM_TX_PV] payload. Must stay in lockstep with the layout at
|
||||
* `SERIAL_MSG_CAM_TX_PV` in `serial_link.h`, which the firmware decodes into `gn_lpv_t`.
|
||||
*
|
||||
* Every field is something the ESP32-C5 cannot know by itself, since it has no GNSS and no clock
|
||||
* on the OCB channel. That is why its GN header used to carry fixed bench placeholders instead,
|
||||
* describing a stationary car at the bench while the CAM inside described the moving rider.
|
||||
*/
|
||||
data class GnPositionVector(
|
||||
/** Pseudonym, 6 bytes: both the 802.11 source address and the GN_ADDR MID. */
|
||||
val mac: ByteArray,
|
||||
/** TS 102 894-2 StationType. */
|
||||
val stationType: Int,
|
||||
/** Position Accuracy Indicator. */
|
||||
val pai: Boolean,
|
||||
/** TimestampIts at which the position was acquired; reduced modulo 2^32 on the wire. */
|
||||
val tstMs: Long,
|
||||
/** 1/10 microdegree. */
|
||||
val latTenMicroDeg: Int,
|
||||
/** 1/10 microdegree. */
|
||||
val lonTenMicroDeg: Int,
|
||||
/** 0.01 m/s, within the GN field's 15-bit signed range. */
|
||||
val speedCms: Int,
|
||||
/** 0.1 degree from north, clockwise, 0..3599. */
|
||||
val headingDeciDeg: Int,
|
||||
) {
|
||||
init {
|
||||
require(mac.size == 6) { "a MAC is 6 bytes, got ${mac.size}" }
|
||||
}
|
||||
|
||||
/** The 24-byte prefix, little-endian like the rest of this framing. */
|
||||
fun toSerialPrefix(): ByteArray {
|
||||
val out = ByteArray(PREFIX_SIZE)
|
||||
mac.copyInto(out, destinationOffset = 0)
|
||||
out[6] = stationType.toByte()
|
||||
out[7] = (if (pai) 0x01 else 0x00).toByte()
|
||||
putLe(out, 8, tstMs, 4)
|
||||
putLe(out, 12, latTenMicroDeg.toLong(), 4)
|
||||
putLe(out, 16, lonTenMicroDeg.toLong(), 4)
|
||||
putLe(out, 20, speedCms.toLong(), 2)
|
||||
putLe(out, 22, headingDeciDeg.toLong(), 2)
|
||||
return out
|
||||
}
|
||||
|
||||
// Generated equals/hashCode would compare the MAC array by identity.
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is GnPositionVector) return false
|
||||
return mac.contentEquals(other.mac) && stationType == other.stationType &&
|
||||
pai == other.pai && tstMs == other.tstMs && latTenMicroDeg == other.latTenMicroDeg &&
|
||||
lonTenMicroDeg == other.lonTenMicroDeg && speedCms == other.speedCms &&
|
||||
headingDeciDeg == other.headingDeciDeg
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var h = mac.contentHashCode()
|
||||
for (v in listOf(stationType, pai.hashCode(), tstMs.hashCode(), latTenMicroDeg,
|
||||
lonTenMicroDeg, speedCms, headingDeciDeg)) h = 31 * h + v
|
||||
return h
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val PREFIX_SIZE = 24
|
||||
|
||||
/** The GN speed field is 15-bit signed, in 0.01 m/s. */
|
||||
const val SPEED_MIN_CMS = -16384
|
||||
const val SPEED_MAX_CMS = 16383
|
||||
|
||||
/**
|
||||
* Largest Android horizontal accuracy, in metres, that still sets the Position Accuracy
|
||||
* Indicator.
|
||||
*
|
||||
* EN 302 636-4-1 sets PAI when the 95% semi-major confidence is below itsGnPaiInterval / 2,
|
||||
* and itsGnPaiInterval defaults to 80 m, so the bound is 40 m at 95%. Android reports a 68%
|
||||
* radius instead, and for a circular 2-D error the 95% radius is about 1.62 times the 68%
|
||||
* one, so 40 m becomes about 24.7 m on Android's scale.
|
||||
*/
|
||||
const val PAI_MAX_ACCURACY_M = 24.7f
|
||||
|
||||
/**
|
||||
* The position vector for [cam], built from the same values the CAM payload carries, so
|
||||
* the two layers of one frame describe the same station at the same moment. [accuracyM] is
|
||||
* Android's horizontal accuracy; null or 0 means unknown and leaves PAI clear.
|
||||
*/
|
||||
fun fromCam(cam: Cam, accuracyM: Float?, mac: ByteArray): GnPositionVector =
|
||||
GnPositionVector(
|
||||
mac = mac,
|
||||
stationType = cam.stationType,
|
||||
pai = accuracyM != null && accuracyM > 0f && accuracyM <= PAI_MAX_ACCURACY_M,
|
||||
tstMs = ItsTime.timestampIts(cam.timestamp),
|
||||
// Same rounding as CamUperCodec's referencePosition, so the GN position and the
|
||||
// CAM's own position agree to the last digit.
|
||||
latTenMicroDeg = (cam.latitude * 1e7).roundToLong().toInt(),
|
||||
lonTenMicroDeg = (cam.longitude * 1e7).roundToLong().toInt(),
|
||||
// Clamped, never wrapped: a wrapped 15-bit speed flips sign and reads as reversing.
|
||||
speedCms = if (cam.speedMps.isFinite()) {
|
||||
(cam.speedMps * 100).roundToInt().coerceIn(SPEED_MIN_CMS, SPEED_MAX_CMS)
|
||||
} else 0,
|
||||
headingDeciDeg = if (cam.headingDeg.isFinite()) {
|
||||
Math.floorMod((cam.headingDeg * 10).roundToInt(), 3600)
|
||||
} else 0,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun putLe(out: ByteArray, offset: Int, value: Long, bytes: Int) {
|
||||
for (i in 0 until bytes) out[offset + i] = ((value ushr (8 * i)) and 0xFF).toByte()
|
||||
}
|
||||
|
||||
data class DecodedFrame(val type: Int, val payload: ByteArray)
|
||||
|
||||
object SerialFrameEncoder {
|
||||
|
||||
@@ -329,12 +329,14 @@ class UsbSerialTransport @Inject constructor(
|
||||
prev.oversizeDrops != status.oversizeDrops ||
|
||||
prev.txFailures != status.txFailures ||
|
||||
prev.rxCrcErrors != status.rxCrcErrors ||
|
||||
prev.status != status.status
|
||||
prev.status != status.status ||
|
||||
prev.capabilities != status.capabilities
|
||||
) {
|
||||
Log.i(TAG, "ESP32 counters: status=${status.status} " +
|
||||
"oversizeDrops=${status.oversizeDrops} " +
|
||||
"txFailures=${status.txFailures} " +
|
||||
"rxCrcErrors=${status.rxCrcErrors}")
|
||||
"rxCrcErrors=${status.rxCrcErrors} " +
|
||||
"capabilities=${status.capabilities}")
|
||||
}
|
||||
_linkStatus.value = status
|
||||
}
|
||||
@@ -386,6 +388,25 @@ class UsbSerialTransport @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/** Which frame type the last CAM went out as, so a change of path is logged once, not per CAM. */
|
||||
@Volatile private var lastTxWithPositionVector: Boolean? = null
|
||||
|
||||
/**
|
||||
* Logs whenever CAMs switch between [SerialFrameType.CAM_TX_PV] and legacy
|
||||
* [SerialFrameType.CAM_TX]. Without it, "the GN header still says bench" has no visible cause
|
||||
* in a logcat capture: it looks identical whether the firmware is old or the phone is.
|
||||
*/
|
||||
private fun noteTxPath(withPositionVector: Boolean, requested: Boolean) {
|
||||
if (lastTxWithPositionVector == withPositionVector) return
|
||||
lastTxWithPositionVector = withPositionVector
|
||||
Log.i(TAG, when {
|
||||
withPositionVector -> "CAM TX path: CAM_TX_PV, GN position vector supplied by the phone"
|
||||
requested -> "CAM TX path: legacy CAM_TX, firmware has not advertised CAM_TX_PV yet; " +
|
||||
"GN position vector is the firmware's bench placeholder"
|
||||
else -> "CAM TX path: legacy CAM_TX, no position vector supplied"
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes [camUperBytes] as a [SerialFrameType.CAM_TX] frame and writes it to the port.
|
||||
* No-op (returns false) if not currently connected — callers (the CAM transmit loop) should
|
||||
@@ -394,15 +415,28 @@ class UsbSerialTransport @Inject constructor(
|
||||
* [consecutiveWriteFailures] so they can't stay invisible.
|
||||
*
|
||||
* Blocking: writes with a 200 ms timeout, so call from a background dispatcher.
|
||||
*
|
||||
* [positionVector], when given, travels with the CAM as a [SerialFrameType.CAM_TX_PV] frame so
|
||||
* the ESP32 builds the GeoNetworking Source Position Vector from real values. It is used only
|
||||
* once the heartbeat advertises [EspLinkStatus.supportsCamTxPv]. Until then, and against
|
||||
* firmware that predates it, the CAM goes out as a plain [SerialFrameType.CAM_TX] exactly as
|
||||
* before and the GN header carries the firmware's bench placeholders. Neither mixed-version
|
||||
* combination breaks transmission; `serial_link.h` explains why.
|
||||
*/
|
||||
fun sendCamTx(camUperBytes: ByteArray): Boolean {
|
||||
fun sendCamTx(camUperBytes: ByteArray, positionVector: GnPositionVector? = null): Boolean {
|
||||
val p = port
|
||||
if (p == null) {
|
||||
_consecutiveWriteFailures.update { it + 1 }
|
||||
return false
|
||||
}
|
||||
return try {
|
||||
val frame = SerialFrameEncoder.encode(SerialFrameType.CAM_TX, camUperBytes)
|
||||
val pv = positionVector?.takeIf { _linkStatus.value?.supportsCamTxPv == true }
|
||||
noteTxPath(withPositionVector = pv != null, requested = positionVector != null)
|
||||
val frame = if (pv != null) {
|
||||
SerialFrameEncoder.encode(SerialFrameType.CAM_TX_PV, pv.toSerialPrefix() + camUperBytes)
|
||||
} else {
|
||||
SerialFrameEncoder.encode(SerialFrameType.CAM_TX, camUperBytes)
|
||||
}
|
||||
p.write(frame, /* timeout ms */ 200)
|
||||
_consecutiveWriteFailures.value = 0
|
||||
true
|
||||
|
||||
@@ -34,9 +34,6 @@ object CamUperCodec {
|
||||
/** Encode buffer size — matches `cam.c`'s `cam_payload[96]`, the known-sufficient size. */
|
||||
private const val ENCODE_BUFFER_BYTES = 96
|
||||
|
||||
// TimestampIts epoch: 2004-01-01T00:00:00Z, in Unix epoch milliseconds.
|
||||
private const val TS_ITS_EPOCH_MS = 1_072_915_200_000L
|
||||
|
||||
// ASN.1 "unavailable" sentinel values, straight from the CAM/ITS-Container modules (also
|
||||
// documented inline in cam.c against each field).
|
||||
private const val HEADING_UNAVAILABLE = 3601
|
||||
@@ -47,9 +44,13 @@ object CamUperCodec {
|
||||
private const val ACCEL_UNAVAILABLE = 161
|
||||
private const val YAW_RATE_UNAVAILABLE = 32767
|
||||
|
||||
/** Converts a wall-clock epoch-ms timestamp to a UPER GenerationDeltaTime (TimestampIts mod 65536). */
|
||||
/**
|
||||
* Converts a wall-clock epoch-ms timestamp to a UPER GenerationDeltaTime (TimestampIts mod
|
||||
* 65536). Goes through [ItsTime], the same rule the GeoNetworking TST uses, so the two
|
||||
* timestamps in one transmitted frame cannot disagree.
|
||||
*/
|
||||
fun generationDeltaTime(epochMs: Long): Int {
|
||||
val itsMs = epochMs - TS_ITS_EPOCH_MS
|
||||
val itsMs = ItsTime.timestampIts(epochMs)
|
||||
// floorMod so this stays well-defined even for epochMs before the ITS epoch (shouldn't
|
||||
// happen with a real clock, but avoids a negative/UB result if it ever does).
|
||||
return Math.floorMod(itsMs, 65536L).toInt()
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.hawhamburg.micr0bu.domain.asn1
|
||||
|
||||
/**
|
||||
* ITS time, as used by every timestamp this app puts on the air.
|
||||
*
|
||||
* TimestampIts (ETSI TS 102 894-2) counts milliseconds from 2004-01-01T00:00:00Z. Two fields in a
|
||||
* single transmitted frame come from it: the CAM's generationDeltaTime (modulo 65536) and the
|
||||
* GeoNetworking Source Position Vector's TST (modulo 2^32). A receiver can compare the two, so
|
||||
* they must follow one rule. Both go through here so they cannot drift apart.
|
||||
*
|
||||
* **Which clock.** The input should be GNSS time, not the phone's wall clock. A phone with no SIM
|
||||
* and no internet time has no automatic time source at all, and the bench phone was found 24
|
||||
* minutes fast that way. `GnssTimeSource` moves a timestamp onto GNSS time, using [onGnssTime],
|
||||
* before it gets here.
|
||||
*
|
||||
* **Open question: leap seconds.** This is Unix time minus the 2004 epoch, with no leap-second
|
||||
* term. If TimestampIts is read as TAI-based, the correct value is currently 5 s higher, for the
|
||||
* five leap seconds inserted since 2004. Whichever reading turns out right, it is changed here and
|
||||
* nowhere else. Settling it needs a frame from a third-party stack with a trusted clock, such as
|
||||
* the RSU's CAM compared against GNSS time, and no such traffic was on air when this was written.
|
||||
*/
|
||||
object ItsTime {
|
||||
/** 2004-01-01T00:00:00Z in Unix epoch milliseconds. */
|
||||
const val EPOCH_MS = 1_072_915_200_000L
|
||||
|
||||
/** TimestampIts for wall-clock [epochMs], before any modulo is applied. */
|
||||
fun timestampIts(epochMs: Long): Long = epochMs - EPOCH_MS
|
||||
|
||||
/**
|
||||
* Moves [systemMs], a reading of this phone's wall clock, onto GNSS time, using one pair of
|
||||
* simultaneous readings of both clocks: [gnssNowMs] and [systemNowMs]. Their difference is the
|
||||
* wall clock's error, whatever caused it, and the age of [systemMs] is preserved. Returns
|
||||
* [systemMs] unchanged when there is no GNSS reading.
|
||||
*
|
||||
* Pure so the arithmetic can be tested apart from the Android clock API, which is where the
|
||||
* readings come from (see `GnssTimeSource`).
|
||||
*/
|
||||
fun onGnssTime(systemMs: Long, gnssNowMs: Long?, systemNowMs: Long): Long =
|
||||
if (gnssNowMs == null) systemMs else systemMs + (gnssNowMs - systemNowMs)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package com.hawhamburg.micr0bu.domain.cam
|
||||
|
||||
/**
|
||||
* Which station IDs belong to this phone, and therefore must never be treated as another road
|
||||
* user when a frame comes back off the air.
|
||||
*
|
||||
* ## Why this exists
|
||||
* A receiver that fails to recognise its own transmissions tracks itself: a station sitting exactly
|
||||
* on top of the ego position, moving at the ego's own speed and heading, handed to
|
||||
* [com.hawhamburg.micr0bu.domain.usecase.UseCaseDetectionEngine] as a collision partner for itself.
|
||||
* The phone's own frames can come back to it off the air, for example relayed by the CiT One's
|
||||
* radio when a phone is connected to both OBUs at once.
|
||||
*
|
||||
* ## Which IDs count
|
||||
* - The current transmit pseudonym used by [com.hawhamburg.micr0bu.service.CamTransmitLoop], and
|
||||
* the one or two it most recently replaced. The pseudonym rotates every ten minutes (see
|
||||
* [Pseudonym]), and a frame sent just before a rotation can come back just after it, so a
|
||||
* retired ID has to stay ours for a while. `PseudonymManager.ownStationIds()` supplies these.
|
||||
* - On the CiT One path, the OBU's own ID learned from obu_gnss.
|
||||
* - [BENCH_PING], but only while this phone's own pinger is running or has just stopped. See
|
||||
* [benchPingIsOurs].
|
||||
*
|
||||
* ## Why the bench ID is conditional
|
||||
* It used to count as ours unconditionally, on every phone, and that hid other phones' pings. On
|
||||
* the 2026-09-10 bench one phone pinged through an ESP32 while a second phone watched through the
|
||||
* CiT One, and the watcher silently discarded every ping as its own frame heard back, although it
|
||||
* had sent none. A fixed ID shared by every MicrOBU is only ours on the phone actually using it.
|
||||
* The one case this cannot resolve is two phones pinging at the same time: each hides the other.
|
||||
*/
|
||||
object OwnStationIds {
|
||||
|
||||
/**
|
||||
* The bench pinger's station ID. Fixed rather than derived so a ping is recognisable at a
|
||||
* glance in a capture or a log line.
|
||||
*/
|
||||
const val BENCH_PING = 999_999L
|
||||
|
||||
/**
|
||||
* The bench pinger's link-layer address, which the ESP32 writes into both the 802.11 source
|
||||
* address and the GN_ADDR MID. It is the address the firmware always used for its fixed
|
||||
* pseudonym, so bench traffic looks the same in a capture before and after the phone took
|
||||
* over the GeoNetworking identity. A fresh copy each time, so no caller can alter it for all.
|
||||
*/
|
||||
val BENCH_PING_MAC: ByteArray get() = byteArrayOf(0x02, 0x00, 0x00, 0x00, 0x00, 0x01)
|
||||
|
||||
/**
|
||||
* How long after this phone's pinger stops its pings still count as ours. A frame sent just
|
||||
* before Stop can arrive just after it, relayed through another radio. A relay takes a
|
||||
* fraction of a second, so five seconds leaves ample margin without hiding a genuine sender
|
||||
* for long.
|
||||
*/
|
||||
const val BENCH_PING_GRACE_MS = 5_000L
|
||||
|
||||
/**
|
||||
* True when station [BENCH_PING] on air is this phone's own ping: while [pingerActive], or
|
||||
* within [BENCH_PING_GRACE_MS] of [pingerStoppedAtMs]. Both times must come from one monotonic
|
||||
* clock. A [nowMs] earlier than the stop time means that clock is not monotonic after all, and
|
||||
* the ping is then not claimed.
|
||||
*/
|
||||
fun benchPingIsOurs(pingerActive: Boolean, pingerStoppedAtMs: Long?, nowMs: Long): Boolean {
|
||||
if (pingerActive) return true
|
||||
val stoppedAt = pingerStoppedAtMs ?: return false
|
||||
return nowMs - stoppedAt in 0..BENCH_PING_GRACE_MS
|
||||
}
|
||||
|
||||
/**
|
||||
* True when [stationId] is one this phone transmits under.
|
||||
*
|
||||
* [ownIds] is every non-bench ID currently counted as ours: the current and recently retired
|
||||
* transmit pseudonyms, plus the CiT One's own ID on that path. [benchPingIsOurs] says whether
|
||||
* [BENCH_PING] is ours right now; see the function of the same name.
|
||||
*
|
||||
* Station ID 0 is never ours: it is the "not known yet" placeholder used while the ego
|
||||
* identity is still being resolved, and matching on it would swallow real traffic.
|
||||
*/
|
||||
fun isOwn(stationId: Long, ownIds: Set<Long>, benchPingIsOurs: Boolean): Boolean {
|
||||
if (stationId == 0L) return false
|
||||
if (stationId == BENCH_PING) return benchPingIsOurs
|
||||
return stationId in ownIds
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.hawhamburg.micr0bu.domain.cam
|
||||
|
||||
/**
|
||||
* A tally of this phone's own transmissions heard back off the air.
|
||||
*
|
||||
* On the ESP32-C5 path the radio receives promiscuously, so a frame the phone sent out over the
|
||||
* serial link comes back through the receive path a moment later. Those frames are deliberately
|
||||
* kept out of the detection engine, since the phone is not a road user to itself, but they are
|
||||
* worth counting: a frame completing that round trip is direct evidence that the serial link, the
|
||||
* ESP32's transmit path and its receive path all work. That is exactly what
|
||||
* [com.hawhamburg.micr0bu.service.CamPinger] exists to demonstrate.
|
||||
*
|
||||
* Compare [frames] against the pinger's own sent count to see the loop rate. Equal numbers mean
|
||||
* every ping made it out and back; a shortfall means frames are being lost on air or dropped in
|
||||
* the receive chain, which is a different fault from "nothing is being sent at all".
|
||||
*/
|
||||
data class OwnTxLoopback(
|
||||
/** How many own frames have been heard back since the tally was last reset. */
|
||||
val frames: Int,
|
||||
|
||||
/**
|
||||
* Signal strength of the most recent one, dBm, or null if no transport reported it. Retained
|
||||
* across frames that carry no reading rather than being cleared, so the figure does not blink
|
||||
* in and out on screen.
|
||||
*/
|
||||
val lastRssiDbm: Int?,
|
||||
|
||||
/** Wall-clock ms the most recent own frame was heard back. */
|
||||
val lastHeardMs: Long,
|
||||
)
|
||||
@@ -24,10 +24,10 @@ object PhoneCamBuilder {
|
||||
* @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, 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 stationId the station ID to transmit under: the current pseudonym from
|
||||
* [com.hawhamburg.micr0bu.data.cam.PseudonymManager], or the bench pinger's fixed ID.
|
||||
* Receivers track a station across successive CAMs by this ID, which is why it only ever
|
||||
* changes in a coordinated rotation together with the link-layer address.
|
||||
* @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,
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.hawhamburg.micr0bu.domain.cam
|
||||
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* The identity this phone transmits under on the ESP32-C5 path: the CAM stationID, and the
|
||||
* link-layer address the firmware writes into both the GeoNetworking GN_ADDR and the 802.11
|
||||
* source address.
|
||||
*
|
||||
* ## Why the two change together
|
||||
* A pseudonym only makes a station harder to follow if every identifier on the frame changes at
|
||||
* the same moment. Rotating the address while keeping the stationID, or the reverse, leaves the
|
||||
* unchanged one as a stable handle, so a receiver loses nothing and the rotation buys nothing.
|
||||
* Holding both in one value that is only ever replaced whole makes a partial rotation impossible
|
||||
* to express.
|
||||
*
|
||||
* ## Why every [ROTATION_INTERVAL_MS]
|
||||
* Real ITS stacks change pseudonym every few minutes, 5 to 15 being typical, and the CiT One was
|
||||
* seen rotating its station ID twice within one bench session. Ten minutes sits in that range.
|
||||
*
|
||||
* ## A limit worth stating
|
||||
* Nothing this app transmits is signed (there is no ETSI TS 103 097 security), so rotation gives
|
||||
* nominal unlinkability at best: an unsigned frame's content can still be correlated across a
|
||||
* change. This is the correct behaviour to build on, not a privacy guarantee.
|
||||
*/
|
||||
data class Pseudonym(
|
||||
val stationId: Long,
|
||||
/** Six bytes, locally administered and unicast. See [generate]. */
|
||||
val mac: ByteArray,
|
||||
/** Wall-clock ms this pseudonym was created, for [isExpired]. */
|
||||
val createdAtMs: Long,
|
||||
) {
|
||||
init {
|
||||
require(mac.size == 6) { "a MAC is 6 bytes, got ${mac.size}" }
|
||||
}
|
||||
|
||||
/**
|
||||
* True once this pseudonym has been in use for [intervalMs], or if the clock has moved back
|
||||
* past its creation time. The second case rotates rather than trusting a creation time that
|
||||
* now lies in the future, which would otherwise pin one identity until the clock caught up.
|
||||
*/
|
||||
fun isExpired(nowMs: Long, intervalMs: Long = ROTATION_INTERVAL_MS): Boolean =
|
||||
nowMs < createdAtMs || nowMs - createdAtMs >= intervalMs
|
||||
|
||||
// Generated equals/hashCode would compare the MAC array by identity, so two pseudonyms with
|
||||
// the same bytes would compare unequal.
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is Pseudonym) return false
|
||||
return stationId == other.stationId && createdAtMs == other.createdAtMs &&
|
||||
mac.contentEquals(other.mac)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int =
|
||||
31 * (31 * stationId.hashCode() + mac.contentHashCode()) + createdAtMs.hashCode()
|
||||
|
||||
companion object {
|
||||
const val ROTATION_INTERVAL_MS = 10 * 60_000L
|
||||
|
||||
/**
|
||||
* A fresh identity. StationID is INTEGER(0..4294967295); 0 is avoided because it is the
|
||||
* "not yet known" placeholder elsewhere in this app, and [OwnStationIds.BENCH_PING] is
|
||||
* avoided so a rider can never be mistaken for the bench pinger.
|
||||
*
|
||||
* The MAC is random with the locally-administered bit set and the group bit clear. A
|
||||
* source address must never be a group address, and a random one must not claim a real
|
||||
* vendor's OUI. [OwnStationIds.BENCH_PING_MAC] is excluded for the same reason as the ID.
|
||||
*/
|
||||
fun generate(nowMs: Long, random: Random = Random.Default): Pseudonym {
|
||||
var stationId: Long
|
||||
do {
|
||||
stationId = random.nextLong(1L, 0xFFFF_FFFEL)
|
||||
} while (stationId == OwnStationIds.BENCH_PING)
|
||||
|
||||
var mac: ByteArray
|
||||
do {
|
||||
mac = random.nextBytes(6)
|
||||
mac[0] = ((mac[0].toInt() and 0xFC) or 0x02).toByte()
|
||||
} while (mac.contentEquals(OwnStationIds.BENCH_PING_MAC))
|
||||
|
||||
return Pseudonym(stationId, mac, nowMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,26 @@ package com.hawhamburg.micr0bu.domain.detection
|
||||
* All detection thresholds in one place.
|
||||
*
|
||||
* Pass a custom instance to [EventDetector] to tune behaviour without
|
||||
* recompiling. The defaults match the Phase A specification.
|
||||
* recompiling.
|
||||
*
|
||||
* **These defaults are the values the app actually runs.** They are *not* the
|
||||
* Phase A specification figures. Phase A specified a more sensitive detector;
|
||||
* running it on a real bicycle over-triggered, so every signal threshold was
|
||||
* raised and every sustain requirement lengthened. Those tuned values used to
|
||||
* live as literals in `TripRecordingService`'s constructor, which meant the
|
||||
* unit tests exercised the Phase A defaults and nothing exercised what shipped.
|
||||
* They are the defaults now so that there is exactly one configuration.
|
||||
*
|
||||
* The original Phase A figures, kept for provenance:
|
||||
* `brakingSpeedDropThreshold` 0.5, `brakingAccelStdDevThreshold` 1.2,
|
||||
* `brakingSustainedFrames` 15, `turningGyroMeanThreshold` 0.4,
|
||||
* `turningBearingChangeThreshold` 10.0, `turningSustainedFrames` 20,
|
||||
* `stoppingSpeedThreshold` 0.5, `stoppingFrames` 100,
|
||||
* `stoppingAccelStdDevThreshold` 0.15.
|
||||
*
|
||||
* What motivated each change was never recorded, and the effect on the
|
||||
* false-positive and false-negative rates has never been measured. That
|
||||
* remains open; sensitivity is deliberately unchanged by this consolidation.
|
||||
*/
|
||||
data class DetectionConfig(
|
||||
|
||||
@@ -17,42 +36,51 @@ data class DetectionConfig(
|
||||
* Minimum speed drop (m/s) from the reference speed at braking onset for
|
||||
* a frame to qualify as a braking frame.
|
||||
*/
|
||||
val brakingSpeedDropThreshold: Double = 0.5,
|
||||
val brakingSpeedDropThreshold: Double = 1.0,
|
||||
|
||||
/** Minimum accel std-dev (m/s²) required for a frame to count as braking. */
|
||||
val brakingAccelStdDevThreshold: Double = 1.2,
|
||||
val brakingAccelStdDevThreshold: Double = 1.8,
|
||||
|
||||
/** Consecutive braking frames required before an event is emitted. */
|
||||
val brakingSustainedFrames: Int = 15,
|
||||
val brakingSustainedFrames: Int = 25,
|
||||
|
||||
/**
|
||||
* Peak speed-drop rate (m/s per GPS update ≈ m/s²) above which the braking
|
||||
* confidence is upgraded from MEDIUM to HIGH.
|
||||
* Peak *cumulative* speed drop (m/s) from the onset reference speed above
|
||||
* which the braking confidence is upgraded from MEDIUM to HIGH.
|
||||
*
|
||||
* This is a total drop for the episode, not a rate. It was previously
|
||||
* named `brakingHighConfidenceRate` and documented as "m/s per GPS update
|
||||
* ≈ m/s²", but the quantity it is compared against in
|
||||
* [EventDetector.detectBraking] has always been the cumulative drop, which
|
||||
* grows for as long as the episode lasts. The name was wrong, not the
|
||||
* comparison: "the rider lost more than this much speed in one braking
|
||||
* episode" is a coherent criterion, so the name was corrected to match the
|
||||
* behaviour rather than the other way round. Detector output is unchanged.
|
||||
*/
|
||||
val brakingHighConfidenceRate: Double = 1.5,
|
||||
val brakingHighConfidencePeakDrop: Double = 1.5,
|
||||
|
||||
// ── TURNING ───────────────────────────────────────────────────────────────
|
||||
/** Minimum gyro mean (rad/s) required for a frame to qualify as turning. */
|
||||
val turningGyroMeanThreshold: Double = 0.4,
|
||||
val turningGyroMeanThreshold: Double = 0.6,
|
||||
|
||||
/** Bearing-change rate (°/s) that must be exceeded when speed is above the
|
||||
* minimum threshold for a HIGH-confidence turning confirmation. */
|
||||
val turningBearingChangeThreshold: Double = 10.0,
|
||||
val turningBearingChangeThreshold: Double = 15.0,
|
||||
|
||||
/** GPS speed (m/s) above which the bearing-change criterion is enforced. */
|
||||
val turningMinSpeedThreshold: Double = 2.0,
|
||||
|
||||
/** Consecutive turning frames required before an event is emitted. */
|
||||
val turningSustainedFrames: Int = 20,
|
||||
val turningSustainedFrames: Int = 30,
|
||||
|
||||
// ── STOPPING ─────────────────────────────────────────────────────────────
|
||||
/** GPS speed (m/s) below which a frame is considered a potential stop. */
|
||||
val stoppingSpeedThreshold: Double = 0.5,
|
||||
val stoppingSpeedThreshold: Double = 0.3,
|
||||
|
||||
/** Consecutive stop frames required (> this value) before an event is emitted.
|
||||
* At 50 Hz, 100 frames ≈ 2 s. */
|
||||
val stoppingFrames: Int = 100,
|
||||
* At 50 Hz, 150 frames ≈ 3 s. */
|
||||
val stoppingFrames: Int = 150,
|
||||
|
||||
/** Maximum accel std-dev (m/s²) allowed for a frame to count as stationary. */
|
||||
val stoppingAccelStdDevThreshold: Double = 0.15,
|
||||
val stoppingAccelStdDevThreshold: Double = 0.10,
|
||||
)
|
||||
|
||||
@@ -14,6 +14,16 @@ import kotlin.math.abs
|
||||
* to [events] (a hot [SharedFlow]). Debounce is implemented with
|
||||
* consecutive-frame counters, not timers.
|
||||
*
|
||||
* **Who consumes this.** The detector's live consumer is the CAM transmit-rate
|
||||
* policy: [com.hawhamburg.micr0bu.service.TripRecordingService] forwards every
|
||||
* emitted event to
|
||||
* [com.hawhamburg.micr0bu.service.CamTransmitLoop.onDetectedEvent], which
|
||||
* raises the CAM rate from 1 Hz to the elevated rate for a hold window so that
|
||||
* nearby stations get denser updates *through* a manoeuvre rather than only at
|
||||
* the instant it was detected. These thresholds therefore govern a V2X
|
||||
* behaviour, not a statistic. Events are also persisted per trip for offline
|
||||
* analysis and CSV export, but nothing in the UI displays them.
|
||||
*
|
||||
* GPS updates at 1 Hz whilst sensors fire at ~50 Hz. [speedMps] and
|
||||
* [bearingChangeDegPerSec] should be the values from the last known GPS fix;
|
||||
* the detector compares speed against a *reference speed at braking onset*
|
||||
@@ -37,7 +47,7 @@ class EventDetector(private val config: DetectionConfig = DetectionConfig()) {
|
||||
private var brakingFrames = 0
|
||||
private var brakingOnsetSpeed = 0.0 // reference speed when braking started
|
||||
private var brakingStartTime = 0L
|
||||
private var peakBrakingDrop = 0.0 // peak speed drop observed during this window
|
||||
private var peakBrakingDrop = 0.0 // peak CUMULATIVE drop from onset speed, m/s (not a rate)
|
||||
private var peakAccelBraking = 0.0
|
||||
|
||||
// ── Turning state ─────────────────────────────────────────────────────────
|
||||
@@ -124,7 +134,7 @@ class EventDetector(private val config: DetectionConfig = DetectionConfig()) {
|
||||
|
||||
if (brakingFrames == config.brakingSustainedFrames) {
|
||||
val confidence =
|
||||
if (peakBrakingDrop > config.brakingHighConfidenceRate) Confidence.HIGH
|
||||
if (peakBrakingDrop > config.brakingHighConfidencePeakDrop) Confidence.HIGH
|
||||
else Confidence.MEDIUM
|
||||
|
||||
_events.tryEmit(
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
package com.hawhamburg.micr0bu.service
|
||||
|
||||
import android.content.Context
|
||||
import android.os.SystemClock
|
||||
import com.hawhamburg.micr0bu.data.GnssReading
|
||||
import com.hawhamburg.micr0bu.data.GnssTimeSource
|
||||
import com.hawhamburg.micr0bu.data.SensorRepository
|
||||
import com.hawhamburg.micr0bu.data.transport.GnPositionVector
|
||||
import com.hawhamburg.micr0bu.data.transport.UsbSerialTransport
|
||||
import com.hawhamburg.micr0bu.domain.asn1.RealAsn1UperCodec
|
||||
import com.hawhamburg.micr0bu.domain.cam.OwnStationIds
|
||||
import com.hawhamburg.micr0bu.domain.cam.PhoneCamBuilder
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -67,6 +71,20 @@ class CamPinger @Inject constructor(
|
||||
/** False while the pinger is running but has no GNSS fix yet — nothing is being transmitted. */
|
||||
val hasFix: StateFlow<Boolean> = _hasFix.asStateFlow()
|
||||
|
||||
/** [SystemClock.elapsedRealtime] when the pinger last stopped, or null if it never ran. */
|
||||
@Volatile private var stoppedAtElapsedMs: Long? = null
|
||||
|
||||
/**
|
||||
* True while station [OwnStationIds.BENCH_PING] on air is this phone's own ping: while the
|
||||
* pinger runs, and briefly after it stops, so a frame sent just before Stop is not taken for a
|
||||
* stranger. Uses elapsed realtime, so changing the wall clock cannot move the window.
|
||||
*
|
||||
* Otherwise that ID belongs to someone else, typically another MicrOBU phone pinging on the
|
||||
* same bench, and must be shown like any remote station. See [OwnStationIds.benchPingIsOurs].
|
||||
*/
|
||||
fun benchPingIsOurs(): Boolean =
|
||||
OwnStationIds.benchPingIsOurs(_isActive.value, stoppedAtElapsedMs, SystemClock.elapsedRealtime())
|
||||
|
||||
fun start() {
|
||||
if (job?.isActive == true) return
|
||||
_sentCount.value = 0
|
||||
@@ -86,13 +104,17 @@ class CamPinger @Inject constructor(
|
||||
_hasFix.value = gnss != null
|
||||
if (gnss != null) {
|
||||
val cam = PhoneCamBuilder.build(
|
||||
gnss = gnss,
|
||||
// Stamped on GNSS time rather than the phone clock; see GnssTimeSource.
|
||||
gnss = gnss.copy(timestamp = GnssTimeSource.correct(gnss.timestamp)),
|
||||
gyroZRadPerSec = latestGyroZ,
|
||||
stationId = PING_STATION_ID,
|
||||
stationId = OwnStationIds.BENCH_PING,
|
||||
longitudinalAccelMps2 = longitudinalAccel(gnss),
|
||||
)
|
||||
val bytes = codec.encodeCam(cam)
|
||||
if (usbSerialTransport.sendCamTx(bytes)) {
|
||||
// Fixed bench identity on every layer, the link-layer address included, so a ping
|
||||
// stays recognisable in a capture and never rotates.
|
||||
val pv = GnPositionVector.fromCam(cam, gnss.accuracyM, OwnStationIds.BENCH_PING_MAC)
|
||||
if (usbSerialTransport.sendCamTx(bytes, pv)) {
|
||||
_sentCount.update { it + 1 }
|
||||
}
|
||||
}
|
||||
@@ -119,6 +141,9 @@ class CamPinger @Inject constructor(
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
// Only a real stop opens the grace window. stop() is also called unconditionally on
|
||||
// teardown, and that must not make a phone that never pinged claim 999999 for a while.
|
||||
if (_isActive.value) stoppedAtElapsedMs = SystemClock.elapsedRealtime()
|
||||
job?.cancel()
|
||||
job = null
|
||||
_isActive.value = false
|
||||
@@ -131,11 +156,10 @@ class CamPinger @Inject constructor(
|
||||
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
|
||||
// The station id these pings go out under lives in
|
||||
// [com.hawhamburg.micr0bu.domain.cam.OwnStationIds.BENCH_PING], not here. It is not a
|
||||
// private detail of this class: the ESP32 hears these frames back off the air, so the
|
||||
// receive path has to recognise the same value, and a second copy of it is exactly how
|
||||
// the two sides would drift apart.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,11 @@ package com.hawhamburg.micr0bu.service
|
||||
|
||||
import android.content.Context
|
||||
import com.hawhamburg.micr0bu.data.GnssReading
|
||||
import com.hawhamburg.micr0bu.data.GnssTimeSource
|
||||
import com.hawhamburg.micr0bu.data.SensorRepository
|
||||
import com.hawhamburg.micr0bu.data.cam.PseudonymManager
|
||||
import com.hawhamburg.micr0bu.data.mqtt.ObuHardwarePreferences
|
||||
import com.hawhamburg.micr0bu.data.transport.GnPositionVector
|
||||
import com.hawhamburg.micr0bu.data.transport.ObuHardware
|
||||
import com.hawhamburg.micr0bu.data.transport.UsbSerialTransport
|
||||
import com.hawhamburg.micr0bu.domain.asn1.RealAsn1UperCodec
|
||||
@@ -49,6 +52,7 @@ class CamTransmitLoop @Inject constructor(
|
||||
private val obuHardwarePrefs: ObuHardwarePreferences,
|
||||
private val usbSerialTransport: UsbSerialTransport,
|
||||
private val codec: RealAsn1UperCodec,
|
||||
private val pseudonymManager: PseudonymManager,
|
||||
) {
|
||||
private val config = CamTransmitConfig()
|
||||
private val sensorRepository = SensorRepository(context)
|
||||
@@ -63,14 +67,6 @@ class CamTransmitLoop @Inject constructor(
|
||||
/** 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
|
||||
|
||||
/**
|
||||
* Call when a braking/turning/stopping event fires during an active trip — bumps the CAM
|
||||
* rate to [CamTransmitConfig.elevatedRateHz] for [ELEVATED_HOLD_MS] so nearby stations get
|
||||
@@ -90,7 +86,6 @@ class CamTransmitLoop @Inject constructor(
|
||||
elevatedUntilMs = 0L
|
||||
previousGnss = null
|
||||
job = scope.launch {
|
||||
stationId = obuHardwarePrefs.getOrCreateOwnStationId()
|
||||
obuHardwarePrefs.obuHardwareFlow.collectLatest { hardware ->
|
||||
if (hardware != ObuHardware.ESP32_C5) return@collectLatest
|
||||
runTransmitLoop()
|
||||
@@ -111,9 +106,15 @@ class CamTransmitLoop @Inject constructor(
|
||||
while (true) {
|
||||
val gnss = latestGnss
|
||||
if (gnss != null) {
|
||||
val cam = PhoneCamBuilder.build(gnss, latestGyroZ, stationId, longitudinalAccel(gnss))
|
||||
// Asked for per CAM rather than once per trip: that is what lets a pseudonym
|
||||
// rotation fall cleanly between two frames instead of inside one.
|
||||
val pseudonym = pseudonymManager.current()
|
||||
// Stamped on GNSS time rather than the phone clock; see GnssTimeSource. Only the
|
||||
// outgoing CAM is: acceleration below still differences wall-clock samples.
|
||||
val fix = gnss.copy(timestamp = GnssTimeSource.correct(gnss.timestamp))
|
||||
val cam = PhoneCamBuilder.build(fix, latestGyroZ, pseudonym.stationId, longitudinalAccel(gnss))
|
||||
val bytes = codec.encodeCam(cam)
|
||||
usbSerialTransport.sendCamTx(bytes)
|
||||
usbSerialTransport.sendCamTx(bytes, GnPositionVector.fromCam(cam, gnss.accuracyM, pseudonym.mac))
|
||||
}
|
||||
delay((1000.0 / currentRateHz(gnss)).toLong())
|
||||
}
|
||||
|
||||
@@ -26,9 +26,7 @@ import com.hawhamburg.micr0bu.R
|
||||
import com.hawhamburg.micr0bu.data.TripRepository
|
||||
import com.hawhamburg.micr0bu.data.cam.CamUseCaseRepository
|
||||
import com.hawhamburg.micr0bu.data.db.AppDatabase
|
||||
import com.hawhamburg.micr0bu.domain.detection.DetectionConfig
|
||||
import com.hawhamburg.micr0bu.domain.detection.EventDetector
|
||||
import com.hawhamburg.micr0bu.domain.detection.EventType
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -108,19 +106,10 @@ class TripRecordingService : Service() {
|
||||
// V2xMessageEntity's KDoc for why nothing is retained outside of one.
|
||||
@Inject lateinit var camUseCaseRepository: CamUseCaseRepository
|
||||
private var v2xLoggingJob: Job? = null
|
||||
private val detector = EventDetector(
|
||||
DetectionConfig(
|
||||
brakingSpeedDropThreshold = 1.0,
|
||||
brakingAccelStdDevThreshold = 1.8,
|
||||
brakingSustainedFrames = 25,
|
||||
turningGyroMeanThreshold = 0.6,
|
||||
turningBearingChangeThreshold = 15.0,
|
||||
turningSustainedFrames = 30,
|
||||
stoppingSpeedThreshold = 0.3,
|
||||
stoppingFrames = 150,
|
||||
stoppingAccelStdDevThreshold = 0.10,
|
||||
)
|
||||
)
|
||||
// These nine thresholds used to be overridden here; they are now the DetectionConfig
|
||||
// defaults, so there is one configuration and the unit tests exercise it. Behaviour is
|
||||
// unchanged - see DetectionConfig's KDoc.
|
||||
private val detector = EventDetector()
|
||||
|
||||
// ── Sensor fusion state ───────────────────────────────────────────────────
|
||||
|
||||
@@ -153,10 +142,11 @@ class TripRecordingService : Service() {
|
||||
private val gpsTrackBuilder = StringBuilder("[")
|
||||
private var gpsPointCount = 0
|
||||
|
||||
// Event counts
|
||||
private var brakingCount = 0
|
||||
private var turningCount = 0
|
||||
private var stoppingCount = 0
|
||||
// Number of manoeuvres the detector fired during this trip. The only thing kept about
|
||||
// them: it fills the trips.eventCount column, which predates this change and cannot be
|
||||
// dropped without rebuilding the trips table. See EventDetector's KDoc for why the
|
||||
// detector still runs at all.
|
||||
private var detectedEventCount = 0
|
||||
|
||||
// ── SensorEventListener ───────────────────────────────────────────────────
|
||||
|
||||
@@ -253,9 +243,7 @@ class TripRecordingService : Service() {
|
||||
).also { it.acquire() }
|
||||
|
||||
detector.reset()
|
||||
brakingCount = 0
|
||||
turningCount = 0
|
||||
stoppingCount = 0
|
||||
detectedEventCount = 0
|
||||
distanceMetres = 0f
|
||||
prevLat = Double.NaN
|
||||
prevLon = Double.NaN
|
||||
@@ -273,35 +261,20 @@ class TripRecordingService : Service() {
|
||||
isRecording = true,
|
||||
currentTripId = currentTripId,
|
||||
elapsedSeconds = 0L,
|
||||
brakingCount = 0,
|
||||
turningCount = 0,
|
||||
stoppingCount = 0,
|
||||
currentSpeedMs = 0f,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Collect detector events and persist them
|
||||
// Collect detector events. The CAM transmit-rate policy is their only consumer:
|
||||
// detected manoeuvres are not persisted, exported, or displayed.
|
||||
serviceScope.launch {
|
||||
detector.events.collect { event ->
|
||||
detector.events.collect { _ ->
|
||||
if (currentTripId < 0) return@collect
|
||||
repository.insertEvent(currentTripId, event)
|
||||
// Bump the CAM transmit rate through the maneuver, not just at detection instant.
|
||||
// No-op on the CiT One path (see CamTransmitLoop's KDoc).
|
||||
camTransmitLoop.onDetectedEvent()
|
||||
when (event.type) {
|
||||
EventType.BRAKING -> brakingCount++
|
||||
EventType.TURNING -> turningCount++
|
||||
EventType.STOPPING -> stoppingCount++
|
||||
}
|
||||
TripServiceBus.update {
|
||||
copy(
|
||||
brakingCount = this@TripRecordingService.brakingCount,
|
||||
turningCount = this@TripRecordingService.turningCount,
|
||||
stoppingCount = this@TripRecordingService.stoppingCount,
|
||||
)
|
||||
}
|
||||
updateNotification()
|
||||
detectedEventCount++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,7 +329,7 @@ class TripRecordingService : Service() {
|
||||
v2xLoggingJob = null
|
||||
|
||||
val endTime = System.currentTimeMillis()
|
||||
val totalEvents = brakingCount + turningCount + stoppingCount
|
||||
val totalEvents = detectedEventCount
|
||||
|
||||
// Close GPS track JSON
|
||||
gpsTrackBuilder.append("]")
|
||||
@@ -454,10 +427,7 @@ class TripRecordingService : Service() {
|
||||
private fun buildNotification(elapsedSeconds: Long) =
|
||||
NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle("Recording trip")
|
||||
.setContentText(
|
||||
"⏱ ${formatElapsed(elapsedSeconds)} · " +
|
||||
"🚨 $brakingCount 🔄 $turningCount 🛑 $stoppingCount"
|
||||
)
|
||||
.setContentText("⏱ ${formatElapsed(elapsedSeconds)}")
|
||||
.setSmallIcon(R.mipmap.ic_launcher_foreground)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
|
||||
@@ -17,9 +17,6 @@ object TripServiceBus {
|
||||
val isRecording: Boolean = false,
|
||||
val currentTripId: Long = -1L,
|
||||
val elapsedSeconds: Long = 0L,
|
||||
val brakingCount: Int = 0,
|
||||
val turningCount: Int = 0,
|
||||
val stoppingCount: Int = 0,
|
||||
val currentSpeedMs: Float = 0f,
|
||||
)
|
||||
|
||||
|
||||
@@ -76,6 +76,13 @@ private fun Screen.ownsRoute(route: String?): Boolean {
|
||||
return when (this) {
|
||||
Screen.Settings -> route.startsWith("settings/")
|
||||
Screen.TripHistory -> route.startsWith("trip_review")
|
||||
// Connection, Map and Sensors are only reachable from the Dashboard's own cards, and
|
||||
// the session log only from Record, so those tabs stay lit while the rider is inside
|
||||
// one of them. Without this the bar goes blank on screens that clearly belong to a tab.
|
||||
Screen.Dashboard -> route == Screen.Connection.route ||
|
||||
route == Screen.Map.route ||
|
||||
route == Screen.Sensors.route
|
||||
Screen.Record -> route == Screen.Log.route
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
@@ -87,23 +94,26 @@ fun BottomNavBar(navController: NavController) {
|
||||
|
||||
NavigationBar {
|
||||
bottomNavItems.forEach { screen ->
|
||||
val onThisTab = screen.ownsRoute(currentRoute)
|
||||
NavigationBarItem(
|
||||
selected = onThisTab,
|
||||
selected = screen.ownsRoute(currentRoute),
|
||||
onClick = {
|
||||
if (onThisTab && currentRoute != screen.route) {
|
||||
// Already inside this tab, just deeper in: pop back to the tab's own
|
||||
// screen. Navigating instead would restoreState the saved back stack and
|
||||
// land straight back on the sub-screen, which reads as the tap doing
|
||||
// nothing - the reason Settings > Connection could not be left by tapping
|
||||
// Settings. Leaves the rest of the stack intact, so Back still works
|
||||
// exactly as before.
|
||||
navController.popBackStack(screen.route, inclusive = false)
|
||||
} else {
|
||||
// One rule for every tab, including the one already selected: a tap lands on
|
||||
// that tab's own screen. Nothing happens only when we are already on it.
|
||||
if (currentRoute != screen.route) {
|
||||
// Prefer a pop when this tab's screen is still on the back stack. That is
|
||||
// exactly what Back or a back swipe would do, so tapping Settings from
|
||||
// Settings > Connection, or Dashboard from the Map, behaves identically
|
||||
// whichever way the rider asks for it. popBackStack reports false when the
|
||||
// screen is not on the stack, which is the case for a genuine tab switch.
|
||||
if (!navController.popBackStack(screen.route, inclusive = false)) {
|
||||
// No saveState/restoreState here. The graph is flat, so a restored
|
||||
// back stack brings back the sub-screen the rider was on rather than
|
||||
// the tab's own screen, which is the opposite of what the tap asked
|
||||
// for. Tab state that matters lives in the view models anyway.
|
||||
navController.navigate(screen.route) {
|
||||
popUpTo(Screen.Dashboard.route) { saveState = true }
|
||||
popUpTo(Screen.Dashboard.route)
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package com.hawhamburg.micr0bu.ui.screens
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -28,6 +31,7 @@ import androidx.compose.material.icons.filled.GpsOff
|
||||
import androidx.compose.material.icons.filled.Map
|
||||
import androidx.compose.material.icons.filled.Sensors
|
||||
import androidx.compose.material.icons.filled.SensorsOff
|
||||
import androidx.compose.material.icons.filled.Traffic
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -37,14 +41,18 @@ import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
@@ -53,7 +61,14 @@ import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
|
||||
import com.hawhamburg.micr0bu.data.transport.ObuHardware
|
||||
import com.hawhamburg.micr0bu.data.transport.TransportType
|
||||
import com.hawhamburg.micr0bu.data.transport.UsbSerialState
|
||||
import com.hawhamburg.micr0bu.domain.cam.Cam
|
||||
import com.hawhamburg.micr0bu.domain.denm.DenmEvent
|
||||
import com.hawhamburg.micr0bu.domain.denm.DenmParser
|
||||
import com.hawhamburg.micr0bu.domain.spat.SignalPhase
|
||||
import com.hawhamburg.micr0bu.domain.spat.SpatIntersection
|
||||
import com.hawhamburg.micr0bu.domain.usecase.GeoMath
|
||||
import com.hawhamburg.micr0bu.viewmodel.SensorUiState
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlin.math.sqrt
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -67,10 +82,14 @@ fun DashboardScreen(
|
||||
usbCableConnected: Boolean = false,
|
||||
obuStationTypeWarning: Boolean = false,
|
||||
obuStationType: Int? = null,
|
||||
hazards: List<DenmEvent> = emptyList(),
|
||||
signals: List<SpatIntersection> = emptyList(),
|
||||
ownPosition: Cam? = null,
|
||||
onNavigateToConnection: () -> Unit,
|
||||
onNavigateToSensors: () -> Unit,
|
||||
onNavigateToMap: () -> Unit,
|
||||
onNavigateToRecord: () -> Unit = {},
|
||||
onNavigateToV2x: () -> Unit = {},
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
@@ -357,6 +376,52 @@ fun DashboardScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Live V2X, below the status cards: the hazard that matters most and the signalised
|
||||
// intersection about to change. Both are summaries of what the V2X screen shows in full,
|
||||
// so tapping either opens that screen rather than repeating its detail here. One of each
|
||||
// is shown deliberately: a dashboard read from a bike mount has room for the single most
|
||||
// relevant thing, not for a list.
|
||||
val ownLatLon = ownPosition?.let { it.latitude to it.longitude }
|
||||
?: state.gnss?.let { it.latitude to it.longitude }
|
||||
|
||||
val rankedHazards = remember(hazards, ownLatLon) {
|
||||
hazards
|
||||
.map { denm ->
|
||||
val distance = ownLatLon?.let { (lat, lon) ->
|
||||
GeoMath.haversineMeters(lat, lon, denm.latitude, denm.longitude)
|
||||
}
|
||||
denm to distance
|
||||
}
|
||||
// Closest first. A hazard whose distance cannot be worked out, because there is
|
||||
// no fix yet, sorts last rather than being dropped: it is still a real hazard,
|
||||
// we just cannot say how far away it is.
|
||||
.sortedBy { (_, d) -> d ?: Double.MAX_VALUE }
|
||||
}
|
||||
rankedHazards.firstOrNull()?.let { (denm, distance) ->
|
||||
HazardCard(
|
||||
hazard = denm,
|
||||
distanceMeters = distance,
|
||||
additionalCount = rankedHazards.size - 1,
|
||||
onClick = onNavigateToV2x,
|
||||
)
|
||||
}
|
||||
|
||||
// Signals cannot be ranked by distance: SPATEM carries no position at all. The geometry
|
||||
// that would place an intersection lives in MAPEM, which nothing on the air is currently
|
||||
// sending. So the one shown is the one changing soonest, which is in any case the one a
|
||||
// rider approaching a junction needs to see.
|
||||
val nextSignal = remember(signals) {
|
||||
val now = System.currentTimeMillis()
|
||||
signals.minByOrNull { it.secondsToNextChange(now) ?: Double.MAX_VALUE }
|
||||
}
|
||||
nextSignal?.let { signal ->
|
||||
SignalCard(
|
||||
signal = signal,
|
||||
additionalCount = signals.size - 1,
|
||||
onClick = onNavigateToV2x,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
if (state.pressureHpa != null)
|
||||
@@ -485,3 +550,208 @@ private fun QuickStatRow(label: String, value: String) {
|
||||
Text(value, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
|
||||
}
|
||||
}
|
||||
|
||||
// Hazard red and the three signal states. Kept local to this file for the same reason the V2X
|
||||
// screen keeps its own: these are traffic-light and warning semantics, not theme roles, and
|
||||
// tying them to the colour scheme would let a theme change turn a red light amber.
|
||||
private val HazardRed = Color(0xFFE53935)
|
||||
private val HazardRedBg = Color(0xFF3A0A0A)
|
||||
private val SignalGreen = Color(0xFF4CAF50)
|
||||
private val SignalAmber = Color(0xFFFFC107)
|
||||
private val SignalGray = Color(0xFF8B949E)
|
||||
|
||||
/** How many signal groups fit on the dashboard before the rest are summarised as a count. */
|
||||
private const val DASH_MAX_SIGNAL_GROUPS = 6
|
||||
|
||||
/**
|
||||
* Seconds until the first of this intersection's signal groups changes, or null when no group
|
||||
* supplies a usable countdown. Marks already in the past are excluded: a change that has already
|
||||
* happened says nothing about what the light will do next.
|
||||
*/
|
||||
private fun SpatIntersection.secondsToNextChange(nowMs: Long): Double? =
|
||||
state.movements
|
||||
.mapNotNull { it.current?.secondsUntil(nowMs) }
|
||||
.filter { it >= 0.0 }
|
||||
.minOrNull()
|
||||
|
||||
/**
|
||||
* The nearest received hazard, as a glanceable summary.
|
||||
*
|
||||
* Deliberately says less than the V2X screen's row: what it is, how far away, and whether there
|
||||
* are others behind it. Anything more detailed belongs on the screen this card opens.
|
||||
*/
|
||||
@Composable
|
||||
private fun HazardCard(
|
||||
hazard: DenmEvent,
|
||||
distanceMeters: Double?,
|
||||
additionalCount: Int,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val title = DenmParser.causeCodeName(hazard.causeCode)
|
||||
?: hazard.causeCode?.let {
|
||||
stringResource(R.string.v2x_denm_rx_cause_code, it, hazard.subCauseCode ?: 0)
|
||||
}
|
||||
?: stringResource(R.string.v2x_map_denm_plain, hazard.stationId)
|
||||
|
||||
val detail = listOfNotNull(
|
||||
distanceMeters?.let { stringResource(R.string.v2x_cam_rx_distance, it) }
|
||||
?: stringResource(R.string.v2x_cam_rx_distance_unknown),
|
||||
stringResource(R.string.dash_hazard_station, hazard.stationId),
|
||||
if (additionalCount > 0) stringResource(R.string.dash_more_count, additionalCount) else null,
|
||||
).joinToString(" · ")
|
||||
|
||||
androidx.compose.material3.Card(
|
||||
modifier = Modifier.fillMaxWidth().clickable { onClick() },
|
||||
colors = androidx.compose.material3.CardDefaults.cardColors(containerColor = HazardRedBg),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Icon(Icons.Default.Warning, null, tint = HazardRed, modifier = Modifier.size(28.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
stringResource(R.string.dash_hazard_warning),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = HazardRed,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
detail,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The signalised intersection changing soonest: its leading phase with a countdown, then every
|
||||
* signal group as a coloured chip.
|
||||
*
|
||||
* Signal groups are bare numbers because that is all the app knows. Calling one "your lane" needs
|
||||
* MAPEM geometry, and a friendlier label would claim knowledge that is not there.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun SignalCard(
|
||||
signal: SpatIntersection,
|
||||
additionalCount: Int,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
// The countdown has to advance on its own clock. SPATEM repeats at about 2 Hz, so
|
||||
// recomposition would roughly keep pace while the RSU is transmitting, but the moment it
|
||||
// stops, a frozen "3 s" would go on claiming the light is about to change.
|
||||
val nowMs = remember { mutableLongStateOf(System.currentTimeMillis()) }
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
nowMs.longValue = System.currentTimeMillis()
|
||||
delay(500L)
|
||||
}
|
||||
}
|
||||
val now = nowMs.longValue
|
||||
|
||||
val leading = signal.state.movements.minByOrNull { movement ->
|
||||
movement.current?.secondsUntil(now)?.takeIf { it >= 0.0 } ?: Double.MAX_VALUE
|
||||
}
|
||||
val phase = leading?.current?.phase
|
||||
val tint = phaseTint(phase)
|
||||
val countdown = leading?.current?.secondsUntil(now)?.takeIf { it in 0.0..99.0 }
|
||||
|
||||
val hiddenGroups = signal.state.movements.size - DASH_MAX_SIGNAL_GROUPS
|
||||
val footer = listOfNotNull(
|
||||
if (hiddenGroups > 0) stringResource(R.string.dash_more_count, hiddenGroups) else null,
|
||||
if (additionalCount > 0) stringResource(R.string.dash_signal_more, additionalCount) else null,
|
||||
).joinToString(" · ")
|
||||
|
||||
androidx.compose.material3.Card(
|
||||
modifier = Modifier.fillMaxWidth().clickable { onClick() },
|
||||
colors = androidx.compose.material3.CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Icon(Icons.Default.Traffic, null, tint = tint, modifier = Modifier.size(28.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
stringResource(R.string.dash_signal_title, signal.state.key),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = tint,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
text = countdown
|
||||
?.let { stringResource(R.string.dash_signal_countdown, phaseLabel(phase), it) }
|
||||
?: phaseLabel(phase),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
|
||||
// Wraps rather than scrolls: a horizontal scroller inside a scrolling dashboard
|
||||
// is awkward to drive one-handed, and the chip row is short by construction.
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
signal.state.movements.take(DASH_MAX_SIGNAL_GROUPS).forEach { movement ->
|
||||
val groupTint = phaseTint(movement.current?.phase)
|
||||
val groupCountdown =
|
||||
movement.current?.secondsUntil(now)?.takeIf { it in 0.0..99.0 }
|
||||
Text(
|
||||
text = stringResource(R.string.v2x_spat_group, movement.signalGroup) +
|
||||
(groupCountdown?.let { " " + stringResource(R.string.v2x_spat_countdown, it) } ?: ""),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = groupTint,
|
||||
modifier = Modifier
|
||||
.padding(vertical = 2.dp)
|
||||
.clip(androidx.compose.foundation.shape.RoundedCornerShape(4.dp))
|
||||
.background(groupTint.copy(alpha = 0.15f))
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (footer.isNotEmpty()) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
footer,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Traffic-light colour for a phase: go is green, stop is red, anything in between is amber. */
|
||||
@Composable
|
||||
private fun phaseTint(phase: SignalPhase?): Color = when {
|
||||
phase == null -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
phase.isGo -> SignalGreen
|
||||
phase.isStop -> HazardRed
|
||||
phase.isTransition -> SignalAmber
|
||||
else -> SignalGray
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun phaseLabel(phase: SignalPhase?): String = when {
|
||||
phase == null -> stringResource(R.string.dash_signal_phase_unknown)
|
||||
phase.isGo -> stringResource(R.string.dash_signal_phase_go)
|
||||
phase.isStop -> stringResource(R.string.dash_signal_phase_stop)
|
||||
phase.isTransition -> stringResource(R.string.dash_signal_phase_changing)
|
||||
phase == SignalPhase.DARK -> stringResource(R.string.dash_signal_phase_dark)
|
||||
else -> stringResource(R.string.dash_signal_phase_unknown)
|
||||
}
|
||||
|
||||
@@ -134,6 +134,7 @@ fun MqttTopicViewerScreen(
|
||||
val camPingerActive by viewModel.camPingerActive.collectAsState()
|
||||
val camPingerSentCount by viewModel.camPingerSentCount.collectAsState()
|
||||
val camPingerHasFix by viewModel.camPingerHasFix.collectAsState()
|
||||
val ownTxLoopback by viewModel.ownTxLoopback.collectAsState()
|
||||
val camSendFailures by viewModel.camSendFailures.collectAsState()
|
||||
val espLinkStatus by viewModel.espLinkStatus.collectAsState()
|
||||
val denmEvents by viewModel.denmEvents.collectAsState()
|
||||
@@ -239,6 +240,7 @@ fun MqttTopicViewerScreen(
|
||||
camPingerActive = camPingerActive,
|
||||
camPingerSentCount = camPingerSentCount,
|
||||
camPingerHasFix = camPingerHasFix,
|
||||
ownTxLoopback = ownTxLoopback,
|
||||
camSendFailures = camSendFailures,
|
||||
espLinkStatus = espLinkStatus,
|
||||
ownCamPosition = ownCamPosition,
|
||||
@@ -279,6 +281,7 @@ private fun TopicListPane(
|
||||
camPingerActive: Boolean = false,
|
||||
camPingerSentCount: Int = 0,
|
||||
camPingerHasFix: Boolean = false,
|
||||
ownTxLoopback: com.hawhamburg.micr0bu.domain.cam.OwnTxLoopback? = null,
|
||||
camSendFailures: Int = 0,
|
||||
espLinkStatus: EspLinkStatus? = null,
|
||||
ownCamPosition: com.hawhamburg.micr0bu.domain.cam.Cam? = null,
|
||||
@@ -322,6 +325,7 @@ private fun TopicListPane(
|
||||
pingerActive = camPingerActive,
|
||||
sentCount = camPingerSentCount,
|
||||
hasFix = camPingerHasFix,
|
||||
loopback = ownTxLoopback,
|
||||
sendFailures = camSendFailures,
|
||||
linkStatus = espLinkStatus,
|
||||
onStart = onStartCamPinger,
|
||||
@@ -1108,6 +1112,7 @@ private fun CamPingerCard(
|
||||
pingerActive: Boolean,
|
||||
sentCount: Int,
|
||||
hasFix: Boolean,
|
||||
loopback: com.hawhamburg.micr0bu.domain.cam.OwnTxLoopback?,
|
||||
sendFailures: Int,
|
||||
linkStatus: EspLinkStatus?,
|
||||
onStart: () -> Unit,
|
||||
@@ -1176,8 +1181,24 @@ private fun CamPingerCard(
|
||||
|
||||
// ── 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
|
||||
// accepted them and the radio rejected every one. These lines are the difference
|
||||
// between a bench session that tells you something and one that doesn't.
|
||||
|
||||
// The round trip closing: sent over serial, transmitted, and heard again by the same
|
||||
// radio. Compared against Sent above, a shortfall separates "nothing is going out"
|
||||
// from "it goes out but is not coming back".
|
||||
loopback?.takeIf { it.frames > 0 }?.let { lb ->
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
text = lb.lastRssiDbm?.let {
|
||||
stringResource(R.string.mqtt_cam_pinger_loopback, lb.frames, it)
|
||||
} ?: stringResource(R.string.mqtt_cam_pinger_loopback_no_rssi, lb.frames),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = ConnectedGreen,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
}
|
||||
|
||||
if (sendFailures > 0) {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
|
||||
@@ -141,46 +141,6 @@ fun RecordingScreen(
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
// ── Event Detection Counters ─────────────────────────────────────────
|
||||
if (state.isRecording || tripServiceState.isRecording) {
|
||||
Text(
|
||||
stringResource(R.string.rec_events_detected),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.align(Alignment.Start),
|
||||
)
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
) {
|
||||
EventCountBadge(
|
||||
label = stringResource(R.string.rec_event_braking),
|
||||
count = tripServiceState.brakingCount,
|
||||
color = Color(0xFFFF5252),
|
||||
)
|
||||
EventCountBadge(
|
||||
label = stringResource(R.string.rec_event_turning),
|
||||
count = tripServiceState.turningCount,
|
||||
color = Color(0xFFFFB300),
|
||||
)
|
||||
EventCountBadge(
|
||||
label = stringResource(R.string.rec_event_stopping),
|
||||
count = tripServiceState.stoppingCount,
|
||||
color = Color(0xFF42A5F5),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
}
|
||||
|
||||
// ── CSV Session Log shortcut ─────────────────────────────────────────
|
||||
if (!state.isRecording) {
|
||||
OutlinedButton(
|
||||
@@ -228,25 +188,6 @@ fun RecordingScreen(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EventCountBadge(label: String, count: Int, color: Color) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = count.toString(),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = color,
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StreamRow(label: String, active: Boolean) {
|
||||
Row(
|
||||
|
||||
@@ -145,7 +145,6 @@ private fun TripCard(
|
||||
val durationSec = ((trip.endTime - trip.startTime) / 1000).coerceAtLeast(0)
|
||||
TripStatChip("⏱ ${formatDuration(durationSec)}")
|
||||
TripStatChip("📍 ${formatDistance(trip.distanceMetres)}")
|
||||
TripStatChip("🚨 ${trip.eventCount} events")
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onOpen) {
|
||||
|
||||
@@ -42,9 +42,7 @@ import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import com.hawhamburg.micr0bu.data.db.DetectedEventEntity
|
||||
import com.hawhamburg.micr0bu.data.db.RecordedTripEntity
|
||||
import com.hawhamburg.micr0bu.viewmodel.TripRecordingViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import org.osmdroid.config.Configuration
|
||||
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
|
||||
@@ -61,7 +59,6 @@ import java.util.Locale
|
||||
@Composable
|
||||
fun TripReviewScreen(
|
||||
trip: RecordedTripEntity,
|
||||
viewModel: TripRecordingViewModel,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
@@ -70,15 +67,8 @@ fun TripReviewScreen(
|
||||
// provider is ready before MapView is constructed in the factory block.
|
||||
initOsmReview(context)
|
||||
|
||||
LaunchedEffect(trip.id) { viewModel.loadTripEvents(trip.id) }
|
||||
val events by viewModel.selectedTripEvents.collectAsState()
|
||||
|
||||
val gpsPoints = remember(trip.gpsTrackJson) { parseGpsTrack(trip.gpsTrackJson) }
|
||||
|
||||
var selectedEvent by remember { mutableStateOf<DetectedEventEntity?>(null) }
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val mapViewRef = remember { mutableStateOf<MapView?>(null) }
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
|
||||
@@ -108,13 +98,6 @@ fun TripReviewScreen(
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
"🚨 ${events.count { it.type == "BRAKING" }} " +
|
||||
"🔄 ${events.count { it.type == "TURNING" }} " +
|
||||
"🛑 ${events.count { it.type == "STOPPING" }}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Map ───────────────────────────────────────────────────────────────
|
||||
@@ -141,33 +124,6 @@ fun TripReviewScreen(
|
||||
mv.overlays.add(polyline)
|
||||
}
|
||||
|
||||
// Event pins
|
||||
events.forEach { event ->
|
||||
val pinColor = when (event.type) {
|
||||
"BRAKING" -> Color(0xFFFF5252)
|
||||
"TURNING" -> Color(0xFFFFB300)
|
||||
"STOPPING" -> Color(0xFF42A5F5)
|
||||
else -> Color.Gray
|
||||
}
|
||||
val marker = Marker(mv).apply {
|
||||
position = GeoPoint(event.latitude, event.longitude)
|
||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
||||
title = "${event.type} (${event.confidence})"
|
||||
setOnMarkerClickListener { _, _ ->
|
||||
selectedEvent = event
|
||||
scope.launch { sheetState.show() }
|
||||
true
|
||||
}
|
||||
// Solid-circle pin in the event color
|
||||
icon = GradientDrawable().apply {
|
||||
shape = GradientDrawable.OVAL
|
||||
setColor(pinColor.toArgb())
|
||||
setSize(32, 32)
|
||||
}
|
||||
}
|
||||
mv.overlays.add(marker)
|
||||
}
|
||||
|
||||
// Auto-fit the camera to the track — deferred via post() so the
|
||||
// MapView has been measured before zoomToBoundingBox is called.
|
||||
// Calling it with width/height == 0 (before first layout) crashes osmdroid.
|
||||
@@ -193,82 +149,6 @@ fun TripReviewScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// ── Event detail bottom sheet ─────────────────────────────────────────────
|
||||
val ev = selectedEvent
|
||||
if (ev != null) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { selectedEvent = null },
|
||||
sheetState = sheetState,
|
||||
dragHandle = { BottomSheetDefaults.DragHandle() },
|
||||
) {
|
||||
EventDetailSheet(event = ev, onDismiss = {
|
||||
scope.launch { sheetState.hide() }.invokeOnCompletion { selectedEvent = null }
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Event detail sheet content ────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun EventDetailSheet(event: DetectedEventEntity, onDismiss: () -> Unit) {
|
||||
// Created here (not as a top-level static field) so it always uses the
|
||||
// current locale even if the user changes it while the app is running.
|
||||
val sdf = remember { SimpleDateFormat("HH:mm:ss", Locale.getDefault()) }
|
||||
|
||||
val accentColor = when (event.type) {
|
||||
"BRAKING" -> Color(0xFFFF5252)
|
||||
"TURNING" -> Color(0xFFFFB300)
|
||||
"STOPPING" -> Color(0xFF42A5F5)
|
||||
else -> MaterialTheme.colorScheme.primary
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.padding(horizontal = 20.dp).padding(bottom = 32.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
event.type.replaceFirstChar { it.titlecase() },
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = accentColor,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(Icons.Default.Close, contentDescription = "Close")
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
"Confidence: ${event.confidence}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
sdf.format(Date(event.timestamp)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
EventDetailRow("Speed", "%.1f m/s".format(event.speedMps))
|
||||
EventDetailRow("Peak accel", "%.2f m/s²".format(event.peakAccelMagnitude))
|
||||
EventDetailRow("Peak gyro", "%.3f rad/s".format(event.peakGyroMagnitude))
|
||||
EventDetailRow("Duration", "${event.durationMs} ms")
|
||||
EventDetailRow("Location", "%.5f°, %.5f°".format(event.latitude, event.longitude))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EventDetailRow(label: String, value: String) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(value, style = MaterialTheme.typography.bodyMedium, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Medium)
|
||||
}
|
||||
}
|
||||
|
||||
// ── GPS track parsing ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -101,7 +101,24 @@ class MqttViewModel @Inject constructor(
|
||||
/** 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()
|
||||
/**
|
||||
* Own transmissions heard back off the air, null until one is.
|
||||
*
|
||||
* This is the pinger's actual proof of life. [camPingerSentCount] only says frames were
|
||||
* handed to the ESP32; this says they went out and came back, which is the round trip the
|
||||
* bench test is there to demonstrate. See
|
||||
* [com.hawhamburg.micr0bu.domain.cam.OwnTxLoopback].
|
||||
*/
|
||||
val ownTxLoopback: StateFlow<com.hawhamburg.micr0bu.domain.cam.OwnTxLoopback?> =
|
||||
camUseCaseRepository.ownTxLoopback
|
||||
|
||||
fun startCamPinger() {
|
||||
// Reset first, so the tally counts this run rather than accumulating across runs and
|
||||
// making the comparison against sent count meaningless.
|
||||
camUseCaseRepository.resetOwnTxLoopback()
|
||||
camPinger.start()
|
||||
}
|
||||
|
||||
fun stopCamPinger() = camPinger.stop()
|
||||
|
||||
// ── Prefs ─────────────────────────────────────────────────────────────────
|
||||
@@ -127,13 +144,27 @@ class MqttViewModel @Inject constructor(
|
||||
val obuStationType: StateFlow<Int?> = _obuStationType.asStateFlow()
|
||||
|
||||
/**
|
||||
* True when the OBU has reported a stationType other than 2 (cyclist).
|
||||
* True when the CiT One has reported a stationType other than 2 (cyclist).
|
||||
* Triggers a persistent warning banner — an incorrect stationType means this OBU will
|
||||
* not be detected as a VRU at equipped intersections.
|
||||
*
|
||||
* Suppressed in ESP32-C5 mode. The value behind it comes from the CiT One's
|
||||
* `v2x/rx/obu_gnss` topic, which the ESP32-C5 does not publish, so a warning raised before a
|
||||
* mode switch would otherwise stay on screen reporting on an OBU that is no longer in use.
|
||||
* There is nothing for it to warn about on that path either: the phone builds its own CAM
|
||||
* ([com.hawhamburg.micr0bu.domain.cam.PhoneCamBuilder]), which sets stationType to cyclist
|
||||
* locally rather than reading it back from an OBU.
|
||||
*
|
||||
* The underlying [obuStationType] is deliberately not cleared on the switch. It remains the
|
||||
* last thing that OBU actually said, and obu_gnss refreshes it at ~4 Hz on returning to the
|
||||
* CiT One path, so the warning re-evaluates against fresh data within a fraction of a second.
|
||||
*/
|
||||
val obuStationTypeWarning: StateFlow<Boolean> = _obuStationType
|
||||
.map { it != null && it != 2 }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
|
||||
val obuStationTypeWarning: StateFlow<Boolean> = combine(
|
||||
_obuStationType,
|
||||
repo.obuHardware,
|
||||
) { stationType, hardware ->
|
||||
hardware == ObuHardware.CIT_ONE && stationType != null && stationType != 2
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, false)
|
||||
|
||||
// ── DENM reception (live map hazard pins) ─────────────────────────────────
|
||||
|
||||
@@ -141,11 +172,16 @@ class MqttViewModel @Inject constructor(
|
||||
* 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.
|
||||
*
|
||||
* Two sources, merged: the CiT One path's `v2x-uca/output/json/denm` MQTT topic (parsed by
|
||||
* [DenmParser]), and the ESP32-C5 path's over-the-air DENMs (GeoBroadcast, BTP port 2002,
|
||||
* decoded by [com.hawhamburg.micr0bu.domain.asn1.DenmUperCodec]). Only one is ever active at a
|
||||
* time since the hardware selection decides the transport, so merging costs nothing and keeps
|
||||
* the UI transport-agnostic.
|
||||
* Two sources, merged: the CiT One Use Case app's `v2x-uca/output/json/denm` MQTT topic
|
||||
* (parsed by [DenmParser]), and UPER decoded by
|
||||
* [com.hawhamburg.micr0bu.domain.asn1.DenmUperCodec] from whichever raw path is live, the
|
||||
* ESP32-C5 serial link or the CiT One's `v2x/rx/denm` protobuf topic.
|
||||
*
|
||||
* Where both describe the same hazard, the decoded one wins. Both key on ETSI's actionID, so
|
||||
* the `associateBy` below collapses them to one entry, and the decoded list is concatenated
|
||||
* second so it is the one that survives. That is the intended preference: the Use Case app
|
||||
* rate-limits and drops messages, and reduces what it does publish to the fields it cared
|
||||
* about, so it can only ever be a lossier account of the same event.
|
||||
*
|
||||
* Events carrying `termination` are filtered out rather than shown — the hazard is over.
|
||||
*/
|
||||
@@ -156,7 +192,7 @@ class MqttViewModel @Inject constructor(
|
||||
},
|
||||
// Air DENMs accumulate here rather than being a snapshot: the serial path delivers one
|
||||
// event at a time, so runningFold keeps the set of hazards heard so far.
|
||||
camUseCaseRepository.airDenm
|
||||
camUseCaseRepository.decodedDenm
|
||||
.runningFold(emptyMap<String, DenmEvent>()) { acc, denm -> acc + (denm.dedupKey to denm) }
|
||||
.map { it.values.toList() },
|
||||
// Expiry has to be driven by a clock, not by arrivals. Both upstream flows only re-emit
|
||||
@@ -164,11 +200,11 @@ class MqttViewModel @Inject constructor(
|
||||
// power, leaves range - would otherwise leave its hazard on the map forever: there is no
|
||||
// further emission to recompute the list. This tick is what makes a hazard fade.
|
||||
tickerFlow(DENM_EXPIRY_TICK_MS),
|
||||
) { fromMqtt, fromAir, _ ->
|
||||
) { fromUseCaseApp, fromDecoder, _ ->
|
||||
val now = System.currentTimeMillis()
|
||||
(fromMqtt + fromAir)
|
||||
(fromUseCaseApp + fromDecoder)
|
||||
.filterNot { it.isTermination } // the hazard is over - stop drawing it
|
||||
.associateBy { it.dedupKey } // last write wins = most recent per hazard
|
||||
.associateBy { it.dedupKey } // last write wins, so the decoded one is kept
|
||||
.values
|
||||
// Not heard from in DENM_TTL_MS: treat as gone. DENMs repeat at roughly 1 Hz, so a
|
||||
// full minute of silence is ~60 missed repetitions - well past "we briefly lost one".
|
||||
@@ -179,15 +215,16 @@ class MqttViewModel @Inject constructor(
|
||||
/**
|
||||
* Live signal state per intersection, newest first, keyed by [IntersectionSignalState.key].
|
||||
*
|
||||
* ESP32-C5 path only: SPATEM arrives over the air on BTP port 2004. The CiT One path publishes
|
||||
* SPATEM on its own MQTT topic in a different (protobuf-wrapped) shape, which is not wired up.
|
||||
* Both hardware paths: SPATEM arrives over the air on BTP port 2004 via the ESP32-C5 serial
|
||||
* link, or on the CiT One's `v2x/rx/spatem` protobuf topic. The CiT One's processed
|
||||
* `v2x-uca/output/json/spat` topic is not used, since the raw topic carries every repetition.
|
||||
*
|
||||
* One entry per intersection, not per message: SPATEM repeats at ~2 Hz per RSU, so a log would
|
||||
* grow without telling anyone anything. Entries expire like DENMs do - an intersection left
|
||||
* behind stops transmitting, and the same clock-driven argument applies.
|
||||
*/
|
||||
val spatIntersections: StateFlow<List<SpatIntersection>> = combine(
|
||||
camUseCaseRepository.airSpat
|
||||
camUseCaseRepository.decodedSpat
|
||||
.runningFold(emptyMap<String, SpatIntersection>()) { acc, spat ->
|
||||
acc + spat.intersections.associate { i ->
|
||||
i.key to SpatIntersection(i, spat.stationId, spat.rssiDbm, spat.timestamp)
|
||||
|
||||
@@ -9,7 +9,6 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.hawhamburg.micr0bu.data.TripRepository
|
||||
import com.hawhamburg.micr0bu.data.shareTripCsv
|
||||
import com.hawhamburg.micr0bu.data.db.AppDatabase
|
||||
import com.hawhamburg.micr0bu.data.db.DetectedEventEntity
|
||||
import com.hawhamburg.micr0bu.data.db.RecordedTripEntity
|
||||
import com.hawhamburg.micr0bu.service.TripRecordingService
|
||||
import com.hawhamburg.micr0bu.service.TripServiceBus
|
||||
@@ -66,20 +65,6 @@ class TripRecordingViewModel(application: Application) : AndroidViewModel(applic
|
||||
/** All recorded trips, newest first. */
|
||||
val trips: Flow<List<RecordedTripEntity>> = repository.getAllTrips()
|
||||
|
||||
// ── Trip review ───────────────────────────────────────────────────────────
|
||||
|
||||
private val _selectedTripEvents = MutableStateFlow<List<DetectedEventEntity>>(emptyList())
|
||||
val selectedTripEvents: StateFlow<List<DetectedEventEntity>> = _selectedTripEvents.asStateFlow()
|
||||
|
||||
/** Load events for [tripId] into [selectedTripEvents]. */
|
||||
fun loadTripEvents(tripId: Long) {
|
||||
viewModelScope.launch {
|
||||
repository.getEventsForTrip(tripId).collect { events ->
|
||||
_selectedTripEvents.value = events
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Recording control ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -132,7 +117,6 @@ class TripRecordingViewModel(application: Application) : AndroidViewModel(applic
|
||||
shareTripCsv(
|
||||
context = context,
|
||||
trip = trip,
|
||||
events = repository.getEventsForTripOnce(tripId),
|
||||
v2xMessages = repository.getV2xMessagesForTripOnce(tripId),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -31,6 +31,19 @@
|
||||
<string name="dash_samples">Messwerte</string>
|
||||
<string name="dash_start_driving_session">Fahrsitzung starten</string>
|
||||
<string name="dash_initialising">Wird initialisiert…</string>
|
||||
|
||||
<!-- Dashboard live V2X panel: nearest hazard and nearest signalised intersection -->
|
||||
<string name="dash_hazard_warning">Gefahrenwarnung</string>
|
||||
<string name="dash_hazard_station">Station %1$d</string>
|
||||
<string name="dash_more_count">+%1$d weitere</string>
|
||||
<string name="dash_signal_title">Ampel %1$s</string>
|
||||
<string name="dash_signal_more">+%1$d weitere Kreuzung(en)</string>
|
||||
<string name="dash_signal_phase_go">Grün</string>
|
||||
<string name="dash_signal_phase_stop">Rot</string>
|
||||
<string name="dash_signal_phase_changing">Wechselt</string>
|
||||
<string name="dash_signal_phase_dark">Dunkel</string>
|
||||
<string name="dash_signal_phase_unknown">Unbekannt</string>
|
||||
<string name="dash_signal_countdown">%1$s · %2$.0f s</string>
|
||||
<string name="stat_pressure">Luftdruck</string>
|
||||
<string name="stat_altitude">Höhe</string>
|
||||
<string name="stat_heading">Richtung</string>
|
||||
@@ -234,6 +247,8 @@
|
||||
<string name="mqtt_cam_pinger_sent_count">Gesendet: %1$d</string>
|
||||
<string name="mqtt_cam_pinger_send_failures">Schreibfehler: %1$d in Folge - CAMs erreichen den ESP32 nicht</string>
|
||||
<string name="mqtt_cam_pinger_fw_counters">ESP32: TX-Fehler %1$d · zu groß %2$d · CRC-Fehler %3$d</string>
|
||||
<string name="mqtt_cam_pinger_loopback">Eigene Sendung empfangen: %1$d Frames · %2$d dBm</string>
|
||||
<string name="mqtt_cam_pinger_loopback_no_rssi">Eigene Sendung empfangen: %1$d Frames</string>
|
||||
<string name="mqtt_start_pinger">Pinger starten</string>
|
||||
<string name="mqtt_stop_pinger">Pinger stoppen</string>
|
||||
|
||||
@@ -277,10 +292,6 @@
|
||||
<string name="nav_trips">Fahrten</string>
|
||||
|
||||
<!-- Phase A: Recording screen event counters -->
|
||||
<string name="rec_events_detected">Erkannte Ereignisse</string>
|
||||
<string name="rec_event_braking">Bremsen</string>
|
||||
<string name="rec_event_turning">Abbiegen</string>
|
||||
<string name="rec_event_stopping">Anhalten</string>
|
||||
<string name="rec_stream_event_detection">Ereigniserkennung</string>
|
||||
<string name="rec_open_session_log">CSV-Sitzungsprotokoll</string>
|
||||
|
||||
|
||||
@@ -32,6 +32,19 @@
|
||||
<string name="dash_samples">samples</string>
|
||||
<string name="dash_start_driving_session">Start Driving Session</string>
|
||||
<string name="dash_initialising">Initialising…</string>
|
||||
|
||||
<!-- Dashboard live V2X panel: nearest hazard and nearest signalised intersection -->
|
||||
<string name="dash_hazard_warning">Hazard warning</string>
|
||||
<string name="dash_hazard_station">station %1$d</string>
|
||||
<string name="dash_more_count">+%1$d more</string>
|
||||
<string name="dash_signal_title">Traffic light %1$s</string>
|
||||
<string name="dash_signal_more">+%1$d more intersection(s)</string>
|
||||
<string name="dash_signal_phase_go">Green</string>
|
||||
<string name="dash_signal_phase_stop">Red</string>
|
||||
<string name="dash_signal_phase_changing">Changing</string>
|
||||
<string name="dash_signal_phase_dark">Dark</string>
|
||||
<string name="dash_signal_phase_unknown">Unknown</string>
|
||||
<string name="dash_signal_countdown">%1$s · %2$.0f s</string>
|
||||
<string name="stat_pressure">Pressure</string>
|
||||
<string name="stat_altitude">Altitude</string>
|
||||
<string name="stat_heading">Heading</string>
|
||||
@@ -247,6 +260,8 @@
|
||||
<string name="mqtt_cam_pinger_sent_count">Sent: %1$d</string>
|
||||
<string name="mqtt_cam_pinger_send_failures">Write failures: %1$d consecutive - CAMs are not reaching the ESP32</string>
|
||||
<string name="mqtt_cam_pinger_fw_counters">ESP32: tx fail %1$d · oversize %2$d · crc err %3$d</string>
|
||||
<string name="mqtt_cam_pinger_loopback">Own TX heard back: %1$d frames · %2$d dBm</string>
|
||||
<string name="mqtt_cam_pinger_loopback_no_rssi">Own TX heard back: %1$d frames</string>
|
||||
<string name="mqtt_start_pinger">Start Pinger</string>
|
||||
<string name="mqtt_stop_pinger">Stop Pinger</string>
|
||||
|
||||
@@ -290,10 +305,6 @@
|
||||
<string name="nav_trips">Trips</string>
|
||||
|
||||
<!-- Phase A: Recording screen event counters -->
|
||||
<string name="rec_events_detected">Detected Events</string>
|
||||
<string name="rec_event_braking">Braking</string>
|
||||
<string name="rec_event_turning">Turning</string>
|
||||
<string name="rec_event_stopping">Stopping</string>
|
||||
<string name="rec_stream_event_detection">Event Detection</string>
|
||||
<string name="rec_open_session_log">CSV Session Log</string>
|
||||
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
package com.hawhamburg.micr0bu
|
||||
|
||||
import com.hawhamburg.micr0bu.data.transport.EspLinkStatus
|
||||
import com.hawhamburg.micr0bu.data.transport.GnPositionVector
|
||||
import com.hawhamburg.micr0bu.domain.asn1.ItsTime
|
||||
import com.hawhamburg.micr0bu.domain.cam.Cam
|
||||
import com.hawhamburg.micr0bu.domain.cam.StationType
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pins the phone side of SERIAL_MSG_CAM_TX_PV: the 24-byte prefix the ESP32 turns into the
|
||||
* GeoNetworking Source Position Vector, and the heartbeat capability bit that decides whether the
|
||||
* phone may send that message at all.
|
||||
*
|
||||
* ## Where the expected bytes come from
|
||||
* Not from this code. They were produced with Python's `struct.pack("<IiihH", ...)` from the
|
||||
* layout documented at SERIAL_MSG_CAM_TX_PV in `serial_link.h`, independently of this encoder, so
|
||||
* an agreement here is not an encoder agreeing with itself.
|
||||
*
|
||||
* That same `struct.pack` call is what the bench harness used on 2026-09-10 to drive an
|
||||
* ESP32-C5 over its native USB port with this message. The CiT One OBU, an independent
|
||||
* GeoNetworking stack, decoded every Source Position Vector field of the resulting
|
||||
* transmissions (station type, PAI, latitude, longitude, speed, heading and timestamp) back to
|
||||
* the values sent. These are bytes a third-party receiver has accepted on air, not only bytes
|
||||
* this app agrees with.
|
||||
*/
|
||||
class CamTxPvSerialTest {
|
||||
|
||||
private fun String.hexToBytes(): ByteArray =
|
||||
chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
|
||||
private fun ByteArray.u32le(at: Int): Long =
|
||||
(0 until 4).fold(0L) { acc, i -> acc or ((this[at + i].toLong() and 0xFF) shl (8 * i)) }
|
||||
|
||||
// ---- the wire layout -------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `encodes the prefix byte for byte`() {
|
||||
val pv = GnPositionVector(
|
||||
mac = "024d49435230".hexToBytes(),
|
||||
stationType = 2,
|
||||
pai = true,
|
||||
tstMs = 0x12345678L,
|
||||
latTenMicroDeg = 535_543_026,
|
||||
lonTenMicroDeg = 100_226_476,
|
||||
speedCms = 543,
|
||||
headingDeciDeg = 1234,
|
||||
)
|
||||
// 024d49435230 | 02 | 01 | 78563412 | f2bceb1f | ac55f905 | 1f02 | d204
|
||||
assertEquals("024d49435230020178563412f2bceb1fac55f9051f02d204", pv.toSerialPrefix().toHex())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `encodes negative, extreme and flag-clear values`() {
|
||||
// Southern and western hemisphere, full reverse speed, heading at its maximum, PAI clear:
|
||||
// the sign handling that a northern-hemisphere bench test never exercises.
|
||||
val pv = GnPositionVector(
|
||||
mac = "020000000001".hexToBytes(),
|
||||
stationType = 2,
|
||||
pai = false,
|
||||
tstMs = 0xFFFF_FFFFL,
|
||||
latTenMicroDeg = -335_543_026,
|
||||
lonTenMicroDeg = -100_226_476,
|
||||
speedCms = -16384,
|
||||
headingDeciDeg = 3599,
|
||||
)
|
||||
assertEquals("0200000000010200ffffffff0e0500ec54aa06fa00c00f0e", pv.toSerialPrefix().toHex())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the timestamp is reduced modulo 2^32 on the wire`() {
|
||||
// TimestampIts passed 2^32 ms about 49.7 days after its 2004 epoch, so every real value
|
||||
// today is wider than 32 bits and the reduction is the normal case, not an edge case.
|
||||
val pv = vectorAt(tstMs = 716_121_572_779L)
|
||||
assertEquals(3_157_001_643L, pv.toSerialPrefix().u32le(8))
|
||||
}
|
||||
|
||||
// ---- building it from a CAM ------------------------------------------------------------
|
||||
|
||||
private val cam = Cam(
|
||||
stationId = 1_234_567_890L,
|
||||
stationType = StationType.CYCLIST,
|
||||
latitude = 53.5543026,
|
||||
longitude = 10.0226476,
|
||||
speedMps = 5.43,
|
||||
headingDeg = 123.4,
|
||||
yawRateDps = null,
|
||||
accelerationMps2 = null,
|
||||
timestamp = 1_789_036_772_779L,
|
||||
isOwn = true,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `fromCam takes the same values the CAM payload carries`() {
|
||||
val pv = GnPositionVector.fromCam(cam, accuracyM = 5f, mac = "024d49435230".hexToBytes())
|
||||
assertEquals(2, pv.stationType)
|
||||
assertEquals(535_543_026, pv.latTenMicroDeg)
|
||||
assertEquals(100_226_476, pv.lonTenMicroDeg)
|
||||
assertEquals(543, pv.speedCms)
|
||||
assertEquals(1234, pv.headingDeciDeg)
|
||||
assertTrue(pv.pai)
|
||||
// The GN TST and the CAM's generationDeltaTime must follow one time rule.
|
||||
assertEquals(ItsTime.timestampIts(cam.timestamp), pv.tstMs)
|
||||
assertEquals(716_121_572_779L, pv.tstMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `speed is clamped to the 15-bit field, never wrapped`() {
|
||||
// A wrapped 15-bit speed flips its sign bit and reads as reversing at speed.
|
||||
assertEquals(16383, GnPositionVector.fromCam(cam.copy(speedMps = 400.0), 5f, mac).speedCms)
|
||||
assertEquals(-16384, GnPositionVector.fromCam(cam.copy(speedMps = -400.0), 5f, mac).speedCms)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `heading wraps into 0 to 3599`() {
|
||||
assertEquals(0, GnPositionVector.fromCam(cam.copy(headingDeg = 360.0), 5f, mac).headingDeciDeg)
|
||||
assertEquals(50, GnPositionVector.fromCam(cam.copy(headingDeg = 725.0), 5f, mac).headingDeciDeg)
|
||||
assertEquals(3590, GnPositionVector.fromCam(cam.copy(headingDeg = -1.0), 5f, mac).headingDeciDeg)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-finite speed or heading does not throw`() {
|
||||
val pv = GnPositionVector.fromCam(
|
||||
cam.copy(speedMps = Double.NaN, headingDeg = Double.POSITIVE_INFINITY), 5f, mac,
|
||||
)
|
||||
assertEquals(0, pv.speedCms)
|
||||
assertEquals(0, pv.headingDeciDeg)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PAI follows the horizontal accuracy`() {
|
||||
assertTrue(GnPositionVector.fromCam(cam, GnPositionVector.PAI_MAX_ACCURACY_M, mac).pai)
|
||||
assertFalse(GnPositionVector.fromCam(cam, 25f, mac).pai)
|
||||
// Android reports 0 when it has no accuracy estimate: unknown is not accurate.
|
||||
assertFalse(GnPositionVector.fromCam(cam, 0f, mac).pai)
|
||||
assertFalse(GnPositionVector.fromCam(cam, null, mac).pai)
|
||||
}
|
||||
|
||||
@Test(expected = IllegalArgumentException::class)
|
||||
fun `an address that is not six bytes is rejected`() {
|
||||
GnPositionVector.fromCam(cam, 5f, ByteArray(5))
|
||||
}
|
||||
|
||||
// ---- capability negotiation ------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `firmware that predates the capability byte advertises nothing`() {
|
||||
// Old firmware sends a 7-byte heartbeat. Reading that as "no CAM_TX_PV" is what keeps a
|
||||
// new app on the legacy message, which that firmware still understands.
|
||||
val status = EspLinkStatus.parse("00000000000000".hexToBytes())!!
|
||||
assertEquals(0, status.capabilities)
|
||||
assertFalse(status.supportsCamTxPv)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `firmware that advertises CAM_TX_PV is recognised`() {
|
||||
val status = EspLinkStatus.parse("0000000000000001".hexToBytes())!!
|
||||
assertTrue(status.supportsCamTxPv)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a capability byte without the CAM_TX_PV bit does not enable it`() {
|
||||
assertFalse(EspLinkStatus.parse("0000000000000002".hexToBytes())!!.supportsCamTxPv)
|
||||
}
|
||||
|
||||
private val mac = "024d49435230".hexToBytes()
|
||||
|
||||
private fun vectorAt(tstMs: Long) = GnPositionVector(
|
||||
mac = mac, stationType = 2, pai = false, tstMs = tstMs,
|
||||
latTenMicroDeg = 0, lonTenMicroDeg = 0, speedCms = 0, headingDeciDeg = 0,
|
||||
)
|
||||
|
||||
private fun ByteArray.toHex() = joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
@@ -21,34 +21,34 @@ import kotlin.math.sqrt
|
||||
*
|
||||
* No Android emulator required — all production classes have zero Android imports.
|
||||
*
|
||||
* The test [config] uses a smaller window and fewer sustained frames than the
|
||||
* production defaults so tests run in milliseconds without generating thousands
|
||||
* of synthetic samples.
|
||||
* The test [config] shortens only the window and the sustained-frame counts, so
|
||||
* tests run in milliseconds instead of generating thousands of synthetic
|
||||
* samples. Every *signal* threshold is inherited from [DetectionConfig]'s
|
||||
* defaults, which are the values the app actually runs — the two cannot drift
|
||||
* apart, which they previously did: the service overrode nine of the twelve
|
||||
* parameters and these tests validated the un-overridden ones.
|
||||
*
|
||||
* Accel-std-dev notes
|
||||
* -------------------
|
||||
* A production threshold of 1.2 m/s² requires genuine variability in the window.
|
||||
* In the "hard brake" tests we alternate between high and low accel values
|
||||
* (e.g. 3.5 / 0.5), which yields std dev ≈ 1.5 with a 10-sample window.
|
||||
* The braking accel-std-dev threshold of 1.8 m/s² requires genuine variability
|
||||
* in the window. In the "hard brake" tests we alternate between high and low
|
||||
* accel values (4.5 / 0.5), which yields a population std dev of |hi − lo| / 2
|
||||
* = 2.0 in a full window — above the threshold with margin.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class EventDetectorTest {
|
||||
|
||||
/** Tighter config so fewer frames are needed to trigger each event. */
|
||||
/**
|
||||
* Shortens the window and the sustained-frame counts so fewer synthetic frames are
|
||||
* needed per test. Every signal threshold is deliberately left at its default, so
|
||||
* these tests exercise the thresholds the app ships with. Do not restate a signal
|
||||
* threshold here — that is exactly how the two configurations drifted apart before.
|
||||
*/
|
||||
private val config = DetectionConfig(
|
||||
windowSize = 10,
|
||||
brakingSustainedFrames = 5,
|
||||
turningSustainedFrames = 8,
|
||||
stoppingFrames = 20,
|
||||
// Keep production thresholds for all signal values:
|
||||
brakingSpeedDropThreshold = 0.5,
|
||||
brakingAccelStdDevThreshold = 1.2,
|
||||
brakingHighConfidenceRate = 1.5,
|
||||
turningGyroMeanThreshold = 0.4,
|
||||
turningBearingChangeThreshold = 10.0,
|
||||
turningMinSpeedThreshold = 2.0,
|
||||
stoppingSpeedThreshold = 0.5,
|
||||
stoppingAccelStdDevThreshold = 0.15,
|
||||
)
|
||||
|
||||
private lateinit var detector: EventDetector
|
||||
@@ -71,12 +71,12 @@ class EventDetectorTest {
|
||||
|
||||
/**
|
||||
* Produces [n] frames with alternating accelMagnitude values of [hi] and [lo],
|
||||
* giving a population std dev of |hi - lo| / 2, which exceeds the production
|
||||
* threshold of 1.2 m/s² when hi=3.5 and lo=0.5 (std dev = 1.5).
|
||||
* giving a population std dev of |hi - lo| / 2, which exceeds the shipping
|
||||
* threshold of 1.8 m/s² when hi=4.5 and lo=0.5 (std dev = 2.0).
|
||||
*/
|
||||
private fun alternatingAccelFrames(
|
||||
n: Int,
|
||||
hi: Double = 3.5,
|
||||
hi: Double = 4.5,
|
||||
lo: Double = 0.5,
|
||||
speedMps: Double = 10.0,
|
||||
bearingChangeDps: Double = 0.0,
|
||||
@@ -139,12 +139,12 @@ class EventDetectorTest {
|
||||
@Test fun `hard brake with large speed drop has HIGH confidence`() = runCollecting { events ->
|
||||
// Variability established before the drop - see the note in the test above.
|
||||
alternatingAccelFrames(n = config.windowSize, speedMps = 10.0, timeOffset = 0)
|
||||
// Drop of 8 m/s > brakingHighConfidenceRate (1.5)
|
||||
// Drop of 8 m/s > brakingHighConfidencePeakDrop (1.5)
|
||||
alternatingAccelFrames(
|
||||
n = config.brakingSustainedFrames + 5,
|
||||
hi = 3.5,
|
||||
hi = 4.5,
|
||||
lo = 0.5,
|
||||
speedMps = 2.0, // drop from 10 → 8 m/s
|
||||
speedMps = 2.0, // drop from 10 → 2 m/s
|
||||
timeOffset = config.windowSize,
|
||||
)
|
||||
val braking = events.filter { it.type == EventType.BRAKING }
|
||||
@@ -159,12 +159,16 @@ class EventDetectorTest {
|
||||
@Test fun `moderate speed drop has MEDIUM confidence`() = runCollecting { events ->
|
||||
// Variability established before the drop - see `hard brake triggers BRAKING event`.
|
||||
alternatingAccelFrames(n = config.windowSize, speedMps = 3.0, timeOffset = 0)
|
||||
// Drop of 0.8 m/s — above speed-drop threshold (0.5) but below high-conf rate (1.5)
|
||||
// Drop of 1.2 m/s — above the speed-drop threshold (1.0) but below the
|
||||
// high-confidence peak drop (1.5), so this must land as MEDIUM. The window
|
||||
// between those two values is narrow at the shipping thresholds, which is
|
||||
// itself worth knowing: MEDIUM braking is only emitted for drops in
|
||||
// (1.0, 1.5] m/s.
|
||||
alternatingAccelFrames(
|
||||
n = config.brakingSustainedFrames + 5,
|
||||
hi = 3.5,
|
||||
hi = 4.5,
|
||||
lo = 0.5,
|
||||
speedMps = 2.2, // drop = 0.8 m/s
|
||||
speedMps = 1.8, // drop = 1.2 m/s
|
||||
timeOffset = config.windowSize,
|
||||
)
|
||||
val braking = events.filter { it.type == EventType.BRAKING }
|
||||
@@ -179,9 +183,9 @@ class EventDetectorTest {
|
||||
repeat(total) { i ->
|
||||
detector.processSample(
|
||||
accelMagnitude = 0.3,
|
||||
gyroMagnitude = 0.8, // mean → well above 0.4 threshold
|
||||
gyroMagnitude = 0.8, // mean → above the 0.6 threshold
|
||||
speedMps = 4.0, // above 2 m/s → bearing also checked
|
||||
bearingChangeDegPerSec = 15.0, // above 10 °/s → both signals agree
|
||||
bearingChangeDegPerSec = 20.0, // above 15 °/s → both signals agree
|
||||
latitude = 53.5,
|
||||
longitude = 10.0,
|
||||
timestamp = i * 20L,
|
||||
@@ -193,7 +197,7 @@ class EventDetectorTest {
|
||||
@Test fun `turning with both signals agreeing gets HIGH confidence`() = runCollecting { events ->
|
||||
val total = config.windowSize + config.turningSustainedFrames + 4
|
||||
repeat(total) { i ->
|
||||
detector.processSample(0.3, 0.8, 4.0, 15.0, 53.5, 10.0, i * 20L)
|
||||
detector.processSample(0.3, 0.8, 4.0, 20.0, 53.5, 10.0, i * 20L)
|
||||
}
|
||||
val turning = events.filter { it.type == EventType.TURNING }
|
||||
assertTrue(turning.isNotEmpty())
|
||||
@@ -205,9 +209,9 @@ class EventDetectorTest {
|
||||
repeat(total) { i ->
|
||||
detector.processSample(
|
||||
accelMagnitude = 0.2,
|
||||
gyroMagnitude = 0.6, // above gyro threshold
|
||||
gyroMagnitude = 0.9, // above the 0.6 gyro threshold
|
||||
speedMps = 1.0, // below 2 m/s → bearing not enforced
|
||||
bearingChangeDegPerSec = 3.0, // below bearing threshold
|
||||
bearingChangeDegPerSec = 3.0, // below the 15 °/s bearing threshold
|
||||
latitude = 53.5,
|
||||
longitude = 10.0,
|
||||
timestamp = i * 20L,
|
||||
@@ -253,7 +257,7 @@ class EventDetectorTest {
|
||||
// Speed stays at zero; occasional accel/gyro spikes from bag jostle
|
||||
repeat(50) { i ->
|
||||
val accel = if (i % 5 == 0) 1.8 else 0.3 // jitter but mean is below std-dev threshold
|
||||
val gyro = if (i % 7 == 0) 0.35 else 0.05 // occasional spike but mean stays < 0.4
|
||||
val gyro = if (i % 7 == 0) 0.35 else 0.05 // occasional spike but mean stays < 0.6
|
||||
detector.processSample(
|
||||
accelMagnitude = accel,
|
||||
gyroMagnitude = gyro,
|
||||
@@ -265,7 +269,7 @@ class EventDetectorTest {
|
||||
)
|
||||
}
|
||||
// speed = 0 → no speed drop possible → no BRAKING
|
||||
// gyro mean stays below 0.4 (only 1/7 frames spike to 0.35) → no TURNING
|
||||
// gyro mean stays below 0.6 (only 1/7 frames spike to 0.35) → no TURNING
|
||||
val unwanted = events.filter { it.type == EventType.BRAKING || it.type == EventType.TURNING }
|
||||
assertTrue("Bag movement must not trigger BRAKING or TURNING, got: $events", unwanted.isEmpty())
|
||||
}
|
||||
@@ -299,13 +303,12 @@ class EventDetectorTest {
|
||||
}
|
||||
// Second stop episode. Deliberately longer than the first: stopping also requires the
|
||||
// accel std dev to be BELOW a threshold, and the rolling window still holds the five
|
||||
// moving samples above. It takes 8 further frames for those to drain out far enough for
|
||||
// the std dev to fall under 0.15, and only then does the counter start. The first episode
|
||||
// needs no such allowance because the window begins empty.
|
||||
//
|
||||
// stoppingFrames + 5 was not enough - the second episode reached 17 of the 21 frames it
|
||||
// needs and silently emitted nothing, which is what made this test fail.
|
||||
repeat(config.stoppingFrames + 10) {
|
||||
// moving samples above. At the shipping threshold of 0.10 m/s² even a single 0.5 sample
|
||||
// left in a 10-sample window gives a std dev of ~0.14, so ALL five have to be evicted
|
||||
// before the counter can start - that is a full windowSize of stationary frames. Only
|
||||
// then do the 21 qualifying frames the event needs begin to accumulate. The first
|
||||
// episode needs no such allowance because the window begins empty.
|
||||
repeat(config.stoppingFrames + 20) {
|
||||
detector.processSample(0.02, 0.01, 0.1, 0.0, 53.5, 10.0, t++ * 20L)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.hawhamburg.micr0bu
|
||||
|
||||
import com.hawhamburg.micr0bu.domain.asn1.ItsTime
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pins the arithmetic that moves a transmit timestamp from the phone's wall clock onto GNSS time.
|
||||
*
|
||||
* The cases come from the 2026-09-10 bench session. The sending phone's clock was 1456 s fast
|
||||
* because it had no automatic time source, and every CAM it sent was stamped 24 minutes in the
|
||||
* future. After a manual correction it was 6 s slow. Both have to come out on GNSS time.
|
||||
*/
|
||||
class ItsTimeTest {
|
||||
|
||||
private val gnssNow = 1_789_038_922_000L
|
||||
|
||||
@Test
|
||||
fun `without a GNSS reading the wall-clock time is used unchanged`() {
|
||||
assertEquals(1_000L, ItsTime.onGnssTime(systemMs = 1_000L, gnssNowMs = null, systemNowMs = 5_000L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a phone clock running fast is pulled back onto GNSS time`() {
|
||||
val systemNow = gnssNow + 1_456_000L
|
||||
// A fix the wall clock stamped 0.8 s ago. It must still be 0.8 s old afterwards.
|
||||
val fix = systemNow - 800L
|
||||
assertEquals(gnssNow - 800L, ItsTime.onGnssTime(fix, gnssNow, systemNow))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a phone clock running slow is pushed forward onto GNSS time`() {
|
||||
val systemNow = gnssNow - 6_000L
|
||||
assertEquals(gnssNow - 250L, ItsTime.onGnssTime(systemNow - 250L, gnssNow, systemNow))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an accurate phone clock is left where it is`() {
|
||||
assertEquals(gnssNow - 40L, ItsTime.onGnssTime(gnssNow - 40L, gnssNow, gnssNow))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.hawhamburg.micr0bu
|
||||
|
||||
import com.hawhamburg.micr0bu.domain.cam.OwnStationIds
|
||||
import com.hawhamburg.micr0bu.domain.cam.OwnStationIds.BENCH_PING
|
||||
import com.hawhamburg.micr0bu.domain.cam.OwnStationIds.BENCH_PING_GRACE_MS
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pins the rule that decides whether a received CAM is one this phone sent.
|
||||
*
|
||||
* ## The bugs this exists to prevent
|
||||
* Getting it wrong fails in two opposite directions, and each has happened:
|
||||
*
|
||||
* - **Too narrow.** An own frame that is not recognised comes back as a remote road user sitting
|
||||
* exactly on the ego position, and is fed to the detection engine as a collision partner for
|
||||
* itself. That happened with the bench pinger's separate ID, and pseudonym rotation creates the
|
||||
* same risk for an ID that has just been retired.
|
||||
* - **Too wide.** On 2026-09-10 the bench ID counted as ours on every phone, so a phone watching
|
||||
* through the CiT One silently discarded another phone's pings as its own, although it had sent
|
||||
* none. Nothing appeared on its V2X screen while the broker was full of them.
|
||||
*/
|
||||
class OwnStationIdsTest {
|
||||
|
||||
private val current = 1_691_338_363L
|
||||
private val retired = 2_222_222_222L
|
||||
private val ours = setOf(current, retired)
|
||||
|
||||
@Test
|
||||
fun `recognises the current transmit id`() {
|
||||
assertTrue(OwnStationIds.isOwn(current, ours, benchPingIsOurs = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recognises a recently retired id, so a frame sent just before a rotation is still ours`() {
|
||||
assertTrue(OwnStationIds.isOwn(retired, ours, benchPingIsOurs = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `another phone's bench ping is shown, not swallowed as our own`() {
|
||||
// The 2026-09-10 regression: this phone is not pinging, so 999999 is someone else.
|
||||
assertFalse(OwnStationIds.isOwn(BENCH_PING, ours, benchPingIsOurs = false))
|
||||
assertFalse(OwnStationIds.isOwn(BENCH_PING, emptySet(), benchPingIsOurs = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `our own bench ping is recognised while we are pinging, even before any transmit id loads`() {
|
||||
assertTrue(OwnStationIds.isOwn(BENCH_PING, emptySet(), benchPingIsOurs = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `treats a genuine remote station as remote`() {
|
||||
assertFalse(OwnStationIds.isOwn(2_741_041_966L, ours, benchPingIsOurs = true))
|
||||
assertFalse(OwnStationIds.isOwn(2_741_041_966L, emptySet(), benchPingIsOurs = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `station id zero is never ours`() {
|
||||
// 0 is the "not resolved yet" placeholder for the ego identity. Matching on it would
|
||||
// swallow real traffic from any station that reported 0.
|
||||
assertFalse(OwnStationIds.isOwn(0L, setOf(0L), benchPingIsOurs = true))
|
||||
}
|
||||
|
||||
// ---- when the bench id is ours ---------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `the bench id is ours while the pinger runs`() {
|
||||
assertTrue(OwnStationIds.benchPingIsOurs(pingerActive = true, pingerStoppedAtMs = null, nowMs = 0L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the bench id is not ours on a phone that never pinged`() {
|
||||
assertFalse(OwnStationIds.benchPingIsOurs(pingerActive = false, pingerStoppedAtMs = null, nowMs = 50_000L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the bench id stays ours for the grace window after Stop, and not a moment longer`() {
|
||||
val stop = 100_000L
|
||||
assertTrue(OwnStationIds.benchPingIsOurs(false, stop, stop + BENCH_PING_GRACE_MS))
|
||||
assertFalse(OwnStationIds.benchPingIsOurs(false, stop, stop + BENCH_PING_GRACE_MS + 1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a clock reading before the stop time does not claim the bench id`() {
|
||||
assertFalse(OwnStationIds.benchPingIsOurs(false, pingerStoppedAtMs = 100_000L, nowMs = 99_000L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the bench MAC is a locally administered unicast address`() {
|
||||
// Bit 1 set, bit 0 clear. A source address must never be a group address.
|
||||
assertEquals(0x02, OwnStationIds.BENCH_PING_MAC[0].toInt() and 0x03)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.hawhamburg.micr0bu
|
||||
|
||||
import com.hawhamburg.micr0bu.domain.cam.OwnStationIds
|
||||
import com.hawhamburg.micr0bu.domain.cam.Pseudonym
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* Pins what a transmit pseudonym is allowed to look like, and when it rotates.
|
||||
*
|
||||
* The address rules matter on air, not just in the app: the ESP32 writes this MAC straight into
|
||||
* the 802.11 source address. A group (multicast) source address is invalid, and a random address
|
||||
* without the locally-administered bit claims to belong to a real hardware vendor.
|
||||
*/
|
||||
class PseudonymTest {
|
||||
|
||||
@Test
|
||||
fun `rotates every ten minutes`() {
|
||||
assertEquals(10 * 60_000L, Pseudonym.ROTATION_INTERVAL_MS)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `expires exactly at the rotation interval, not a millisecond before`() {
|
||||
val p = Pseudonym(stationId = 42L, mac = mac(0x02), createdAtMs = 1_000L)
|
||||
assertFalse(p.isExpired(1_000L + Pseudonym.ROTATION_INTERVAL_MS - 1))
|
||||
assertTrue(p.isExpired(1_000L + Pseudonym.ROTATION_INTERVAL_MS))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a clock that moved back past the creation time forces a rotation`() {
|
||||
// Otherwise a creation time now lying in the future would pin one identity until the
|
||||
// clock caught up, which after a large correction could be hours.
|
||||
val p = Pseudonym(stationId = 42L, mac = mac(0x02), createdAtMs = 1_000L)
|
||||
assertTrue(p.isExpired(999L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `generated addresses are locally administered unicast, whatever the random bytes`() {
|
||||
repeat(500) { seed ->
|
||||
val first = Pseudonym.generate(0L, Random(seed)).mac[0].toInt()
|
||||
assertEquals("seed $seed: bit 1 set, bit 0 clear", 0x02, first and 0x03)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `generated station ids stay in range`() {
|
||||
repeat(500) { seed ->
|
||||
val id = Pseudonym.generate(0L, Random(seed)).stationId
|
||||
assertTrue("seed $seed: $id", id in 1L until 0xFFFF_FFFEL)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `never generates the bench pinger's identity`() {
|
||||
// Scripted so the exclusion loops actually run: the first draw of each is the bench
|
||||
// value, which must be rejected in favour of the second.
|
||||
val random = ScriptedRandom(
|
||||
longs = ArrayDeque(listOf(OwnStationIds.BENCH_PING, 42L)),
|
||||
bytes = ArrayDeque(listOf(OwnStationIds.BENCH_PING_MAC, byteArrayOf(0x13, 1, 2, 3, 4, 5))),
|
||||
)
|
||||
val p = Pseudonym.generate(0L, random)
|
||||
assertEquals(42L, p.stationId)
|
||||
assertEquals("0x13 with the group bit cleared and the local bit set", 0x12, p.mac[0].toInt() and 0xFF)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a rotation replaces the station id and the address together`() {
|
||||
val a = Pseudonym.generate(0L, Random(1))
|
||||
val b = Pseudonym.generate(Pseudonym.ROTATION_INTERVAL_MS, Random(2))
|
||||
assertNotEquals(a.stationId, b.stationId)
|
||||
assertFalse(a.mac.contentEquals(b.mac))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `equality compares the address bytes, not the array instance`() {
|
||||
assertEquals(
|
||||
Pseudonym(7L, mac(0x02), 5L),
|
||||
Pseudonym(7L, mac(0x02), 5L),
|
||||
)
|
||||
}
|
||||
|
||||
private fun mac(first: Int) = byteArrayOf(first.toByte(), 0x11, 0x22, 0x33, 0x44, 0x55)
|
||||
|
||||
private class ScriptedRandom(
|
||||
private val longs: ArrayDeque<Long>,
|
||||
private val bytes: ArrayDeque<ByteArray>,
|
||||
) : Random() {
|
||||
override fun nextBits(bitCount: Int): Int = error("not used by Pseudonym.generate")
|
||||
override fun nextLong(from: Long, until: Long): Long = longs.removeFirst()
|
||||
override fun nextBytes(size: Int): ByteArray = bytes.removeFirst().copyOf()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package com.hawhamburg.micr0bu
|
||||
|
||||
import com.hawhamburg.micr0bu.data.mqtt.RecvV2xMessage
|
||||
import com.hawhamburg.micr0bu.domain.asn1.CamUperCodec
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pins [RecvV2xMessage] to the protobuf wire format of consider it's `RecvV2XMessage`
|
||||
* (`v2x_interface.proto`, V2X RX protocol v2.4.2), the envelope the CiT One publishes on its raw
|
||||
* `v2x/rx` topics.
|
||||
*
|
||||
* ## Where the fixtures come from
|
||||
* The envelope bytes are written out here by hand from the protobuf encoding rules and the field
|
||||
* numbers in that `.proto`, with the derivation in the comments, so a reviewer can check them
|
||||
* without running anything. They are deliberately **not** produced by an encoder in this
|
||||
* repository: a fixture generated by our own code would agree with our own reader no matter how
|
||||
* wrong both were, which is exactly the failure mode the ASN.1 work in this project ran into
|
||||
* three times.
|
||||
*
|
||||
* The CAM payload inside is the golden UPER frame from [CamEncodeGoldenTest], itself verified
|
||||
* against `asn1tools` and the real ETSI modules in `asn1/`.
|
||||
*
|
||||
* ## Why this matters
|
||||
* Field numbers are wire-format constants with no self-describing names on the wire. Reading
|
||||
* field 2 where the schema says field 3 does not fail loudly, it silently yields a plausible
|
||||
* looking byte string that decodes to nothing. These tests are what should fail if the constants
|
||||
* in [RecvV2xMessage] are ever "tidied".
|
||||
*/
|
||||
class RecvV2xMessageTest {
|
||||
|
||||
/**
|
||||
* The golden CAM UPER, 43 bytes, from [CamEncodeGoldenTest]. Its ItsPduHeader reads
|
||||
* protocolVersion 2, messageID 2 (CAM), stationID 0x000f423f = 999999.
|
||||
*/
|
||||
private val goldenCam =
|
||||
"0202000f423f3700402ab215af6e286477dffffffc23b7743e0027ffc0d0fe0118329337feebfff6000000"
|
||||
|
||||
/**
|
||||
* A complete `RecvV2XMessage` carrying [goldenCam], byte by byte:
|
||||
*
|
||||
* ```
|
||||
* 0a 05 field 1 (btpHeader), length-delimited, 5 bytes
|
||||
* 08 02 field 1 (type) varint = 2, CAM
|
||||
* 10 d1 0f field 2 (destinationPort) varint = 2001
|
||||
* 12 07 field 2 (gnHeader), length-delimited, 7 bytes
|
||||
* 42 05 field 8 (dest), length-delimited, 5 bytes
|
||||
* 0a 03 field 1 (area), length-delimited, 3 bytes
|
||||
* 18 f4 03 field 3 (distA) varint = 500 metres
|
||||
* 1a 2b field 3 (payload), length-delimited, 0x2b = 43 bytes
|
||||
* ```
|
||||
*/
|
||||
private val camEnvelope = "0a05080210d10f120742050a0318f4031a2b" + goldenCam
|
||||
|
||||
private fun String.hexToBytes(): ByteArray =
|
||||
chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
|
||||
// ---- the happy path --------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `parses btp header, geo radius and payload from a full envelope`() {
|
||||
val msg = RecvV2xMessage.parse(camEnvelope.hexToBytes())
|
||||
assertNotNull("envelope should parse", msg)
|
||||
msg!!
|
||||
|
||||
assertEquals("btpHeader.type: CAM", 2, msg.pduType)
|
||||
assertEquals("btpHeader.destinationPort", 2001, msg.destinationPort)
|
||||
assertEquals("gnHeader.dest.area.distA, metres", 500, msg.destAreaRadiusM)
|
||||
assertTrue(
|
||||
"payload must be the CAM UPER byte for byte",
|
||||
msg.payload.contentEquals(goldenCam.hexToBytes()),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `extracted payload is decodable UPER, not a mangled copy`() {
|
||||
val msg = RecvV2xMessage.parse(camEnvelope.hexToBytes())!!
|
||||
// The whole point of carrying bytes rather than a String through the MQTT layer: a UTF-8
|
||||
// round trip would replace most of these bytes and this decode would fail.
|
||||
val cam = CamUperCodec.decode(msg.payload, receivedAtEpochMs = 1_787_100_000_000L)
|
||||
assertNotNull("payload should decode as a CAM", cam)
|
||||
assertEquals("stationID from the ItsPduHeader", 999_999L, cam!!.stationId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reads a DENM envelope's relevance radius`() {
|
||||
// Same shape, DENM values: type 1, port 2002, distA 1000 m, a 2-byte stand-in payload.
|
||||
// 0a 05 08 01 10 d2 0f | 12 07 42 05 0a 03 18 e8 07 | 1a 02 02 01
|
||||
val msg = RecvV2xMessage.parse("0a05080110d20f120742050a0318e8071a020201".hexToBytes())
|
||||
assertNotNull(msg)
|
||||
assertEquals(1, msg!!.pduType)
|
||||
assertEquals(2002, msg.destinationPort)
|
||||
assertEquals(1000, msg.destAreaRadiusM)
|
||||
}
|
||||
|
||||
// ---- forward compatibility -------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `skips unknown fields and does not depend on field order`() {
|
||||
// payload first, then an unknown varint (field 7) and an unknown fixed32 (field 6) that
|
||||
// this schema revision does not define, then the btpHeader. Protobuf permits all three,
|
||||
// and a reader that assumed order or choked on unknowns would break the first time
|
||||
// consider it added a field.
|
||||
val bytes = ("1a2b" + goldenCam + "38b96035deadbeef0a05080210d10f").hexToBytes()
|
||||
val msg = RecvV2xMessage.parse(bytes)
|
||||
assertNotNull(msg)
|
||||
assertEquals(2, msg!!.pduType)
|
||||
assertEquals(2001, msg.destinationPort)
|
||||
assertTrue(msg.payload.contentEquals(goldenCam.hexToBytes()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `accepts an envelope carrying nothing but a payload`() {
|
||||
val msg = RecvV2xMessage.parse(("1a2b" + goldenCam).hexToBytes())
|
||||
assertNotNull(msg)
|
||||
assertNull("no btpHeader was sent", msg!!.pduType)
|
||||
assertNull("no gnHeader was sent", msg.destAreaRadiusM)
|
||||
assertTrue(msg.payload.contentEquals(goldenCam.hexToBytes()))
|
||||
}
|
||||
|
||||
// ---- malformed input -------------------------------------------------------------------
|
||||
// These arrive off a network topic. A reader that throws takes the MQTT callback thread with
|
||||
// it, so every one of these must return null instead.
|
||||
|
||||
@Test
|
||||
fun `returns null for a truncated envelope`() {
|
||||
val full = camEnvelope.hexToBytes()
|
||||
assertNull(RecvV2xMessage.parse(full.copyOfRange(0, full.size / 2)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns null when the payload field is present but empty`() {
|
||||
assertNull(RecvV2xMessage.parse("1a00".hexToBytes()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns null when there is no payload field at all`() {
|
||||
assertNull(RecvV2xMessage.parse("0a05080210d10f".hexToBytes()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns null for empty input and for bytes that are not protobuf`() {
|
||||
assertNull(RecvV2xMessage.parse(ByteArray(0)))
|
||||
// A run of continuation bytes: a varint that never terminates, which is what would walk
|
||||
// an unguarded reader off the end of the buffer.
|
||||
assertNull(RecvV2xMessage.parse(ByteArray(24) { 0xFF.toByte() }))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
# Sniffer board and capture tooling
|
||||
|
||||
How to put an ESP32-C5 on the ITS-G5 channel as a passive sniffer, pull its captures onto this
|
||||
PC, and check what is on air. The sniffer firmware itself is the third-party
|
||||
`its-g5-receiver-firmware` checkout beside this repo; only the tooling and these notes are ours.
|
||||
|
||||
| File | What it does |
|
||||
|---|---|
|
||||
| `live_capture.py` | Streams the device's captures into a growing `.pcap` while it runs. The usual choice. |
|
||||
| `dump_pcap.py` | Pulls one capture out of the device's in-memory buffer after the fact. |
|
||||
| `../obu-firmware/test/pcap_gn_tally.py` | Tallies GeoNetworking headers per station over a `.pcap`. |
|
||||
|
||||
One-time: `pip install pyserial` (present in Python 3.11 on the bench PC, so `py -3.11` works).
|
||||
|
||||
## Which port
|
||||
|
||||
The sniffer firmware's console, and with it the pcap stream, goes out **UART0** - the board's
|
||||
USB-bridge port (a CH343, its own COM number), not the native USB-C port. A board with only one
|
||||
USB-C port cannot be used as a sniffer for this reason. On the bench this has been COM5 and, after
|
||||
a re-enumeration, COM8.
|
||||
|
||||
## Live capture (preferred)
|
||||
|
||||
```powershell
|
||||
cd capture
|
||||
py -3.11 live_capture.py COM8
|
||||
```
|
||||
|
||||
It writes `recordings/capture_<timestamp>.pcap` next to itself, flushing after every packet, so
|
||||
the file can be read while it grows. Stop it with Ctrl+C. Use `-o <dir>` to write elsewhere;
|
||||
`recordings/` is gitignored, since captures are large and are data rather than source. Captures
|
||||
taken before 2026-09-14 are still in `its-g5-receiver-firmware/recordings/`; the host tests read
|
||||
both directories.
|
||||
|
||||
### The CR insertion, and why captures used to be corrupt
|
||||
|
||||
ESP-IDF's newlib console converts LF to CRLF on its way out, and that applies to every `0x0a` byte
|
||||
of the **binary** pcap stream, not only to log text. Each inserted CR shifts everything after it,
|
||||
so pcap record headers and captured frames alike come out corrupt, and the file stops being
|
||||
parseable at the first occurrence.
|
||||
|
||||
Measured on 2026-09-14: a 787 KB capture parsed cleanly for only 82 of about 2000 records, and
|
||||
DENMs appeared on nonsense BTP ports because their payloads contain `0x0a` often. `undo_crlf()` in
|
||||
`live_capture.py` reverses it on the raw stream before any framing, which is exact; afterwards a
|
||||
capture parsed to EOF and DENMs read as port 2002 again.
|
||||
|
||||
**Captures taken before 2026-09-14 are truncated at their first corrupted record.** Anything
|
||||
measured from them is worth re-checking. `dump_pcap.py` reads the same console and has not been
|
||||
given the same treatment yet.
|
||||
|
||||
## Checking a capture
|
||||
|
||||
```powershell
|
||||
py -3.11 ..\obu-firmware\test\pcap_gn_tally.py recordings\capture_<timestamp>.pcap
|
||||
```
|
||||
|
||||
One row per station, packet type, BTP port and GN lifetime. For the messages themselves, decode
|
||||
the payloads with `asn1tools` against the modules in `../asn1/` and re-encode them: identical bytes
|
||||
mean the message was read exactly, wrong bytes mean it was not. `obu-firmware/test/check_replay.py`
|
||||
does this over a whole capture.
|
||||
|
||||
## Flashing the sniffer firmware
|
||||
|
||||
From the receiver checkout, with its **pinned** ESP-IDF (not the global 5.5.4 install):
|
||||
|
||||
```powershell
|
||||
cd its-g5-receiver-firmware
|
||||
git submodule update --init --recursive
|
||||
.\esp-idf\install.bat
|
||||
Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass
|
||||
.\esp-idf\export.ps1
|
||||
idf.py set-target esp32c5
|
||||
idf.py -p COM8 -b 921600 flash
|
||||
```
|
||||
|
||||
Its `sdkconfig` for a bare board (nothing wired) needs SPI Ethernet off, and the pcap destination
|
||||
set to Memory, both under `idf.py menuconfig`. Wired variants have ready-made configs in that
|
||||
checkout: `sdkconfig.proto-w5500`, `sdkconfig.proto-enc28j60`, `sdkconfig.proto-spi-eppp`.
|
||||
|
||||
To reflash a board that already has a built image, without a toolchain terminal:
|
||||
|
||||
```powershell
|
||||
cd its-g5-receiver-firmware\build
|
||||
C:\Espressif\python_env\idf5.5_py3.11_env\Scripts\python.exe -m esptool --chip esp32c5 -p COM8 -b 921600 write_flash --flash_mode dio --flash_freq 80m --flash_size 16MB 0x2000 bootloader/bootloader.bin 0x8000 partition_table/partition-table.bin 0x1e000 ota_data_initial.bin 0x20000 its-g5-receiver-firmware.bin
|
||||
```
|
||||
|
||||
## Pulling a capture after the fact
|
||||
|
||||
Only for the Memory destination, and the buffer is small (`SNIFFER_PCAP_MEMORY_SIZE`, 4096 bytes
|
||||
by default) - a smoke test, not a session. In the device console (`idf.py -p COM8 monitor`, exit
|
||||
with Ctrl+T then Ctrl+X):
|
||||
|
||||
```
|
||||
sniffer -P
|
||||
sniffer --stop
|
||||
```
|
||||
|
||||
Then, with the port free:
|
||||
|
||||
```powershell
|
||||
py -3.11 dump_pcap.py COM8
|
||||
```
|
||||
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Pulls a capture off the ITS-G5 receiver's in-memory pcap buffer over the existing USB serial
|
||||
connection and saves it as a real .pcap file on this machine.
|
||||
|
||||
Requires the firmware to be built with:
|
||||
Example Configuration -> Select destination to store pcap file -> Memory
|
||||
|
||||
Usage (typical):
|
||||
1. Close idf.py monitor (only one program can hold the COM port at a time).
|
||||
2. Run a capture on the device: `sniffer -P` ... let it run ... `sniffer --stop`
|
||||
3. python dump_pcap.py COM5
|
||||
|
||||
The device has no access to this computer's filesystem, so it can't write here directly. Instead,
|
||||
`pcap --dump` streams the raw pcap bytes back over the same serial link, wrapped in plain-text
|
||||
markers ("===PCAP-DUMP-START:<len>===" ... raw bytes ... "===PCAP-DUMP-END==="). This script finds
|
||||
those markers and writes just the raw bytes out as a .pcap file.
|
||||
|
||||
Install dependency once: pip install pyserial
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import re
|
||||
import sys
|
||||
|
||||
try:
|
||||
import serial
|
||||
except ImportError:
|
||||
print("Missing dependency. Install it with: pip install pyserial", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
START_RE = re.compile(rb"===PCAP-DUMP-START:(\d+)===\n")
|
||||
END_MARKER = b"\n===PCAP-DUMP-END===\n"
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("port", help="Serial port the device is on, e.g. COM5")
|
||||
parser.add_argument("-b", "--baud", type=int, default=115200, help="Baud rate (default: 115200)")
|
||||
parser.add_argument("-o", "--outdir", default="recordings", help="Output directory (default: ./recordings)")
|
||||
parser.add_argument("-t", "--timeout", type=float, default=15.0, help="Seconds to wait for the dump to start")
|
||||
args = parser.parse_args()
|
||||
|
||||
import os
|
||||
os.makedirs(args.outdir, exist_ok=True)
|
||||
|
||||
print(f"Opening {args.port} @ {args.baud}...")
|
||||
with serial.Serial(args.port, args.baud, timeout=1) as ser:
|
||||
# Nudge the console in case there's stale input, then request the dump.
|
||||
ser.reset_input_buffer()
|
||||
ser.write(b"\r\n")
|
||||
ser.write(b"pcap -f dump --dump\r\n")
|
||||
|
||||
print("Waiting for dump to start...")
|
||||
buf = b""
|
||||
match = None
|
||||
deadline = datetime.datetime.now() + datetime.timedelta(seconds=args.timeout)
|
||||
while datetime.datetime.now() < deadline:
|
||||
chunk = ser.read(256)
|
||||
if chunk:
|
||||
buf += chunk
|
||||
match = START_RE.search(buf)
|
||||
if match:
|
||||
break
|
||||
if not match:
|
||||
print("Timed out waiting for '===PCAP-DUMP-START:...===' marker.\n"
|
||||
"Check that: the firmware is built with the Memory pcap destination, a capture was\n"
|
||||
"actually taken ('sniffer -P' then 'sniffer --stop'), and no other program (like\n"
|
||||
"idf.py monitor) is holding the serial port open.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
length = int(match.group(1))
|
||||
print(f"Dump starting, {length} bytes expected.")
|
||||
|
||||
# Anything after the marker in our buffer is already part of the payload.
|
||||
payload = buf[match.end():]
|
||||
remaining = length - len(payload)
|
||||
while remaining > 0:
|
||||
chunk = ser.read(min(remaining, 4096))
|
||||
if not chunk:
|
||||
print(f"Serial read timed out with {remaining} bytes still missing.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
payload += chunk
|
||||
remaining -= len(chunk)
|
||||
|
||||
# Drain (and sanity-check) the trailing end marker, but don't fail hard if it's not exact.
|
||||
tail = ser.read(len(END_MARKER))
|
||||
if tail != END_MARKER:
|
||||
print("Warning: end marker didn't match exactly - payload may still be fine.", file=sys.stderr)
|
||||
|
||||
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
outpath = os.path.join(args.outdir, f"capture_{timestamp}.pcap")
|
||||
with open(outpath, "wb") as f:
|
||||
f.write(payload)
|
||||
|
||||
print(f"Saved {len(payload)} bytes to {outpath}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,226 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Continuously listens on the ITS-G5 receiver's serial console and writes every captured packet into a
|
||||
live-growing .pcap file, with no console commands needed on the device side.
|
||||
|
||||
The firmware streams every packet it captures out over the same serial connection the console runs on,
|
||||
automatically, as soon as the sniffer is running (which happens on boot by default). Each packet is framed
|
||||
with plain-text markers so this script can pull the binary pcap bytes out of the stream even though
|
||||
regular log lines are interleaved with it:
|
||||
|
||||
===PCAP-LIVE-HEADER:<len>===\\n<24 raw bytes>\\n (sent once, the pcap global header)
|
||||
===PCAP-LIVE-PKT:<len>===\\n<raw bytes>\\n (sent once per captured packet)
|
||||
|
||||
Usage:
|
||||
python live_capture.py COM5
|
||||
|
||||
Runs until you press Ctrl+C. Writes to recordings/capture_<timestamp>.pcap, flushing after every packet
|
||||
so you can open the file in Wireshark while it's still being written (use "File > Open" again, or
|
||||
Wireshark's own "Follow" won't auto-refresh but re-opening will show the latest packets).
|
||||
|
||||
Install dependency once: pip install pyserial
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
|
||||
try:
|
||||
import serial
|
||||
except ImportError:
|
||||
print("Missing dependency. Install it with: pip install pyserial", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# \r? because the ESP console emits CRLF: on Windows the markers arrive as
|
||||
# "===PCAP-LIVE-PKT:310===\r\n", which never matched a bare \n and left the capture silently
|
||||
# empty while the device was streaming perfectly well.
|
||||
HEADER_RE = re.compile(rb"===PCAP-LIVE-HEADER:(\d+)===\r?\n")
|
||||
PKT_RE = re.compile(rb"===PCAP-LIVE-PKT:(\d+)===\r?\n")
|
||||
|
||||
LINKTYPE_ETHERNET = 1
|
||||
LINKTYPE_IEEE802_11_RADIOTAP = 127
|
||||
|
||||
|
||||
def mac_str(b):
|
||||
return ":".join(f"{x:02x}" for x in b)
|
||||
|
||||
|
||||
def build_default_pcap_header(link_type):
|
||||
"""Synthesizes the same 24-byte global pcap header the firmware would have sent, for when we
|
||||
connect after the device's one-time header already went out (see the race note in main())."""
|
||||
header = bytearray(24)
|
||||
header[0:4] = bytes([0xD4, 0xC3, 0xB2, 0xA1]) # magic (LE bytes of 0xA1B2C3D4)
|
||||
header[4:6] = (2).to_bytes(2, "little") # major version
|
||||
header[6:8] = (4).to_bytes(2, "little") # minor version
|
||||
header[16:20] = (0x40000).to_bytes(4, "little") # snaplen
|
||||
header[20:24] = link_type.to_bytes(4, "little")
|
||||
return bytes(header)
|
||||
|
||||
|
||||
def summarize_packet(link_type, record_bytes, index):
|
||||
"""Best-effort human-readable one-line summary of a captured packet, for live feedback.
|
||||
record_bytes is the raw 16-byte pcap record header followed by the captured frame."""
|
||||
seconds = int.from_bytes(record_bytes[0:4], "little")
|
||||
microseconds = int.from_bytes(record_bytes[4:8], "little")
|
||||
cap_len = int.from_bytes(record_bytes[8:12], "little")
|
||||
frame = record_bytes[16:]
|
||||
ts = f"{seconds}.{microseconds:06d}"
|
||||
|
||||
if link_type == LINKTYPE_IEEE802_11_RADIOTAP and len(frame) >= 24:
|
||||
radiotap_len = int.from_bytes(frame[2:4], "little")
|
||||
rssi = frame[8] - 256 if frame[8] >= 128 else frame[8]
|
||||
station_id = int.from_bytes(frame[16:24], "little")
|
||||
mac_frame = frame[radiotap_len:]
|
||||
if len(mac_frame) >= 16:
|
||||
dst = mac_str(mac_frame[4:10])
|
||||
src = mac_str(mac_frame[10:16])
|
||||
else:
|
||||
dst = src = "?"
|
||||
station = f"{station_id:012x}" if station_id else "unknown"
|
||||
return (f"#{index:<5} [{ts}] len={cap_len:<5} rssi={rssi:>4}dBm "
|
||||
f"station={station} {src} -> {dst}")
|
||||
|
||||
if link_type == LINKTYPE_ETHERNET and len(frame) >= 14:
|
||||
dst = mac_str(frame[0:6])
|
||||
src = mac_str(frame[6:12])
|
||||
ethertype = int.from_bytes(frame[12:14], "big")
|
||||
return f"#{index:<5} [{ts}] len={cap_len:<5} eth {src} -> {dst} type=0x{ethertype:04x}"
|
||||
|
||||
return f"#{index:<5} [{ts}] len={cap_len:<5} (unrecognized frame format)"
|
||||
|
||||
|
||||
def undo_crlf(chunk, state):
|
||||
"""Undo the CR the device console inserts before every LF.
|
||||
|
||||
ESP-IDF's newlib console converts LF to CRLF on its way out, and that happens to every 0x0A
|
||||
byte of the binary pcap stream too, not only to log text. Each inserted CR shifts everything
|
||||
after it, so pcap record headers and captured frames alike come out corrupt. This is the
|
||||
"byte inserted mid-frame" seen in older recordings; with DENM traffic on air it wrecks most
|
||||
of a capture (measured 2026-09-14: a 787 KB file parsed cleanly for only 82 records).
|
||||
|
||||
Dropping one CR immediately before each LF undoes it exactly, provided it is done on the raw
|
||||
stream before any framing and a trailing CR is carried across read boundaries. CR and LF are
|
||||
written as byte values here so the transformation cannot be confused with an escape.
|
||||
"""
|
||||
CR, LF = bytes([13]), bytes([10])
|
||||
if state["pending_cr"]:
|
||||
chunk = CR + chunk
|
||||
state["pending_cr"] = False
|
||||
if chunk.endswith(CR):
|
||||
chunk = chunk[:-1]
|
||||
state["pending_cr"] = True
|
||||
return chunk.replace(CR + LF, LF)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("port", help="Serial port the device is on, e.g. COM5")
|
||||
parser.add_argument("-b", "--baud", type=int, default=115200, help="Baud rate (default: 115200)")
|
||||
parser.add_argument("-o", "--outdir", default="recordings", help="Output directory (default: ./recordings)")
|
||||
args = parser.parse_args()
|
||||
|
||||
os.makedirs(args.outdir, exist_ok=True)
|
||||
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
outpath = os.path.join(args.outdir, f"capture_{timestamp}.pcap")
|
||||
|
||||
print(f"Opening {args.port} @ {args.baud}...")
|
||||
print(f"Writing live capture to {outpath}")
|
||||
print("Press Ctrl+C to stop.")
|
||||
|
||||
header_written = False
|
||||
link_type = None
|
||||
packet_count = 0
|
||||
buf = b""
|
||||
total_bytes = 0
|
||||
last_status = time.monotonic()
|
||||
printed_raw_preview = False
|
||||
|
||||
crlf_state = {"pending_cr": False}
|
||||
|
||||
with serial.Serial(args.port, args.baud, timeout=1) as ser, open(outpath, "wb") as outfile:
|
||||
def read_bytes(n):
|
||||
return undo_crlf(ser.read(n), crlf_state)
|
||||
|
||||
try:
|
||||
while True:
|
||||
chunk = read_bytes(256)
|
||||
if chunk:
|
||||
buf += chunk
|
||||
total_bytes += len(chunk)
|
||||
|
||||
now = time.monotonic()
|
||||
if now - last_status >= 2:
|
||||
last_status = now
|
||||
print(f"[diagnostic] {total_bytes} raw bytes received so far, "
|
||||
f"{packet_count} packets recognized, header_written={header_written}")
|
||||
if total_bytes > 0 and not printed_raw_preview and not header_written and not PKT_RE.search(buf):
|
||||
# We're getting bytes but none of them look like our markers - show a preview
|
||||
# so we can tell whether this is plain log text (markers just haven't shown up
|
||||
# yet), garbage (baud/port mismatch), or something else entirely.
|
||||
preview = buf[:200]
|
||||
print(f"[diagnostic] no markers matched yet - raw preview: {preview!r}")
|
||||
printed_raw_preview = True
|
||||
elif total_bytes == 0:
|
||||
print("[diagnostic] zero bytes received from the port at all - this points at "
|
||||
"the wrong COM port, another program holding the port, or a port that "
|
||||
"isn't actually wired to the console/sniffer output.")
|
||||
|
||||
# The device only sends the global header once, right when the sniffer first starts
|
||||
# (typically within a second or two of boot). If this script connects even slightly
|
||||
# late - very likely right after a fresh flash, since esptool itself resets the board -
|
||||
# that header is already gone before we ever see it. Rather than blocking forever
|
||||
# waiting for a header that's never coming, look for whichever marker shows up first.
|
||||
header_match = None if header_written else HEADER_RE.search(buf)
|
||||
pkt_match = PKT_RE.search(buf)
|
||||
|
||||
if header_match and (not pkt_match or header_match.start() < pkt_match.start()):
|
||||
length = int(header_match.group(1))
|
||||
buf = buf[header_match.end():]
|
||||
while len(buf) < length:
|
||||
buf += read_bytes(length - len(buf))
|
||||
header_bytes = buf[:length]
|
||||
outfile.write(header_bytes)
|
||||
outfile.flush()
|
||||
buf = buf[length:]
|
||||
header_written = True
|
||||
if length >= 24:
|
||||
link_type = int.from_bytes(header_bytes[20:24], "little")
|
||||
print(f"Got pcap global header (link type {link_type}) - device is streaming.\n")
|
||||
continue
|
||||
|
||||
if not header_written and pkt_match:
|
||||
link_type = LINKTYPE_IEEE802_11_RADIOTAP
|
||||
outfile.write(build_default_pcap_header(link_type))
|
||||
outfile.flush()
|
||||
header_written = True
|
||||
print("Note: missed the device's one-time pcap header (it was likely sent before "
|
||||
"this script connected, e.g. right after a flash/reset) - assuming WLAN "
|
||||
"radiotap capture and writing a default header instead.\n")
|
||||
# fall through and process pkt_match below, don't discard this packet
|
||||
|
||||
if not pkt_match:
|
||||
# Keep the buffer from growing unbounded while waiting for a marker, but don't
|
||||
# discard anything - a marker could be split across reads.
|
||||
if len(buf) > 65536:
|
||||
buf = buf[-4096:]
|
||||
continue
|
||||
|
||||
length = int(pkt_match.group(1))
|
||||
buf = buf[pkt_match.end():]
|
||||
while len(buf) < length:
|
||||
buf += read_bytes(length - len(buf))
|
||||
record_bytes = buf[:length]
|
||||
outfile.write(record_bytes)
|
||||
outfile.flush()
|
||||
buf = buf[length:]
|
||||
packet_count += 1
|
||||
print(summarize_packet(link_type, record_bytes, packet_count))
|
||||
except KeyboardInterrupt:
|
||||
print(f"\nStopped. {packet_count} packets saved to {outpath}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -139,8 +139,8 @@ share no code. The requirement is satisfied twice, by different means.
|
||||
| 11.1 | Orientation-Independent Sensor Strategy | **Done** | `SensorRepository.kt` (magnitude-based) | — |
|
||||
| 11.2 | Running Standard Deviation Event Detector | **Done** | `EventDetector.kt`, `RunningStats.kt` | **18 unit tests, 0 failures** |
|
||||
| 11.3 | Trip Recording Architecture | **Done** | `TripRepository.kt`, `TripRecordingService.kt` *(cited)* | — |
|
||||
| 11.4 | Data Model | **Done** | `data/db/` Room entities *(cited)* | — |
|
||||
| 11.5 | New UI Elements for Phase A | **Done** | `TripHistoryScreen.kt`, `TripReviewScreen.kt` | — |
|
||||
| 11.4 | Data Model | **Partial — scope reduced** | `data/db/` Room entities *(cited)* | `detected_events` dropped in schema v5, see scope note |
|
||||
| 11.5 | New UI Elements for Phase A | **Partial — scope reduced** | `TripHistoryScreen.kt`, `TripReviewScreen.kt` | event pins/counters removed by decision, see note |
|
||||
| 11.6 | Phase A Success Criteria | **Partial** | — | needs a real ride; see Open Items |
|
||||
|
||||
**Correction note (11.2).** Four `EventDetectorTest` cases had been failing since the initial commit.
|
||||
@@ -149,6 +149,26 @@ described stimuli the detector cannot physically see, because they ignored the s
|
||||
rolling standard-deviation window. Tests corrected, assertions unchanged, detector untouched. This is
|
||||
worth reporting — it is a finding about test design, not a defect.
|
||||
|
||||
**Scope note (11.5).** The event-detection UI — the live per-type counters on the recording screen,
|
||||
the coloured event pins and detail sheet on the trip review map, and the event count on the trip
|
||||
history card — was removed deliberately. A count of the rider's own braking events is not a goal of
|
||||
this project. The detector itself still runs: it is the input to the CAM transmit-rate policy
|
||||
(§ 13), which raises the beacon rate from 1 Hz to the elevated rate for five seconds after a
|
||||
detected manoeuvre. That is now its only effect: the `detected_events` table was dropped in schema
|
||||
v5 and the per-event rows removed from the trip CSV, so a detected manoeuvre is consumed and
|
||||
discarded. `trips.eventCount` is kept as a single integer per ride, since dropping a SQLite column
|
||||
means recreating the table.
|
||||
|
||||
**Defect note (11.2).** Two defects found while documenting the detector were fixed on 2026-09-07.
|
||||
The nine threshold overrides in `TripRecordingService`'s constructor were promoted to
|
||||
`DetectionConfig`'s defaults and the override deleted, so there is one configuration and
|
||||
`EventDetectorTest` exercises the shipping thresholds rather than the superseded Phase A ones;
|
||||
detector sensitivity is unchanged, and the synthetic stimuli were re-derived because several no
|
||||
longer cleared the stricter real thresholds. `brakingHighConfidenceRate` was renamed
|
||||
`brakingHighConfidencePeakDrop`: it was documented as a rate but has always been compared against
|
||||
the peak cumulative speed drop. The name was corrected rather than the comparison, so detector
|
||||
output is unchanged and the confidence assertions remain valid evidence.
|
||||
|
||||
## 12. Future Architecture & Open Design Questions
|
||||
|
||||
| § | Title | Status | Notes |
|
||||
|
||||
@@ -0,0 +1,508 @@
|
||||
<mxfile host="Electron" agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) draw.io/22.1.2 Chrome/114.0.5735.289 Electron/25.9.4 Safari/537.36" modified="2026-09-10T12:53:19.876Z" etag="qSD0sUq7Dcw_rxL1Y7Bl" version="22.1.2" type="device">
|
||||
<diagram id="90a13364-a465-7bf4-72fc-28e22215d7a0" name="Seite-1">
|
||||
<mxGraphModel dx="1678" dy="1125" grid="1" gridSize="10" guides="1" tooltips="1" connect="0" arrows="1" fold="1" page="1" pageScale="1.5" pageWidth="1169" pageHeight="826" background="none" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="0" style=";html=1;" />
|
||||
<mxCell id="1" style=";html=1;" parent="0" />
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-92" value="" style="rounded=0;whiteSpace=wrap;html=1;dashed=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="950" y="960" width="70" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="3a17f1ce550125da-2" value="Mobile Phone" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="290" y="280" width="650" height="780" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="3a17f1ce550125da-10" value="ESP32-C5" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="1040" y="540" width="580" height="520" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-4" value="<font style="">ESPAR<br>Antenna</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;" parent="1" vertex="1">
|
||||
<mxGeometry x="1150" y="320" width="110" height="90" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-11" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.5;entryY=0;entryDx=0;entryDy=0;exitX=0.499;exitY=0.954;exitDx=0;exitDy=0;exitPerimeter=0;" parent="1" source="Iy5nDPde1Y9wQlhL-YeU-10" target="3a17f1ce550125da-2" edge="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-30" value="static map data" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontColor=#B3B3B3;" parent="Iy5nDPde1Y9wQlhL-YeU-11" connectable="0" vertex="1">
|
||||
<mxGeometry x="-0.2209" y="2" relative="1" as="geometry">
|
||||
<mxPoint x="-8" y="3" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-10" value="<font style="font-size: 18px;">GeoServer</font>" style="ellipse;shape=cloud;whiteSpace=wrap;html=1;align=center;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="540" y="130" width="150" height="100" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-12" value="<font style="">PoTi</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="780" y="670" width="150" height="110" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-13" value="<font style="">GNSS</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="793" y="700" width="60" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-14" value="<font style="">IMU</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="863" y="700" width="60" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-15" value="<font style="">Time Source</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="793" y="740" width="132" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-16" value="<font style="">HMI Support</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="440" y="670" width="171" height="110" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-17" value="<font style="">Display</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="450" y="700" width="70" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-18" value="Haptic" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="530" y="700" width="70" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-19" value="<font style="">Speaker</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="489.5" y="740" width="78" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-84" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="Iy5nDPde1Y9wQlhL-YeU-21" target="Iy5nDPde1Y9wQlhL-YeU-33" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="700" y="770" />
|
||||
<mxPoint x="650" y="770" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-85" value="encoded<br>VAM" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-84" vertex="1" connectable="0">
|
||||
<mxGeometry x="0.01" y="1" relative="1" as="geometry">
|
||||
<mxPoint y="-12" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-21" value="<font style="">VBS</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="440" y="500" width="410" height="160" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-22" value="<font style="">Encode</font><div><font style="">VAM</font></div>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="651.75" y="600" width="68.5" height="52" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-23" value="<font style="">Transmission</font><div><font style="">Management</font></div>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="730" y="600" width="111.5" height="52" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-24" value="GeoServer to<br>area-risk mapping" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="527" y="370" width="149" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-29" value="<font style="font-size: 20px;">VRU-Tx ITS-S Architecture of micrOBU</font>" style="text;html=1;whiteSpace=wrap;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;rounded=0;labelBorderColor=default;" parent="1" vertex="1">
|
||||
<mxGeometry x="850" y="154" width="223" height="52" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-31" value="VRU Basic Service Management" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="567.5" y="530" width="203" height="52" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-32" value="<font style="">GeoNetworking</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="685" y="947" width="150" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-33" value="<font style="">BTP-B</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="606.5" y="807" width="77" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-53" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.75;entryY=1;entryDx=0;entryDy=0;endArrow=classic;endFill=1;startArrow=classic;startFill=1;" parent="1" source="Iy5nDPde1Y9wQlhL-YeU-34" target="Iy5nDPde1Y9wQlhL-YeU-4" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="1270" y="480" />
|
||||
<mxPoint x="1232" y="480" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-147" value="RF Signal" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-53" vertex="1" connectable="0">
|
||||
<mxGeometry x="0.4593" y="1" relative="1" as="geometry">
|
||||
<mxPoint x="9" y="27" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-34" value="<font style="">ITS-G5 radio<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="1230" y="568" width="140" height="32" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-38" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.25;entryY=1;entryDx=0;entryDy=0;" parent="1" source="Iy5nDPde1Y9wQlhL-YeU-39" target="Iy5nDPde1Y9wQlhL-YeU-4" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="1100" y="480" />
|
||||
<mxPoint x="1178" y="480" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-41" value="Steering Signal" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-38" vertex="1" connectable="0">
|
||||
<mxGeometry x="0.2737" y="-1" relative="1" as="geometry">
|
||||
<mxPoint x="39" y="8" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-39" value="<font style="">Antenna<br>Steering<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="1060" y="746" width="80" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-40" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;innerLoopWaypoints=1;exitX=0.5;exitY=1;exitDx=0;exitDy=0;" parent="1" source="3a17f1ce550125da-10" target="3a17f1ce550125da-10" edge="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-1" value="" style="endArrow=none;html=1;rounded=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0;" parent="1" edge="1">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="430" y="790" as="sourcePoint" />
|
||||
<mxPoint x="940" y="790" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-3" value="<font style="">BLE</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="520" y="900" width="330" height="150" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-7" value="<font style="">ATT/ GATT</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="685" y="927" width="150" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-8" value="<font style="">L2CAP</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="685" y="968" width="150" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-9" value="<font style="">BLE Link Layer</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="685" y="1010" width="150" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-10" value="<strong data-end="7298" data-start="7253">≤512 B GATT attribute/application message</strong><br data-end="7301" data-start="7298"><br/><em data-end="7372" data-start="7303">L2CAP / Link Layer segmentation and reassembly handled by BLE stack</em>" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" parent="1" vertex="1">
|
||||
<mxGeometry x="530" y="930" width="140" height="110" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-34" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.5;entryY=1;entryDx=0;entryDy=0;" parent="1" source="ynnYWYYo9pkcd0MtkCud-15" target="ynnYWYYo9pkcd0MtkCud-32" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="1215" y="890" />
|
||||
<mxPoint x="1215" y="890" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-151" value="TX Message" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-34" vertex="1" connectable="0">
|
||||
<mxGeometry x="-0.2929" y="-2" relative="1" as="geometry">
|
||||
<mxPoint as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-116" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;fontColor=#B3B3B3;strokeColor=#B3B3B3;" parent="1" source="ynnYWYYo9pkcd0MtkCud-15" target="ynnYWYYo9pkcd0MtkCud-3" edge="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-118" value="BLE packet" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontColor=#B3B3B3;" parent="ynnYWYYo9pkcd0MtkCud-116" vertex="1" connectable="0">
|
||||
<mxGeometry x="0.0818" y="1" relative="1" as="geometry">
|
||||
<mxPoint x="6" y="1" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-15" value="<font style="">BLE</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="1130" y="900" width="180" height="150" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-16" value="<font style="">ATT/ GATT</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="1145" y="930" width="150" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-17" value="<font style="">L2CAP</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="1145" y="970" width="150" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-18" value="<font style="">BLE Link Layer</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="1145" y="1010" width="150" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-22" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" parent="1" source="ynnYWYYo9pkcd0MtkCud-9" target="ynnYWYYo9pkcd0MtkCud-18" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="1127" y="1025" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-42" value="segmented<br>BLE packets" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-22" vertex="1" connectable="0">
|
||||
<mxGeometry x="-0.5098" y="-1" relative="1" as="geometry">
|
||||
<mxPoint x="74" y="-1" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-30" value="<font style="">GeoNetworking SHB</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="531" y="846" width="177" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-90" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;endArrow=none;endFill=0;" parent="1" source="ynnYWYYo9pkcd0MtkCud-31" target="ynnYWYYo9pkcd0MtkCud-30" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="710" y="861" />
|
||||
<mxPoint x="710" y="861" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-31" value="<font style="">synced transmission dir.</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="720.25" y="846" width="210" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-144" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="ynnYWYYo9pkcd0MtkCud-32" target="ynnYWYYo9pkcd0MtkCud-141" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="1185" y="830" />
|
||||
<mxPoint x="1185" y="830" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-150" value="Transmission<br>Power" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-144" vertex="1" connectable="0">
|
||||
<mxGeometry x="-0.3161" relative="1" as="geometry">
|
||||
<mxPoint y="-3" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-145" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="ynnYWYYo9pkcd0MtkCud-32" target="Iy5nDPde1Y9wQlhL-YeU-39" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="1100" y="830" />
|
||||
<mxPoint x="1100" y="830" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-149" value="Transmission<br>Direction" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-145" vertex="1" connectable="0">
|
||||
<mxGeometry x="-0.3251" y="1" relative="1" as="geometry">
|
||||
<mxPoint x="1" y="-3" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-32" value="<font style="">Message Splitter<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="1060" y="837" width="310" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-48" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.5;entryY=1;entryDx=0;entryDy=0;" parent="1" source="ynnYWYYo9pkcd0MtkCud-44" target="ynnYWYYo9pkcd0MtkCud-45" edge="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-129" value="MPDU" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-48" vertex="1" connectable="0">
|
||||
<mxGeometry x="-0.4731" y="2" relative="1" as="geometry">
|
||||
<mxPoint x="2" y="-4" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-44" value="<font style="">MAC<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="1229" y="698" width="140" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-49" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;" parent="1" source="ynnYWYYo9pkcd0MtkCud-45" target="Iy5nDPde1Y9wQlhL-YeU-34" edge="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-130" value="PPDU" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-49" vertex="1" connectable="0">
|
||||
<mxGeometry x="-0.5724" y="-1" relative="1" as="geometry">
|
||||
<mxPoint x="-1" y="-7" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-127" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#B3B3B3;" parent="1" source="ynnYWYYo9pkcd0MtkCud-45" target="ynnYWYYo9pkcd0MtkCud-126" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="1439" y="640" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-45" value="<font style="">PHY<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="1229" y="630" width="140" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-50" value="" style="verticalLabelPosition=bottom;shadow=0;dashed=0;align=center;html=1;verticalAlign=top;shape=mxgraph.electrical.radio.aerial_-_antenna_1;" parent="1" vertex="1">
|
||||
<mxGeometry x="1187" y="280" width="30" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-56" value="802.11p @ 5.9GHz" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="1" vertex="1" connectable="0">
|
||||
<mxGeometry x="1250.0006451612903" y="310" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-57" value="" style="endArrow=none;html=1;rounded=0;entryX=1;entryY=0.75;entryDx=0;entryDy=0;" parent="1" source="3a17f1ce550125da-2" edge="1">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="590" y="790" as="sourcePoint" />
|
||||
<mxPoint x="940" y="790" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-68" value="" style="endArrow=none;html=1;rounded=0;exitX=0;exitY=0.5;exitDx=0;exitDy=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0;" parent="1" edge="1">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="431" y="490" as="sourcePoint" />
|
||||
<mxPoint x="941" y="490" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-70" value="if rx enabled" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="720" y="320" width="205" height="160" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-71" value="GLOSA" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="730" y="355" width="180" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-72" value="Collision Warning" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="730" y="396" width="180" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-73" value="Recorder/ Logger" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="730" y="435" width="180" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-79" value="Decode<br>VAM" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="451.5" y="600" width="68.5" height="52" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-80" value="Reception<br>Management" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="531" y="600" width="111.5" height="52" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-82" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.71;entryY=1.005;entryDx=0;entryDy=0;entryPerimeter=0;" parent="1" source="Iy5nDPde1Y9wQlhL-YeU-12" target="Iy5nDPde1Y9wQlhL-YeU-21" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="850" y="610" as="targetPoint" />
|
||||
<Array as="points">
|
||||
<mxPoint x="731" y="725" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-83" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.559;entryY=1.01;entryDx=0;entryDy=0;entryPerimeter=0;" parent="1" source="Iy5nDPde1Y9wQlhL-YeU-16" target="Iy5nDPde1Y9wQlhL-YeU-21" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="670" y="725" />
|
||||
<mxPoint x="670" y="680" />
|
||||
<mxPoint x="669" y="680" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-86" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="Iy5nDPde1Y9wQlhL-YeU-33" target="ynnYWYYo9pkcd0MtkCud-30" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="596" y="840" as="targetPoint" />
|
||||
<Array as="points">
|
||||
<mxPoint x="570" y="822" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-93" value="encrypted" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;" parent="1" vertex="1">
|
||||
<mxGeometry x="946" y="935" width="80" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-95" value="<font style="">Platform<br>Security<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="1500" y="580" width="110" height="470" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-99" value="<font style="">Secure<br>Boot<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="1510" y="837" width="90" height="48" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-102" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;fontColor=#B3B3B3;strokeColor=#B3B3B3;" parent="1" source="ynnYWYYo9pkcd0MtkCud-100" target="ynnYWYYo9pkcd0MtkCud-15" edge="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-153" value="BLE Credentials" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontColor=#B3B3B3;" parent="ynnYWYYo9pkcd0MtkCud-102" vertex="1" connectable="0">
|
||||
<mxGeometry x="0.5722" y="1" relative="1" as="geometry">
|
||||
<mxPoint x="52" y="9" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-100" value="<font style="">BLE<br>credentials<br>config<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="1510" y="965" width="90" height="73" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-101" value="<font style="">Debug<br>lockdown<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="1510" y="902" width="90" height="48" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-106" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="ynnYWYYo9pkcd0MtkCud-103" target="Iy5nDPde1Y9wQlhL-YeU-21" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="430" y="580" />
|
||||
<mxPoint x="430" y="580" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-103" value="<font style="">Platform<br>Security<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="300" y="300" width="120" height="740" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-105" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="ynnYWYYo9pkcd0MtkCud-104" target="ynnYWYYo9pkcd0MtkCud-3" edge="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-104" value="<font style="">BLE<br>credentials<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="310" y="982" width="100" height="48" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-108" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=1.008;entryY=0.559;entryDx=0;entryDy=0;entryPerimeter=0;" parent="1" source="Iy5nDPde1Y9wQlhL-YeU-12" target="Iy5nDPde1Y9wQlhL-YeU-33" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="850" y="824" />
|
||||
<mxPoint x="684" y="824" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-109" value="PCI" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-108" vertex="1" connectable="0">
|
||||
<mxGeometry x="-0.0782" y="2" relative="1" as="geometry">
|
||||
<mxPoint as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-110" value="<font style="">LLC/SNAP<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="1230" y="760" width="140" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-111" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="ynnYWYYo9pkcd0MtkCud-32" target="ynnYWYYo9pkcd0MtkCud-110" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="1300" y="810" as="targetPoint" />
|
||||
<Array as="points">
|
||||
<mxPoint x="1300" y="830" />
|
||||
<mxPoint x="1300" y="830" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-112" value="GNPDU" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-111" vertex="1" connectable="0">
|
||||
<mxGeometry x="-0.1526" relative="1" as="geometry">
|
||||
<mxPoint as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-113" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.502;entryY=1;entryDx=0;entryDy=0;entryPerimeter=0;" parent="1" source="ynnYWYYo9pkcd0MtkCud-110" target="ynnYWYYo9pkcd0MtkCud-44" edge="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-114" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.297;entryY=-0.004;entryDx=0;entryDy=0;entryPerimeter=0;" parent="1" source="ynnYWYYo9pkcd0MtkCud-30" target="ynnYWYYo9pkcd0MtkCud-3" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="618" y="890" />
|
||||
<mxPoint x="618" y="890" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-115" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="ynnYWYYo9pkcd0MtkCud-31" target="ynnYWYYo9pkcd0MtkCud-3" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="810" y="896" />
|
||||
<mxPoint x="810" y="896" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-119" value="<font style="">Certificate<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="310" y="720" width="100" height="33" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-122" value="<font style="">Private Key Handling<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="310" y="920" width="100" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-123" value="<font style="">Signer Selection<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="310" y="770" width="100" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-125" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="ynnYWYYo9pkcd0MtkCud-124" target="ynnYWYYo9pkcd0MtkCud-30" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="470" y="865" />
|
||||
<mxPoint x="470" y="865" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-124" value="<font style="">Secured<br>Message<br>Creation<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="310" y="837" width="100" height="70" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-126" value="<font style="">DCC-ACC Calculation<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="1390" y="655" width="97" height="55" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-128" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;jumpStyle=arc;fontColor=#B3B3B3;strokeColor=#B3B3B3;" parent="1" source="ynnYWYYo9pkcd0MtkCud-126" target="ynnYWYYo9pkcd0MtkCud-15" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="1129" y="930" as="targetPoint" />
|
||||
<Array as="points">
|
||||
<mxPoint x="1440" y="930" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-152" value="DCC Feedback" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontColor=#B3B3B3;" parent="ynnYWYYo9pkcd0MtkCud-128" vertex="1" connectable="0">
|
||||
<mxGeometry x="0.7303" relative="1" as="geometry">
|
||||
<mxPoint x="13" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-131" value="" style="endArrow=none;html=1;rounded=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0;" parent="1" edge="1">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="430" y="793" as="sourcePoint" />
|
||||
<mxPoint x="940" y="793" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-133" value="" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="847" y="787" width="6" height="9" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-136" value="" style="endArrow=none;html=1;rounded=0;exitX=0;exitY=0.5;exitDx=0;exitDy=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0;" parent="1" edge="1">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="431" y="883.78" as="sourcePoint" />
|
||||
<mxPoint x="941" y="883.78" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-137" value="" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="647" y="787" width="6" height="9" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-140" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="ynnYWYYo9pkcd0MtkCud-139" target="Iy5nDPde1Y9wQlhL-YeU-21" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="895" y="580" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-139" value="<font style="">DCC-<br>FAC<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="865" y="500" width="60" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-142" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="ynnYWYYo9pkcd0MtkCud-141" target="Iy5nDPde1Y9wQlhL-YeU-34" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="1185" y="584" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-148" value="TX power <br>config" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-142" vertex="1" connectable="0">
|
||||
<mxGeometry x="-0.4822" y="1" relative="1" as="geometry">
|
||||
<mxPoint as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-141" value="<font style="">Radio<br>Control<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="1150" y="746" width="70" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-155" value="" style="endArrow=none;html=1;rounded=0;exitX=0;exitY=0.5;exitDx=0;exitDy=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0;" parent="1" edge="1">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="431" y="887" as="sourcePoint" />
|
||||
<mxPoint x="941" y="887" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-154" value="" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="615" y="881" width="6" height="9" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-157" value="" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="807" y="881" width="6" height="9" as="geometry" />
|
||||
</mxCell>
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
</mxfile>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 237 KiB |
Binary file not shown.
Binary file not shown.
@@ -51,3 +51,49 @@ Known gaps, tracked as TODOs in the source: no real GNSS (lat/long hardcoded
|
||||
0), no real time source (detectionTime/referenceTime hardcoded 0, decodes as
|
||||
2004-01-01), fixed (non-rotating) pseudonym MAC, SHB instead of GeoBroadcast
|
||||
(no multi-hop forwarding), unsecured (no IEEE 1609.2 signing).
|
||||
|
||||
## Running it as a bench beacon
|
||||
|
||||
This firmware needs no phone: it beacons a CAM every second by itself
|
||||
(`TX_INTERVAL_MS`) from station `0x0BADC0DE` (195936478), stationType 5
|
||||
(passengerCar), at the hardcoded bench position, under the fixed MAC
|
||||
`02:00:00:00:00:01`, on 5900 MHz. That makes it the quickest way to put known,
|
||||
repeatable traffic on air, and it is how the 4-bit `yawRateConfidence` encoding
|
||||
was confirmed over the air on 2026-09-14.
|
||||
|
||||
A board with only one USB-C port is fine. This firmware's console is on UART0,
|
||||
so such a board shows no log output, but nothing here needs the console.
|
||||
|
||||
Flash it from the toolchain terminal (ESP-IDF 5.5.4, see the table above):
|
||||
|
||||
```powershell
|
||||
cd C:\Users\Ashin\AndroidStudioProjects\MicrOBU\obu-cam-transmistter
|
||||
idf.py -p COM10 -b 921600 flash
|
||||
```
|
||||
|
||||
Or flash the existing build without any toolchain terminal:
|
||||
|
||||
```powershell
|
||||
cd obu-cam-transmistter\build
|
||||
C:\Espressif\python_env\idf5.5_py3.11_env\Scripts\python.exe -m esptool --chip esp32c5 -p COM10 -b 921600 write_flash --flash_mode dio --flash_freq 80m --flash_size 2MB 0x2000 bootloader/bootloader.bin 0x8000 partition_table/partition-table.bin 0x10000 obu_firmware.bin
|
||||
```
|
||||
|
||||
It starts beaconing as soon as it boots, so there is nothing to start by hand,
|
||||
and unplugging it is how you stop it.
|
||||
|
||||
**It transmits under the same MAC as the phone's CAM pinger**, so on air the two
|
||||
are told apart by station ID (195936478 here, 999999 for the pinger), never by
|
||||
source address.
|
||||
|
||||
To see what it is sending, capture on the sniffer board and decode:
|
||||
|
||||
```powershell
|
||||
cd capture
|
||||
py -3.11 live_capture.py COM8
|
||||
py -3.11 ..\obu-firmware\test\pcap_gn_tally.py recordings\capture_<timestamp>.pcap
|
||||
```
|
||||
|
||||
The tally lists it as SHB / port 2001 / lifetime `0x05`. For the message itself,
|
||||
decode the payload with `asn1tools` against `asn1/cam_1_4_1.asn` +
|
||||
`asn1/cdd_1_3_1_1.asn`; re-encoding must return the identical bytes. On
|
||||
2026-09-14, 72 of 72 frames did.
|
||||
|
||||
@@ -117,9 +117,13 @@ int cam_encode(const cam_fields_t *f, uint8_t *buf, size_t buf_len)
|
||||
bw_put_bits(&bw, 0, 1); // extension bit: value is in the root list
|
||||
bw_put_bits(&bw, 2, 2); // unavailable(2)
|
||||
// YawRate: YawRateValue(-32766..32767)->16 (offset from -32766),
|
||||
// YawRateConfidence ENUM 8 values -> 3 bits
|
||||
// YawRateConfidence ENUM with NINE values, degSec-000-01(0) .. unavailable(8)
|
||||
// (cdd_1_3_1_1.asn) -> 4 bits. This wrote 3 bits with value 7, one bit short and the
|
||||
// wrong symbol (7 is outOfRange), so every field after it shifted for any
|
||||
// standards-compliant receiver. The app's CamUperCodec.kt fixed the same line on
|
||||
// 2026-08-20; this copy was missed until 2026-09-11.
|
||||
bw_put_bits(&bw, 32767 - (uint32_t)(-32766), 16); // yawRateValue: unavailable(32767)
|
||||
bw_put_bits(&bw, 7, 3); // yawRateConfidence: unavailable(7)
|
||||
bw_put_bits(&bw, 8, 4); // yawRateConfidence: unavailable(8)
|
||||
|
||||
// ---- LowFrequencyContainer ---- CHOICE { basicVehicleContainerLowFrequency,
|
||||
// ... } - EXTENSIBLE, 1 root alternative (index needs 0 bits).
|
||||
|
||||
@@ -19,7 +19,10 @@ int geonet_wrap_shb(const uint8_t *its_payload, int its_len,
|
||||
// ---- GN Basic Header (4 bytes) ---- (EN 302 636-4-1 clause 9.6)
|
||||
*p++ = (uint8_t)((1 << 4) | 1); // version=1, NextHeader=1 (Common Header, unsecured)
|
||||
*p++ = 0x00; // reserved
|
||||
*p++ = 0x83; // lifetime (~60s in the base/multiplier encoding) - tune if needed
|
||||
// Lifetime: multiplier in the upper 6 bits, base in the lower 2 (0 = 50 ms, 1 = 1 s, 2 = 10 s,
|
||||
// 3 = 100 s). 0x05 = 1 x 1 s, what real stations send their CAMs with. Was 0x83, commented as
|
||||
// ~60 s but decoding to 32 x 100 s = 3200 s. See obu-firmware's geonet.c.
|
||||
*p++ = 0x05;
|
||||
*p++ = 1; // remaining hop limit = 1 (SHB single-hop; matches CAM in the Rust reference)
|
||||
|
||||
// ---- GN Common Header (8 bytes) ---- (clause 9.7)
|
||||
|
||||
@@ -122,9 +122,13 @@ int cam_encode(const cam_fields_t *f, uint8_t *buf, size_t buf_len)
|
||||
bw_put_bits(&bw, 0, 1); // extension bit: value is in the root list
|
||||
bw_put_bits(&bw, 2, 2); // unavailable(2)
|
||||
// YawRate: YawRateValue(-32766..32767)->16 (offset from -32766),
|
||||
// YawRateConfidence ENUM 8 values -> 3 bits
|
||||
// YawRateConfidence ENUM with NINE values, degSec-000-01(0) .. unavailable(8)
|
||||
// (cdd_1_3_1_1.asn) -> 4 bits. This wrote 3 bits with value 7, one bit short and the
|
||||
// wrong symbol (7 is outOfRange), so every field after it shifted for any
|
||||
// standards-compliant receiver. The app's CamUperCodec.kt fixed the same line on
|
||||
// 2026-08-20; this reference copy was missed until 2026-09-11.
|
||||
bw_put_bits(&bw, 32767 - (uint32_t)(-32766), 16); // yawRateValue: unavailable(32767)
|
||||
bw_put_bits(&bw, 7, 3); // yawRateConfidence: unavailable(7)
|
||||
bw_put_bits(&bw, 8, 4); // yawRateConfidence: unavailable(8)
|
||||
|
||||
// ---- LowFrequencyContainer ---- CHOICE { basicVehicleContainerLowFrequency,
|
||||
// ... } - EXTENSIBLE, 1 root alternative (index needs 0 bits).
|
||||
|
||||
+39
-23
@@ -1,9 +1,23 @@
|
||||
#include "geonet.h"
|
||||
#include <string.h>
|
||||
|
||||
// GeoNetworking is big-endian throughout, unlike this project's serial framing.
|
||||
static void put_be16(uint8_t **p, uint16_t v)
|
||||
{
|
||||
*(*p)++ = (uint8_t)(v >> 8);
|
||||
*(*p)++ = (uint8_t)(v);
|
||||
}
|
||||
|
||||
static void put_be32(uint8_t **p, uint32_t v)
|
||||
{
|
||||
*(*p)++ = (uint8_t)(v >> 24);
|
||||
*(*p)++ = (uint8_t)(v >> 16);
|
||||
*(*p)++ = (uint8_t)(v >> 8);
|
||||
*(*p)++ = (uint8_t)(v);
|
||||
}
|
||||
|
||||
int geonet_wrap_shb(const uint8_t *its_payload, int its_len,
|
||||
const uint8_t mac[6], uint8_t station_type,
|
||||
int32_t latitude_tenmicrodeg, int32_t longitude_tenmicrodeg,
|
||||
const gn_lpv_t *lpv,
|
||||
uint16_t btp_dest_port,
|
||||
uint8_t *out, size_t out_len)
|
||||
{
|
||||
@@ -19,7 +33,12 @@ int geonet_wrap_shb(const uint8_t *its_payload, int its_len,
|
||||
// ---- GN Basic Header (4 bytes) ---- (EN 302 636-4-1 clause 9.6)
|
||||
*p++ = (uint8_t)((1 << 4) | 1); // version=1, NextHeader=1 (Common Header, unsecured)
|
||||
*p++ = 0x00; // reserved
|
||||
*p++ = 0x83; // lifetime (~60s in the base/multiplier encoding) - tune if needed
|
||||
// Lifetime: multiplier in the upper 6 bits, base in the lower 2 (0 = 50 ms, 1 = 1 s, 2 = 10 s,
|
||||
// 3 = 100 s). 0x05 = 1 x 1 s, which is what every other station in
|
||||
// its-g5-receiver-firmware/recordings sends its CAMs with. This was 0x83, commented as ~60 s but
|
||||
// decoding to 32 x 100 s = 3200 s, beyond the 600 s itsGnMaxPacketLifetime a sender may use at
|
||||
// all. A DENM would want a longer one (the captured GeoBroadcast DENMs use 0x79, 30 s).
|
||||
*p++ = 0x05;
|
||||
*p++ = 1; // remaining hop limit = 1 (SHB single-hop; matches CAM in the Rust reference)
|
||||
|
||||
// ---- GN Common Header (8 bytes) ---- (clause 9.7)
|
||||
@@ -50,27 +69,24 @@ int geonet_wrap_shb(const uint8_t *its_payload, int its_len,
|
||||
// defined to BE the link-layer (802.11) address - so this must match
|
||||
// the source address dot11p_build_frame uses, not just "look similar."
|
||||
uint8_t gn_addr[8];
|
||||
gn_addr[0] = (uint8_t)((0 << 7) | ((station_type & 0x1F) << 2)); // M=0, ST=station_type, top 2 reserved bits=0
|
||||
gn_addr[0] = (uint8_t)((0 << 7) | ((lpv->station_type & 0x1F) << 2)); // M=0, ST=station_type, top 2 reserved bits=0
|
||||
gn_addr[1] = 0x00; // remaining 8 reserved bits
|
||||
memcpy(&gn_addr[2], mac, 6); // MID = link-layer address
|
||||
memcpy(&gn_addr[2], lpv->mac, 6); // MID = link-layer address
|
||||
memcpy(p, gn_addr, 8); p += 8;
|
||||
// Timestamp (4 bytes, ms since 2004-01-01 mod 2^32) - placeholder 0,
|
||||
// same caveat as detectionTime in denm.c.
|
||||
memset(p, 0, 4); p += 4;
|
||||
// Latitude/Longitude (4+4 bytes, signed, big-endian, 1/10 microdegree) -
|
||||
// fixed-width binary fields, not UPER bit-packed.
|
||||
uint32_t lat_u = (uint32_t)latitude_tenmicrodeg;
|
||||
*p++ = (uint8_t)(lat_u >> 24); *p++ = (uint8_t)(lat_u >> 16);
|
||||
*p++ = (uint8_t)(lat_u >> 8); *p++ = (uint8_t)(lat_u);
|
||||
uint32_t lon_u = (uint32_t)longitude_tenmicrodeg;
|
||||
*p++ = (uint8_t)(lon_u >> 24); *p++ = (uint8_t)(lon_u >> 16);
|
||||
*p++ = (uint8_t)(lon_u >> 8); *p++ = (uint8_t)(lon_u);
|
||||
// PAI(1 bit) + Speed(15 bits), packed into 2 bytes: 0 = PAI false,
|
||||
// speed 0 - which is actually correct semantics for a STATIONARY
|
||||
// vehicle beacon, not just a placeholder.
|
||||
*p++ = 0x00; *p++ = 0x00;
|
||||
// Heading (16 bits, 0.1 degree units): 0 = due north / unavailable
|
||||
*p++ = 0x00; *p++ = 0x00;
|
||||
// TST (4 bytes): when the position below was acquired, ms, TimestampIts mod 2^32.
|
||||
put_be32(&p, lpv->tst_ms);
|
||||
// Latitude/Longitude (4+4 bytes, signed, 1/10 microdegree) - fixed-width binary fields, not
|
||||
// UPER bit-packed like the CAM payload's own position.
|
||||
put_be32(&p, (uint32_t)lpv->lat_tenmicrodeg);
|
||||
put_be32(&p, (uint32_t)lpv->lon_tenmicrodeg);
|
||||
// PAI (1 bit) + Speed (15 bits, signed, 0.01 m/s). Clamped, not masked: a 15-bit value that
|
||||
// overflows wraps its sign bit and reads as travelling backwards at speed.
|
||||
int32_t speed = lpv->speed_cms;
|
||||
if (speed > 16383) speed = 16383;
|
||||
if (speed < -16384) speed = -16384;
|
||||
put_be16(&p, (uint16_t)(((lpv->pai ? 1u : 0u) << 15) | ((uint16_t)speed & 0x7FFFu)));
|
||||
// Heading (16 bits, 0.1 degree from north, clockwise, 0..3599).
|
||||
put_be16(&p, (uint16_t)(lpv->heading_decideg % 3600u));
|
||||
// Reserved (4 bytes) - clause 9.8.4: the SHB extended header is the 24-byte Source Position
|
||||
// Vector FOLLOWED BY a 4-byte reserved field (media-dependent data), 28 bytes in total. These
|
||||
// four bytes were missing, which is why a standards-compliant receiver read our CAM payload's
|
||||
@@ -84,7 +100,7 @@ int geonet_wrap_shb(const uint8_t *its_payload, int its_len,
|
||||
*p++ = (uint8_t)(btp_dest_port & 0xFF);
|
||||
*p++ = 0x00; *p++ = 0x00; // destination port info, unused for BTP-B
|
||||
|
||||
// ---- ITS payload (DENM UPER bytes) ----
|
||||
// ---- ITS payload (CAM UPER bytes from the phone) ----
|
||||
memcpy(p, its_payload, its_len);
|
||||
p += its_len;
|
||||
|
||||
|
||||
+43
-29
@@ -1,43 +1,57 @@
|
||||
#ifndef GEONET_H
|
||||
#define GEONET_H
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
|
||||
// Wraps an ITS application payload (e.g. from denm_encode) with a minimal
|
||||
// GeoNetworking Basic Header + Common Header + Single-Hop-Broadcast
|
||||
// extended header (HeaderType=TSB(5), HeaderSubtype=SINGLE_HOP(0), per
|
||||
// ETSI EN 302 636-4-1 table 9), then prepends a BTP-B header addressed to
|
||||
// the DENM service port (2002).
|
||||
// The variable content of a GeoNetworking Long Position Vector (ETSI EN 302 636-4-1 clause
|
||||
// 9.5.2): who the sender is and where it was. This is the Source Position Vector every
|
||||
// GeoNetworking packet from this firmware carries.
|
||||
//
|
||||
// `mac` is the 6-byte pseudonym/link-layer address - pass the SAME address
|
||||
// you hand to dot11p_build_frame's src address, since GN_ADDR's MID field
|
||||
// (the last 6 bytes of the 8-byte GN_ADDR) is defined to BE that
|
||||
// link-layer address (EN 302 636-4-1 clause 9.5.1). `station_type` is the
|
||||
// 5-bit ITS-S type from the same clause (5 = passengerCar) and gets packed
|
||||
// into GN_ADDR alongside the address.
|
||||
// Every field here used to be a compile-time constant: the bench coordinates, speed 0, heading 0,
|
||||
// timestamp 0, passengerCar, and one fixed MAC. The phone never told the firmware where it was,
|
||||
// so the GN layer described a stationary car parked at the bench while the CAM inside it
|
||||
// described a moving cyclist somewhere else. The phone now supplies these values with each frame
|
||||
// (SERIAL_MSG_CAM_TX_PV in serial_link.h) and this firmware only lays them out on the wire.
|
||||
typedef struct {
|
||||
// Pseudonym. Written into GN_ADDR's MID field here AND, by dot11p_build_frame, into the
|
||||
// 802.11 source address. Clause 9.5.1 defines the MID as the link-layer address, so the two
|
||||
// must be the same six bytes; taking both from this one field is what keeps them identical
|
||||
// when the pseudonym rotates.
|
||||
uint8_t mac[6];
|
||||
// ITS-S type, TS 102 894-2 StationType (2 = cyclist). Only the low 5 bits fit in GN_ADDR.
|
||||
uint8_t station_type;
|
||||
// Position Accuracy Indicator.
|
||||
bool pai;
|
||||
// TST: the moment lat/lon were acquired, in ms, as TimestampIts modulo 2^32.
|
||||
uint32_t tst_ms;
|
||||
// 1/10 microdegree, signed.
|
||||
int32_t lat_tenmicrodeg;
|
||||
int32_t lon_tenmicrodeg;
|
||||
// 0.01 m/s. The wire field is 15-bit signed, so this is clamped to -16384..16383 on encode.
|
||||
int16_t speed_cms;
|
||||
// 0.1 degree from north, clockwise. Wrapped into 0..3599 on encode.
|
||||
uint16_t heading_decideg;
|
||||
} gn_lpv_t;
|
||||
|
||||
// Wraps an ITS application payload (the CAM UPER bytes the phone built) in a GeoNetworking Basic
|
||||
// Header + Common Header + Single-Hop-Broadcast extended header (HeaderType=TSB(5),
|
||||
// HeaderSubtype=SINGLE_HOP(0), EN 302 636-4-1 table 9), then a BTP-B header addressed to
|
||||
// `btp_dest_port`.
|
||||
//
|
||||
// `latitude_tenmicrodeg`/`longitude_tenmicrodeg` go into the Source Long
|
||||
// Position Vector (clause 9.5.2) as plain 32-bit signed big-endian fields -
|
||||
// NOT UPER bit-packed like the DENM payload's position fields, this is a
|
||||
// fixed-width binary protocol. Pass the SAME values you gave denm_encode's
|
||||
// eventPosition, so the GN-layer position and the DENM's own claimed
|
||||
// position agree.
|
||||
// Single-hop broadcast is the correct packet type for CAM, which ETSI defines as never forwarded,
|
||||
// so it has no destination area and no sequence number. A future DENM transmit path would need
|
||||
// GeoBroadcast (HeaderType=4) instead, which this function does not build.
|
||||
//
|
||||
// Deliberate simplification: real DENM dissemination normally uses
|
||||
// GeoBroadcast (GBC, HeaderType=4) so RSUs/OBUs can forward it across an
|
||||
// area - that needs a sequence number + geo-area fields this skeleton
|
||||
// doesn't build yet. Single-hop broadcast is simpler and is the
|
||||
// best-tested decode path in the receiver firmware you already have
|
||||
// working (same extended header shape as CAM). Fine for a single-vehicle
|
||||
// beacon; revisit if you need real multi-hop forwarding later.
|
||||
// `lpv` supplies the Source Position Vector. Hand the SAME lpv->mac to dot11p_build_frame as its
|
||||
// source address, or the GN and 802.11 layers will name two different senders.
|
||||
//
|
||||
// `btp_dest_port` is the BTP-B destination port for the service being carried
|
||||
// (ETSI TS 103 248): 2001 = CAM, 2002 = DENM, 2003 = MAPEM, 2004 = SPATEM, ...
|
||||
// `btp_dest_port` is the BTP-B destination port (ETSI TS 103 248): 2001 = CAM, 2002 = DENM,
|
||||
// 2003 = MAPEM, 2004 = SPATEM.
|
||||
//
|
||||
// Returns bytes written, or -1 if out buffer too small.
|
||||
// Returns bytes written, or -1 if the out buffer is too small.
|
||||
int geonet_wrap_shb(const uint8_t *its_payload, int its_len,
|
||||
const uint8_t mac[6], uint8_t station_type,
|
||||
int32_t latitude_tenmicrodeg, int32_t longitude_tenmicrodeg,
|
||||
const gn_lpv_t *lpv,
|
||||
uint16_t btp_dest_port,
|
||||
uint8_t *out, size_t out_len);
|
||||
|
||||
|
||||
+131
-15
@@ -28,9 +28,20 @@
|
||||
#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_NEXT_HEADER_COMMON (1) // unsecured: the Common Header follows
|
||||
#define GN_NEXT_HEADER_SECURED (2) // a TS 103 097 envelope follows, Common Header inside it
|
||||
#define GN_COMMON_NEXT_HEADER_BTP_B (2)
|
||||
|
||||
// Common Header field (clause 9.7): length of everything after the GeoNetworking headers, i.e.
|
||||
// the BTP-B header plus the ITS payload.
|
||||
#define GN_COMMON_PAYLOAD_LEN_OFFSET (4)
|
||||
|
||||
// IEEE 1609.2 / TS 103 097 envelope, COER encoded - see unwrap_secured().
|
||||
#define IEEE1609DOT2_VERSION (3)
|
||||
#define CONTENT_TAG_UNSECURED_DATA (0x80) // Ieee1609Dot2Content CHOICE, context tag 0
|
||||
#define CONTENT_TAG_SIGNED_DATA (0x81) // context tag 1
|
||||
#define SIGNED_PAYLOAD_HAS_DATA (0x40) // SignedDataPayload preamble: `data` present
|
||||
|
||||
#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
|
||||
@@ -51,6 +62,90 @@ static uint16_t be16(const uint8_t *p)
|
||||
return (uint16_t)(((uint16_t)p[0] << 8) | (uint16_t)p[1]);
|
||||
}
|
||||
|
||||
// COER length determinant (ITU-T X.696): a first byte below 0x80 is the length itself; otherwise
|
||||
// its low 7 bits count the big-endian length bytes that follow. Two of them cover anything this
|
||||
// radio can deliver. Returns how many bytes the determinant occupies, or 0 if it does not fit in
|
||||
// `avail` or uses a form this does not read.
|
||||
static int coer_length(const uint8_t *p, int avail, int *len)
|
||||
{
|
||||
if (avail < 1) {
|
||||
return 0;
|
||||
}
|
||||
if (p[0] < 0x80) {
|
||||
*len = p[0];
|
||||
return 1;
|
||||
}
|
||||
const int n = p[0] & 0x7F;
|
||||
if (n < 1 || n > 2 || avail < 1 + n) {
|
||||
return 0;
|
||||
}
|
||||
int v = 0;
|
||||
for (int i = 1; i <= n; i++) {
|
||||
v = (v << 8) | p[i];
|
||||
}
|
||||
*len = v;
|
||||
return 1 + n;
|
||||
}
|
||||
|
||||
// Locates the GeoNetworking packet inside a secured one. `offset` points just past the Basic
|
||||
// Header. Returns the offset of the inner Common Header and sets *inner_end to where the envelope
|
||||
// says the inner packet ends - which lies beyond frame_len if the capture was cut short - or
|
||||
// returns -1 for anything this does not unwrap.
|
||||
//
|
||||
// The envelope is an Ieee1609Dot2Data (IEEE 1609.2, profiled by TS 103 097 v1.3.1 and later),
|
||||
// COER encoded. A signed message starts:
|
||||
//
|
||||
// 03 protocolVersion 3
|
||||
// 81 content = signedData
|
||||
// 00 hashId (sha256; any one-byte value is accepted - the hash is not checked)
|
||||
// 40 tbsData.payload preamble: `data` present (bit 6)
|
||||
// 03 80 <len> payload.data: an Ieee1609Dot2Data holding unsecuredData of <len> bytes, which
|
||||
// are the Common Header, extended header, BTP-B header and ITS payload
|
||||
// ... headerInfo, signer, signature: not read
|
||||
//
|
||||
// The inner packet comes first inside tbsData, so it is found without parsing the certificate
|
||||
// or the signature, and its explicit length is what separates it from them. The shape is
|
||||
// measured, not only read from the standard: all 157 signed frames in
|
||||
// capture_20260817_171055.pcap have it (150 CAM, 7 GeoBroadcast DENM; <len> in all three COER
|
||||
// forms), and asn1tools decodes every one of them to the same unsecuredData. A top-level
|
||||
// unsecuredData (03 80 <len>, no signature at all) is accepted too.
|
||||
static int unwrap_secured(const uint8_t *frame, int offset, int frame_len,
|
||||
int *inner_end, bool *is_signed)
|
||||
{
|
||||
const uint8_t *p = frame + offset;
|
||||
const int avail = frame_len - offset;
|
||||
int i;
|
||||
|
||||
if (avail < 2 || p[0] != IEEE1609DOT2_VERSION) {
|
||||
return -1; // includes the legacy TS 103 097 v1.2.1 envelope, protocolVersion 2
|
||||
}
|
||||
if (p[1] == CONTENT_TAG_SIGNED_DATA) {
|
||||
if (avail < 6 ||
|
||||
p[2] >= 0x80 || // hashId: a one-byte enumerated value
|
||||
!(p[3] & SIGNED_PAYLOAD_HAS_DATA) || // signs only a hash of data sent elsewhere
|
||||
p[4] != IEEE1609DOT2_VERSION ||
|
||||
p[5] != CONTENT_TAG_UNSECURED_DATA) { // nested signing or encryption
|
||||
return -1;
|
||||
}
|
||||
i = 6;
|
||||
*is_signed = true;
|
||||
} else if (p[1] == CONTENT_TAG_UNSECURED_DATA) {
|
||||
i = 2;
|
||||
*is_signed = false;
|
||||
} else {
|
||||
return -1; // encryptedData, certificate requests
|
||||
}
|
||||
|
||||
int len;
|
||||
const int used = coer_length(p + i, avail - i, &len);
|
||||
if (used == 0) {
|
||||
return -1;
|
||||
}
|
||||
i += used;
|
||||
*inner_end = offset + i + len;
|
||||
return offset + i;
|
||||
}
|
||||
|
||||
bool gn_unwrap_its(const uint8_t *frame, int frame_len, gn_rx_t *out)
|
||||
{
|
||||
if (!frame || !out || frame_len < IEEE80211_HEADER_LEN) {
|
||||
@@ -91,22 +186,33 @@ bool gn_unwrap_its(const uint8_t *frame, int frame_len, gn_rx_t *out)
|
||||
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;
|
||||
}
|
||||
const uint8_t basic_next_header = frame[offset] & 0x0F;
|
||||
offset += GN_BASIC_HEADER_LEN;
|
||||
|
||||
// The headers from here on must end before `limit`: the end of the frame, or for a secured
|
||||
// packet the end of the envelope's inner packet if that comes first.
|
||||
int limit = frame_len;
|
||||
int envelope_end = -1;
|
||||
if (basic_next_header == GN_NEXT_HEADER_SECURED) {
|
||||
offset = unwrap_secured(frame, offset, frame_len, &envelope_end, &out->signed_unverified);
|
||||
if (offset < 0) {
|
||||
return false;
|
||||
}
|
||||
if (envelope_end < limit) {
|
||||
limit = envelope_end;
|
||||
}
|
||||
} else if (basic_next_header != GN_NEXT_HEADER_COMMON) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---- GN Common Header (8 bytes) ----
|
||||
if (frame_len < offset + GN_COMMON_HEADER_LEN) {
|
||||
if (limit < 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;
|
||||
const int gn_payload_len = be16(frame + offset + GN_COMMON_PAYLOAD_LEN_OFFSET);
|
||||
if (next_header != GN_COMMON_NEXT_HEADER_BTP_B) {
|
||||
return false;
|
||||
}
|
||||
@@ -126,7 +232,7 @@ bool gn_unwrap_its(const uint8_t *frame, int frame_len, gn_rx_t *out)
|
||||
} else {
|
||||
return false; // Beacon / GeoUnicast / GeoAnycast / multi-hop TSB - see header comment
|
||||
}
|
||||
if (frame_len < offset + ext_len) {
|
||||
if (limit < offset + ext_len) {
|
||||
return false;
|
||||
}
|
||||
if (is_gbc) {
|
||||
@@ -138,7 +244,7 @@ bool gn_unwrap_its(const uint8_t *frame, int frame_len, gn_rx_t *out)
|
||||
offset += ext_len;
|
||||
|
||||
// ---- BTP-B header (4 bytes) ----
|
||||
if (frame_len < offset + BTP_B_HEADER_LEN) {
|
||||
if (limit < offset + BTP_B_HEADER_LEN) {
|
||||
return false;
|
||||
}
|
||||
uint16_t dest_port = be16(frame + offset);
|
||||
@@ -146,16 +252,26 @@ bool gn_unwrap_its(const uint8_t *frame, int frame_len, gn_rx_t *out)
|
||||
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;
|
||||
// ---- ITS payload: exactly as long as the Common Header declares ----
|
||||
// Not "whatever is left of the frame": see "Payload bounds" in gn_unwrap.h for the 8 trailing
|
||||
// bytes every received frame carries and the signature that follows a secured packet.
|
||||
if (gn_payload_len <= BTP_B_HEADER_LEN) {
|
||||
return false; // no ITS payload at all
|
||||
}
|
||||
const int payload_start = offset + BTP_B_HEADER_LEN;
|
||||
const int payload_end = offset + gn_payload_len;
|
||||
if (envelope_end >= 0 && payload_end > envelope_end) {
|
||||
return false; // the inner packet claims more than its envelope holds
|
||||
}
|
||||
out->truncated = payload_end > frame_len;
|
||||
const int payload_len = (out->truncated ? frame_len : payload_end) - payload_start;
|
||||
if (payload_len <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
out->btp_dest_port = dest_port;
|
||||
out->payload = frame + offset;
|
||||
out->payload = frame + payload_start;
|
||||
out->payload_len = payload_len;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
|
||||
// Inverse of geonet_wrap_shb() + dot11p_build_frame(): takes a raw 802.11 frame as delivered by
|
||||
// the WiFi driver's promiscuous RX callback and strips 802.11 header -> LLC/SNAP ->
|
||||
// GeoNetworking Basic/Common/extended header -> BTP-B header, leaving the ITS payload (a UPER
|
||||
// message) plus the metadata the phone needs to know what it received.
|
||||
// GeoNetworking Basic Header -> [security envelope] -> Common/extended header -> BTP-B header,
|
||||
// leaving the ITS payload (a UPER message) plus the metadata the phone needs to know what it
|
||||
// received.
|
||||
//
|
||||
// ---- Supported GeoNetworking header types --------------------------------------------------
|
||||
// Two shapes, chosen by the Common Header's HeaderType, with DIFFERENT extended-header lengths:
|
||||
@@ -28,6 +29,23 @@
|
||||
// Beacon, GeoUnicast, GeoAnycast and multi-hop TSB are still rejected - nothing this project
|
||||
// talks to sends them, and each has its own extended-header length that would need measuring.
|
||||
//
|
||||
// ---- Secured packets -----------------------------------------------------------------------
|
||||
// A Basic Header NextHeader of 2 means an ETSI TS 103 097 (IEEE 1609.2) envelope follows, with
|
||||
// the Common Header onward inside it. Signed messages are unwrapped WITHOUT verifying the
|
||||
// signature or the certificate - this firmware has no trust store - and are reported with
|
||||
// signed_unverified set so the phone can tell. Most real traffic is signed: the 2026-08-17
|
||||
// capture held 157 signed frames from 15 source MACs. Encrypted payloads, nested signing and the
|
||||
// legacy v1.2.1 envelope are rejected. The layout is documented at unwrap_secured() in
|
||||
// gn_unwrap.c. Before 2026-09-11 every secured packet was rejected.
|
||||
//
|
||||
// ---- Payload bounds ------------------------------------------------------------------------
|
||||
// The payload is exactly as long as the Common Header's payload-length field says, minus the
|
||||
// BTP-B header - not "the rest of the frame". After the message comes, in a signed packet, the
|
||||
// signature; and every frame recorded through this chip's promiscuous RX API (~15 000 of them)
|
||||
// ends in 8 more bytes that are not part of the 802.11 frame and not a valid FCS. Until
|
||||
// 2026-09-11 those 8 bytes were forwarded to the phone as the tail of every message. UPER
|
||||
// decoders stop where the message ends, which is why nothing visibly broke.
|
||||
//
|
||||
// ---- Accepted BTP-B ports (ETSI TS 103 248) ------------------------------------------------
|
||||
// 2001 (CAM), 2002 (DENM) and 2004 (SPATEM). MAPEM (2003) and the rest are deliberately not
|
||||
// accepted yet: the phone has no decoder for them, so forwarding would just burn serial
|
||||
@@ -38,17 +56,11 @@
|
||||
// counted as an oversize drop rather than forwarded. The bench RSU trigger emits ~58-byte SPATEMs
|
||||
// and is unaffected, but real road RSUs measured 555 bytes median and 1243 max (2026-03-18 drive,
|
||||
// 79k messages), i.e. roughly 70% would be dropped. Raising the cap is deliberately deferred: it
|
||||
// also requires enlarging RX_FRAME_MAX_LEN and moving rx_item_t off the WiFi callback stack,
|
||||
// which at that size would overflow it.
|
||||
// also requires enlarging main.c's RX_FRAME_MAX_LEN.
|
||||
//
|
||||
// ---- What is NOT handled -------------------------------------------------------------------
|
||||
// Secured packets (GN Basic Header NextHeader=2, i.e. ETSI TS 103 097 signed messages). The
|
||||
// units on this bench run with ItsGnSecurity=0 so everything observed is unsecured; a secured
|
||||
// packet is rejected rather than mis-parsed.
|
||||
//
|
||||
// No FCS/CRC check: the WiFi driver has already validated and stripped it.
|
||||
// No FCS/CRC check here: the WiFi driver has already validated the frame.
|
||||
typedef struct {
|
||||
// BTP-B destination port, identifying the service: 2001 = CAM, 2002 = DENM.
|
||||
// BTP-B destination port, identifying the service: 2001 = CAM, 2002 = DENM, 2004 = SPATEM.
|
||||
uint16_t btp_dest_port;
|
||||
|
||||
// ITS payload (UPER message bytes). Points INTO the caller's `frame` buffer - NOT a copy, so
|
||||
@@ -64,11 +76,20 @@ typedef struct {
|
||||
int32_t geo_area_lat_tenmicrodeg;
|
||||
int32_t geo_area_lon_tenmicrodeg;
|
||||
uint16_t geo_area_distance_a_m;
|
||||
|
||||
// The packet arrived inside a TS 103 097 signed envelope. The signature was NOT checked.
|
||||
bool signed_unverified;
|
||||
|
||||
// The frame ended before the payload its headers declare. On the board only main.c's
|
||||
// RX_FRAME_MAX_LEN capture limit causes this (the driver drops frames that fail their FCS).
|
||||
// payload/payload_len then cover just the part that arrived, so it must not be forwarded.
|
||||
bool truncated;
|
||||
} gn_rx_t;
|
||||
|
||||
// Returns true and fills *out if this was a well-formed, supported ITS frame. Returns false
|
||||
// otherwise (wrong ethertype, secured, unsupported header type, unaccepted BTP port, truncated,
|
||||
// or promiscuous-capture garbage) - all common and expected on an open-air capture, so the caller
|
||||
// Returns true and fills *out if this was a well-formed, supported ITS frame - check `truncated`
|
||||
// before using the payload. Returns false otherwise (wrong ethertype, encrypted or unsupported
|
||||
// envelope, unsupported header type, unaccepted BTP port, headers cut short, or
|
||||
// promiscuous-capture garbage) - all common and expected on an open-air capture, so the caller
|
||||
// should treat false as "not for us", not as an error worth logging per frame.
|
||||
bool gn_unwrap_its(const uint8_t *frame, int frame_len, gn_rx_t *out);
|
||||
|
||||
|
||||
+85
-21
@@ -21,7 +21,9 @@
|
||||
static const char *TAG = "obu-tx";
|
||||
|
||||
// Phase 03: CAM is no longer built on this chip. The phone fuses its own GNSS+IMU, UPER-encodes
|
||||
// CAM itself, and hands the finished bytes down over serial_link (SERIAL_MSG_CAM_TX) - this
|
||||
// CAM itself, and hands the finished bytes down over serial_link (SERIAL_MSG_CAM_TX_PV, together
|
||||
// with the GeoNetworking position vector to send them under; plain SERIAL_MSG_CAM_TX from an app
|
||||
// that predates it) - this
|
||||
// firmware's job on transmit shrinks to "GeoNetworking/BTP-wrap + 802.11-wrap + key the PA the
|
||||
// instant a CAM arrives." There is no on-chip transmit timer anymore; the phone's send cadence
|
||||
// (1 Hz baseline, faster near intersections/events - all decided app-side) IS the air cadence.
|
||||
@@ -41,26 +43,25 @@ static const char *TAG = "obu-tx";
|
||||
#define TX_FREQ_MHZ 5900
|
||||
|
||||
// ---- CAM beacon profile (used for the GeoNetworking layer only now - see below) ----
|
||||
#define STATION_TYPE 5 // passengerCar (TS 102 894-2 StationType) - matches gn_addr's ST field
|
||||
#define STATION_TYPE 2 // cyclist (TS 102 894-2 StationType), legacy CAM_TX path only - see legacy_lpv()
|
||||
#define BTP_PORT_CAM 2001 // BTP-B destination port for CAM (ETSI TS 103 248)
|
||||
|
||||
// Bench location, hardcoded since there's no GNSS module wired in yet and the unit is genuinely
|
||||
// stationary here: 53°33'16.8"N 10°01'20.6"E, in 1/10-microdegree units. Used ONLY for the
|
||||
// GeoNetworking Source Long Position Vector now (geonet_wrap_shb's own claimed position) - the
|
||||
// CAM payload's own referencePosition comes from the phone's real GNSS and can legitimately
|
||||
// differ from this bench placeholder until the GN layer is also given a real position source.
|
||||
// TODO: feed this from the phone too (e.g. a lightweight position update piggybacked on
|
||||
// SERIAL_MSG_CAM_TX, or a new small message type) instead of a fixed bench location.
|
||||
// Bench location, 53°33'16.8"N 10°01'20.6"E, in 1/10-microdegree units. Used only by the legacy
|
||||
// SERIAL_MSG_CAM_TX path (see legacy_lpv), which carries no position of its own. A current app
|
||||
// sends SERIAL_MSG_CAM_TX_PV instead, and the GN Source Position Vector then comes from the
|
||||
// phone's real fix, the same one the CAM payload's own referencePosition is built from.
|
||||
#define BENCH_LATITUDE_TENMICRODEG 535546667
|
||||
#define BENCH_LONGITUDE_TENMICRODEG 100223889
|
||||
|
||||
// Single source of truth for the pseudonym/link-layer address: used both as
|
||||
// the 802.11 source MAC (Addr2) and as GN_ADDR's MID field, since the GN
|
||||
// spec defines those as being the same address. Locally-administered bit
|
||||
// set (0x02) per normal MAC convention. Fixed/non-rotating for now - real
|
||||
// stacks rotate this every 5-15 min for privacy. Owned entirely by this firmware (not the
|
||||
// phone) per the Phase 03 design decision - simplest given the phone never needs to know it.
|
||||
static const uint8_t pseudonym_mac[6] = {0x02, 0x00, 0x00, 0x00, 0x00, 0x01};
|
||||
// Link-layer address for the legacy SERIAL_MSG_CAM_TX path only. Locally-administered bit set
|
||||
// (0x02), per normal MAC convention.
|
||||
//
|
||||
// This reverses the Phase 03 decision that the pseudonym is owned entirely by this firmware. That
|
||||
// was simplest while the address never changed, but a pseudonym only protects anyone if the
|
||||
// 802.11 address, the GN_ADDR MID and the CAM's stationID all change together, and the phone owns
|
||||
// the stationID. One identity needs one owner, so with CAM_TX_PV the phone sends the address with
|
||||
// every frame and rotates it, and this constant is only what the legacy path falls back to.
|
||||
static const uint8_t LEGACY_MAC[6] = {0x02, 0x00, 0x00, 0x00, 0x00, 0x01};
|
||||
|
||||
// Undocumented libphy.a calls that push the radio into 802.11p OCB mode on
|
||||
// the 5.9 GHz ITS-G5 band. See docs/04-transmit-setup.md for source + what
|
||||
@@ -77,6 +78,7 @@ extern void phy_change_channel(int freq_mhz, int bw_mode, int sec_chan_offset, i
|
||||
typedef struct {
|
||||
uint8_t data[SERIAL_LINK_MAX_PAYLOAD];
|
||||
int len;
|
||||
gn_lpv_t lpv; // the Source Position Vector this CAM goes out under
|
||||
} cam_tx_item_t;
|
||||
|
||||
static QueueHandle_t s_tx_queue;
|
||||
@@ -87,6 +89,23 @@ static QueueHandle_t s_tx_queue;
|
||||
// tx_radio_task below, off the UART parsing path entirely. xQueueSend with 0 timeout: if the
|
||||
// radio task is somehow behind, drop this CAM rather than stall UART frame parsing - the next
|
||||
// one is only ~1s (or less, at elevated rate) away regardless.
|
||||
// Source Position Vector for the legacy SERIAL_MSG_CAM_TX path, which carries no position of its
|
||||
// own. Everything here describes the bench, not the rider: a fixed point, standing still, at an
|
||||
// unknown time, under a fixed address. That is exactly why the phone now sends CAM_TX_PV. Kept so
|
||||
// an app that predates it still transmits what it always did, except that the station type now
|
||||
// says cyclist to agree with the CAM inside.
|
||||
static void legacy_lpv(gn_lpv_t *lpv)
|
||||
{
|
||||
memcpy(lpv->mac, LEGACY_MAC, sizeof(lpv->mac));
|
||||
lpv->station_type = STATION_TYPE;
|
||||
lpv->pai = false;
|
||||
lpv->tst_ms = 0;
|
||||
lpv->lat_tenmicrodeg = BENCH_LATITUDE_TENMICRODEG;
|
||||
lpv->lon_tenmicrodeg = BENCH_LONGITUDE_TENMICRODEG;
|
||||
lpv->speed_cms = 0;
|
||||
lpv->heading_decideg = 0;
|
||||
}
|
||||
|
||||
static void on_cam_tx_from_phone(const uint8_t *cam_uper, int cam_len)
|
||||
{
|
||||
if (cam_len <= 0 || cam_len > SERIAL_LINK_MAX_PAYLOAD) {
|
||||
@@ -96,6 +115,42 @@ static void on_cam_tx_from_phone(const uint8_t *cam_uper, int cam_len)
|
||||
cam_tx_item_t item;
|
||||
item.len = cam_len;
|
||||
memcpy(item.data, cam_uper, (size_t)cam_len);
|
||||
legacy_lpv(&item.lpv);
|
||||
if (xQueueSend(s_tx_queue, &item, 0) != pdTRUE) {
|
||||
ESP_LOGW(TAG, "tx queue full, dropping CAM from phone");
|
||||
}
|
||||
}
|
||||
|
||||
static uint16_t le16(const uint8_t *p)
|
||||
{
|
||||
return (uint16_t)(p[0] | (p[1] << 8));
|
||||
}
|
||||
|
||||
static uint32_t le32(const uint8_t *p)
|
||||
{
|
||||
return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24);
|
||||
}
|
||||
|
||||
// SERIAL_MSG_CAM_TX_PV: the phone's CAM plus the position vector to send it under. The prefix
|
||||
// layout is documented at SERIAL_MSG_CAM_TX_PV in serial_link.h. Same speed constraint as
|
||||
// on_cam_tx_from_phone: decode, queue, return.
|
||||
static void on_cam_tx_pv_from_phone(const uint8_t *prefix, const uint8_t *cam_uper, int cam_len)
|
||||
{
|
||||
if (cam_len <= 0 || cam_len > SERIAL_LINK_MAX_PAYLOAD) {
|
||||
ESP_LOGW(TAG, "on_cam_tx_pv_from_phone: bad length %d", cam_len);
|
||||
return;
|
||||
}
|
||||
cam_tx_item_t item;
|
||||
item.len = cam_len;
|
||||
memcpy(item.data, cam_uper, (size_t)cam_len);
|
||||
memcpy(item.lpv.mac, prefix, sizeof(item.lpv.mac));
|
||||
item.lpv.station_type = prefix[6];
|
||||
item.lpv.pai = (prefix[7] & 0x01) != 0;
|
||||
item.lpv.tst_ms = le32(prefix + 8);
|
||||
item.lpv.lat_tenmicrodeg = (int32_t)le32(prefix + 12);
|
||||
item.lpv.lon_tenmicrodeg = (int32_t)le32(prefix + 16);
|
||||
item.lpv.speed_cms = (int16_t)le16(prefix + 20);
|
||||
item.lpv.heading_decideg = le16(prefix + 22);
|
||||
if (xQueueSend(s_tx_queue, &item, 0) != pdTRUE) {
|
||||
ESP_LOGW(TAG, "tx queue full, dropping CAM from phone");
|
||||
}
|
||||
@@ -116,8 +171,7 @@ static void tx_radio_task(void *arg)
|
||||
// singleton, created once in app_main. Both wrap functions bounds-check against the size
|
||||
// passed in and return <= 0 on overflow, so an oversized CAM is rejected, not written past.
|
||||
static uint8_t gn_payload[SERIAL_LINK_MAX_PAYLOAD + 64];
|
||||
int gn_len = geonet_wrap_shb(item.data, item.len, pseudonym_mac, STATION_TYPE,
|
||||
BENCH_LATITUDE_TENMICRODEG, BENCH_LONGITUDE_TENMICRODEG,
|
||||
int gn_len = geonet_wrap_shb(item.data, item.len, &item.lpv,
|
||||
BTP_PORT_CAM, gn_payload, sizeof(gn_payload));
|
||||
if (gn_len <= 0) {
|
||||
ESP_LOGW(TAG, "geonet_wrap_shb failed (cam_len=%d)", item.len);
|
||||
@@ -125,7 +179,9 @@ static void tx_radio_task(void *arg)
|
||||
}
|
||||
|
||||
static uint8_t frame[SERIAL_LINK_MAX_PAYLOAD + 192];
|
||||
int frame_len = dot11p_build_frame(gn_payload, gn_len, pseudonym_mac, frame,
|
||||
// Source address from the same lpv the GN header was built from, so the 802.11 and
|
||||
// GeoNetworking layers always name the same sender, including across a pseudonym change.
|
||||
int frame_len = dot11p_build_frame(gn_payload, gn_len, item.lpv.mac, frame,
|
||||
sizeof(frame), false);
|
||||
if (frame_len <= 0) {
|
||||
ESP_LOGW(TAG, "dot11p_build_frame failed (gn_len=%d)", gn_len);
|
||||
@@ -224,8 +280,16 @@ static void rx_forward_task(void *arg)
|
||||
// returning false here is the common case, not an error, so it isn't logged per frame.
|
||||
gn_rx_t rx;
|
||||
if (gn_unwrap_its(item.data, item.len, &rx)) {
|
||||
if (rx.truncated) {
|
||||
// Longer than the RX_FRAME_MAX_LEN bytes captured above, so it cannot be forwarded
|
||||
// whole - and at that size it could not cross the serial link either. Counted as
|
||||
// an oversize drop, as it was when the cut-off frame still reached
|
||||
// serial_link_send_v2x_rx() and failed the size check there.
|
||||
serial_link_note_oversize_drop(rx.btp_dest_port);
|
||||
continue;
|
||||
}
|
||||
serial_link_send_v2x_rx(rx.btp_dest_port, item.rssi,
|
||||
rx.has_geo_area,
|
||||
rx.has_geo_area, rx.signed_unverified,
|
||||
rx.geo_area_lat_tenmicrodeg,
|
||||
rx.geo_area_lon_tenmicrodeg,
|
||||
rx.geo_area_distance_a_m,
|
||||
@@ -339,7 +403,7 @@ void app_main(void)
|
||||
|
||||
xTaskCreate(tx_radio_task, "tx_radio", 4096, NULL, 6, NULL);
|
||||
xTaskCreate(rx_forward_task, "rx_forward", 4096, NULL, 5, NULL);
|
||||
serial_link_init(on_cam_tx_from_phone);
|
||||
serial_link_init(on_cam_tx_from_phone, on_cam_tx_pv_from_phone);
|
||||
|
||||
ESP_LOGW(TAG, "OCB @ %d MHz - TX/RX armed, driven by serial_link (no on-chip TX timer)",
|
||||
TX_FREQ_MHZ);
|
||||
|
||||
@@ -13,6 +13,7 @@ static const char *TAG = "serial_link";
|
||||
#define SYNC1 0x55
|
||||
|
||||
static serial_link_cam_tx_cb_t s_on_cam_tx;
|
||||
static serial_link_cam_tx_pv_cb_t s_on_cam_tx_pv;
|
||||
|
||||
// ---- Counters reported to the phone in every heartbeat (see SERIAL_MSG_STATUS in the header).
|
||||
// Saturating rather than wrapping: "65535 drops" reads as "lots and still going", whereas a wrap
|
||||
@@ -37,6 +38,13 @@ void serial_link_note_tx_failure(void)
|
||||
bump(&s_tx_failures);
|
||||
}
|
||||
|
||||
void serial_link_note_oversize_drop(uint16_t btp_dest_port)
|
||||
{
|
||||
bump(&s_oversize_drops);
|
||||
ESP_LOGW(TAG, "port %u message larger than the RX capture buffer, total oversize drops %u",
|
||||
btp_dest_port, s_oversize_drops);
|
||||
}
|
||||
|
||||
// ---- CRC-16/CCITT-FALSE (poly 0x1021, init 0xFFFF, no reflect, no xorout) ----
|
||||
// Bytewise (no table) - frames here are at most SERIAL_LINK_MAX_PAYLOAD + 3 bytes, so table
|
||||
// lookup isn't worth the flash/RAM tradeoff. MUST match the Kotlin-side implementation exactly
|
||||
@@ -113,7 +121,7 @@ static bool send_frame(uint8_t type, const uint8_t *payload, int len)
|
||||
}
|
||||
|
||||
bool serial_link_send_v2x_rx(uint16_t btp_dest_port, int8_t rssi,
|
||||
bool has_geo_area,
|
||||
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,
|
||||
@@ -137,7 +145,7 @@ bool serial_link_send_v2x_rx(uint16_t btp_dest_port, int8_t rssi,
|
||||
s_v2x_payload[0] = (uint8_t)(btp_dest_port & 0xFF);
|
||||
s_v2x_payload[1] = (uint8_t)((btp_dest_port >> 8) & 0xFF);
|
||||
s_v2x_payload[2] = (uint8_t)rssi;
|
||||
s_v2x_payload[3] = has_geo_area ? 0x01 : 0x00;
|
||||
s_v2x_payload[3] = (uint8_t)((has_geo_area ? 0x01 : 0x00) | (signed_unverified ? 0x02 : 0x00));
|
||||
uint32_t lat = (uint32_t)geo_area_lat_tenmicrodeg;
|
||||
uint32_t lon = (uint32_t)geo_area_lon_tenmicrodeg;
|
||||
s_v2x_payload[4] = (uint8_t)(lat & 0xFF);
|
||||
@@ -157,9 +165,9 @@ bool serial_link_send_v2x_rx(uint16_t btp_dest_port, int8_t rssi,
|
||||
|
||||
bool serial_link_send_status(uint8_t status)
|
||||
{
|
||||
// [status:1][oversize_drops:2 LE][tx_failures:2 LE][rx_crc_errors:2 LE] - keep in lockstep
|
||||
// with EspLinkStatus.parse() in the app's SerialFrame.kt.
|
||||
uint8_t payload[7];
|
||||
// [status:1][oversize_drops:2 LE][tx_failures:2 LE][rx_crc_errors:2 LE][capabilities:1] -
|
||||
// keep in lockstep with EspLinkStatus.parse() in the app's SerialFrame.kt.
|
||||
uint8_t payload[8];
|
||||
payload[0] = status;
|
||||
payload[1] = (uint8_t)(s_oversize_drops & 0xFF);
|
||||
payload[2] = (uint8_t)((s_oversize_drops >> 8) & 0xFF);
|
||||
@@ -167,6 +175,9 @@ bool serial_link_send_status(uint8_t status)
|
||||
payload[4] = (uint8_t)((s_tx_failures >> 8) & 0xFF);
|
||||
payload[5] = (uint8_t)(s_rx_crc_errors & 0xFF);
|
||||
payload[6] = (uint8_t)((s_rx_crc_errors >> 8) & 0xFF);
|
||||
// What this firmware accepts. The app reads it to decide whether it may send CAM_TX_PV, which
|
||||
// is what lets a new app keep working against firmware that predates that message.
|
||||
payload[7] = SERIAL_CAP_CAM_TX_PV;
|
||||
return send_frame(SERIAL_MSG_STATUS, payload, sizeof(payload));
|
||||
}
|
||||
|
||||
@@ -262,9 +273,19 @@ static void rx_task(void *arg)
|
||||
uint16_t crc_calc = crc16_ccitt_false(crc_buf, (size_t)(3 + len));
|
||||
|
||||
if (crc_calc == crc_recv) {
|
||||
if (type == SERIAL_MSG_CAM_TX && s_on_cam_tx) {
|
||||
s_on_cam_tx(payload, len);
|
||||
} else if (type != SERIAL_MSG_CAM_TX) {
|
||||
if (type == SERIAL_MSG_CAM_TX) {
|
||||
if (s_on_cam_tx) s_on_cam_tx(payload, len);
|
||||
} else if (type == SERIAL_MSG_CAM_TX_PV) {
|
||||
// A frame that is all prefix has nothing to transmit.
|
||||
if (len > SERIAL_CAM_TX_PV_PREFIX_LEN) {
|
||||
if (s_on_cam_tx_pv) {
|
||||
s_on_cam_tx_pv(payload, payload + SERIAL_CAM_TX_PV_PREFIX_LEN,
|
||||
len - SERIAL_CAM_TX_PV_PREFIX_LEN);
|
||||
}
|
||||
} else {
|
||||
ESP_LOGW(TAG, "rx: CAM_TX_PV of %u bytes carries no CAM, ignoring", len);
|
||||
}
|
||||
} else {
|
||||
ESP_LOGW(TAG, "rx: unexpected frame type 0x%02x from phone, ignoring", type);
|
||||
}
|
||||
} else {
|
||||
@@ -279,9 +300,11 @@ static void rx_task(void *arg)
|
||||
}
|
||||
}
|
||||
|
||||
void serial_link_init(serial_link_cam_tx_cb_t on_cam_tx)
|
||||
void serial_link_init(serial_link_cam_tx_cb_t on_cam_tx,
|
||||
serial_link_cam_tx_pv_cb_t on_cam_tx_pv)
|
||||
{
|
||||
s_on_cam_tx = on_cam_tx;
|
||||
s_on_cam_tx_pv = on_cam_tx_pv;
|
||||
|
||||
s_tx_mutex = xSemaphoreCreateMutex();
|
||||
if (!s_tx_mutex) {
|
||||
|
||||
@@ -37,10 +37,13 @@
|
||||
// [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
|
||||
// [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.
|
||||
@@ -49,9 +52,11 @@
|
||||
// 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:
|
||||
// [status:1][oversize_drops:2 LE][tx_failures:2 LE][rx_crc_errors:2 LE]
|
||||
// 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.
|
||||
@@ -59,10 +64,40 @@
|
||||
#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
|
||||
@@ -83,24 +118,34 @@
|
||||
// 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; the real upstream ceiling on the RX path is
|
||||
// rx_item_t.data (400 bytes) in main.c, so nothing larger can get here anyway.
|
||||
// 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. `on_cam_tx` is invoked (from the RX task's
|
||||
// context - keep it fast, it blocks the next frame's parsing) whenever a complete, checksummed
|
||||
// SERIAL_MSG_CAM_TX frame arrives from the phone.
|
||||
// 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);
|
||||
void serial_link_init(serial_link_cam_tx_cb_t on_cam_tx);
|
||||
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 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,
|
||||
@@ -115,4 +160,9 @@ bool serial_link_send_status(uint8_t status);
|
||||
// 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
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# Host-side tests for obu-firmware. See README.md: needs gcc and make on PATH (MSYS2 UCRT64), and
|
||||
# asn1tools under `py -3.11` for the replay check.
|
||||
#
|
||||
# make build and run everything: chain, replay, fuzz
|
||||
# make chain | replay | fuzz one of them
|
||||
# make fuzz FUZZ_ITER=50000000 FUZZ_SEED=7 a longer or different fuzz run
|
||||
# make clean remove build/
|
||||
#
|
||||
# The firmware sources are compiled straight from ../../main, never copied.
|
||||
|
||||
CC = gcc
|
||||
PYTHON = python
|
||||
PYTHON_ASN1 = py -3.11
|
||||
FW = ../../main
|
||||
BUILD = build
|
||||
EXE = $(if $(filter Windows_NT,$(OS)),.exe,)
|
||||
# Captures live in capture/recordings/ since 2026-09-14; older ones are still in the
|
||||
# receiver checkout beside this repo.
|
||||
RECORDINGS = $(wildcard ../../../capture/recordings/*.pcap ../../../its-g5-receiver-firmware/recordings/*.pcap)
|
||||
FUZZ_ITER = 2000000
|
||||
FUZZ_SEED = 1
|
||||
|
||||
# -Werror: these sources must stay warning-free on the host compiler too.
|
||||
# UBSan in trap mode needs no runtime library, so it works on MinGW; a trap shows up as a crash.
|
||||
CFLAGS = -std=c11 -O2 -g -Wall -Wextra -Wpedantic -Werror -I$(FW) \
|
||||
-fsanitize=undefined -fsanitize-undefined-trap-on-error
|
||||
|
||||
FW_SRCS = $(FW)/geonet.c $(FW)/dot11p.c $(FW)/gn_unwrap.c
|
||||
DEPS = test_util.c test_util.h $(FW_SRCS) $(wildcard $(FW)/*.h)
|
||||
|
||||
.PHONY: all check chain replay fuzz clean
|
||||
|
||||
all: check
|
||||
|
||||
check: chain replay fuzz
|
||||
|
||||
$(BUILD):
|
||||
mkdir -p $@
|
||||
|
||||
$(BUILD)/%$(EXE): %.c $(DEPS) | $(BUILD)
|
||||
$(CC) $(CFLAGS) -o $@ $< test_util.c $(FW_SRCS)
|
||||
|
||||
# The pcap goes through pcap_gn_tally.py as a second, independent parser of the same frames.
|
||||
chain: $(BUILD)/test_chain$(EXE)
|
||||
$(BUILD)/test_chain$(EXE) $(BUILD)/test_chain.pcap
|
||||
$(PYTHON) ../pcap_gn_tally.py $(BUILD)/test_chain.pcap
|
||||
|
||||
replay: $(BUILD)/test_replay$(EXE)
|
||||
ifeq ($(RECORDINGS),)
|
||||
@echo "replay: no recordings in ../../../its-g5-receiver-firmware/recordings, skipped"
|
||||
else
|
||||
$(BUILD)/test_replay$(EXE) $(BUILD)/replay.tsv $(RECORDINGS)
|
||||
$(PYTHON_ASN1) check_replay.py $(BUILD)/replay.tsv $(RECORDINGS)
|
||||
endif
|
||||
|
||||
fuzz: $(BUILD)/fuzz_gn_unwrap$(EXE)
|
||||
$(BUILD)/fuzz_gn_unwrap$(EXE) $(FUZZ_ITER) $(FUZZ_SEED) $(RECORDINGS)
|
||||
|
||||
clean:
|
||||
rm -rf $(BUILD)
|
||||
@@ -0,0 +1,151 @@
|
||||
# Host-side tests for obu-firmware
|
||||
|
||||
**Status (2026-09-11):** the chain test, the capture replay and the fuzzer are written and pass;
|
||||
results under "Running". Toolchain: MSYS2 UCRT64 gcc, Option B below (chosen and
|
||||
installed 2026-09-11).
|
||||
|
||||
`geonet.c`, `dot11p.c` and `gn_unwrap.c` include nothing but standard C headers, so they compile
|
||||
unmodified on a PC. That makes three things possible without a board: a TX -> RX round trip
|
||||
through our own code, replaying real captures through the RX parser, and fuzzing the parser that
|
||||
reads untrusted radio bytes. ESP-IDF only builds `main/` (and `components/`), so nothing under
|
||||
`test/` ever affects the firmware image.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
obu-firmware/
|
||||
├── main/ firmware sources; the tests compile these directly, never copies
|
||||
└── test/
|
||||
├── pcap_gn_tally.py GN header fields per station over .pcap captures (Python only)
|
||||
└── host/
|
||||
├── README.md this file
|
||||
├── Makefile `make`: builds ../../main/{geonet,dot11p,gn_unwrap}.c + tests, runs them
|
||||
├── test_chain.c geonet_wrap_shb -> dot11p_build_frame -> gn_unwrap_its, byte-checked
|
||||
├── test_util.c/.h shared: guard page, crash report, pcap read/write
|
||||
├── test_replay.c every recorded frame through gn_unwrap_its, results to a TSV
|
||||
├── check_replay.py re-derives each result independently, then asn1tools on the messages
|
||||
├── fuzz_gn_unwrap.c mutation fuzzer; each input ends against a no-access guard page, so
|
||||
│ an over-read faults (no ASan on MinGW)
|
||||
└── build/ compiler output; already ignored by the repo's `build/` rule
|
||||
```
|
||||
|
||||
The repo's `vanetza/` folder is a gitignored reading copy only; it is not built. `check_replay.py`
|
||||
reads the IEEE 1609.2 ASN.1 modules from it (they carry no licence header, so they are not copied
|
||||
into `asn1/`). Without it, signed frames are still replayed but not checked independently.
|
||||
|
||||
## Running
|
||||
|
||||
From PowerShell, with MSYS2 on PATH for the session (Option B step 3):
|
||||
|
||||
```powershell
|
||||
$env:PATH = "C:\msys64\ucrt64\bin;C:\msys64\usr\bin;$env:PATH"
|
||||
cd C:\Users\Ashin\AndroidStudioProjects\MicrOBU\obu-firmware\test\host
|
||||
make
|
||||
```
|
||||
|
||||
`make` runs three things (`make chain`, `make replay`, `make fuzz` run one). A non-zero exit, or a
|
||||
`CRASH ... during: <what>` line (from the fuzzer, followed by the input as hex), is a failure.
|
||||
|
||||
- **chain** (`test_chain.c`): frames built by the firmware's own TX code, checked byte by byte
|
||||
against EN 302 636-4-1 and parsed back. Covers the CAM layout (non-QoS and QoS), Source Position
|
||||
Vector edges, output-buffer bounds, a 512-byte payload through `main.c`'s buffer sizes,
|
||||
hand-built GeoBroadcast frames in all three shapes, signed frames in all three COER length
|
||||
forms plus a top-level unsecuredData, one-byte mutations that must be rejected or accepted, the
|
||||
Common Header's payload length as the message boundary, the 8-byte RX trailer, and every
|
||||
truncation length of each frame against the guard page. `pcap_gn_tally.py` then reads the
|
||||
frames back as a second parser; `build/test_chain.pcap` opens in Wireshark too.
|
||||
2026-09-11: 1731 checks, 0 failed.
|
||||
- **replay** (`test_replay.c` + `check_replay.py`): every record in
|
||||
`its-g5-receiver-firmware/recordings/*.pcap` through `gn_unwrap_its`, each cut to `main.c`'s
|
||||
800-byte capture buffer as on the board. `check_replay.py` re-derives each result on its own
|
||||
(its own GN/BTP parse; the security envelope decoded by asn1tools from the IEEE 1609.2 modules),
|
||||
compares record by record, then decodes and re-encodes every distinct message with asn1tools -
|
||||
a byte-identical re-encode is only possible when the message was cut at exactly the right byte.
|
||||
Needs `py -3.11` with asn1tools. 2026-09-11: 15 145 records, 15 131 accepted (10 831 CAM,
|
||||
4 300 DENM; 157 of them signed); C and Python agree on every record; 11 043 of the 11 106
|
||||
distinct messages re-encode byte-identically. The other 63 fail the same way with the old 8
|
||||
trailing bytes put back, so the boundary is not the cause: 5 are our own CAMs from before the
|
||||
2026-08-20 yawRateConfidence fix, 56 come from the CiT One and 1 from another station (see
|
||||
`TODO.md`), and 1 uses an extension asn1tools cannot re-encode.
|
||||
- **fuzz** (`fuzz_gn_unwrap.c`): random edits of every recorded frame, each run against the guard
|
||||
page; an over-read crashes, an accepted payload outside its input fails. Default 2 000 000
|
||||
iterations (about 2 s); `make fuzz FUZZ_ITER=50000000 FUZZ_SEED=7` for a longer run.
|
||||
2026-09-11: 50 000 000 iterations, no crash.
|
||||
|
||||
Not covered: `main.c` (serial prefix parsing, queues) and `serial_link.c`, which need ESP-IDF;
|
||||
and the phone's encoder, whose bytes are opaque here (asn1tools and the app's golden test cover
|
||||
it).
|
||||
|
||||
## Option A (not used): WSL2 + Ubuntu 24.04
|
||||
|
||||
Kept as the fallback if AddressSanitizer or libFuzzer are ever needed; Option B has neither.
|
||||
|
||||
1. In **PowerShell as Administrator**:
|
||||
|
||||
```powershell
|
||||
wsl --install -d Ubuntu-24.04
|
||||
```
|
||||
|
||||
Reboot if it asks. Ubuntu then opens and asks for a Linux username and password (separate from
|
||||
the Windows account). Checked 2026-09-11: Hyper-V is already running on this PC, so no BIOS
|
||||
change should be needed. If the install says virtualization is disabled, enable Intel VT-x /
|
||||
AMD SVM in the BIOS.
|
||||
|
||||
2. Confirm it is WSL **2**: `wsl -l -v` should list `Ubuntu-24.04` with VERSION `2`.
|
||||
|
||||
3. Inside Ubuntu, the compilers:
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install -y build-essential clang cmake ninja-build git pkg-config python3
|
||||
```
|
||||
|
||||
4. Smoke test: the firmware sources compile on the host (expect no output):
|
||||
|
||||
```bash
|
||||
cd /mnt/c/Users/Ashin/AndroidStudioProjects/MicrOBU/obu-firmware/test/host
|
||||
cc -std=c11 -Wall -Wextra -fsyntax-only ../../main/geonet.c ../../main/dot11p.c ../../main/gn_unwrap.c
|
||||
```
|
||||
|
||||
## Option B (chosen): native Windows gcc via MSYS2
|
||||
|
||||
Enough for the round-trip test, the capture replay and the guard-page fuzzer. No libFuzzer and no
|
||||
AddressSanitizer with MinGW gcc, which is why the fuzzer uses a guard page instead.
|
||||
|
||||
1. In PowerShell: `winget install -e --id MSYS2.MSYS2` (installs to `C:\msys64`).
|
||||
2. Open **MSYS2 UCRT64** from the Start menu and run `pacman -Syu`. If the window closes, reopen
|
||||
it and run `pacman -Syu` again. Then:
|
||||
|
||||
```bash
|
||||
pacman -S --needed mingw-w64-ucrt-x86_64-gcc make
|
||||
```
|
||||
|
||||
3. **`C:\msys64\ucrt64\bin` must be on PATH.** Calling `C:\msys64\ucrt64\bin\gcc.exe` by its full
|
||||
path alone exits 1 with no message, because gcc's compiler stages load their DLLs from that
|
||||
folder. `make` lives on the MSYS side, in `C:\msys64\usr\bin`. Either work inside the
|
||||
**MSYS2 UCRT64** shell, which has both, or put them on PATH for the current session:
|
||||
|
||||
```powershell
|
||||
$env:PATH = "C:\msys64\ucrt64\bin;C:\msys64\usr\bin;$env:PATH" # PowerShell
|
||||
```
|
||||
|
||||
```bash
|
||||
export PATH=/c/msys64/ucrt64/bin:/c/msys64/usr/bin:$PATH # Git Bash
|
||||
```
|
||||
|
||||
Adding them to the user PATH permanently also works; it was deliberately not done by setup.
|
||||
4. Smoke test (expect no output):
|
||||
|
||||
```powershell
|
||||
cd C:\Users\Ashin\AndroidStudioProjects\MicrOBU\obu-firmware\test\host
|
||||
gcc -std=c11 -Wall -Wextra -fsyntax-only ../../main/geonet.c ../../main/dot11p.c ../../main/gn_unwrap.c
|
||||
```
|
||||
|
||||
Installed on this PC 2026-09-11: MSYS2 20260611, gcc 16.2.0 (UCRT64), GNU Make 4.4.1. All three
|
||||
firmware sources compile with `-std=c11 -O2 -Wall -Wextra -Wpedantic` and no warnings.
|
||||
|
||||
## Line endings
|
||||
|
||||
The repo runs with `core.autocrlf=true`, so Windows checkouts have CRLF line endings. C compilers
|
||||
don't care; shell scripts run from WSL do (`bash: $'\r': command not found`). When the first `.sh`
|
||||
file lands here, add `*.sh text eol=lf` to a root `.gitattributes` (none exists yet).
|
||||
@@ -0,0 +1,253 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Independent check of test_replay's output. See README.md.
|
||||
|
||||
For every recorded frame this works out on its own what gn_unwrap_its should have produced -
|
||||
GeoNetworking and BTP parsed here from EN 302 636-4-1, the TS 103 097 security envelope decoded by
|
||||
asn1tools from the IEEE 1609.2 ASN.1 modules rather than by hand - and compares that with what the
|
||||
C code did, record by record. Then it decodes every distinct extracted message with asn1tools and
|
||||
re-encodes it. Only a message cut at exactly the right byte re-encodes to the same bytes, so this
|
||||
is what proves that no trailer or signature bytes came along with it.
|
||||
|
||||
py -3.11 check_replay.py replay.tsv capture.pcap [capture.pcap ...]
|
||||
|
||||
Needs asn1tools (installed for Python 3.11 on this PC). Message modules come from the repo's
|
||||
asn1/; the IEEE 1609.2 ones from asn1/ or, failing that, the gitignored vanetza/ checkout.
|
||||
Exit status 1 if the C code and this disagree about any record.
|
||||
"""
|
||||
import collections
|
||||
import pathlib
|
||||
import struct
|
||||
import sys
|
||||
|
||||
import asn1tools
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[3]
|
||||
PORT_NAMES = {2001: "CAM", 2002: "DENM", 2004: "SPATEM"}
|
||||
# main.c's RX_FRAME_MAX_LEN: the most of any frame the board hands to gn_unwrap_its. test_replay
|
||||
# cuts frames to it, so this does too.
|
||||
RX_FRAME_MAX_LEN = 800
|
||||
|
||||
|
||||
def compile_specs():
|
||||
a = ROOT / "asn1"
|
||||
uper = {
|
||||
2001: asn1tools.compile_files([str(a / "cam_1_4_1.asn"), str(a / "cdd_1_3_1_1.asn")], "uper"),
|
||||
2002: asn1tools.compile_files([str(a / "denm_1_3_1.asn"), str(a / "cdd_1_3_1_1.asn")], "uper"),
|
||||
2004: asn1tools.compile_files(
|
||||
[str(a / n) for n in ("spatem_2_2_1.asn", "mapem_2_2_1.asn", "dsrc_2_2_1.asn", "cdd_2_2_1.asn")],
|
||||
"uper"),
|
||||
}
|
||||
names = ("IEEE1609dot2BaseTypes.asn", "IEEE1609dot2.asn")
|
||||
for d in (a, ROOT / "vanetza" / "asn1"):
|
||||
if all((d / n).exists() for n in names):
|
||||
return uper, asn1tools.compile_files([str(d / n) for n in names], "oer"), d
|
||||
return uper, None, None
|
||||
|
||||
|
||||
def frames(path):
|
||||
"""(record index, 802.11 frame) for every record, numbered the way test_util.c numbers them."""
|
||||
d = pathlib.Path(path).read_bytes()
|
||||
if len(d) < 24:
|
||||
return
|
||||
magic = struct.unpack("<I", d[:4])[0]
|
||||
e = "<" if magic in (0xA1B2C3D4, 0xA1B23C4D) else ">"
|
||||
link = struct.unpack(e + "I", d[20:24])[0]
|
||||
if link not in (105, 127):
|
||||
return
|
||||
off, index = 24, 0
|
||||
while off + 16 <= len(d):
|
||||
incl = struct.unpack(e + "I", d[off + 8:off + 12])[0]
|
||||
if incl > len(d) - off - 16:
|
||||
break
|
||||
pkt = d[off + 16:off + 16 + incl]
|
||||
off += 16 + incl
|
||||
if link == 127:
|
||||
rl = pkt[2] | pkt[3] << 8 if len(pkt) >= 4 else len(pkt) + 1
|
||||
if rl > len(pkt):
|
||||
index += 1
|
||||
continue
|
||||
pkt = pkt[rl:]
|
||||
yield index, pkt
|
||||
index += 1
|
||||
|
||||
|
||||
def open_envelope(sec, env):
|
||||
"""(inner GeoNetworking packet, signed) of a TS 103 097 envelope per asn1tools, or None."""
|
||||
try:
|
||||
m = sec.decode("Ieee1609Dot2Data", env)
|
||||
except Exception:
|
||||
return None
|
||||
if m["protocolVersion"] != 3:
|
||||
return None
|
||||
kind, content = m["content"]
|
||||
if kind == "unsecuredData":
|
||||
return content, False
|
||||
if kind == "signedData":
|
||||
data = content["tbsData"]["payload"].get("data")
|
||||
if data and data["protocolVersion"] == 3 and data["content"][0] == "unsecuredData":
|
||||
return data["content"][1], True
|
||||
return None
|
||||
|
||||
|
||||
def u16(b):
|
||||
return int.from_bytes(b, "big")
|
||||
|
||||
|
||||
def s32(b):
|
||||
return int.from_bytes(b, "big", signed=True)
|
||||
|
||||
|
||||
def expect(f, sec):
|
||||
"""What gn_unwrap_its should report for frame f: None, or a dict matching test_replay's row.
|
||||
Second value: how many bytes after the message the pre-2026-09-11 code would have forwarded."""
|
||||
if len(f) < 24 or (f[0] >> 2) & 3 != 2 or f[1] & 3 == 3:
|
||||
return None, None
|
||||
o = 24 + (2 if f[0] & 0x80 else 0)
|
||||
if f[o:o + 8] != b"\xaa\xaa\x03\x00\x00\x00\x89\x47" or len(f) < o + 12:
|
||||
return None, None
|
||||
nh = f[o + 8] & 0x0F
|
||||
o += 12
|
||||
if nh == 2:
|
||||
if sec is None:
|
||||
return "unchecked", None
|
||||
opened = open_envelope(sec, f[o:])
|
||||
if opened is None:
|
||||
return None, None
|
||||
region, signed = opened
|
||||
elif nh == 1:
|
||||
region, signed = f[o:], False
|
||||
else:
|
||||
return None, None
|
||||
|
||||
if len(region) < 8 or region[0] >> 4 != 2:
|
||||
return None, None
|
||||
ht, hst, pl = region[1] >> 4, region[1] & 0x0F, u16(region[4:6])
|
||||
if ht == 5 and hst == 0:
|
||||
ext, area = 28, None
|
||||
elif ht == 4:
|
||||
ext = 44
|
||||
else:
|
||||
return None, None
|
||||
if len(region) < 8 + ext + 4:
|
||||
return None, None
|
||||
if ht == 4:
|
||||
a = 8 + 28
|
||||
area = (s32(region[a:a + 4]), s32(region[a + 4:a + 8]), u16(region[a + 8:a + 10]))
|
||||
port = u16(region[8 + ext:8 + ext + 2])
|
||||
if port not in PORT_NAMES or pl <= 4:
|
||||
return None, None
|
||||
start, end = 8 + ext + 4, 8 + ext + pl
|
||||
if signed is not None and nh == 2 and end > len(region):
|
||||
return None, None # the inner packet claims more than its envelope holds
|
||||
payload = region[start:min(end, len(region))]
|
||||
if not payload:
|
||||
return None, None
|
||||
old_extra = len(region) - end if nh == 1 else None
|
||||
return dict(port=port, signed=signed, truncated=end > len(region), area=area, payload=payload), old_extra
|
||||
|
||||
|
||||
def read_tsv(path):
|
||||
rows = {}
|
||||
with open(path, encoding="ascii") as fh:
|
||||
next(fh)
|
||||
for line in fh:
|
||||
c = line.rstrip("\n").split("\t")
|
||||
key = (c[0], int(c[1]))
|
||||
if c[2] == "0":
|
||||
rows[key] = None
|
||||
else:
|
||||
rows[key] = dict(port=int(c[3]), signed=c[4] == "1", truncated=c[5] == "1",
|
||||
area=(int(c[7]), int(c[8]), int(c[9])) if c[6] == "1" else None,
|
||||
payload=bytes.fromhex(c[10]))
|
||||
return rows
|
||||
|
||||
|
||||
def describe(r):
|
||||
if r is None:
|
||||
return "rejected"
|
||||
return "port %d signed %s truncated %s area %s %d bytes" % (
|
||||
r["port"], r["signed"], r["truncated"], r["area"], len(r["payload"]))
|
||||
|
||||
|
||||
def main(argv):
|
||||
if len(argv) < 2:
|
||||
sys.exit(__doc__)
|
||||
got = read_tsv(argv[0])
|
||||
uper, sec, sec_dir = compile_specs()
|
||||
if sec is None:
|
||||
print("WARNING: IEEE 1609.2 modules not found; secured frames are not checked independently")
|
||||
|
||||
stats, extra, disagreements, messages = collections.Counter(), collections.Counter(), [], {}
|
||||
for path in argv[1:]:
|
||||
for index, f in frames(path):
|
||||
key = (path, index)
|
||||
stats["records"] += 1
|
||||
stats["capped"] += len(f) > RX_FRAME_MAX_LEN
|
||||
want, old_extra = expect(f[:RX_FRAME_MAX_LEN], sec)
|
||||
if key not in got:
|
||||
disagreements.append((key, "no row from test_replay"))
|
||||
continue
|
||||
have = got.pop(key)
|
||||
if want == "unchecked":
|
||||
stats["secured, unchecked"] += 1
|
||||
continue
|
||||
if want != have:
|
||||
disagreements.append((key, "C: %s | independent: %s" % (describe(have), describe(want))))
|
||||
continue
|
||||
if want:
|
||||
stats["accepted"] += 1
|
||||
stats[PORT_NAMES[want["port"]]] += 1
|
||||
stats["signed"] += want["signed"]
|
||||
stats["truncated"] += want["truncated"]
|
||||
if old_extra is not None:
|
||||
extra[old_extra] += 1
|
||||
messages.setdefault((want["port"], want["payload"]), key)
|
||||
for key in got:
|
||||
disagreements.append((key, "row from test_replay for a record this did not see"))
|
||||
|
||||
print("check_replay: %d records (%d cut to %d bytes), %d accepted (CAM %d, DENM %d, SPATEM %d; "
|
||||
"%d signed, %d truncated)"
|
||||
% (stats["records"], stats["capped"], RX_FRAME_MAX_LEN, stats["accepted"], stats["CAM"],
|
||||
stats["DENM"], stats["SPATEM"], stats["signed"], stats["truncated"]))
|
||||
if sec_dir:
|
||||
print(" envelopes decoded with asn1tools using %s" % sec_dir.relative_to(ROOT))
|
||||
print(" C vs independent parse: %s" % ("agree on every record" if not disagreements
|
||||
else "%d DISAGREEMENTS" % len(disagreements)))
|
||||
for key, why in disagreements[:15]:
|
||||
print(" %s record %d: %s" % (pathlib.Path(key[0]).name, key[1], why))
|
||||
|
||||
outcome, failures = collections.Counter(), []
|
||||
for (port, payload), key in messages.items():
|
||||
name, spec = PORT_NAMES[port], uper[port]
|
||||
try:
|
||||
decoded = spec.decode(name, payload)
|
||||
except Exception as e:
|
||||
outcome[(name, "does not decode")] += 1
|
||||
failures.append((key, name, len(payload), str(e)[:110]))
|
||||
continue
|
||||
try:
|
||||
encoded = spec.encode(name, decoded)
|
||||
except Exception as e:
|
||||
# asn1tools decodes an alternative from a later module version (a CHOICE extension)
|
||||
# as (None, None) and then cannot encode it back. Says nothing about where the message
|
||||
# was cut, so it is reported apart from real mismatches.
|
||||
outcome[(name, "decodes; uses a newer extension")] += 1
|
||||
failures.append((key, name, len(payload), "cannot re-encode: " + str(e)[:90]))
|
||||
continue
|
||||
if encoded == payload:
|
||||
outcome[(name, "re-encodes byte-identically")] += 1
|
||||
else:
|
||||
outcome[(name, "re-encodes differently")] += 1
|
||||
failures.append((key, name, len(payload), "re-encoded to different bytes"))
|
||||
print(" asn1tools on the %d distinct extracted messages:" % len(messages))
|
||||
for (name, what), n in sorted(outcome.items()):
|
||||
print(" %-6s %-28s %d" % (name, what, n))
|
||||
for key, name, n, why in failures[:15]:
|
||||
print(" %s record %d, %s %d bytes: %s" % (pathlib.Path(key[0]).name, key[1], name, n, why))
|
||||
print(" bytes the pre-2026-09-11 code forwarded after each unsecured message: %s"
|
||||
% dict(sorted(extra.items())))
|
||||
return 1 if disagreements else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
@@ -0,0 +1,218 @@
|
||||
// Mutation fuzzer for gn_unwrap_its, the one function in this firmware that parses bytes from the
|
||||
// air. See README.md.
|
||||
//
|
||||
// Seeds are every recorded frame in the pcaps given, plus frames built by the firmware's own TX
|
||||
// code. Each iteration takes a seed, applies 1-4 random edits - bit flips, random bytes, boundary
|
||||
// bytes such as COER length markers, 16-bit length fields, truncation, insertion, deletion,
|
||||
// appended bytes - mostly within the first 128 bytes where the headers are, and runs gn_unwrap_its
|
||||
// on the result placed against the guard page. A read past the end crashes and prints the input;
|
||||
// an accepted frame whose payload is not inside the input is reported the same way. MinGW has
|
||||
// neither libFuzzer nor AddressSanitizer, hence this rather than coverage guidance. The same seed
|
||||
// gives the same run.
|
||||
//
|
||||
// Usage: fuzz_gn_unwrap iterations seed [capture.pcap ...]
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "dot11p.h"
|
||||
#include "geonet.h"
|
||||
#include "gn_unwrap.h"
|
||||
#include "test_util.h"
|
||||
|
||||
#define MAX_FRAME 2048 // within the guard page's one page, and above any 802.11 frame
|
||||
|
||||
static uint8_t **s_seeds;
|
||||
static int *s_seed_len;
|
||||
static int s_nseeds;
|
||||
static int s_seed_cap;
|
||||
|
||||
static void add_seed(const uint8_t *f, int len)
|
||||
{
|
||||
if (len <= 0 || len > MAX_FRAME) {
|
||||
return;
|
||||
}
|
||||
if (s_nseeds == s_seed_cap) {
|
||||
s_seed_cap = s_seed_cap ? 2 * s_seed_cap : 1024;
|
||||
s_seeds = realloc(s_seeds, (size_t)s_seed_cap * sizeof *s_seeds);
|
||||
s_seed_len = realloc(s_seed_len, (size_t)s_seed_cap * sizeof *s_seed_len);
|
||||
if (!s_seeds || !s_seed_len) {
|
||||
fprintf(stderr, "out of memory\n");
|
||||
exit(2);
|
||||
}
|
||||
}
|
||||
s_seeds[s_nseeds] = malloc((size_t)len);
|
||||
if (!s_seeds[s_nseeds]) {
|
||||
fprintf(stderr, "out of memory\n");
|
||||
exit(2);
|
||||
}
|
||||
memcpy(s_seeds[s_nseeds], f, (size_t)len);
|
||||
s_seed_len[s_nseeds++] = len;
|
||||
}
|
||||
|
||||
static void on_frame(const uint8_t *f, int len, int index, void *ctx)
|
||||
{
|
||||
(void)index;
|
||||
(void)ctx;
|
||||
add_seed(f, len);
|
||||
}
|
||||
|
||||
static void add_own_seeds(void)
|
||||
{
|
||||
static const uint8_t cam[] = {0x02, 0x02, 0x00, 0x0f, 0x42, 0x3f, 0x37, 0x00, 0x40, 0x2a, 0xb2};
|
||||
gn_lpv_t lpv;
|
||||
memset(&lpv, 0, sizeof lpv);
|
||||
lpv.mac[0] = 0x02;
|
||||
lpv.station_type = 2;
|
||||
uint8_t gn[256];
|
||||
uint8_t f[512];
|
||||
const int gn_len = geonet_wrap_shb(cam, (int)sizeof cam, &lpv, 2001, gn, sizeof gn);
|
||||
for (int qos = 0; qos <= 1; qos++) {
|
||||
add_seed(f, dot11p_build_frame(gn, gn_len, lpv.mac, f, sizeof f, qos));
|
||||
}
|
||||
}
|
||||
|
||||
static uint64_t s_rng;
|
||||
|
||||
static uint64_t rnd(void)
|
||||
{
|
||||
s_rng ^= s_rng << 13;
|
||||
s_rng ^= s_rng >> 7;
|
||||
s_rng ^= s_rng << 17;
|
||||
return s_rng;
|
||||
}
|
||||
|
||||
static int below(int n)
|
||||
{
|
||||
return n <= 0 ? 0 : (int)(rnd() % (uint64_t)n);
|
||||
}
|
||||
|
||||
// Three times in four inside the headers, otherwise anywhere.
|
||||
static int pick_pos(int len)
|
||||
{
|
||||
return below(4) ? below(len < 128 ? len : 128) : below(len);
|
||||
}
|
||||
|
||||
static const uint8_t k_bytes[] = {0x00, 0x01, 0x02, 0x03, 0x05, 0x10, 0x12, 0x20,
|
||||
0x40, 0x50, 0x7F, 0x80, 0x81, 0x82, 0x83, 0xFF};
|
||||
static const uint16_t k_words[] = {0x0000, 0x0001, 0x0003, 0x0004, 0x0005, 0x007F,
|
||||
0x0080, 0x00FF, 0x0100, 0x7FFF, 0x8000, 0xFFFF};
|
||||
|
||||
static void mutate(uint8_t *b, int *len)
|
||||
{
|
||||
const int edits = 1 + below(4);
|
||||
for (int e = 0; e < edits; e++) {
|
||||
const int n = *len;
|
||||
switch (below(8)) {
|
||||
case 0: // flip a bit
|
||||
if (n) {
|
||||
b[pick_pos(n)] ^= (uint8_t)(1u << below(8));
|
||||
}
|
||||
break;
|
||||
case 1: // random byte
|
||||
if (n) {
|
||||
b[pick_pos(n)] = (uint8_t)rnd();
|
||||
}
|
||||
break;
|
||||
case 2: // boundary byte
|
||||
if (n) {
|
||||
b[pick_pos(n)] = k_bytes[below((int)sizeof k_bytes)];
|
||||
}
|
||||
break;
|
||||
case 3: // truncate
|
||||
*len = below(n + 1);
|
||||
break;
|
||||
case 4: { // append
|
||||
const int add = 1 + below(16);
|
||||
if (n + add <= MAX_FRAME) {
|
||||
for (int i = 0; i < add; i++) {
|
||||
b[n + i] = (uint8_t)rnd();
|
||||
}
|
||||
*len = n + add;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 5: // insert a byte
|
||||
if (n < MAX_FRAME) {
|
||||
const int p = below(n + 1);
|
||||
memmove(b + p + 1, b + p, (size_t)(n - p));
|
||||
b[p] = (uint8_t)rnd();
|
||||
*len = n + 1;
|
||||
}
|
||||
break;
|
||||
case 6: // delete a byte
|
||||
if (n) {
|
||||
const int p = below(n);
|
||||
memmove(b + p, b + p + 1, (size_t)(n - p - 1));
|
||||
*len = n - 1;
|
||||
}
|
||||
break;
|
||||
default: // boundary 16-bit big-endian value, e.g. a length field
|
||||
if (n >= 2) {
|
||||
const int p = pick_pos(n - 1);
|
||||
const uint16_t v = k_words[below((int)(sizeof k_words / sizeof k_words[0]))];
|
||||
b[p] = (uint8_t)(v >> 8);
|
||||
b[p + 1] = (uint8_t)v;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
if (argc < 3) {
|
||||
fprintf(stderr, "usage: fuzz_gn_unwrap iterations seed [capture.pcap ...]\n");
|
||||
return 2;
|
||||
}
|
||||
const unsigned long long iterations = strtoull(argv[1], NULL, 10);
|
||||
s_rng = (strtoull(argv[2], NULL, 10) * 0x9E3779B97F4A7C15ull) | 1;
|
||||
|
||||
tu_install_crash_handler();
|
||||
tu_guard_init();
|
||||
for (int i = 3; i < argc; i++) {
|
||||
if (tu_pcap_foreach(argv[i], on_frame, NULL) < 0) {
|
||||
fprintf(stderr, "cannot read %s as a pcap\n", argv[i]);
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
add_own_seeds();
|
||||
|
||||
static uint8_t buf[MAX_FRAME];
|
||||
int len = 0;
|
||||
tu_set_crash_input(buf, &len);
|
||||
unsigned long long accepted = 0, signed_frames = 0, truncated = 0;
|
||||
for (unsigned long long it = 0; it < iterations; it++) {
|
||||
const int s = below(s_nseeds);
|
||||
len = s_seed_len[s];
|
||||
memcpy(buf, s_seeds[s], (size_t)len);
|
||||
mutate(buf, &len);
|
||||
tu_set_context("fuzz iteration %llu (seed %s), mutated from seed frame %d", it, argv[2], s);
|
||||
|
||||
const uint8_t *g = tu_guarded(buf, len);
|
||||
gn_rx_t rx;
|
||||
if (gn_unwrap_its(g, len, &rx)) {
|
||||
accepted++;
|
||||
signed_frames += rx.signed_unverified;
|
||||
truncated += rx.truncated;
|
||||
const uintptr_t lo = (uintptr_t)g;
|
||||
const uintptr_t p = (uintptr_t)rx.payload;
|
||||
if (p < lo || rx.payload_len <= 0 || p + (uintptr_t)rx.payload_len > lo + (uintptr_t)len) {
|
||||
fprintf(stderr, "FAIL during %s: accepted payload lies outside the input\ninput (%d bytes): ",
|
||||
tu_context(), len);
|
||||
for (int i = 0; i < len; i++) {
|
||||
fprintf(stderr, "%02x", buf[i]);
|
||||
}
|
||||
fputc('\n', stderr);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
printf("fuzz_gn_unwrap: %llu iterations from %d seed frames, %llu accepted (%llu signed, "
|
||||
"%llu truncated), no crash\n",
|
||||
iterations, s_nseeds, accepted, signed_frames, truncated);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,730 @@
|
||||
// Host-side chain test for obu-firmware. See README.md in this folder.
|
||||
//
|
||||
// Builds frames with the firmware's own TX code (geonet_wrap_shb -> dot11p_build_frame) and parses
|
||||
// them back with its own RX code (gn_unwrap_its), all compiled from ../../main unmodified.
|
||||
//
|
||||
// Two kinds of check, on purpose. The round trip proves TX and RX agree with each other. The byte
|
||||
// checks at fixed offsets prove they agree with EN 302 636-4-1, and only those catch a mistake made
|
||||
// the same way on both sides: the missing 4-byte SHB field (fixed 2026-08-13) round-tripped fine
|
||||
// between two ESP32s and was wrong against every other station. So expected values here come from
|
||||
// the standard, vanetza's serializers and real captures - never from reading geonet.c.
|
||||
//
|
||||
// Every frame handed to gn_unwrap_its is first copied so that its last byte sits right before a
|
||||
// no-access page (test_util.c): reading even one byte past a frame crashes the test instead of
|
||||
// passing quietly. MinGW has no AddressSanitizer; this covers what matters for a radio parser.
|
||||
//
|
||||
// Usage: test_chain [out.pcap]
|
||||
// With a path, also writes every test frame to a pcap (linktype 105, bare 802.11) so a second,
|
||||
// independent parser can read them: ../pcap_gn_tally.py, or Wireshark. The GeoBroadcast and
|
||||
// signed frames carry stub payloads/signatures; their headers are real.
|
||||
|
||||
#include <stdarg.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "dot11p.h"
|
||||
#include "geonet.h"
|
||||
#include "gn_unwrap.h"
|
||||
#include "serial_link.h" // SERIAL_LINK_MAX_PAYLOAD only; serial_link.c itself needs ESP-IDF
|
||||
#include "test_util.h"
|
||||
|
||||
// Same buffer sizes as tx_radio_task in main.c. Keep them in step with it.
|
||||
#define GN_BUF_LEN (SERIAL_LINK_MAX_PAYLOAD + 64)
|
||||
#define FRAME_BUF_LEN (SERIAL_LINK_MAX_PAYLOAD + 192)
|
||||
|
||||
// ---- Checks ---------------------------------------------------------------------------------
|
||||
|
||||
static int s_checks;
|
||||
static int s_failures;
|
||||
|
||||
static void check(bool ok, int line, const char *fmt, ...)
|
||||
{
|
||||
s_checks++;
|
||||
if (ok) {
|
||||
return;
|
||||
}
|
||||
s_failures++;
|
||||
va_list ap;
|
||||
va_start(ap, fmt);
|
||||
fprintf(stderr, "FAIL line %d [%s]: ", line, tu_context());
|
||||
vfprintf(stderr, fmt, ap);
|
||||
fputc('\n', stderr);
|
||||
va_end(ap);
|
||||
}
|
||||
#define CHECK(cond, ...) check((cond), __LINE__, __VA_ARGS__)
|
||||
|
||||
// ---- Test data ------------------------------------------------------------------------------
|
||||
|
||||
static uint16_t rd16(const uint8_t *p)
|
||||
{
|
||||
return (uint16_t)((p[0] << 8) | p[1]);
|
||||
}
|
||||
|
||||
static uint32_t rd32(const uint8_t *p)
|
||||
{
|
||||
return ((uint32_t)p[0] << 24) | ((uint32_t)p[1] << 16) | ((uint32_t)p[2] << 8) | p[3];
|
||||
}
|
||||
|
||||
// The app's reference CAM: the expected bytes of CamEncodeGoldenTest.kt (stationID 999999), which
|
||||
// asn1tools decodes and re-encodes byte-identically. The chain treats it as opaque bytes. Not taken
|
||||
// from the old captures on purpose: our own CAMs there predate the 2026-08-20 yawRateConfidence fix
|
||||
// and do not decode.
|
||||
static const uint8_t k_cam[] = {
|
||||
0x02, 0x02, 0x00, 0x0f, 0x42, 0x3f, 0x37, 0x00, 0x40, 0x2a, 0xb2, 0x15, 0xaf, 0x6e, 0x28, 0x64,
|
||||
0x77, 0xdf, 0xff, 0xff, 0xfc, 0x23, 0xb7, 0x74, 0x3e, 0x00, 0x27, 0xff, 0xc0, 0xd0, 0xfe, 0x01,
|
||||
0x18, 0x32, 0x93, 0x37, 0xfe, 0xeb, 0xff, 0xf6, 0x00, 0x00, 0x00,
|
||||
};
|
||||
#define CAM_LEN ((int)sizeof k_cam)
|
||||
|
||||
static const uint8_t k_mac[6] = {0x02, 0x11, 0x22, 0x33, 0x44, 0x55};
|
||||
static const uint8_t k_bcast[6] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};
|
||||
static const uint8_t k_llc_snap_gn[8] = {0xAA, 0xAA, 0x03, 0x00, 0x00, 0x00, 0x89, 0x47};
|
||||
|
||||
// The 8 bytes that follow the 802.11 frame in every frame recorded through the ESP32-C5's
|
||||
// promiscuous RX (these from capture_20260817_171055.pcap). See gn_unwrap.h, "Payload bounds".
|
||||
static const uint8_t k_rx_trailer[8] = {0xc8, 0x01, 0x00, 0x00, 0x88, 0x00, 0x00, 0x00};
|
||||
|
||||
// Header lengths from EN 302 636-4-1 / IEEE 802.11, used to compute every offset below.
|
||||
enum {
|
||||
MAC_HDR = 24, QOS_CTRL = 2, LLC_SNAP = 8, GN_BASIC = 4, GN_COMMON = 8,
|
||||
SHB_EXT = 28, // Source Position Vector (24) + DCC-MCO / reserved (4)
|
||||
GBC_EXT = 44, // SN (2) + reserved (2) + SO PV (24) + area (12) + reserved (4)
|
||||
BTP_B = 4,
|
||||
GN_COMMON_PAYLOAD_LEN_FIELD = 4, // offset of the payload-length field in the Common Header
|
||||
};
|
||||
// Where the ITS payload starts in a non-QoS SHB frame.
|
||||
#define SHB_PAYLOAD_OFFSET (MAC_HDR + LLC_SNAP + GN_BASIC + GN_COMMON + SHB_EXT + BTP_B)
|
||||
|
||||
static gn_lpv_t test_lpv(void)
|
||||
{
|
||||
gn_lpv_t lpv;
|
||||
memset(&lpv, 0, sizeof lpv);
|
||||
memcpy(lpv.mac, k_mac, sizeof lpv.mac);
|
||||
lpv.station_type = 2; // cyclist
|
||||
lpv.pai = true;
|
||||
lpv.tst_ms = 0x89ABCDEFu;
|
||||
lpv.lat_tenmicrodeg = 535546667; // the bench, 53.5546667 N
|
||||
lpv.lon_tenmicrodeg = -10022389; // negative on purpose: the sign has to survive
|
||||
lpv.speed_cms = 1234;
|
||||
lpv.heading_decideg = 2700;
|
||||
return lpv;
|
||||
}
|
||||
|
||||
// The TX path exactly as tx_radio_task runs it. Returns the frame length, or <= 0 on failure.
|
||||
static int build(const uint8_t *payload, int len, const gn_lpv_t *lpv, uint16_t port, bool qos,
|
||||
uint8_t *frame, size_t frame_size)
|
||||
{
|
||||
static uint8_t gn[GN_BUF_LEN];
|
||||
int gn_len = geonet_wrap_shb(payload, len, lpv, port, gn, sizeof gn);
|
||||
if (gn_len <= 0) {
|
||||
return gn_len;
|
||||
}
|
||||
return dot11p_build_frame(gn, gn_len, lpv->mac, frame, frame_size, qos);
|
||||
}
|
||||
|
||||
// Every prefix of a frame, each ending against the guard page. Up to the end of the BTP-B header
|
||||
// nothing may be accepted; a frame cut inside the payload is accepted as truncated with what
|
||||
// arrived; from `payload_end` on, the payload is exactly the declared one whatever follows it.
|
||||
static void sweep(const char *name, const uint8_t *f, int len, int payload_start, int payload_end)
|
||||
{
|
||||
for (int n = 0; n <= len; n++) {
|
||||
tu_set_context("truncation sweep, %s, %d of %d bytes", name, n, len);
|
||||
const uint8_t *g = tu_guarded(f, n);
|
||||
gn_rx_t rx;
|
||||
const bool ok = gn_unwrap_its(g, n, &rx);
|
||||
if (n <= payload_start) {
|
||||
CHECK(!ok, "%s cut to %d bytes was accepted", name, n);
|
||||
} else if (n < payload_end) {
|
||||
CHECK(ok && rx.truncated && rx.payload == g + payload_start &&
|
||||
rx.payload_len == n - payload_start,
|
||||
"%s cut to %d bytes: ok=%d truncated=%d payload_len=%d", name, n, ok,
|
||||
ok && rx.truncated, ok ? rx.payload_len : -1);
|
||||
} else {
|
||||
CHECK(ok && !rx.truncated && rx.payload == g + payload_start &&
|
||||
rx.payload_len == payload_end - payload_start,
|
||||
"%s at %d bytes: ok=%d truncated=%d payload_len=%d", name, n, ok,
|
||||
ok && rx.truncated, ok ? rx.payload_len : -1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- SHB: layout, position vector, bounds ---------------------------------------------------
|
||||
|
||||
// Every byte of a CAM frame against the standard, then back through the RX path.
|
||||
static void test_shb_layout(bool qos)
|
||||
{
|
||||
tu_set_context("SHB layout, qos=%d", qos);
|
||||
const gn_lpv_t lpv = test_lpv();
|
||||
uint8_t f[FRAME_BUF_LEN];
|
||||
const int len = build(k_cam, CAM_LEN, &lpv, 2001, qos, f, sizeof f);
|
||||
|
||||
const int llc = MAC_HDR + (qos ? QOS_CTRL : 0);
|
||||
const int basic = llc + LLC_SNAP;
|
||||
const int common = basic + GN_BASIC;
|
||||
const int shb = common + GN_COMMON;
|
||||
const int btp = shb + SHB_EXT;
|
||||
const int pay = btp + BTP_B;
|
||||
CHECK(len == pay + CAM_LEN, "frame length %d, expected %d", len, pay + CAM_LEN);
|
||||
if (len != pay + CAM_LEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 802.11 MAC header
|
||||
CHECK(f[0] == (qos ? 0x88 : 0x08) && f[1] == 0x00, "frame control %02x %02x", f[0], f[1]);
|
||||
CHECK(memcmp(f + 4, k_bcast, 6) == 0, "addr1 must be broadcast");
|
||||
CHECK(memcmp(f + 10, k_mac, 6) == 0, "addr2 must be the pseudonym");
|
||||
CHECK(memcmp(f + 16, k_bcast, 6) == 0, "addr3 (BSSID) must be the OCB wildcard");
|
||||
if (qos) {
|
||||
CHECK(f[24] == 0 && f[25] == 0, "QoS control %02x %02x", f[24], f[25]);
|
||||
}
|
||||
CHECK(memcmp(f + llc, k_llc_snap_gn, 8) == 0, "LLC/SNAP with EtherType 0x8947");
|
||||
|
||||
// GN Basic Header
|
||||
CHECK(f[basic + 0] == 0x11, "basic: version 1 + NextHeader 1 (Common, unsecured), got %02x",
|
||||
f[basic + 0]);
|
||||
CHECK(f[basic + 1] == 0x00, "basic: reserved, got %02x", f[basic + 1]);
|
||||
// Multiplier 1 in the upper 6 bits, base 1 (= 1 s) in the lower 2: 1 s, what every real station
|
||||
// in the recordings sends its CAMs with. Was 0x83 = 3200 s until 2026-09-11.
|
||||
CHECK(f[basic + 2] == 0x05, "basic: lifetime 0x05 (1 s), got 0x%02x", f[basic + 2]);
|
||||
CHECK(f[basic + 3] == 1, "basic: remaining hop limit 1, got %d", f[basic + 3]);
|
||||
|
||||
// GN Common Header
|
||||
CHECK(f[common + 0] == 0x20, "common: NextHeader 2 (BTP-B), got %02x", f[common + 0]);
|
||||
CHECK(f[common + 1] == 0x50, "common: HeaderType 5 (TSB) / subtype 0 (single hop), got %02x",
|
||||
f[common + 1]);
|
||||
CHECK(f[common + 2] == 0x02, "common: traffic class TC-ID 2 (DP2, CAM), got %02x", f[common + 2]);
|
||||
CHECK(f[common + 3] == 0x80, "common: flags = mobile, got %02x", f[common + 3]);
|
||||
CHECK(rd16(f + common + 4) == BTP_B + CAM_LEN,
|
||||
"common: payload length must count BTP-B + payload only, got %d", rd16(f + common + 4));
|
||||
CHECK(f[common + 6] == 1, "common: maximum hop limit 1, got %d", f[common + 6]);
|
||||
CHECK(f[common + 7] == 0, "common: reserved, got %02x", f[common + 7]);
|
||||
|
||||
// SHB extended header: Source Position Vector, then 4 bytes DCC-MCO / reserved
|
||||
// GN_ADDR: M flag bit 15, station type bits 14..10 (vanetza geonet/address.cpp), MID.
|
||||
CHECK(rd16(f + shb) == (2u << 10), "GN_ADDR: M=0, station type 2, got %04x", rd16(f + shb));
|
||||
CHECK(memcmp(f + shb + 2, k_mac, 6) == 0, "GN_ADDR MID must equal the 802.11 source address");
|
||||
CHECK(rd32(f + shb + 8) == 0x89ABCDEFu, "TST, got %08lx", (unsigned long)rd32(f + shb + 8));
|
||||
CHECK((int32_t)rd32(f + shb + 12) == 535546667, "latitude, got %ld",
|
||||
(long)(int32_t)rd32(f + shb + 12));
|
||||
CHECK((int32_t)rd32(f + shb + 16) == -10022389, "longitude, got %ld",
|
||||
(long)(int32_t)rd32(f + shb + 16));
|
||||
CHECK(rd16(f + shb + 20) == (0x8000 | 1234), "PAI bit 15 + speed 1234, got %04x",
|
||||
rd16(f + shb + 20));
|
||||
CHECK(rd16(f + shb + 22) == 2700, "heading, got %d", rd16(f + shb + 22));
|
||||
CHECK(rd32(f + shb + 24) == 0, "DCC-MCO / reserved: present and zero, got %08lx",
|
||||
(unsigned long)rd32(f + shb + 24));
|
||||
|
||||
// BTP-B, payload
|
||||
CHECK(rd16(f + btp) == 2001, "BTP-B destination port, got %d", rd16(f + btp));
|
||||
CHECK(rd16(f + btp + 2) == 0, "BTP-B destination port info, got %d", rd16(f + btp + 2));
|
||||
CHECK(memcmp(f + pay, k_cam, (size_t)CAM_LEN) == 0, "payload bytes");
|
||||
|
||||
// ...and back through the RX path
|
||||
const uint8_t *g = tu_guarded(f, len);
|
||||
gn_rx_t rx;
|
||||
const bool ok = gn_unwrap_its(g, len, &rx);
|
||||
CHECK(ok, "gn_unwrap_its rejected our own frame");
|
||||
if (ok) {
|
||||
CHECK(rx.btp_dest_port == 2001, "RX port %d", rx.btp_dest_port);
|
||||
CHECK(!rx.has_geo_area, "RX reports a geo area for an SHB frame");
|
||||
CHECK(!rx.signed_unverified, "RX reports an unsecured frame as signed");
|
||||
CHECK(!rx.truncated, "RX reports a whole frame as truncated");
|
||||
CHECK(rx.payload == g + pay, "RX payload offset %d, expected %d", (int)(rx.payload - g), pay);
|
||||
CHECK(rx.payload_len == CAM_LEN, "RX payload length %d, expected %d", rx.payload_len, CAM_LEN);
|
||||
CHECK(rx.payload_len == CAM_LEN && memcmp(rx.payload, k_cam, (size_t)CAM_LEN) == 0,
|
||||
"RX payload bytes differ from what was sent");
|
||||
}
|
||||
tu_pcap_add(f, len);
|
||||
}
|
||||
|
||||
// The Source Position Vector's packed fields at their edges.
|
||||
static void test_pv_encoding(void)
|
||||
{
|
||||
// Offsets inside the GeoNetworking packet (no 802.11 header): SO PV starts after Basic + Common.
|
||||
const int pv = GN_BASIC + GN_COMMON;
|
||||
uint8_t gn[GN_BUF_LEN];
|
||||
|
||||
// Speed is 15-bit signed (geonet.h), clamped rather than wrapped; PAI is bit 15.
|
||||
static const struct { int16_t speed; bool pai; uint16_t expect; } speeds[] = {
|
||||
{0, false, 0x0000}, {1234, true, 0x84D2}, {16383, false, 0x3FFF},
|
||||
{16384, false, 0x3FFF}, {32767, false, 0x3FFF}, {-100, false, 0x7F9C},
|
||||
{-16384, false, 0x4000}, {-32768, true, 0xC000},
|
||||
};
|
||||
for (size_t i = 0; i < sizeof speeds / sizeof speeds[0]; i++) {
|
||||
tu_set_context("PV speed %d pai %d", speeds[i].speed, speeds[i].pai);
|
||||
gn_lpv_t lpv = test_lpv();
|
||||
lpv.speed_cms = speeds[i].speed;
|
||||
lpv.pai = speeds[i].pai;
|
||||
const int n = geonet_wrap_shb(k_cam, CAM_LEN, &lpv, 2001, gn, sizeof gn);
|
||||
CHECK(n > 0 && rd16(gn + pv + 20) == speeds[i].expect, "speed %d pai %d: got %04x, expected %04x",
|
||||
speeds[i].speed, speeds[i].pai, rd16(gn + pv + 20), speeds[i].expect);
|
||||
}
|
||||
|
||||
// Heading is 0..3599 tenths of a degree, wrapped.
|
||||
static const struct { uint16_t in, expect; } headings[] = {
|
||||
{0, 0}, {2700, 2700}, {3599, 3599}, {3600, 0}, {3601, 1}, {65535, 735},
|
||||
};
|
||||
for (size_t i = 0; i < sizeof headings / sizeof headings[0]; i++) {
|
||||
tu_set_context("PV heading %d", headings[i].in);
|
||||
gn_lpv_t lpv = test_lpv();
|
||||
lpv.heading_decideg = headings[i].in;
|
||||
const int n = geonet_wrap_shb(k_cam, CAM_LEN, &lpv, 2001, gn, sizeof gn);
|
||||
CHECK(n > 0 && rd16(gn + pv + 22) == headings[i].expect, "heading %d: got %d, expected %d",
|
||||
headings[i].in, rd16(gn + pv + 22), headings[i].expect);
|
||||
}
|
||||
|
||||
// Station type has 5 bits; anything larger must not spill into the M flag.
|
||||
static const struct { uint8_t in; uint16_t expect; } types[] = {
|
||||
{2, 0x0800}, {15, 0x3C00}, {0xFF, 0x7C00},
|
||||
};
|
||||
for (size_t i = 0; i < sizeof types / sizeof types[0]; i++) {
|
||||
tu_set_context("PV station type %d", types[i].in);
|
||||
gn_lpv_t lpv = test_lpv();
|
||||
lpv.station_type = types[i].in;
|
||||
const int n = geonet_wrap_shb(k_cam, CAM_LEN, &lpv, 2001, gn, sizeof gn);
|
||||
CHECK(n > 0 && rd16(gn + pv) == types[i].expect, "station type %d: GN_ADDR %04x, expected %04x",
|
||||
types[i].in, rd16(gn + pv), types[i].expect);
|
||||
}
|
||||
}
|
||||
|
||||
// Output buffers: an exact fit works and writes nothing past its end; one byte less is refused.
|
||||
static void test_bounds(void)
|
||||
{
|
||||
const gn_lpv_t lpv = test_lpv();
|
||||
const int gn_len = GN_BASIC + GN_COMMON + SHB_EXT + BTP_B + CAM_LEN;
|
||||
|
||||
tu_set_context("geonet_wrap_shb bounds");
|
||||
CHECK(geonet_wrap_shb(k_cam, CAM_LEN, &lpv, 2001, tu_guard_buf(gn_len), (size_t)gn_len) == gn_len,
|
||||
"geonet_wrap_shb: exact-size buffer must fit");
|
||||
CHECK(geonet_wrap_shb(k_cam, CAM_LEN, &lpv, 2001, tu_guard_buf(gn_len - 1), (size_t)gn_len - 1) == -1,
|
||||
"geonet_wrap_shb: one byte short must be refused");
|
||||
|
||||
uint8_t gn[GN_BUF_LEN];
|
||||
geonet_wrap_shb(k_cam, CAM_LEN, &lpv, 2001, gn, sizeof gn);
|
||||
for (int qos = 0; qos <= 1; qos++) {
|
||||
tu_set_context("dot11p_build_frame bounds, qos=%d", qos);
|
||||
const int frame_len = MAC_HDR + (qos ? QOS_CTRL : 0) + LLC_SNAP + gn_len;
|
||||
CHECK(dot11p_build_frame(gn, gn_len, k_mac, tu_guard_buf(frame_len), (size_t)frame_len, qos) ==
|
||||
frame_len,
|
||||
"dot11p_build_frame qos=%d: exact-size buffer must fit", qos);
|
||||
CHECK(dot11p_build_frame(gn, gn_len, k_mac, tu_guard_buf(frame_len - 1), (size_t)frame_len - 1,
|
||||
qos) == -1,
|
||||
"dot11p_build_frame qos=%d: one byte short must be refused", qos);
|
||||
}
|
||||
}
|
||||
|
||||
// The largest CAM the serial link can carry must survive the whole chain in main.c's buffers.
|
||||
static void test_max_payload(void)
|
||||
{
|
||||
static uint8_t big[SERIAL_LINK_MAX_PAYLOAD];
|
||||
for (int i = 0; i < SERIAL_LINK_MAX_PAYLOAD; i++) {
|
||||
big[i] = (uint8_t)(i * 7 + 3);
|
||||
}
|
||||
for (int qos = 0; qos <= 1; qos++) {
|
||||
tu_set_context("max payload %d, qos=%d", SERIAL_LINK_MAX_PAYLOAD, qos);
|
||||
const gn_lpv_t lpv = test_lpv();
|
||||
uint8_t f[FRAME_BUF_LEN];
|
||||
const int hdr = MAC_HDR + (qos ? QOS_CTRL : 0) + LLC_SNAP + GN_BASIC + GN_COMMON + SHB_EXT + BTP_B;
|
||||
const int len = build(big, SERIAL_LINK_MAX_PAYLOAD, &lpv, 2001, qos, f, sizeof f);
|
||||
CHECK(len == hdr + SERIAL_LINK_MAX_PAYLOAD, "qos=%d: frame length %d, expected %d", qos, len,
|
||||
hdr + SERIAL_LINK_MAX_PAYLOAD);
|
||||
if (len <= 0) {
|
||||
continue;
|
||||
}
|
||||
gn_rx_t rx;
|
||||
const uint8_t *g = tu_guarded(f, len);
|
||||
const bool ok = gn_unwrap_its(g, len, &rx);
|
||||
CHECK(ok && !rx.truncated && rx.payload_len == SERIAL_LINK_MAX_PAYLOAD &&
|
||||
memcmp(rx.payload, big, SERIAL_LINK_MAX_PAYLOAD) == 0,
|
||||
"qos=%d: max-size payload did not round-trip", qos);
|
||||
tu_pcap_add(f, len);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- GeoBroadcast ---------------------------------------------------------------------------
|
||||
|
||||
// A GeoBroadcast DENM frame laid out by hand from EN 302 636-4-1 clause 9.8.5, since the firmware
|
||||
// has no GBC builder and real RSUs send DENM this way. QoS Data, lifetime 0x79 (30 s) and the
|
||||
// 44-byte extended header all as seen from the CiT One in the recordings.
|
||||
static const uint8_t k_rsu_mac[6] = {0x02, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE};
|
||||
static const uint8_t k_denm_stub[] = {0x02, 0x01, 0x00, 0x00, 0x30, 0x39, 0xDE, 0xAD, 0xBE,
|
||||
0xEF, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08};
|
||||
#define DENM_STUB_LEN ((int)sizeof k_denm_stub)
|
||||
#define GBC_PAYLOAD_OFFSET (MAC_HDR + QOS_CTRL + LLC_SNAP + GN_BASIC + GN_COMMON + GBC_EXT + BTP_B)
|
||||
|
||||
static uint8_t *put(uint8_t *p, const void *src, int n)
|
||||
{
|
||||
memcpy(p, src, (size_t)n);
|
||||
return p + n;
|
||||
}
|
||||
|
||||
static uint8_t *put16(uint8_t *p, uint16_t v)
|
||||
{
|
||||
*p++ = (uint8_t)(v >> 8);
|
||||
*p++ = (uint8_t)v;
|
||||
return p;
|
||||
}
|
||||
|
||||
static uint8_t *put32(uint8_t *p, uint32_t v)
|
||||
{
|
||||
p = put16(p, (uint16_t)(v >> 16));
|
||||
return put16(p, (uint16_t)v);
|
||||
}
|
||||
|
||||
static int build_gbc(uint8_t subtype, int32_t area_lat, int32_t area_lon, uint16_t dist_a, uint8_t *f)
|
||||
{
|
||||
uint8_t *p = f;
|
||||
// 802.11 QoS Data: FC, duration, addr1..3, sequence control, QoS control
|
||||
p = put16(p, 0x8800);
|
||||
p = put16(p, 0);
|
||||
p = put(p, k_bcast, 6);
|
||||
p = put(p, k_rsu_mac, 6);
|
||||
p = put(p, k_bcast, 6);
|
||||
p = put16(p, 0x1000);
|
||||
p = put16(p, 0);
|
||||
p = put(p, k_llc_snap_gn, 8);
|
||||
// Basic: version 1 / NH common, reserved, lifetime 30 s, RHL 10
|
||||
const uint8_t basic[4] = {0x11, 0x00, 0x79, 10};
|
||||
p = put(p, basic, 4);
|
||||
// Common: NH BTP-B, HT 4 (GBC) / subtype = area shape, TC 1, flags 0 (stationary), PL, MHL, reserved
|
||||
const uint8_t common[4] = {0x20, (uint8_t)(0x40 | subtype), 0x01, 0x00};
|
||||
p = put(p, common, 4);
|
||||
p = put16(p, (uint16_t)(BTP_B + DENM_STUB_LEN));
|
||||
*p++ = 10;
|
||||
*p++ = 0;
|
||||
// GBC extended header: SN, reserved, SO PV (GN_ADDR with station type 15 = RSU, MID, TST, lat,
|
||||
// lon, PAI/speed, heading), area centre lat/lon, DistanceA, DistanceB, angle, reserved
|
||||
p = put16(p, 0x1234);
|
||||
p = put16(p, 0);
|
||||
p = put16(p, 15u << 10);
|
||||
p = put(p, k_rsu_mac, 6);
|
||||
p = put32(p, 1000);
|
||||
p = put32(p, 535540100);
|
||||
p = put32(p, 100220100);
|
||||
p = put16(p, 0);
|
||||
p = put16(p, 0);
|
||||
p = put32(p, (uint32_t)area_lat);
|
||||
p = put32(p, (uint32_t)area_lon);
|
||||
p = put16(p, dist_a);
|
||||
p = put16(p, subtype ? 40 : 0);
|
||||
p = put16(p, subtype ? 900 : 0);
|
||||
p = put16(p, 0);
|
||||
// BTP-B: DENM
|
||||
p = put16(p, 2002);
|
||||
p = put16(p, 0);
|
||||
p = put(p, k_denm_stub, DENM_STUB_LEN);
|
||||
return (int)(p - f);
|
||||
}
|
||||
|
||||
static void test_gbc_rx(void)
|
||||
{
|
||||
static const struct { uint8_t subtype; int32_t lat, lon; uint16_t dist_a; } areas[] = {
|
||||
{0, 535540000, 100220000, 250}, // circle around the bench
|
||||
{1, -338600000, 1512100000, 1000}, // rectangle; southern and far-eastern: signs and range
|
||||
{2, 535540000, -100220000, 65535}, // ellipse; western, largest DistanceA
|
||||
};
|
||||
for (size_t i = 0; i < sizeof areas / sizeof areas[0]; i++) {
|
||||
tu_set_context("GBC subtype %d", areas[i].subtype);
|
||||
uint8_t f[256];
|
||||
const int len = build_gbc(areas[i].subtype, areas[i].lat, areas[i].lon, areas[i].dist_a, f);
|
||||
CHECK(len == GBC_PAYLOAD_OFFSET + DENM_STUB_LEN, "GBC frame length %d", len);
|
||||
gn_rx_t rx;
|
||||
const uint8_t *g = tu_guarded(f, len);
|
||||
const bool ok = gn_unwrap_its(g, len, &rx);
|
||||
CHECK(ok, "GBC subtype %d rejected", areas[i].subtype);
|
||||
if (ok) {
|
||||
CHECK(rx.btp_dest_port == 2002, "GBC port %d", rx.btp_dest_port);
|
||||
CHECK(rx.has_geo_area, "GBC area missing");
|
||||
CHECK(!rx.signed_unverified && !rx.truncated, "GBC flags signed=%d truncated=%d",
|
||||
rx.signed_unverified, rx.truncated);
|
||||
CHECK(rx.geo_area_lat_tenmicrodeg == areas[i].lat, "area lat %ld, expected %ld",
|
||||
(long)rx.geo_area_lat_tenmicrodeg, (long)areas[i].lat);
|
||||
CHECK(rx.geo_area_lon_tenmicrodeg == areas[i].lon, "area lon %ld, expected %ld",
|
||||
(long)rx.geo_area_lon_tenmicrodeg, (long)areas[i].lon);
|
||||
CHECK(rx.geo_area_distance_a_m == areas[i].dist_a, "DistanceA %d, expected %d",
|
||||
rx.geo_area_distance_a_m, areas[i].dist_a);
|
||||
CHECK(rx.payload == g + GBC_PAYLOAD_OFFSET && rx.payload_len == DENM_STUB_LEN &&
|
||||
memcmp(rx.payload, k_denm_stub, (size_t)DENM_STUB_LEN) == 0,
|
||||
"GBC payload offset %d length %d", (int)(rx.payload - g), rx.payload_len);
|
||||
}
|
||||
tu_pcap_add(f, len);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- What gets rejected, and where the payload ends -----------------------------------------
|
||||
|
||||
// One-byte changes to a good CAM frame: what must be dropped, and what must still pass.
|
||||
static void test_rejections(void)
|
||||
{
|
||||
const gn_lpv_t lpv = test_lpv();
|
||||
uint8_t good[FRAME_BUF_LEN];
|
||||
const int len = build(k_cam, CAM_LEN, &lpv, 2001, false, good, sizeof good);
|
||||
|
||||
// Offsets into that non-QoS frame: 802.11 0, LLC/SNAP 24, Basic 32, Common 36, SHB 44, BTP-B 72.
|
||||
static const struct {
|
||||
const char *what;
|
||||
int offset;
|
||||
uint8_t value;
|
||||
bool accept;
|
||||
uint16_t port;
|
||||
} cases[] = {
|
||||
{"management frame (beacon)", 0, 0x80, false, 0},
|
||||
{"WDS (ToDS and FromDS)", 1, 0x03, false, 0},
|
||||
{"LLC DSAP not 0xAA", 24, 0xAB, false, 0},
|
||||
{"EtherType not 0x8947", 30, 0x08, false, 0},
|
||||
{"Basic NextHeader 2 with no security envelope", 32, 0x12, false, 0},
|
||||
{"Basic NextHeader 0 (any)", 32, 0x10, false, 0},
|
||||
{"Common NextHeader 1 (BTP-A)", 36, 0x10, false, 0},
|
||||
{"Beacon (HeaderType 1)", 37, 0x10, false, 0},
|
||||
{"GeoUnicast (HeaderType 2)", 37, 0x20, false, 0},
|
||||
{"multi-hop TSB (HeaderType 5, subtype 1)", 37, 0x51, false, 0},
|
||||
{"BTP port 2003 (MAPEM, not accepted yet)", 73, 0xD3, false, 0},
|
||||
{"BTP port 2018 (VAM, not accepted yet)", 73, 0xE2, false, 0},
|
||||
{"BTP port 2002 (DENM)", 73, 0xD2, true, 2002},
|
||||
{"BTP port 2004 (SPATEM)", 73, 0xD4, true, 2004},
|
||||
};
|
||||
for (size_t i = 0; i < sizeof cases / sizeof cases[0]; i++) {
|
||||
tu_set_context("mutation: %s", cases[i].what);
|
||||
uint8_t f[FRAME_BUF_LEN];
|
||||
memcpy(f, good, (size_t)len);
|
||||
f[cases[i].offset] = cases[i].value;
|
||||
gn_rx_t rx;
|
||||
const bool ok = gn_unwrap_its(tu_guarded(f, len), len, &rx);
|
||||
CHECK(ok == cases[i].accept, "%s: %s", cases[i].what, ok ? "accepted" : "rejected");
|
||||
if (ok && cases[i].accept) {
|
||||
CHECK(rx.btp_dest_port == cases[i].port, "%s: port %d", cases[i].what, rx.btp_dest_port);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The Common Header's payload length, not the end of the frame, decides where the message stops.
|
||||
static void test_payload_length(void)
|
||||
{
|
||||
const gn_lpv_t lpv = test_lpv();
|
||||
uint8_t good[FRAME_BUF_LEN];
|
||||
const int len = build(k_cam, CAM_LEN, &lpv, 2001, false, good, sizeof good);
|
||||
const int pl = MAC_HDR + LLC_SNAP + GN_BASIC + GN_COMMON_PAYLOAD_LEN_FIELD;
|
||||
|
||||
static const struct { const char *what; uint16_t value; bool accept; bool truncated; int payload_len; }
|
||||
cases[] = {
|
||||
{"one byte less than sent", BTP_B + CAM_LEN - 1, true, false, CAM_LEN - 1},
|
||||
{"BTP-B header only, no payload", BTP_B, false, false, 0},
|
||||
{"shorter than the BTP-B header", BTP_B - 1, false, false, 0},
|
||||
{"zero", 0, false, false, 0},
|
||||
{"far more than arrived", 0xFFFF, true, true, CAM_LEN},
|
||||
};
|
||||
for (size_t i = 0; i < sizeof cases / sizeof cases[0]; i++) {
|
||||
tu_set_context("Common payload length: %s", cases[i].what);
|
||||
uint8_t f[FRAME_BUF_LEN];
|
||||
memcpy(f, good, (size_t)len);
|
||||
f[pl] = (uint8_t)(cases[i].value >> 8);
|
||||
f[pl + 1] = (uint8_t)cases[i].value;
|
||||
gn_rx_t rx;
|
||||
const uint8_t *g = tu_guarded(f, len);
|
||||
const bool ok = gn_unwrap_its(g, len, &rx);
|
||||
CHECK(ok == cases[i].accept, "%s: %s", cases[i].what, ok ? "accepted" : "rejected");
|
||||
if (ok && cases[i].accept) {
|
||||
CHECK(rx.truncated == cases[i].truncated && rx.payload == g + SHB_PAYLOAD_OFFSET &&
|
||||
rx.payload_len == cases[i].payload_len,
|
||||
"%s: truncated=%d payload_len=%d", cases[i].what, rx.truncated, rx.payload_len);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The 8 bytes the chip's promiscuous RX appends to every frame must not reach the phone.
|
||||
static void test_trailer(void)
|
||||
{
|
||||
uint8_t f[FRAME_BUF_LEN];
|
||||
for (int qos = 0; qos <= 1; qos++) {
|
||||
tu_set_context("RX trailer after an SHB frame, qos=%d", qos);
|
||||
const gn_lpv_t lpv = test_lpv();
|
||||
const int len = build(k_cam, CAM_LEN, &lpv, 2001, qos, f, sizeof f);
|
||||
memcpy(f + len, k_rx_trailer, sizeof k_rx_trailer);
|
||||
gn_rx_t rx;
|
||||
const bool ok = gn_unwrap_its(tu_guarded(f, len + 8), len + 8, &rx);
|
||||
CHECK(ok && !rx.truncated && rx.payload_len == CAM_LEN &&
|
||||
memcmp(rx.payload, k_cam, (size_t)CAM_LEN) == 0,
|
||||
"qos=%d: payload_len %d with the trailer, expected %d", qos, ok ? rx.payload_len : -1,
|
||||
CAM_LEN);
|
||||
}
|
||||
|
||||
tu_set_context("RX trailer after a GBC frame");
|
||||
const int len = build_gbc(0, 535540000, 100220000, 250, f);
|
||||
memcpy(f + len, k_rx_trailer, sizeof k_rx_trailer);
|
||||
gn_rx_t rx;
|
||||
const bool ok = gn_unwrap_its(tu_guarded(f, len + 8), len + 8, &rx);
|
||||
CHECK(ok && !rx.truncated && rx.payload_len == DENM_STUB_LEN,
|
||||
"GBC: payload_len %d with the trailer, expected %d", ok ? rx.payload_len : -1, DENM_STUB_LEN);
|
||||
}
|
||||
|
||||
// ---- Secured (TS 103 097) -------------------------------------------------------------------
|
||||
|
||||
// Stand-in for what follows the inner packet in a real signed frame: headerInfo, signer and
|
||||
// signature, 94 bytes with a digest signer. Its content is never read.
|
||||
#define SIG_STUB_LEN 94
|
||||
|
||||
static int put_coer_length(int len, uint8_t *out)
|
||||
{
|
||||
if (len < 0x80) {
|
||||
out[0] = (uint8_t)len;
|
||||
return 1;
|
||||
}
|
||||
if (len <= 0xFF) {
|
||||
out[0] = 0x81;
|
||||
out[1] = (uint8_t)len;
|
||||
return 2;
|
||||
}
|
||||
out[0] = 0x82;
|
||||
out[1] = (uint8_t)(len >> 8);
|
||||
out[2] = (uint8_t)len;
|
||||
return 3;
|
||||
}
|
||||
|
||||
// A secured CAM frame shaped like the recorded ones: our own SHB packet with the Basic Header's
|
||||
// NextHeader set to 2 and everything after the Basic Header wrapped as `prefix` + COER length +
|
||||
// inner packet, then the signature stand-in. Sets where the ITS payload starts and ends.
|
||||
static int build_secured(const uint8_t *prefix, int prefix_len, const uint8_t *payload,
|
||||
int payload_len, uint8_t *f, int *payload_start, int *payload_end)
|
||||
{
|
||||
const gn_lpv_t lpv = test_lpv();
|
||||
uint8_t plain[FRAME_BUF_LEN];
|
||||
const int plain_len = build(payload, payload_len, &lpv, 2001, false, plain, sizeof plain);
|
||||
const int basic = MAC_HDR + LLC_SNAP;
|
||||
const int inner = basic + GN_BASIC; // the Common Header onward
|
||||
const int inner_len = plain_len - inner;
|
||||
|
||||
int n = inner;
|
||||
memcpy(f, plain, (size_t)inner);
|
||||
f[basic] = (uint8_t)((f[basic] & 0xF0) | 2); // Basic Header NextHeader: secured
|
||||
memcpy(f + n, prefix, (size_t)prefix_len);
|
||||
n += prefix_len;
|
||||
n += put_coer_length(inner_len, f + n);
|
||||
memcpy(f + n, plain + inner, (size_t)inner_len);
|
||||
*payload_start = n + GN_COMMON + SHB_EXT + BTP_B;
|
||||
n += inner_len;
|
||||
*payload_end = n;
|
||||
for (int i = 0; i < SIG_STUB_LEN; i++) {
|
||||
f[n++] = (uint8_t)(0xA5 ^ i);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
static const uint8_t k_signed_prefix[] = {0x03, 0x81, 0x00, 0x40, 0x03, 0x80};
|
||||
static const uint8_t k_unsecured_prefix[] = {0x03, 0x80};
|
||||
|
||||
static void test_secured(void)
|
||||
{
|
||||
static uint8_t big[300];
|
||||
for (int i = 0; i < (int)sizeof big; i++) {
|
||||
big[i] = (uint8_t)(i * 13 + 1);
|
||||
}
|
||||
static const struct { const char *what; bool is_signed; int payload_len; } cases[] = {
|
||||
{"signed, short length form", true, CAM_LEN}, // inner packet 83 bytes
|
||||
{"signed, 1-byte long length form", true, 100}, // 140
|
||||
{"signed, 2-byte long length form", true, 300}, // 340
|
||||
{"top-level unsecuredData", false, CAM_LEN},
|
||||
};
|
||||
for (size_t c = 0; c < sizeof cases / sizeof cases[0]; c++) {
|
||||
tu_set_context("secured: %s", cases[c].what);
|
||||
const uint8_t *payload = cases[c].payload_len == CAM_LEN ? k_cam : big;
|
||||
uint8_t f[FRAME_BUF_LEN];
|
||||
int start, end;
|
||||
const int len = cases[c].is_signed
|
||||
? build_secured(k_signed_prefix, (int)sizeof k_signed_prefix, payload,
|
||||
cases[c].payload_len, f, &start, &end)
|
||||
: build_secured(k_unsecured_prefix, (int)sizeof k_unsecured_prefix, payload,
|
||||
cases[c].payload_len, f, &start, &end);
|
||||
gn_rx_t rx;
|
||||
const uint8_t *g = tu_guarded(f, len);
|
||||
const bool ok = gn_unwrap_its(g, len, &rx);
|
||||
CHECK(ok, "%s: rejected", cases[c].what);
|
||||
if (ok) {
|
||||
CHECK(rx.signed_unverified == cases[c].is_signed, "%s: signed_unverified %d",
|
||||
cases[c].what, rx.signed_unverified);
|
||||
CHECK(!rx.truncated && rx.btp_dest_port == 2001, "%s: truncated %d port %d",
|
||||
cases[c].what, rx.truncated, rx.btp_dest_port);
|
||||
CHECK(rx.payload == g + start && rx.payload_len == cases[c].payload_len &&
|
||||
memcmp(rx.payload, payload, (size_t)cases[c].payload_len) == 0,
|
||||
"%s: payload offset %d length %d, expected %d / %d", cases[c].what,
|
||||
(int)(rx.payload - g), rx.payload_len, start, cases[c].payload_len);
|
||||
}
|
||||
tu_pcap_add(f, len);
|
||||
sweep(cases[c].what, f, len, start, end);
|
||||
}
|
||||
|
||||
// One-byte changes to the signed, short-form frame. `env` is where the envelope starts.
|
||||
uint8_t good[FRAME_BUF_LEN];
|
||||
int start, end;
|
||||
const int len = build_secured(k_signed_prefix, (int)sizeof k_signed_prefix, k_cam, CAM_LEN,
|
||||
good, &start, &end);
|
||||
const int env = MAC_HDR + LLC_SNAP + GN_BASIC;
|
||||
const int inner = env + (int)sizeof k_signed_prefix + 1; // + one length byte
|
||||
static const struct { const char *what; int offset; uint8_t value; } bad[] = {
|
||||
{"legacy envelope, protocolVersion 2", 0, 0x02},
|
||||
{"encryptedData", 1, 0x82},
|
||||
{"hashId not a one-byte value", 2, 0x80},
|
||||
{"no data, only extDataHash (preamble 0x20)", 3, 0x20},
|
||||
{"inner protocolVersion 2", 4, 0x02},
|
||||
{"nested signedData", 5, 0x81},
|
||||
{"length form with no length bytes (0x80)", 6, 0x80},
|
||||
{"length form with 3 length bytes (0x83)", 6, 0x83},
|
||||
{"envelope too short for the Common Header", 6, 0x05},
|
||||
};
|
||||
for (size_t i = 0; i < sizeof bad / sizeof bad[0]; i++) {
|
||||
tu_set_context("secured mutation: %s", bad[i].what);
|
||||
uint8_t f[FRAME_BUF_LEN];
|
||||
memcpy(f, good, (size_t)len);
|
||||
f[env + bad[i].offset] = bad[i].value;
|
||||
gn_rx_t rx;
|
||||
CHECK(!gn_unwrap_its(tu_guarded(f, len), len, &rx), "%s: accepted", bad[i].what);
|
||||
}
|
||||
|
||||
tu_set_context("secured mutation: inner packet claims more than its envelope holds");
|
||||
uint8_t f[FRAME_BUF_LEN];
|
||||
memcpy(f, good, (size_t)len);
|
||||
f[inner + GN_COMMON_PAYLOAD_LEN_FIELD + 1]++; // Common Header payload length, low byte
|
||||
gn_rx_t rx;
|
||||
CHECK(!gn_unwrap_its(tu_guarded(f, len), len, &rx), "payload overrunning its envelope: accepted");
|
||||
}
|
||||
|
||||
static void test_truncation(void)
|
||||
{
|
||||
const gn_lpv_t lpv = test_lpv();
|
||||
uint8_t f[FRAME_BUF_LEN];
|
||||
for (int qos = 0; qos <= 1; qos++) {
|
||||
const int len = build(k_cam, CAM_LEN, &lpv, 2001, qos, f, sizeof f);
|
||||
const int start = MAC_HDR + (qos ? QOS_CTRL : 0) + LLC_SNAP + GN_BASIC + GN_COMMON + SHB_EXT + BTP_B;
|
||||
sweep(qos ? "SHB QoS" : "SHB", f, len, start, len);
|
||||
}
|
||||
const int len = build_gbc(0, 535540000, 100220000, 250, f);
|
||||
sweep("GBC", f, len, GBC_PAYLOAD_OFFSET, len);
|
||||
}
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
tu_install_crash_handler();
|
||||
tu_guard_init();
|
||||
if (argc > 1 && !tu_pcap_open(argv[1])) {
|
||||
fprintf(stderr, "cannot write %s\n", argv[1]);
|
||||
return 2;
|
||||
}
|
||||
|
||||
test_shb_layout(false);
|
||||
test_shb_layout(true);
|
||||
test_pv_encoding();
|
||||
test_bounds();
|
||||
test_max_payload();
|
||||
test_gbc_rx();
|
||||
test_rejections();
|
||||
test_payload_length();
|
||||
test_trailer();
|
||||
test_secured();
|
||||
test_truncation();
|
||||
|
||||
tu_pcap_close();
|
||||
printf("test_chain: %d checks, %d failed\n", s_checks, s_failures);
|
||||
return s_failures ? 1 : 0;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// Replays recorded air traffic through gn_unwrap_its, one pcap record at a time and exactly as the
|
||||
// promiscuous RX callback hands each frame over (the recordings come through the same API), and
|
||||
// writes what came out to a TSV that check_replay.py verifies independently. See README.md.
|
||||
//
|
||||
// Usage: test_replay out.tsv capture.pcap [capture.pcap ...]
|
||||
//
|
||||
// One row per pcap record:
|
||||
// file, record, accepted, port, signed, truncated, area, area_lat, area_lon, area_dist, payload
|
||||
// with everything after `accepted` empty for rejected frames and the payload in hex. Each frame
|
||||
// is placed against the guard page, and an accepted payload that is not inside its frame is a
|
||||
// failure here already.
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
#include "gn_unwrap.h"
|
||||
#include "test_util.h"
|
||||
|
||||
// main.c's RX_FRAME_MAX_LEN: the promiscuous callback copies at most this many bytes of a frame,
|
||||
// so it is the most gn_unwrap_its ever sees on the board. Keep in step with main.c.
|
||||
#define RX_FRAME_MAX_LEN 800
|
||||
|
||||
typedef struct {
|
||||
FILE *out;
|
||||
const char *path;
|
||||
long records, capped, accepted, cam, denm, spatem, signed_frames, truncated, bad;
|
||||
} replay_t;
|
||||
|
||||
static void on_frame(const uint8_t *frame, int len, int index, void *ctx)
|
||||
{
|
||||
replay_t *r = ctx;
|
||||
r->records++;
|
||||
if (len > RX_FRAME_MAX_LEN) {
|
||||
len = RX_FRAME_MAX_LEN;
|
||||
r->capped++;
|
||||
}
|
||||
tu_set_context("replay %s record %d (%d bytes)", r->path, index, len);
|
||||
const uint8_t *g = tu_guarded(frame, len);
|
||||
gn_rx_t rx;
|
||||
const bool ok = gn_unwrap_its(g, len, &rx);
|
||||
fprintf(r->out, "%s\t%d\t%d", r->path, index, ok);
|
||||
if (ok) {
|
||||
const uintptr_t lo = (uintptr_t)g;
|
||||
const uintptr_t p = (uintptr_t)rx.payload;
|
||||
if (p < lo || rx.payload_len <= 0 || p + (uintptr_t)rx.payload_len > lo + (uintptr_t)len) {
|
||||
fprintf(stderr, "FAIL %s: payload outside the frame\n", tu_context());
|
||||
r->bad++;
|
||||
} else {
|
||||
fprintf(r->out, "\t%u\t%d\t%d\t%d\t%ld\t%ld\t%u\t", rx.btp_dest_port, rx.signed_unverified,
|
||||
rx.truncated, rx.has_geo_area, (long)rx.geo_area_lat_tenmicrodeg,
|
||||
(long)rx.geo_area_lon_tenmicrodeg, rx.geo_area_distance_a_m);
|
||||
for (int i = 0; i < rx.payload_len; i++) {
|
||||
fprintf(r->out, "%02x", rx.payload[i]);
|
||||
}
|
||||
}
|
||||
r->accepted++;
|
||||
r->cam += rx.btp_dest_port == 2001;
|
||||
r->denm += rx.btp_dest_port == 2002;
|
||||
r->spatem += rx.btp_dest_port == 2004;
|
||||
r->signed_frames += rx.signed_unverified;
|
||||
r->truncated += rx.truncated;
|
||||
}
|
||||
fputc('\n', r->out);
|
||||
}
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
if (argc < 3) {
|
||||
fprintf(stderr, "usage: test_replay out.tsv capture.pcap [capture.pcap ...]\n");
|
||||
return 2;
|
||||
}
|
||||
tu_install_crash_handler();
|
||||
tu_guard_init();
|
||||
|
||||
replay_t r = {0};
|
||||
r.out = fopen(argv[1], "w");
|
||||
if (!r.out) {
|
||||
fprintf(stderr, "cannot write %s\n", argv[1]);
|
||||
return 2;
|
||||
}
|
||||
fprintf(r.out, "file\trecord\taccepted\tport\tsigned\ttruncated\tarea\tarea_lat\tarea_lon\tarea_dist\tpayload\n");
|
||||
|
||||
int unreadable = 0;
|
||||
for (int i = 2; i < argc; i++) {
|
||||
r.path = argv[i];
|
||||
if (tu_pcap_foreach(argv[i], on_frame, &r) < 0) {
|
||||
fprintf(stderr, "cannot read %s as a pcap\n", argv[i]);
|
||||
unreadable++;
|
||||
}
|
||||
}
|
||||
fclose(r.out);
|
||||
|
||||
printf("test_replay: %ld records (%ld cut to %d bytes as main.c does), %ld accepted (CAM %ld, "
|
||||
"DENM %ld, SPATEM %ld; %ld signed, %ld truncated), %ld bad\n",
|
||||
r.records, r.capped, RX_FRAME_MAX_LEN, r.accepted, r.cam, r.denm, r.spatem,
|
||||
r.signed_frames, r.truncated, r.bad);
|
||||
return (r.bad || unreadable) ? 1 : 0;
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
#ifndef _WIN32
|
||||
#define _DEFAULT_SOURCE // MAP_ANONYMOUS under -std=c11
|
||||
#endif
|
||||
|
||||
#include "test_util.h"
|
||||
|
||||
#include <stdarg.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <windows.h>
|
||||
#else
|
||||
#include <signal.h>
|
||||
#include <sys/mman.h>
|
||||
#include <unistd.h>
|
||||
#endif
|
||||
|
||||
// ---- Crash reporting ------------------------------------------------------------------------
|
||||
|
||||
static char s_context[200] = "startup";
|
||||
static const uint8_t *s_crash_bytes;
|
||||
static const int *s_crash_len;
|
||||
|
||||
void tu_set_context(const char *fmt, ...)
|
||||
{
|
||||
va_list ap;
|
||||
va_start(ap, fmt);
|
||||
vsnprintf(s_context, sizeof s_context, fmt, ap);
|
||||
va_end(ap);
|
||||
}
|
||||
|
||||
const char *tu_context(void)
|
||||
{
|
||||
return s_context;
|
||||
}
|
||||
|
||||
void tu_set_crash_input(const uint8_t *bytes, const int *len)
|
||||
{
|
||||
s_crash_bytes = bytes;
|
||||
s_crash_len = len;
|
||||
}
|
||||
|
||||
static void report_crash(const char *what)
|
||||
{
|
||||
fprintf(stderr, "CRASH (%s) during: %s\n", what, s_context);
|
||||
if (s_crash_bytes && s_crash_len) {
|
||||
fprintf(stderr, "input (%d bytes): ", *s_crash_len);
|
||||
for (int i = 0; i < *s_crash_len; i++) {
|
||||
fprintf(stderr, "%02x", s_crash_bytes[i]);
|
||||
}
|
||||
fputc('\n', stderr);
|
||||
}
|
||||
fflush(stderr);
|
||||
}
|
||||
|
||||
#ifdef _WIN32
|
||||
static LONG WINAPI on_crash(EXCEPTION_POINTERS *info)
|
||||
{
|
||||
char what[40];
|
||||
snprintf(what, sizeof what, "exception 0x%08lx",
|
||||
(unsigned long)info->ExceptionRecord->ExceptionCode);
|
||||
report_crash(what);
|
||||
return EXCEPTION_EXECUTE_HANDLER; // terminate, with the exception code as the exit status
|
||||
}
|
||||
|
||||
void tu_install_crash_handler(void)
|
||||
{
|
||||
SetUnhandledExceptionFilter(on_crash);
|
||||
}
|
||||
#else
|
||||
static void on_crash(int sig)
|
||||
{
|
||||
char what[40];
|
||||
snprintf(what, sizeof what, "signal %d", sig);
|
||||
report_crash(what); // not async-signal-safe, but the process is ending anyway
|
||||
_exit(2);
|
||||
}
|
||||
|
||||
void tu_install_crash_handler(void)
|
||||
{
|
||||
signal(SIGSEGV, on_crash);
|
||||
signal(SIGBUS, on_crash);
|
||||
signal(SIGILL, on_crash); // -fsanitize-undefined-trap-on-error traps with an illegal instruction
|
||||
}
|
||||
#endif
|
||||
|
||||
// ---- Guard page -----------------------------------------------------------------------------
|
||||
|
||||
static uint8_t *s_guard_end; // first byte of the no-access page
|
||||
static size_t s_page_size;
|
||||
|
||||
void tu_guard_init(void)
|
||||
{
|
||||
#ifdef _WIN32
|
||||
SYSTEM_INFO si;
|
||||
GetSystemInfo(&si);
|
||||
s_page_size = si.dwPageSize;
|
||||
uint8_t *base = VirtualAlloc(NULL, 2 * s_page_size, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);
|
||||
DWORD old;
|
||||
if (!base || !VirtualProtect(base + s_page_size, s_page_size, PAGE_NOACCESS, &old)) {
|
||||
fprintf(stderr, "guard page setup failed\n");
|
||||
exit(2);
|
||||
}
|
||||
#else
|
||||
s_page_size = (size_t)sysconf(_SC_PAGESIZE);
|
||||
uint8_t *base = mmap(NULL, 2 * s_page_size, PROT_READ | PROT_WRITE,
|
||||
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
|
||||
if (base == MAP_FAILED || mprotect(base + s_page_size, s_page_size, PROT_NONE) != 0) {
|
||||
fprintf(stderr, "guard page setup failed\n");
|
||||
exit(2);
|
||||
}
|
||||
#endif
|
||||
s_guard_end = base + s_page_size;
|
||||
}
|
||||
|
||||
uint8_t *tu_guard_buf(int len)
|
||||
{
|
||||
if (len < 0 || (size_t)len > s_page_size) {
|
||||
fprintf(stderr, "tu_guard_buf(%d) exceeds one page\n", len);
|
||||
exit(2);
|
||||
}
|
||||
return s_guard_end - len;
|
||||
}
|
||||
|
||||
const uint8_t *tu_guarded(const uint8_t *frame, int len)
|
||||
{
|
||||
uint8_t *dst = tu_guard_buf(len);
|
||||
if (len > 0) {
|
||||
memcpy(dst, frame, (size_t)len);
|
||||
}
|
||||
return dst;
|
||||
}
|
||||
|
||||
// ---- pcap -----------------------------------------------------------------------------------
|
||||
|
||||
static uint32_t rd_le32(const uint8_t *p)
|
||||
{
|
||||
return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24);
|
||||
}
|
||||
|
||||
static uint32_t rd_be32(const uint8_t *p)
|
||||
{
|
||||
return ((uint32_t)p[0] << 24) | ((uint32_t)p[1] << 16) | ((uint32_t)p[2] << 8) | (uint32_t)p[3];
|
||||
}
|
||||
|
||||
int tu_pcap_foreach(const char *path, tu_frame_fn fn, void *ctx)
|
||||
{
|
||||
FILE *f = fopen(path, "rb");
|
||||
if (!f) {
|
||||
return -1;
|
||||
}
|
||||
fseek(f, 0, SEEK_END);
|
||||
const long size = ftell(f);
|
||||
fseek(f, 0, SEEK_SET);
|
||||
if (size < 24) {
|
||||
fclose(f);
|
||||
return size == 0 ? 0 : -1; // the recordings include empty files
|
||||
}
|
||||
uint8_t *d = malloc((size_t)size);
|
||||
if (!d || fread(d, 1, (size_t)size, f) != (size_t)size) {
|
||||
fclose(f);
|
||||
free(d);
|
||||
return -1;
|
||||
}
|
||||
fclose(f);
|
||||
|
||||
const uint32_t magic = rd_le32(d);
|
||||
const bool swapped = magic == 0xD4C3B2A1u || magic == 0x4D3CB2A1u;
|
||||
if (!swapped && magic != 0xA1B2C3D4u && magic != 0xA1B23C4Du) {
|
||||
free(d);
|
||||
return -1;
|
||||
}
|
||||
uint32_t (*u32)(const uint8_t *) = swapped ? rd_be32 : rd_le32;
|
||||
const uint32_t linktype = u32(d + 20);
|
||||
if (linktype != 127 && linktype != 105) {
|
||||
free(d);
|
||||
return -1;
|
||||
}
|
||||
|
||||
long off = 24;
|
||||
int index = 0;
|
||||
while (off + 16 <= size) {
|
||||
const uint32_t incl = u32(d + off + 8);
|
||||
if (incl > (uint32_t)(size - off - 16)) {
|
||||
break; // last record cut off
|
||||
}
|
||||
const uint8_t *pkt = d + off + 16;
|
||||
int len = (int)incl;
|
||||
off += 16 + (long)incl;
|
||||
if (linktype == 127) {
|
||||
const int rt_len = len >= 4 ? (pkt[2] | (pkt[3] << 8)) : len + 1; // always little-endian
|
||||
if (rt_len > len) {
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
pkt += rt_len;
|
||||
len -= rt_len;
|
||||
}
|
||||
fn(pkt, len, index++, ctx);
|
||||
}
|
||||
free(d);
|
||||
return index;
|
||||
}
|
||||
|
||||
static FILE *s_pcap_out;
|
||||
|
||||
static void put_le32(uint8_t *p, uint32_t v)
|
||||
{
|
||||
p[0] = (uint8_t)v;
|
||||
p[1] = (uint8_t)(v >> 8);
|
||||
p[2] = (uint8_t)(v >> 16);
|
||||
p[3] = (uint8_t)(v >> 24);
|
||||
}
|
||||
|
||||
bool tu_pcap_open(const char *path)
|
||||
{
|
||||
// Global header: magic, version 2.4, zone 0, sigfigs 0, snaplen 65535, linktype 105.
|
||||
static const uint8_t hdr[24] = {
|
||||
0xD4, 0xC3, 0xB2, 0xA1, 0x02, 0x00, 0x04, 0x00, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0xFF, 0xFF, 0x00, 0x00, 105, 0, 0, 0,
|
||||
};
|
||||
s_pcap_out = fopen(path, "wb");
|
||||
return s_pcap_out && fwrite(hdr, 1, sizeof hdr, s_pcap_out) == sizeof hdr;
|
||||
}
|
||||
|
||||
void tu_pcap_add(const uint8_t *frame, int len)
|
||||
{
|
||||
static uint32_t seq;
|
||||
if (!s_pcap_out || len <= 0) {
|
||||
return;
|
||||
}
|
||||
uint8_t rec[16];
|
||||
put_le32(rec + 0, seq++); // timestamp seconds: just the frame's sequence number
|
||||
put_le32(rec + 4, 0);
|
||||
put_le32(rec + 8, (uint32_t)len);
|
||||
put_le32(rec + 12, (uint32_t)len);
|
||||
fwrite(rec, 1, sizeof rec, s_pcap_out);
|
||||
fwrite(frame, 1, (size_t)len, s_pcap_out);
|
||||
}
|
||||
|
||||
void tu_pcap_close(void)
|
||||
{
|
||||
if (s_pcap_out) {
|
||||
fclose(s_pcap_out);
|
||||
s_pcap_out = NULL;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
#ifndef TEST_UTIL_H
|
||||
#define TEST_UTIL_H
|
||||
// Shared by the host tests: crash reporting, the guard page, and pcap reading and writing.
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
|
||||
// What the test is doing right now, printed if it crashes. printf-style.
|
||||
void tu_set_context(const char *fmt, ...);
|
||||
const char *tu_context(void);
|
||||
// Also print these bytes as hex if it crashes (the fuzzer's current input). NULL to clear.
|
||||
void tu_set_crash_input(const uint8_t *bytes, const int *len);
|
||||
void tu_install_crash_handler(void);
|
||||
|
||||
// One read-write page followed by one no-access page.
|
||||
void tu_guard_init(void);
|
||||
// A buffer of exactly `len` bytes (at most one page) whose end touches the no-access page.
|
||||
uint8_t *tu_guard_buf(int len);
|
||||
// Copy of `frame` whose last byte is the last readable one: reading past it crashes.
|
||||
const uint8_t *tu_guarded(const uint8_t *frame, int len);
|
||||
|
||||
// Calls `fn` for every record of a pcap file, with its 802.11 frame. Linktype 127 (radiotap,
|
||||
// what its-g5-receiver-firmware records) has the radiotap header removed; linktype 105 is passed
|
||||
// as is. The frame is otherwise exactly as captured, including the 8 bytes the ESP32-C5's
|
||||
// promiscuous RX appends - which is what obu-firmware's callback receives through the same API.
|
||||
// `index` counts every record, including ones skipped for a malformed radiotap header. Returns
|
||||
// the number of records (0 for an empty file), or -1 if the file can't be read or isn't a pcap
|
||||
// of those linktypes.
|
||||
typedef void (*tu_frame_fn)(const uint8_t *frame, int len, int index, void *ctx);
|
||||
int tu_pcap_foreach(const char *path, tu_frame_fn fn, void *ctx);
|
||||
|
||||
// Writes frames to a pcap, linktype 105 (bare 802.11).
|
||||
bool tu_pcap_open(const char *path);
|
||||
void tu_pcap_add(const uint8_t *frame, int len);
|
||||
void tu_pcap_close(void);
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tally GeoNetworking header fields per sending station across .pcap captures.
|
||||
|
||||
Written to check the GN lifetime byte on air (see TODO.md), and it answers the general question
|
||||
"what do real stations put in this header" too: one row per source MAC, packet type, BTP port and
|
||||
lifetime byte, with a frame count.
|
||||
|
||||
python obu-firmware/test/pcap_gn_tally.py its-g5-receiver-firmware/recordings/*.pcap
|
||||
|
||||
Handles linktype 127 (radiotap, what its-g5-receiver-firmware records) and 105 (bare 802.11).
|
||||
Standard library only. Pseudonym MACs rotate, so one vehicle can appear as several rows. The
|
||||
pcap-over-serial dump path corrupts roughly 0.3% of frames, so a stray odd row is tooling noise.
|
||||
"""
|
||||
import collections
|
||||
import glob
|
||||
import struct
|
||||
import sys
|
||||
|
||||
LLC_SNAP_GN = b"\xaa\xaa\x03\x00\x00\x00\x89\x47"
|
||||
|
||||
# (HeaderType, HeaderSubtype) from the GN Common Header -> name, EN 302 636-4-1 table 9.
|
||||
HEADER_TYPES = {
|
||||
(1, 0): "beacon",
|
||||
(4, 0): "GBC-circle",
|
||||
(4, 1): "GBC-rect",
|
||||
(4, 2): "GBC-ellipse",
|
||||
(5, 0): "SHB",
|
||||
}
|
||||
# Extended header length, i.e. the distance from the end of the Common Header to BTP-B.
|
||||
EXT_LEN = {"SHB": 28, "GBC-circle": 44, "GBC-rect": 44, "GBC-ellipse": 44}
|
||||
|
||||
|
||||
def lifetime_seconds(raw):
|
||||
# Multiplier in the upper 6 bits, base in the lower 2: 50 ms, 1 s, 10 s, 100 s.
|
||||
return (raw >> 2) * (0.05, 1, 10, 100)[raw & 3]
|
||||
|
||||
|
||||
def frames(path):
|
||||
with open(path, "rb") as f:
|
||||
data = f.read()
|
||||
if len(data) < 24:
|
||||
return
|
||||
magic = struct.unpack("<I", data[:4])[0]
|
||||
endian = "<" if magic in (0xA1B2C3D4, 0xA1B23C4D) else ">"
|
||||
linktype = struct.unpack(endian + "I", data[20:24])[0]
|
||||
off = 24
|
||||
while off + 16 <= len(data):
|
||||
incl = struct.unpack(endian + "I", data[off + 8:off + 12])[0]
|
||||
pkt = data[off + 16:off + 16 + incl]
|
||||
off += 16 + incl
|
||||
if linktype == 127:
|
||||
if len(pkt) < 4:
|
||||
continue
|
||||
pkt = pkt[struct.unpack("<H", pkt[2:4])[0]:]
|
||||
elif linktype != 105:
|
||||
continue
|
||||
yield pkt
|
||||
|
||||
|
||||
def gn_fields(f):
|
||||
if len(f) < 24 or (f[0] >> 2) & 3 != 2:
|
||||
return None # Data frames only
|
||||
o = 24 + (2 if f[0] & 0x80 else 0) # QoS Data carries a 2-byte QoS Control field
|
||||
if f[o:o + 8] != LLC_SNAP_GN or len(f) < o + 12:
|
||||
return None
|
||||
o += 8
|
||||
src = f[10:16].hex(":")
|
||||
version, next_header, lifetime = f[o] >> 4, f[o] & 0x0F, f[o + 2]
|
||||
port = "-"
|
||||
if next_header == 2:
|
||||
kind = "secured" # Common Header is inside the security envelope
|
||||
elif next_header == 1 and len(f) >= o + 12:
|
||||
c = o + 4
|
||||
ht = (f[c + 1] >> 4, f[c + 1] & 0x0F)
|
||||
kind = HEADER_TYPES.get(ht, "type %d/%d" % ht)
|
||||
ext = EXT_LEN.get(kind)
|
||||
btp = c + 8 + (ext or 0)
|
||||
if ext and len(f) >= btp + 2:
|
||||
port = str(struct.unpack(">H", f[btp:btp + 2])[0])
|
||||
else:
|
||||
kind = "nh=%d" % next_header
|
||||
return src, version, kind, port, lifetime
|
||||
|
||||
|
||||
def main(argv):
|
||||
# PowerShell does not expand wildcards itself, so do it here.
|
||||
paths = [p for arg in argv for p in (glob.glob(arg) or [arg])]
|
||||
if not paths:
|
||||
sys.exit(__doc__)
|
||||
tally = collections.Counter()
|
||||
for path in paths:
|
||||
for frame in frames(path):
|
||||
fields = gn_fields(frame)
|
||||
if fields:
|
||||
tally[fields] += 1
|
||||
print("%-17s %3s %-11s %5s %8s %8s %7s"
|
||||
% ("source", "ver", "packet", "port", "lifetime", "seconds", "frames"))
|
||||
for (src, ver, kind, port, lt), n in sorted(tally.items()):
|
||||
print("%-17s %3d %-11s %5s %8s %8g %7d"
|
||||
% (src, ver, kind, port, "0x%02x" % lt, lifetime_seconds(lt), n))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(sys.argv[1:])
|
||||
Reference in New Issue
Block a user