Compare commits
26
Commits
3da60d3a99
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3fc9bf66c | ||
|
|
6e4d293c3a | ||
|
|
d107534eb2 | ||
|
|
2f60623e18 | ||
|
|
a08494b56a | ||
|
|
d2fd222a62 | ||
|
|
7285fa19b7 | ||
|
|
21e01499d8 | ||
|
|
01204a2c22 | ||
|
|
0f06cdb339 | ||
|
|
75d6d3b85c | ||
|
|
1baae2c5f6 | ||
|
|
8871708a98 | ||
|
|
d7043bb04d | ||
|
|
e908f7fae1 | ||
|
|
8f397eea20 | ||
|
|
cc395771ea | ||
|
|
83153a0971 | ||
|
|
3eeccfb268 | ||
|
|
5ec3619cbe | ||
|
|
1ad123a6f8 | ||
|
|
83ccf335bb | ||
|
|
034ef22336 | ||
|
|
ebe1c9edfd | ||
|
|
312f094909 | ||
|
|
b6a687982d |
+21
@@ -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,25 @@ 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.
|
||||
~$*
|
||||
|
||||
# Full-flash images read back off the bench boards before reflashing them (16 MB each).
|
||||
# Restore with: esptool --chip esp32c5 -p COM<N> write-flash 0 <image>
|
||||
/firmware-backups/
|
||||
|
||||
# ESP-IDF component manager downloads (espressif/esp-boost for obu-firmware's vanetza-idf), ~125 MB.
|
||||
# dependencies.lock beside the project pins them and is committed; this is its cache.
|
||||
managed_components/
|
||||
|
||||
# The colleague's standalone ESP32-C5 VRU station (its own repository, HAW GitLab
|
||||
# urban-mobility-lab/microbu/microbu-esp32c5). Kept beside the project on the lab laptop, for
|
||||
# reference and because the V2X2MAP bridge runs from its tools/, but not part of this repository:
|
||||
# obu-firmware only needs its vanetza-idf, which is copied to obu-firmware/external/vanetza-idf.
|
||||
/microbu-esp32c5/
|
||||
|
||||
# draw.io keeps a backup beside an open diagram.
|
||||
*.drawio.bkp
|
||||
|
||||
@@ -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,365 @@
|
||||
# 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
|
||||
|
||||
### Signed-TX firmware (vanetza-idf port), VAM and BLE: first on-air checks (added 2026-09-23)
|
||||
|
||||
obu-firmware is now a port of the colleague's `microbu-esp32c5` station (vanetza-idf, TS 103 097
|
||||
signing, station-link protocol, BLE GATT), built with **ESP-IDF 6.0.2**. See `obu-firmware/NOTES.md`.
|
||||
The previous firmware is backed up in `firmware-backups/` (restore command in its README.txt).
|
||||
The app speaks the new protocol over USB or BLE and still falls back to the old frames against the
|
||||
old firmware.
|
||||
|
||||
Done without hardware: IDF 6.0.2 build clean (39 % app partition free); host suite (`make` in
|
||||
`obu-firmware/test/host`) passes unchanged; app unit tests 103/103, including the VAM encoder
|
||||
against asn1tools, the station-link codec against the colleague's Python `messages.py`, and the VAM
|
||||
generation rules. Flashed to **COM3** 2026-09-23 (hash verified); boot log: IDF v6.0.2,
|
||||
`BLE advertising started as 'micrOBU-4AFA'`, station task ready. The radio stays off until the app
|
||||
configures the station. New app build installed on the Pixel 9 Pro (adb, `install -r`).
|
||||
|
||||
First phone session (user, 2026-09-23): BLE works and CAMs go out. Three faults, fixed and
|
||||
reflashed/reinstalled the same day:
|
||||
1. No RX until the CAM pinger ran, with ~177 RX-queue drops: the colleague's `Station::tick()`
|
||||
returned before draining the radio until the first PoTi had set the clock. Now drained always.
|
||||
2. "refused a request: time_regression": the loops re-send the latest fix every tick; a stale fix
|
||||
timestamp read as the clock going back > 1 s, and each time the board rebuilt its stack.
|
||||
`Esp32Link` now sends a PoTi only for a newer fix (or a >= 60 s real clock correction).
|
||||
3. BLE reconnect loop: GATT operations with a 5 s timeout ran during Android's pairing, cut it off
|
||||
and restarted it on every attempt. Encryption/pairing is now settled first (60 s), retries back
|
||||
off to 30 s, and every failure reason is logged and shown; the board logs encryption changes.
|
||||
|
||||
Second session (user, 2026-09-23): USB, RX and signing work; BLE still prompted every time and
|
||||
never connected; time_regression every ~8 s. Found and fixed, reflashed (full flash, NVS erased):
|
||||
4. The board never stored a bond: NVS (24 KB, the colleague's 4 MB-board layout) was full, mostly
|
||||
Wi-Fi settings the previous firmware left behind, and NimBLE's bond write failed. NVS is now
|
||||
80 KB (app moved to 0x20000) and was erased; boot logs `N bonded phone(s) in NVS`.
|
||||
5. The station loop waited `pdMS_TO_TICKS(5)` = 0 ticks at 100 Hz, so it spun on the single core
|
||||
(task watchdog: IDLE starved). Now waits at least one tick.
|
||||
6. The phone clock is ~14 min fast; GnssTimeSource fell back to it whenever GNSS time blinked out
|
||||
indoors, so every transmitted timestamp (CAM generationDeltaTime too) jumped 14 min back and
|
||||
forth. It now keeps the last measured error.
|
||||
|
||||
Watch COM3 (`idf.py -p COM3 monitor`, or `readlog.py`-style with DTR/RTS low) during these; it only
|
||||
resets the board, the phone is on the other port.
|
||||
|
||||
- [x] **USB session.** Settings > Connection > ESP32-C5: link USB-C, transmit CAM, signing on.
|
||||
Phone on the native port, Connect. Expected: the card shows "Provisioning the demo credentials"
|
||||
once, then Connected and `Signing on · tickets 1 · signed N` with N rising while recording.
|
||||
COM3: `radio on channel 180, transmit and receive`, `tx power: … dBm`,
|
||||
`credentials provisioned: 1 roots, 1 authorities, 1 tickets`, and no `radio refused a frame`.
|
||||
Confirmed by the user 2026-09-23: connects, signing works.
|
||||
- [x] **Reception intact.** Same session, sim car (COM8) beaconing: its CAMs (station 195936478) on
|
||||
the V2X map at ~3 Hz as before. Then put a DENM and a SPATEM on air: both show up (they come
|
||||
through the raw V2X_RX path; the vanetza stack drops them because they are not demo-signed).
|
||||
Confirmed 2026-09-23: sim car and the RSU's CAM/SPATEM/MAPEM arrive; DENM not yet re-tested.
|
||||
- [x] **Signed CAM on air** (2026-09-23, CAM pinger over BLE, signing on). Recorded 25 s through
|
||||
the V2X2MAP bridge's `/api/record` (`micrOBU_workspace/v2x-obu-esp32c5/signed-cam-check.pcap`)
|
||||
and checked with the new `obu-firmware/test/verify_signed_pcap.py` (asn1tools + OpenSSL, no
|
||||
vanetza code): 12/12 secured CAMs, station 999999, psid 36, signer = full certificate of the
|
||||
demo AT `B80B49387A4C12EB`, **all signatures valid**, COER canonical, and the bundle's chain
|
||||
(AT <- AA <- root) verifies. The CiT One (192.168.40.201) also receives them (~1 Hz on
|
||||
`v2x/rx/cam`), i.e. a third-party stack unwraps our 1609.2 envelope; its MQTT API exposes no
|
||||
security fields, and it forwards unsigned and unknown-root messages alike, so it cannot say
|
||||
whether it verified them. The same capture showed generationTime wobbling by seconds, with
|
||||
`time_regression` still firing: fixed in Esp32Link (the PoTi never moves the micrOBU's clock
|
||||
back except for a >= 60 s correction). Re-check: no "restarted its stack" lines in logcat.
|
||||
- [x] **Unsigned toggle.** Signing off: the same capture shows next header 1 (common header), as
|
||||
the previous firmware sent. The card's `signed` count stops rising.
|
||||
Confirmed 2026-09-23: unsigned pinger CAMs show on V2X2MAP as unsigned.
|
||||
- [x] **Signed VAM on V2X2MAP.** Since 2026-09-23 17:16 the COM10 bridge is the colleague's
|
||||
v2x2map-0.3.0 from source with a new `verify.py` and `--trust demo-chain.vcr` (launcher:
|
||||
`micrOBU_workspace/v2x-obu-esp32c5/start-v2x2map-signed.bat`, replacing its-g5-bridge.exe).
|
||||
Signed CAMs from the pinger already show "signature verified" live. Switch to VAM with signing
|
||||
on: the VAM must be decoded (cyclist, position) and show "signature verified" too.
|
||||
Confirmed by the user 2026-09-23: signed VAMs decode and verify.
|
||||
- [x] **VAM.** Transmit VAM: BTP port 2018, psid 638; with `tools/wireshark/psid-vru.lua` from the colleague's microbu-esp32c5 repository
|
||||
Wireshark decodes the VAM (stationType cyclist, bicyclist profile in every ~2 s VAM). Rate:
|
||||
≥1 per 5 s standing still, about one per GNSS fix while riding.
|
||||
Covered by the V2X2MAP check above (decoded VAM, psid 638); Wireshark not needed.
|
||||
- [x] **RX without recording.** Connect only (no recording, no pinger): sim-car CAMs appear and
|
||||
the RX-queue drop counter stays at 0 or near it.
|
||||
Confirmed 2026-09-23: messages come in on connect alone.
|
||||
- [ ] **No time_regression.** Record for a few minutes standing still indoors: no "refused" line on
|
||||
the card, and COM3 never logs `ITS time moved back`.
|
||||
- [x] **BLE after the NVS fix.** First forget micrOBU-4AFA in Android's Bluetooth settings (the
|
||||
phone still holds the bond the board lost). Then Connect, passkey 123456 once; a second
|
||||
Connect after an app restart must not prompt again, and COM3's next boot must say
|
||||
`1 bonded phone(s) in NVS`.
|
||||
Confirmed 2026-09-23: pairs once, reconnects after an app restart.
|
||||
- [x] **BLE.** Link Bluetooth, unplug USB, Connect. Android asks to pair with micrOBU-4AFA:
|
||||
passkey 123456. Expected: Connected, CAMs keep going, sim-car CAMs keep arriving.
|
||||
While USB is plugged in and in use, the phone's BLE scan must not see micrOBU-4AFA
|
||||
(COM3: `USB link in use: BLE advertising paused`). If it loops again, the card now says why;
|
||||
"refused this phone's stored pairing" means forget micrOBU-4AFA in Android and pair again.
|
||||
COM3 shows `encryption change status=...` for the board's side.
|
||||
Confirmed 2026-09-23: CAMs and VAMs out, reception in, over BLE.
|
||||
- [ ] **BLE/ITS-G5 coexistence (the unmeasured risk from the hardware review).** With BLE
|
||||
connected, count the sim car's CAMs received per minute and ours at the sniffer; compare
|
||||
with the same over USB. A clear drop, or reception stopping altogether, means the coex
|
||||
arbiter takes the radio off 5900 MHz (our channel is set behind the driver's back with
|
||||
`phy_change_channel`). Then BLE cannot be used while receiving, or needs a longer connection
|
||||
interval.
|
||||
- [ ] **Board reset recovery over BLE.** Press RST mid-session: the app reconnects by itself and
|
||||
reconfigures on the first STATUS saying `not configured`, with no manual Connect. (Over USB a
|
||||
reset re-enumerates the port and needs a manual Connect, as before.)
|
||||
|
||||
### Confirm the RX queue drop counter explains the bench-session frame drops / map flicker (added 2026-09-22)
|
||||
|
||||
Investigated the user's report of "OBU mode keeps dropping a few frames" and "v2x screen comes
|
||||
and goes" while bench-testing against `obu-cam-transmistter`. Found a real, previously invisible
|
||||
drop path: `obu-firmware/main/main.c`'s `wifi_promisc_rx_cb()` calls `xQueueSend(s_rx_queue, ...,
|
||||
0)` (queue depth 8) without checking the return value, so a burst of promiscuously-captured
|
||||
frames arriving faster than `rx_forward_task` can drain them (each drain can legitimately block up
|
||||
to ~400ms under USB/UART contention) silently vanishes. None of the existing `EspLinkStatus`
|
||||
counters (`oversizeDrops`/`txFailures`/`rxCrcErrors`) caught this class of drop.
|
||||
|
||||
This plausibly also explains the map symptom: `UseCaseDetectionEngine.pruneStale()` drops a remote
|
||||
station's marker after `staleRemoteMs` (3 s) with no CAM update. Measured 2026-09-22 via
|
||||
`tools/cit_one_rx_watch.py --host 192.168.40.201` against `obu-cam-transmistter`'s bench beacon
|
||||
(stationID 195936478 / 0x0BADC0DE): **75 CAMs in 25 s, ~3 Hz**, not the 1 Hz this note assumed
|
||||
earlier — faster than assumed means more promiscuous captures per second and a shorter fuse on
|
||||
`staleRemoteMs`, both of which make the queue-overflow theory more likely, not less.
|
||||
|
||||
Fixed to be **visible**, not yet fixed to **not drop**: added a `rxQueueDrops` counter, checked
|
||||
`xQueueSend`'s return value (`main.c`), wired it through the STATUS heartbeat as a new trailing
|
||||
`uint16` field (`serial_link.c/.h`, `SerialFrame.kt`'s `EspLinkStatus`), and surfaced it on the
|
||||
CAM Pinger card (`MqttTopicViewerScreen.kt`, string `mqtt_cam_pinger_fw_counters`). Host build
|
||||
untouched (serial_link.c/main.c aren't in the host test's standard-headers-only set); IDF build
|
||||
verification is the remaining pre-flash check. Deliberately did NOT bump `s_rx_queue`'s depth from
|
||||
8 — no real burst-size data yet, and guessing a bigger number against an unmeasured memory budget
|
||||
is exactly the kind of assumption [[microbu-hw-review]] flags as needing verification first, not
|
||||
capacity that's cheap to reason your way into.
|
||||
|
||||
Needs: a phone attached to the production OBU's native USB port, watching the CAM Pinger card,
|
||||
while `obu-cam-transmistter` (or real traffic) beacons.
|
||||
|
||||
- [x] `idf.py build` succeeds (obu-firmware, IDF 6.1) — clean, both changed files compiled with no
|
||||
warnings, 17% flash free.
|
||||
- [x] Reflashed the production OBU on **COM3** 2026-09-22 (hash verified). Boot log confirms the
|
||||
new build (`21e0149-dirty`, compiled Sep 22 2026 14:14:09), clean boot, OCB @ 5900 MHz
|
||||
TX/RX armed, `serial_link up ... 1 Hz heartbeat`, no panic. Incidentally answers part of the
|
||||
"measure the OBU's actual transmit power" item below: this boot logged
|
||||
`tx power: 72 quarter-dBm = 18.00 dBm (20.00 requested)` — the driver **is** clamping below
|
||||
the requested 20 dBm at 5900 MHz, as that item suspected but had not measured.
|
||||
- [x] 25 s of steady-state console (no phone attached, `obu-cam-transmistter` beaconing nearby):
|
||||
silent — no crash, no `oversize`/`rx queue full`/`crc` warnings. Inconclusive on its own
|
||||
(successful forwards aren't logged, and nothing was attached to trigger the ~400 ms UART
|
||||
stalls the theory needs), but at least rules out a crash-on-boot regression.
|
||||
- [x] Confirmed the wider bench RF path independently via the CiT One OBU broker
|
||||
(`py -3.11 tools/cit_one_rx_watch.py --host 192.168.40.201`): heard `obu-cam-transmistter`'s
|
||||
beacon cleanly, 75/25 s, GN source `14:00:02:00:00:00:00:01`, position in the expected
|
||||
St. Georg route area. This is a *different* receiver from the production OBU though — it
|
||||
shows the beacon is genuinely on air, not that COM3 forwards every one of it without drops.
|
||||
- [x] **Confirmed on real hardware, 2026-09-22.** Installed the updated debug APK (previous build
|
||||
on the phone was from 2026-09-15, predating this fix entirely) on the Pixel 9 Pro (adb over
|
||||
Wi-Fi), relaunched against the freshly-reflashed COM3, and read `rx queue drop` via `adb
|
||||
logcat -s UsbSerialTransport`. The counter mechanism works end-to-end and **the bug is
|
||||
real**: `rxQueueDrops` was 0 at the last flash (14:22), read as 89 at first reconnect
|
||||
(14:48, ~26 min later), and 90 at a second reconnect (14:52). No `oversizeDrops`,
|
||||
`txFailures`, or `rxCrcErrors` moved at all, and zero `decode FAILED` lines — this queue is
|
||||
the only place frames are going missing.
|
||||
Nuance: over a clean ~4.5 min window in between (14:48→14:52) with `obu-cam-transmistter`
|
||||
actively beaconing at a measured **~3.33 Hz** (matches the CiT One's 75/25 s independently)
|
||||
and 490+ CAMs decoding cleanly with steady cadence and no gaps, the counter did **not**
|
||||
move — it only ticked at connect/reconnect moments. So this is a low-rate, bursty drop (matches
|
||||
the user's own "a few frames" framing), not a continuous overflow under steady single-station
|
||||
traffic; it may be specific to WiFi/PHY activity around association or reconnect rather than
|
||||
raw beacon rate. Worth a longer, quieter-boot capture before sizing a `s_rx_queue` bump.
|
||||
Did **not** independently confirm the map-flicker connection this session — that needs eyes
|
||||
on the app's V2X screen while watching this same counter live, not just logcat.
|
||||
|
||||
### On-device check of the full-screen V2X live map (added 2026-09-15)
|
||||
|
||||
The live map moved out of the V2X Monitor's view-mode row into its own full-screen destination
|
||||
(`V2xMapScreen`, route `v2x_map`), reached from the map button in that screen's header. Markers are
|
||||
now cached and reused across updates instead of being rebuilt on every incoming message, and
|
||||
SPATEM intersections are drawn as traffic lights at the position of the RSU's own CAM. All of that
|
||||
compiles and the unit tests pass, but none of it has been seen with live traffic.
|
||||
|
||||
Needs: the phone with the app, plus a CAM/DENM/SPATEM source - either the CiT One, or the OBU
|
||||
ESP32-C5 with a second board or a real RSU transmitting.
|
||||
|
||||
- [ ] Both hardware modes: tap the map button, confirm the map fills the screen (no status bar, no
|
||||
bottom nav) and the back button returns to the V2X Monitor.
|
||||
- [ ] Panning stays smooth while CAMs are arriving - this is what the marker reuse is for. Compare
|
||||
against the old behaviour if it still judders.
|
||||
- [ ] Touching the map stops it recentring; the location FAB resumes follow and lights up.
|
||||
- [ ] A DENM shows the warning triangle, and a SPATEM intersection shows a traffic light with the
|
||||
lamp matching the Dashboard's SignalCard for the same intersection.
|
||||
- [ ] Near a real RSU: confirm the RSU is drawn once, as a traffic light, not as a CAM pin with a
|
||||
light on top of it. If the RSU sends SPATEM but no CAM, the "signals not shown" note should
|
||||
appear instead - worth knowing which of the two the HAW RSUs actually do.
|
||||
|
||||
|
||||
### 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, but it has not been seen on air yet. 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.
|
||||
|
||||
- [ ] Flash `obu-firmware` (see `obu-firmware/FLASHING.md`).
|
||||
- [ ] Connect the phone, let it send CAMs, and confirm the CAM Pinger's `tx fail` counter stays 0.
|
||||
- [ ] Capture with the receiver into `its-g5-receiver-firmware/recordings/`.
|
||||
- [ ] Run `python obu-firmware/test/pcap_gn_tally.py its-g5-receiver-firmware/recordings/<capture>.pcap`.
|
||||
The rows for the phone's pseudonym MACs must show SHB, port 2001, lifetime `0x05`, exactly
|
||||
like every other station's CAMs.
|
||||
- [ ] While the phone is connected: real-station CAMs/DENMs still reach the app (RX path unchanged).
|
||||
|
||||
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.
|
||||
|
||||
### Measure the OBU's actual transmit power (added 2026-09-14)
|
||||
|
||||
Nothing in this project has ever measured it. `main.c` asks for 20 dBm
|
||||
(`esp_wifi_set_max_tx_power(80)`, 0.25 dBm units) and the build's ceiling is the same
|
||||
(`CONFIG_ESP_PHY_MAX_TX_POWER=20`), but a request is a ceiling, not a guarantee: the driver clamps
|
||||
it to its own calibrated table, and 5900 MHz is above the range this chip is rated for, so the
|
||||
table actually in use is channel 177's. The firmware now reads the value back and logs it at boot,
|
||||
which records what the driver admits to, not what leaves the antenna.
|
||||
|
||||
- [ ] Flash and `idf.py -p COM3 monitor`, then note the `tx power:` line. A value below 80 means
|
||||
the driver clamped the request, which the code alone cannot tell you.
|
||||
- [ ] Relative check with the second ESP32-C5 on `its-g5-receiver-firmware`: capture at a measured
|
||||
distance in a straight line, read the RSSI the receive path already reports, and record
|
||||
distance and RSSI together. This gives a comparable number between builds and antennas,
|
||||
which is what matters for range work, without any lab equipment.
|
||||
- [ ] Only a spectrum analyser or a calibrated reference receiver gives real radiated power. Worth
|
||||
it only if the range result looks wrong, or if the thesis needs an absolute figure.
|
||||
|
||||
For context: ETSI allows up to 33 dBm EIRP on the ITS band, and production OBUs sit around
|
||||
20 to 23 dBm, so the requested figure is in the right region if the PA really keys it there.
|
||||
|
||||
### 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. No board runs this firmware right now (the production OBU runs obu-firmware), so this
|
||||
only matters if it is flashed again:
|
||||
|
||||
- [ ] After flashing it: capture, run `pcap_gn_tally.py`, and decode the CAM payload with
|
||||
asn1tools (`py -3.11`, modules in `asn1/`).
|
||||
|
||||
### 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 built on IDF 6.1, but not flashed: the production OBU still runs the
|
||||
2026-09-10 build. Needs the OBU with this build, the phone, and signed traffic - 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.
|
||||
|
||||
- [ ] Flash obu-firmware (this also carries the GN lifetime fix above).
|
||||
- [ ] 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`).
|
||||
- [ ] Unsigned bench traffic still decodes in the app as before (messages now arrive 8 bytes
|
||||
shorter).
|
||||
- [ ] The heartbeat's oversize counter still counts over-long messages (e.g. road SPATEMs).
|
||||
|
||||
### CiT One custom CAM injection over `v2x/tx/v2/cam` (added 2026-09-14)
|
||||
|
||||
The haw-002 unit now runs the special firmware: Cohda's own CAM transmission disabled, and a
|
||||
V2X-Gateway build that accepts a `SendV2XMessage` (schemas.consider-innovation.de/its-s/
|
||||
v2x_interface.proto) carrying a UPER CAM on `v2x/tx/v2/cam`. `tools/cit_one_cam_tx.py` builds
|
||||
and publishes those from a PC; its `--self-test` passes offline, proving only that the bytes
|
||||
match `CamEncodeGoldenTest.kt` and that the protobuf wrapper round-trips. Nothing about what
|
||||
the OBU does with them is established.
|
||||
|
||||
Reach the broker over Wi-Fi or Ethernet for now - the USB-peripheral-mode link needs the phone
|
||||
to be USB host on a `172.25.1.0/24` interface with no DHCP server, which Android cannot
|
||||
configure from inside an app.
|
||||
|
||||
Needs: the CiT One haw-002 on the same network as a PC, and a second ESP32-C5 running
|
||||
`its-g5-receiver-firmware` sniffing G5CC (`-c 5900`) to capture with.
|
||||
|
||||
Bench run 2026-09-14, PC -> haw-002 (192.168.3.201), captured on the RSU (192.168.3.202,
|
||||
**not** .2.202 - that address does not route). `tools/cit_one_rx_watch.py` decodes what a unit
|
||||
hears. Result: the injection path works end to end, with one blocker found.
|
||||
|
||||
- [x] Publishes without the broker refusing the topic. 1.00 Hz, confirmed by subscribing to
|
||||
`v2x/tx/v2/cam` on the OBU itself.
|
||||
- [x] The RSU hears our CAMs on air, 1.00 Hz, matching what we publish.
|
||||
- [x] `ItsPduHeader` **is** expected in the payload - we send it included and it decodes.
|
||||
- [x] BTP destination port 2001. GN source address `08:00:26:93:92:01:91:dc`, the OBU's.
|
||||
- [x] **Our CAM content goes out intact**: position, speed (417), heading (639), width (7) and
|
||||
length (18) arrive byte-exact. The gateway does not touch the content.
|
||||
- [x] **The gateway overwrites `stationID`** with the OBU's own (999999 -> 4033890855, which
|
||||
matches `own_info.stationID` on `v2x/rx/obu_gnss`). This is what the "OBU owns identity"
|
||||
decision wants, so `--follow-obu-identity` is not needed on this unit.
|
||||
- [x] ~~BLOCKER: Cohda's own CAM is still transmitting.~~ Fixed 2026-09-14 by disabling CAM in
|
||||
a second conf file: the RSU now hears only our stream, 0 CAMs with the stack's
|
||||
unavailable dimensions over 30 s. Note the stack restart gave the unit a new identity
|
||||
(stationID 4033890855 -> 2553426533, GN source `08:00:26:...` -> `08:00:a2:...`), which is
|
||||
expected under `ItsGnLocalAddrConfMethod = 2` (anonymous, random at boot).
|
||||
- [x] Re-checked: 41 published / 41 heard over 40 s, 1.02 Hz both ends, inter-arrival a steady
|
||||
1.0 s. 100% delivery, no gateway rate limiting. An earlier 0.40 Hz sample was the stack
|
||||
still settling after the restart and did not persist.
|
||||
Two topics the v6 API does not document, found by subscribing to `#` on haw-002:
|
||||
|
||||
- `v2x/loopback/cam` - a `RecvV2XMessage` (btpHeader.type=2) carrying each CAM the unit
|
||||
transmits, 1:1 with what we publish and **after** the gateway's stationID rewrite. This is the
|
||||
TX confirmation we were going to ask consider it for: it makes "did my CAM go out, and under
|
||||
which identity" answerable on the transmitting unit alone, without an RSU or a second ESP32.
|
||||
- `v2x/rx/obuinfo` at 10 Hz - the protobuf `OwnStationInfo` (binary twin of `obu_gnss`;
|
||||
field 2 decodes to the same stationID, field 10 to the same heading). Output only, so it is
|
||||
not the content-feed input we speculated about.
|
||||
|
||||
- [ ] Wire `v2x/loopback/cam` into `cit_one_rx_watch.py` as a local TX check.
|
||||
- [ ] Sanity-check the rate: `--rate 4` should produce 4 CAMs/s on air, since `ItsDCCEnabled = 0`
|
||||
on this unit.
|
||||
|
||||
## 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-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).
|
||||
@@ -1,5 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<!-- Networking (MQTT / tile downloads) -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
@@ -10,6 +11,15 @@
|
||||
|
||||
<!-- Location -->
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<!-- BLE link to the ESP32-C5 (BleLinkTransport). Android 12+ asks for scan/connect at runtime;
|
||||
older versions use the legacy pair plus location, which is requested anyway. -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
|
||||
android:usesPermissionFlags="neverForLocation"
|
||||
tools:targetApi="s" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
|
||||
<!-- Phase A: foreground service (trip recording) -->
|
||||
|
||||
Binary file not shown.
@@ -26,10 +26,12 @@ import androidx.core.view.WindowCompat
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
|
||||
import com.hawhamburg.micr0bu.data.transport.UsbSerialState
|
||||
import com.hawhamburg.micr0bu.data.transport.Esp32LinkState
|
||||
import com.hawhamburg.micr0bu.data.transport.Esp32Transport
|
||||
import com.hawhamburg.micr0bu.data.transport.ObuHardware
|
||||
import com.hawhamburg.micr0bu.ui.components.StatusTopBar
|
||||
import com.hawhamburg.micr0bu.ui.navigation.BottomNavBar
|
||||
@@ -40,6 +42,7 @@ import com.hawhamburg.micr0bu.ui.screens.MqttTopicViewerScreen
|
||||
import com.hawhamburg.micr0bu.ui.screens.RecordingScreen
|
||||
import com.hawhamburg.micr0bu.ui.screens.SensorScreen
|
||||
import com.hawhamburg.micr0bu.ui.screens.SessionLogScreen
|
||||
import com.hawhamburg.micr0bu.ui.screens.V2xMapScreen
|
||||
import com.hawhamburg.micr0bu.ui.screens.MapScreen
|
||||
import com.hawhamburg.micr0bu.ui.screens.TripHistoryScreen
|
||||
import com.hawhamburg.micr0bu.ui.screens.TripReviewScreen
|
||||
@@ -86,7 +89,16 @@ class MainActivity : AppCompatActivity() {
|
||||
val showBatteryOptPrompt by tripViewModel.showBatteryOptPrompt.collectAsState()
|
||||
val useCaseEnabledMap by mqttViewModel.useCaseEnabledMap.collectAsState()
|
||||
val obuHardware by mqttViewModel.obuHardware.collectAsState()
|
||||
val usbSerialState by mqttViewModel.usbSerialState.collectAsState()
|
||||
val esp32LinkState by mqttViewModel.esp32LinkState.collectAsState()
|
||||
val esp32Transport by mqttViewModel.esp32Transport.collectAsState()
|
||||
val outgoingMessage by mqttViewModel.outgoingMessage.collectAsState()
|
||||
val signOutgoing by mqttViewModel.signOutgoing.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
|
||||
@@ -98,6 +110,13 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
val navController = rememberNavController()
|
||||
|
||||
// The V2X live map is a full-bleed destination: the app's own chrome would eat a
|
||||
// third of the display on the one screen whose entire job is showing where things
|
||||
// are relative to each other. It carries its own floating back button, and system
|
||||
// back still works, so nothing becomes unreachable.
|
||||
val currentBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val isFullBleed = currentBackStackEntry?.destination?.route == Screen.V2xMap.route
|
||||
|
||||
val locationLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestMultiplePermissions()
|
||||
) { permissions ->
|
||||
@@ -113,11 +132,17 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.startImuStreams()
|
||||
// Bluetooth scan/connect ride along with location (Android 12+): the ESP32-C5
|
||||
// can be reached over BLE, and a denial only matters if that is selected, where
|
||||
// BleLinkTransport then says so instead of silently finding nothing.
|
||||
val bluetooth = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
arrayOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT)
|
||||
} else emptyArray()
|
||||
locationLauncher.launch(
|
||||
arrayOf(
|
||||
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||
Manifest.permission.ACCESS_COARSE_LOCATION,
|
||||
)
|
||||
) + bluetooth
|
||||
)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
notificationLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
@@ -126,14 +151,17 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
if (!isFullBleed) {
|
||||
StatusTopBar(
|
||||
state = state,
|
||||
mqttConnectionState = mqttConnectionState,
|
||||
isEsp32 = obuHardware == ObuHardware.ESP32_C5,
|
||||
usbSerialState = usbSerialState,
|
||||
esp32LinkState = esp32LinkState,
|
||||
esp32Bluetooth = esp32Transport == Esp32Transport.BLE,
|
||||
)
|
||||
}
|
||||
},
|
||||
bottomBar = { BottomNavBar(navController) },
|
||||
bottomBar = { if (!isFullBleed) BottomNavBar(navController) },
|
||||
) { innerPadding ->
|
||||
NavHost(
|
||||
navController = navController,
|
||||
@@ -146,24 +174,34 @@ class MainActivity : AppCompatActivity() {
|
||||
mqttConnectionState = mqttConnectionState,
|
||||
activeTransport = activeTransport,
|
||||
obuHardware = obuHardware,
|
||||
usbSerialState = usbSerialState,
|
||||
esp32LinkState = esp32LinkState,
|
||||
esp32Bluetooth = esp32Transport == Esp32Transport.BLE,
|
||||
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
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -176,7 +214,7 @@ class MainActivity : AppCompatActivity() {
|
||||
state = state,
|
||||
mqttConnectionState = mqttConnectionState,
|
||||
obuConnected = if (obuHardware == ObuHardware.ESP32_C5)
|
||||
usbSerialState == UsbSerialState.CONNECTED
|
||||
esp32LinkState == Esp32LinkState.CONNECTED
|
||||
else
|
||||
mqttConnectionState == MqttConnectionState.CONNECTED,
|
||||
tripServiceState = tripServiceState,
|
||||
@@ -233,15 +271,24 @@ class MainActivity : AppCompatActivity() {
|
||||
val trip = trips.firstOrNull { it.id == tripId }
|
||||
|
||||
if (trip != null) {
|
||||
TripReviewScreen(
|
||||
trip = trip,
|
||||
viewModel = tripViewModel,
|
||||
)
|
||||
TripReviewScreen(trip = trip)
|
||||
}
|
||||
}
|
||||
|
||||
composable(Screen.MqttViewer.route) {
|
||||
MqttTopicViewerScreen(viewModel = mqttViewModel)
|
||||
MqttTopicViewerScreen(
|
||||
viewModel = mqttViewModel,
|
||||
onOpenMap = { navController.navigate(Screen.V2xMap.route) },
|
||||
)
|
||||
}
|
||||
composable(Screen.V2xMap.route) {
|
||||
// Activity-scoped instance, like Connection below: a hiltViewModel()
|
||||
// here would be scoped to this NavBackStackEntry and torn down on the
|
||||
// way back out, taking the shared transport with it.
|
||||
V2xMapScreen(
|
||||
viewModel = mqttViewModel,
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable(Screen.Settings.route) {
|
||||
SettingsScreen(
|
||||
@@ -269,6 +316,12 @@ class MainActivity : AppCompatActivity() {
|
||||
onMqttPrefsChange = mqttViewModel::updatePrefs,
|
||||
obuHardware = obuHardware,
|
||||
onObuHardwareChange = mqttViewModel::setObuHardware,
|
||||
esp32Transport = esp32Transport,
|
||||
onEsp32TransportChange = mqttViewModel::setEsp32Transport,
|
||||
outgoingMessage = outgoingMessage,
|
||||
onOutgoingMessageChange = mqttViewModel::setOutgoingMessage,
|
||||
signOutgoing = signOutgoing,
|
||||
onSignOutgoingChange = mqttViewModel::setSignOutgoing,
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
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,
|
||||
* the last error measured is kept, because the wall clock's error changes slowly while GNSS time
|
||||
* comes and goes indoors. Only before any GNSS time since the app started is the wall clock used
|
||||
* unchanged.
|
||||
*
|
||||
* Keeping it matters: with the bench phone 14 minutes fast (2026-09-23), dropping back to the raw
|
||||
* wall clock whenever GNSS time blinked out made every transmitted timestamp jump 14 minutes back
|
||||
* and forth, and the micrOBU restarted its stack at each jump back (time_regression).
|
||||
*
|
||||
* 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
|
||||
|
||||
/** GNSS time minus wall clock at the last reading of both; null until GNSS time was first seen. */
|
||||
@Volatile private var lastErrorMs: Long? = 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
|
||||
}
|
||||
if (gnssNow != null) lastErrorMs = gnssNow - systemNow
|
||||
noteSource(gnssNow, systemNow)
|
||||
return ItsTime.onGnssTime(systemMs, lastErrorMs?.let { systemNow + it }, 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 if (lastErrorMs != null) {
|
||||
Log.i(TAG, "GNSS time unavailable, keeping the last measured phone clock error of " +
|
||||
"${"%+.1f".format(-lastErrorMs!! / 1000.0)} s")
|
||||
} 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,25 +7,32 @@ 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
|
||||
import com.hawhamburg.micr0bu.data.transport.SerialFrameType
|
||||
import com.hawhamburg.micr0bu.data.transport.V2xRxFrame
|
||||
import com.hawhamburg.micr0bu.data.transport.UsbSerialState
|
||||
import com.hawhamburg.micr0bu.data.transport.UsbSerialTransport
|
||||
import com.hawhamburg.micr0bu.data.transport.Esp32LinkState
|
||||
import com.hawhamburg.micr0bu.data.transport.Esp32Link
|
||||
import com.hawhamburg.micr0bu.domain.asn1.DenmUperCodec
|
||||
import com.hawhamburg.micr0bu.domain.asn1.RealAsn1UperCodec
|
||||
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,16 +90,23 @@ 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
|
||||
class CamUseCaseRepository @Inject constructor(
|
||||
private val mqttRepository: MqttRepository,
|
||||
private val prefs: UseCaseAlertPreferences,
|
||||
private val usbSerialTransport: UsbSerialTransport,
|
||||
private val esp32Link: Esp32Link,
|
||||
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
|
||||
@@ -205,9 +285,9 @@ class CamUseCaseRepository @Inject constructor(
|
||||
// this only ever sees CAM UPER bytes. No-op stream on the CiT One path (the transport
|
||||
// just never emits CAM_RX frames if nothing's plugged in over serial).
|
||||
scope.launch {
|
||||
usbSerialTransport.incomingFrames.collect { frame ->
|
||||
esp32Link.incomingFrames.collect { frame ->
|
||||
if (frame.type != SerialFrameType.V2X_RX) return@collect
|
||||
if (usbSerialTransport.state.value != UsbSerialState.CONNECTED) return@collect
|
||||
if (esp32Link.state.value != Esp32LinkState.CONNECTED) return@collect
|
||||
val v2x = V2xRxFrame.parse(frame.payload) ?: return@collect
|
||||
when (v2x.btpPort) {
|
||||
BtpPort.CAM -> handleCamFromSerial(v2x)
|
||||
@@ -224,10 +304,10 @@ class CamUseCaseRepository @Inject constructor(
|
||||
// last-seen positions and their alerts linger on the map and in the use-case panel after
|
||||
// an unplug, which reads as live traffic - the worst kind of stale on a safety display.
|
||||
scope.launch {
|
||||
usbSerialTransport.state.collect { state ->
|
||||
esp32Link.state.collect { state ->
|
||||
// ESP32-only: on the CiT One path this transport is permanently DISCONNECTED and
|
||||
// resetting here would wipe perfectly good MQTT-derived state.
|
||||
if (currentHardware == ObuHardware.ESP32_C5 && state != UsbSerialState.CONNECTED) {
|
||||
if (currentHardware == ObuHardware.ESP32_C5 && state != Esp32LinkState.CONNECTED) {
|
||||
engine.reset()
|
||||
_rsuStations.value = emptyMap()
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
package com.hawhamburg.micr0bu.data.mqtt
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.hawhamburg.micr0bu.data.transport.Esp32Transport
|
||||
import com.hawhamburg.micr0bu.data.transport.ObuHardware
|
||||
import com.hawhamburg.micr0bu.data.transport.OutgoingMessage
|
||||
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 +30,15 @@ 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")
|
||||
// ESP32-C5 only.
|
||||
val ESP32_TRANSPORT = stringPreferencesKey("esp32_transport")
|
||||
val OUTGOING_MESSAGE = stringPreferencesKey("outgoing_message")
|
||||
val SIGN_OUTGOING = booleanPreferencesKey("sign_outgoing")
|
||||
}
|
||||
|
||||
val obuHardwareFlow: Flow<ObuHardware> = context.obuHardwareDataStore.data.map { prefs ->
|
||||
@@ -37,31 +49,67 @@ 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]
|
||||
/** How the phone reaches the ESP32-C5: its native USB-C port (default) or BLE. */
|
||||
val esp32TransportFlow: Flow<Esp32Transport> = context.obuHardwareDataStore.data.map { prefs ->
|
||||
Esp32Transport.entries.firstOrNull { it.id == prefs[Keys.ESP32_TRANSPORT] } ?: Esp32Transport.USB
|
||||
}
|
||||
|
||||
suspend fun setEsp32Transport(transport: Esp32Transport) {
|
||||
context.obuHardwareDataStore.edit { prefs -> prefs[Keys.ESP32_TRANSPORT] = transport.id }
|
||||
}
|
||||
|
||||
/** What the ESP32-C5 path transmits while recording: CAM (default) or VAM. */
|
||||
val outgoingMessageFlow: Flow<OutgoingMessage> = context.obuHardwareDataStore.data.map { prefs ->
|
||||
OutgoingMessage.entries.firstOrNull { it.id == prefs[Keys.OUTGOING_MESSAGE] } ?: OutgoingMessage.CAM
|
||||
}
|
||||
|
||||
suspend fun setOutgoingMessage(message: OutgoingMessage) {
|
||||
context.obuHardwareDataStore.edit { prefs -> prefs[Keys.OUTGOING_MESSAGE] = message.id }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Whether outgoing messages are signed (TS 103 097, demo PKI). Default on. Off sends them
|
||||
* unsigned exactly as the previous firmware did, which verifying receivers may prefer to a
|
||||
* signature they cannot chain to the EU trust list.
|
||||
*/
|
||||
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)
|
||||
val signOutgoingFlow: Flow<Boolean> = context.obuHardwareDataStore.data.map { prefs ->
|
||||
prefs[Keys.SIGN_OUTGOING] ?: true
|
||||
}
|
||||
|
||||
suspend fun setSignOutgoing(sign: Boolean) {
|
||||
context.obuHardwareDataStore.edit { prefs -> prefs[Keys.SIGN_OUTGOING] = sign }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
|
||||
/** 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
package com.hawhamburg.micr0bu.data.transport
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.BluetoothGatt
|
||||
import android.bluetooth.BluetoothGattCallback
|
||||
import android.bluetooth.BluetoothGattCharacteristic
|
||||
import android.bluetooth.BluetoothGattDescriptor
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.bluetooth.BluetoothProfile
|
||||
import android.bluetooth.le.ScanCallback
|
||||
import android.bluetooth.le.ScanResult
|
||||
import android.bluetooth.le.ScanSettings
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
private const val TAG = "BleLinkTransport"
|
||||
|
||||
/**
|
||||
* BLE GATT central for the micrOBU's station link: the Android counterpart of the firmware's
|
||||
* `obu-firmware/main/simple_ble.cpp` (from the colleague's microbu-esp32c5) and of their Python
|
||||
* `microbu_link/ble_transport.py`, which this follows step for step.
|
||||
*
|
||||
* ## The GATT layout (what the firmware actually implements)
|
||||
* The station-link README describes a Nordic-UART-shaped service with fragmentation. The firmware
|
||||
* does something else, and the firmware is what counts here: a custom service
|
||||
* `0000C175-BA5E-4C17-8000-00805F9B34FB` with one characteristic per primitive, and each GATT value
|
||||
* is one complete link message, never fragmented. Requests are written with response to the
|
||||
* characteristic of their opcode ([writeTarget]); replies, STATUS and V2X_RX arrive as
|
||||
* notifications. A message may be up to 512 octets, so the ATT MTU must be raised to 517 first:
|
||||
* the firmware refuses to notify a message that does not fit rather than send it cut short.
|
||||
*
|
||||
* ## Pairing
|
||||
* Every characteristic needs an encrypted, authenticated link. The firmware uses LE Secure
|
||||
* Connections with a fixed passkey, [PASSKEY] (DisplayOnly). Android shows its own pairing dialog
|
||||
* the first time; the user types the passkey there, and the bond is kept on both sides. The
|
||||
* passkey is public, so this gives encryption but no protection against an active attacker
|
||||
* during that first pairing; acceptable for the demo PKI this carries.
|
||||
*
|
||||
* ## RF
|
||||
* BLE shares the C5's single RF front end with 5.9 GHz ITS-G5. The firmware stops advertising
|
||||
* while the USB link is in use; whether an active BLE connection disturbs ITS-G5 has not been
|
||||
* measured yet (TODO.md, "Waiting on hardware").
|
||||
*
|
||||
* Like [UsbSerialTransport], an app-scoped singleton: only an explicit disconnect or the process
|
||||
* dying closes it, never a screen or ViewModel going away.
|
||||
*/
|
||||
@Singleton
|
||||
class BleLinkTransport @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
companion object {
|
||||
const val PASSKEY = "123456"
|
||||
const val NAME_PREFIX = "micrOBU"
|
||||
private val SERVICE: UUID = UUID.fromString("0000c175-ba5e-4c17-8000-00805f9b34fb")
|
||||
private val BTP_REQUEST: UUID = UUID.fromString("0000c176-ba5e-4c17-8000-00805f9b34fb")
|
||||
private val BTP_INDICATION: UUID = UUID.fromString("0000c177-ba5e-4c17-8000-00805f9b34fb")
|
||||
private val POTI: UUID = UUID.fromString("0000c178-ba5e-4c17-8000-00805f9b34fb")
|
||||
/** Read-encrypted, returns nothing useful: only used to find out whether the link is secure. */
|
||||
private val STATUS_CHAR: UUID = UUID.fromString("0000c179-ba5e-4c17-8000-00805f9b34fb")
|
||||
private val ID_EVENT: UUID = UUID.fromString("0000c17a-ba5e-4c17-8000-00805f9b34fb")
|
||||
private val CONFIG: UUID = UUID.fromString("0000c17b-ba5e-4c17-8000-00805f9b34fb")
|
||||
private val RESULT: UUID = UUID.fromString("0000c17c-ba5e-4c17-8000-00805f9b34fb")
|
||||
private val CCCD: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
|
||||
|
||||
private const val REQUESTED_MTU = 517
|
||||
private const val SCAN_TIMEOUT_MS = 15_000L
|
||||
/** Long enough for the user to find and type the passkey in the system dialog. */
|
||||
private const val BOND_TIMEOUT_MS = 60_000L
|
||||
private const val GATT_OP_TIMEOUT_MS = 5_000L
|
||||
/** After a working link dropped: try again soon. */
|
||||
private const val RECONNECT_DELAY_MS = 1_000L
|
||||
/** After failed attempts: 2, 4, 8, 16, then every 30 s, so a broken pairing does not spin. */
|
||||
private const val RETRY_BASE_MS = 2_000L
|
||||
private const val RETRY_MAX_MS = 30_000L
|
||||
/** ATT status codes Android reports when the link lacks the encryption a characteristic needs. */
|
||||
private val AUTH_FAILURES = setOf(5, 8, 15, 137)
|
||||
|
||||
/** Which characteristic a phone -> micrOBU message is written to, by opcode (as ble_transport.py). */
|
||||
fun writeTarget(opcode: Int): UUID = when (opcode) {
|
||||
LinkOpcode.BTP_DATA_REQUEST -> BTP_REQUEST
|
||||
LinkOpcode.POTI_UPDATE -> POTI
|
||||
0x08 /* SF_IDCHANGE_EVENT_RESPONSE */ -> ID_EVENT
|
||||
else -> CONFIG
|
||||
}
|
||||
}
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val bluetoothManager = context.getSystemService(BluetoothManager::class.java)
|
||||
|
||||
private val _state = MutableStateFlow(Esp32LinkState.DISCONNECTED)
|
||||
val state: StateFlow<Esp32LinkState> = _state.asStateFlow()
|
||||
|
||||
/** Why the last attempt failed, or what the user has to do (e.g. type the passkey); null when fine. */
|
||||
private val _detail = MutableStateFlow<String?>(null)
|
||||
val detail: StateFlow<String?> = _detail.asStateFlow()
|
||||
|
||||
private val _incoming = MutableSharedFlow<ByteArray>(extraBufferCapacity = 256)
|
||||
/** Every link message the micrOBU notifies, one GATT value each. */
|
||||
val incoming: SharedFlow<ByteArray> = _incoming.asSharedFlow()
|
||||
|
||||
/** Name of the connected micrOBU, e.g. "micrOBU-4AF8". */
|
||||
@Volatile var deviceName: String? = null
|
||||
private set
|
||||
|
||||
@Volatile private var gatt: BluetoothGatt? = null
|
||||
/** Whether the current GATT connection is up, as the last connection-state callback said. */
|
||||
@Volatile private var linkUp = false
|
||||
@Volatile private var wanted = false
|
||||
private var sessionJob: Job? = null
|
||||
|
||||
/** One GATT operation at a time: Android drops a second one issued before the first completes. */
|
||||
private val gattMutex = Mutex()
|
||||
@Volatile private var pendingOp: CompletableDeferred<Int>? = null
|
||||
@Volatile private var connected: CompletableDeferred<Boolean>? = null
|
||||
@Volatile private var mtuDone: CompletableDeferred<Int>? = null
|
||||
@Volatile private var servicesDone: CompletableDeferred<Boolean>? = null
|
||||
|
||||
fun hasPermissions(): Boolean {
|
||||
val needed = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
listOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT)
|
||||
} else {
|
||||
listOf(Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
}
|
||||
return needed.all { context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED }
|
||||
}
|
||||
|
||||
/** Scans for (or reuses the bond with) a micrOBU, pairs if needed, and opens the link. No-op if already under way. */
|
||||
fun connect() {
|
||||
if (sessionJob?.isActive == true) return
|
||||
wanted = true
|
||||
sessionJob = scope.launch {
|
||||
var failures = 0
|
||||
while (wanted) {
|
||||
val ok = try {
|
||||
session()
|
||||
} catch (e: CancellationException) {
|
||||
throw e // disconnect(): not a failure to report
|
||||
} catch (e: Exception) {
|
||||
fail("BLE session failed: ${e.message}")
|
||||
}
|
||||
closeGatt()
|
||||
if (!wanted) break
|
||||
failures = if (ok) 0 else failures + 1
|
||||
_state.value = if (ok) Esp32LinkState.DEVICE_ATTACHED else Esp32LinkState.ERROR
|
||||
delay(if (ok) RECONNECT_DELAY_MS
|
||||
else minOf(RETRY_MAX_MS, RETRY_BASE_MS shl (failures - 1).coerceAtMost(4)))
|
||||
}
|
||||
_state.value = Esp32LinkState.DISCONNECTED
|
||||
}
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
wanted = false
|
||||
sessionJob?.cancel()
|
||||
sessionJob = null
|
||||
closeGatt()
|
||||
_state.value = Esp32LinkState.DISCONNECTED
|
||||
_detail.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes one complete link message to the characteristic of its opcode, with response.
|
||||
* Suspends until the micrOBU acknowledged the write; false when not connected or it failed.
|
||||
*/
|
||||
@SuppressLint("MissingPermission")
|
||||
suspend fun send(message: ByteArray): Boolean {
|
||||
val g = gatt ?: return false
|
||||
if (_state.value != Esp32LinkState.CONNECTED || message.isEmpty()) return false
|
||||
val characteristic = g.getService(SERVICE)?.getCharacteristic(writeTarget(message[0].toInt() and 0xFF))
|
||||
?: return false
|
||||
return gattOp {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
g.writeCharacteristic(characteristic, message, BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT) ==
|
||||
BluetoothGatt.GATT_SUCCESS
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
|
||||
@Suppress("DEPRECATION")
|
||||
characteristic.value = message
|
||||
@Suppress("DEPRECATION")
|
||||
g.writeCharacteristic(characteristic)
|
||||
}
|
||||
} == BluetoothGatt.GATT_SUCCESS
|
||||
}
|
||||
|
||||
/** One connection lifetime. Returns true if it reached CONNECTED before ending. */
|
||||
@SuppressLint("MissingPermission")
|
||||
private suspend fun session(): Boolean {
|
||||
if (!hasPermissions()) {
|
||||
wanted = false
|
||||
return fail("Bluetooth permission not granted (Android Settings > Apps > MicrOBU > Permissions)")
|
||||
}
|
||||
val adapter = bluetoothManager?.adapter
|
||||
if (adapter == null || !adapter.isEnabled) return fail("Bluetooth is off")
|
||||
_state.value = Esp32LinkState.DEVICE_ATTACHED
|
||||
// A bonded micrOBU is reused without scanning: its address is stable (public address), and
|
||||
// this is what makes a reconnect after a dropout fast.
|
||||
val device = adapter.bondedDevices.firstOrNull { it.name?.startsWith(NAME_PREFIX) == true }
|
||||
?: scan() ?: return fail("No micrOBU advertising nearby (is the phone on its USB port?)")
|
||||
deviceName = device.name
|
||||
_detail.value = null
|
||||
Log.i(TAG, "connecting to ${device.name} ${device.address} (bond state ${device.bondState})")
|
||||
|
||||
connected = CompletableDeferred()
|
||||
gatt = device.connectGatt(context, false, callback, BluetoothDevice.TRANSPORT_LE)
|
||||
if (withTimeoutOrNull(GATT_OP_TIMEOUT_MS * 2) { connected!!.await() } != true) {
|
||||
return fail("Could not connect to ${device.name}")
|
||||
}
|
||||
|
||||
val g = gatt ?: return false
|
||||
// Services first: discovery needs no encryption, and the encryption probe below needs the
|
||||
// STATUS characteristic.
|
||||
servicesDone = CompletableDeferred()
|
||||
g.discoverServices()
|
||||
if (withTimeoutOrNull(GATT_OP_TIMEOUT_MS) { servicesDone!!.await() } != true) {
|
||||
return fail("Service discovery on ${device.name} timed out")
|
||||
}
|
||||
if (g.getService(SERVICE) == null) {
|
||||
return fail("${device.name} does not offer the station-link service (old firmware?)")
|
||||
}
|
||||
|
||||
// Encryption before anything else. Every characteristic needs an encrypted, authenticated
|
||||
// link; the board asks for security as soon as a phone connects. A phone it has a bond with
|
||||
// encrypts with the stored key. Otherwise Android pairs, with its passkey dialog, which takes
|
||||
// as long as the user takes. A GATT operation with a short timeout during that cuts the
|
||||
// pairing off, the link drops, and the next attempt starts pairing again: a loop.
|
||||
val wasBonded = device.bondState == BluetoothDevice.BOND_BONDED
|
||||
if (!awaitEncryption(g, device)) {
|
||||
if (!linkUp) return fail("${device.name} dropped the link while pairing")
|
||||
return fail(
|
||||
if (wasBonded) "${device.name} refused this phone's stored pairing. In Android's Bluetooth " +
|
||||
"settings, forget ${device.name}, then Connect again (passkey $PASSKEY)."
|
||||
else "Pairing with ${device.name} failed or timed out (passkey $PASSKEY)"
|
||||
)
|
||||
}
|
||||
_state.value = Esp32LinkState.DEVICE_ATTACHED
|
||||
_detail.value = null
|
||||
|
||||
mtuDone = CompletableDeferred()
|
||||
g.requestMtu(REQUESTED_MTU)
|
||||
val mtu = withTimeoutOrNull(GATT_OP_TIMEOUT_MS) { mtuDone!!.await() } ?: 23
|
||||
Log.i(TAG, "ATT MTU $mtu")
|
||||
if (mtu < LINK_MAX_MESSAGE + 3) {
|
||||
// The board drops a notification that does not fit rather than truncate it (simple_ble.cpp).
|
||||
Log.w(TAG, "MTU $mtu is below ${LINK_MAX_MESSAGE + 3}: large V2X_RX messages will not arrive")
|
||||
}
|
||||
|
||||
for (uuid in listOf(RESULT, BTP_INDICATION, ID_EVENT)) {
|
||||
if (!enableNotifications(g, uuid)) return fail("Could not subscribe to ${device.name} notifications")
|
||||
}
|
||||
_state.value = Esp32LinkState.CONNECTED
|
||||
Log.i(TAG, "BLE station link ready: ${device.name}")
|
||||
// Wait until the link drops (callback completes `connected` anew with false).
|
||||
val dropped = CompletableDeferred<Boolean>()
|
||||
connected = dropped
|
||||
dropped.await()
|
||||
Log.w(TAG, "BLE link to ${device.name} lost")
|
||||
return true
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private suspend fun scan(): BluetoothDevice? {
|
||||
val scanner = bluetoothManager?.adapter?.bluetoothLeScanner ?: return null
|
||||
val found = CompletableDeferred<BluetoothDevice>()
|
||||
val scanCallback = object : ScanCallback() {
|
||||
override fun onScanResult(callbackType: Int, result: ScanResult) {
|
||||
val name = result.scanRecord?.deviceName ?: result.device.name
|
||||
val offersService = result.scanRecord?.serviceUuids?.any { it.uuid == SERVICE } == true
|
||||
if (offersService || name?.startsWith(NAME_PREFIX) == true) found.complete(result.device)
|
||||
}
|
||||
override fun onScanFailed(errorCode: Int) {
|
||||
Log.w(TAG, "BLE scan failed: $errorCode")
|
||||
}
|
||||
}
|
||||
val settings = ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build()
|
||||
scanner.startScan(null, settings, scanCallback)
|
||||
return try {
|
||||
withTimeoutOrNull(SCAN_TIMEOUT_MS) { found.await() }
|
||||
} finally {
|
||||
runCatching { scanner.stopScan(scanCallback) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns once the link is encrypted, pairing first if needed; false if that fails or the user
|
||||
* does not finish within [BOND_TIMEOUT_MS].
|
||||
*
|
||||
* The probe is a read of the STATUS characteristic, which needs an encrypted and authenticated
|
||||
* link, as the colleague's Python transport does. Android answers a read the link is not
|
||||
* secure enough for by encrypting, or by pairing and showing the passkey dialog, and then
|
||||
* retries the read itself. While it pairs, the card says which passkey to type.
|
||||
*/
|
||||
@SuppressLint("MissingPermission")
|
||||
private suspend fun awaitEncryption(g: BluetoothGatt, device: BluetoothDevice): Boolean {
|
||||
val status = g.getService(SERVICE)?.getCharacteristic(STATUS_CHAR) ?: return false
|
||||
val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(ctx: Context, intent: Intent) {
|
||||
val changed = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE, BluetoothDevice::class.java)
|
||||
} else {
|
||||
@Suppress("DEPRECATION") intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)
|
||||
}
|
||||
if (changed?.address != device.address) return
|
||||
val state = intent.getIntExtra(BluetoothDevice.EXTRA_BOND_STATE, BluetoothDevice.ERROR)
|
||||
Log.i(TAG, "bond state of ${device.name}: $state")
|
||||
if (state == BluetoothDevice.BOND_BONDING) {
|
||||
_state.value = Esp32LinkState.PERMISSION_REQUESTED
|
||||
_detail.value = "Pair with ${device.name}: enter passkey $PASSKEY"
|
||||
}
|
||||
}
|
||||
}
|
||||
register(receiver)
|
||||
try {
|
||||
if (device.bondState == BluetoothDevice.BOND_BONDING) {
|
||||
_state.value = Esp32LinkState.PERMISSION_REQUESTED
|
||||
_detail.value = "Pair with ${device.name}: enter passkey $PASSKEY"
|
||||
}
|
||||
// Up to two reads: the first can come back with an authentication error at the moment
|
||||
// pairing completes, before Android's own retry.
|
||||
repeat(2) { attempt ->
|
||||
val result = gattOp(BOND_TIMEOUT_MS) { g.readCharacteristic(status) }
|
||||
Log.i(TAG, "encryption probe ${attempt + 1}: status $result, bond state ${device.bondState}")
|
||||
if (result == BluetoothGatt.GATT_SUCCESS) return true
|
||||
if (result !in AUTH_FAILURES) return false
|
||||
}
|
||||
return false
|
||||
} finally {
|
||||
runCatching { context.unregisterReceiver(receiver) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun register(receiver: BroadcastReceiver) {
|
||||
val filter = IntentFilter(BluetoothDevice.ACTION_BOND_STATE_CHANGED)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
context.registerReceiver(receiver, filter, Context.RECEIVER_EXPORTED)
|
||||
} else {
|
||||
@Suppress("UnspecifiedRegisterReceiverFlag") context.registerReceiver(receiver, filter)
|
||||
}
|
||||
}
|
||||
|
||||
/** Logs [reason], shows it on the connection card, and ends the attempt. */
|
||||
private fun fail(reason: String): Boolean {
|
||||
Log.w(TAG, reason)
|
||||
_detail.value = reason
|
||||
return false
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private suspend fun enableNotifications(g: BluetoothGatt, uuid: UUID): Boolean {
|
||||
val characteristic = g.getService(SERVICE)?.getCharacteristic(uuid) ?: return false
|
||||
if (!g.setCharacteristicNotification(characteristic, true)) return false
|
||||
val cccd = characteristic.getDescriptor(CCCD) ?: return false
|
||||
val value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
|
||||
return gattOp {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
g.writeDescriptor(cccd, value) == BluetoothGatt.GATT_SUCCESS
|
||||
} else {
|
||||
@Suppress("DEPRECATION") cccd.value = value
|
||||
@Suppress("DEPRECATION") g.writeDescriptor(cccd)
|
||||
}
|
||||
} == BluetoothGatt.GATT_SUCCESS
|
||||
}
|
||||
|
||||
/** Starts one GATT operation and waits for its callback's status; -1 if it could not start or timed out. */
|
||||
private suspend fun gattOp(timeoutMs: Long = GATT_OP_TIMEOUT_MS, start: () -> Boolean): Int = gattMutex.withLock {
|
||||
val op = CompletableDeferred<Int>()
|
||||
pendingOp = op
|
||||
if (!start()) {
|
||||
pendingOp = null
|
||||
return@withLock -1
|
||||
}
|
||||
withTimeoutOrNull(timeoutMs) { op.await() } ?: -1
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun closeGatt() {
|
||||
gatt?.let { runCatching { it.disconnect(); it.close() } }
|
||||
gatt = null
|
||||
pendingOp?.complete(-1)
|
||||
connected?.complete(false)
|
||||
}
|
||||
|
||||
private val callback = object : BluetoothGattCallback() {
|
||||
override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) {
|
||||
Log.i(TAG, "connection state $newState (status $status)")
|
||||
when (newState) {
|
||||
BluetoothProfile.STATE_CONNECTED -> {
|
||||
linkUp = true
|
||||
connected?.complete(true)
|
||||
}
|
||||
BluetoothProfile.STATE_DISCONNECTED -> {
|
||||
linkUp = false
|
||||
connected?.complete(false)
|
||||
pendingOp?.complete(-1)
|
||||
if (_state.value == Esp32LinkState.CONNECTED) _state.value = Esp32LinkState.ERROR
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMtuChanged(g: BluetoothGatt, mtu: Int, status: Int) {
|
||||
mtuDone?.complete(mtu)
|
||||
}
|
||||
|
||||
override fun onServicesDiscovered(g: BluetoothGatt, status: Int) {
|
||||
servicesDone?.complete(status == BluetoothGatt.GATT_SUCCESS)
|
||||
}
|
||||
|
||||
override fun onDescriptorWrite(g: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
|
||||
pendingOp?.complete(status)
|
||||
}
|
||||
|
||||
override fun onCharacteristicWrite(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) {
|
||||
pendingOp?.complete(status)
|
||||
}
|
||||
|
||||
// API 33+ calls this overload; older versions the deprecated one below.
|
||||
override fun onCharacteristicRead(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic,
|
||||
value: ByteArray, status: Int) {
|
||||
pendingOp?.complete(status)
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated in API 33")
|
||||
override fun onCharacteristicRead(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) {
|
||||
pendingOp?.complete(status)
|
||||
}
|
||||
|
||||
// API 33+ delivers the value here and no longer calls the deprecated overload below.
|
||||
override fun onCharacteristicChanged(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray) {
|
||||
_incoming.tryEmit(value.copyOf())
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated in API 33")
|
||||
override fun onCharacteristicChanged(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
|
||||
@Suppress("DEPRECATION")
|
||||
characteristic.value?.let { _incoming.tryEmit(it.copyOf()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
package com.hawhamburg.micr0bu.data.transport
|
||||
|
||||
import android.content.Context
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import com.hawhamburg.micr0bu.data.cam.PseudonymManager
|
||||
import com.hawhamburg.micr0bu.data.mqtt.ObuHardwarePreferences
|
||||
import com.hawhamburg.micr0bu.domain.asn1.ItsTime
|
||||
import com.hawhamburg.micr0bu.domain.cam.StationType
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/** Connection lifecycle of the ESP32-C5 link, over either transport. */
|
||||
enum class Esp32LinkState { DISCONNECTED, DEVICE_ATTACHED, PERMISSION_REQUESTED, CONNECTED, ERROR }
|
||||
|
||||
/** Which physical link the phone uses to reach the ESP32-C5 (Settings). */
|
||||
enum class Esp32Transport(val id: String) { USB("usb"), BLE("ble") }
|
||||
|
||||
/** What the phone transmits while a trip records (Settings). */
|
||||
enum class OutgoingMessage(val id: String) { CAM("cam"), VAM("vam") }
|
||||
|
||||
/**
|
||||
* What the board on the other end speaks. The phone cannot ask, so it listens: the previous
|
||||
* obu-firmware sends a [SerialFrameType.STATUS] heartbeat, the current one a station-link STATUS.
|
||||
*/
|
||||
enum class Esp32Protocol { UNKNOWN, LEGACY_SERIAL, STATION_LINK }
|
||||
|
||||
/** One ITS message for the air, with what the micrOBU needs to know about the sender. */
|
||||
class OutgoingIts(
|
||||
val kind: OutgoingMessage,
|
||||
val uper: ByteArray,
|
||||
/** Pseudonym MAC, station type and position; its [GnPositionVector.tstMs] is the fix time. */
|
||||
val positionVector: GnPositionVector,
|
||||
/** Android horizontal accuracy, metres (68 %); null when unknown. */
|
||||
val accuracyM: Float?,
|
||||
val signed: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* The one entry point the app uses to talk to the ESP32-C5: picks USB ([UsbSerialTransport]) or
|
||||
* BLE ([BleLinkTransport]) from the setting, works out which firmware protocol is on the other end,
|
||||
* and runs the station-link session the current firmware needs.
|
||||
*
|
||||
* ## Station-link session (obu-firmware since 2026-09-23)
|
||||
* The firmware keeps no state the phone depends on, except what it stores itself (credentials in
|
||||
* NVS), so the phone sets it up each time it sees it unconfigured:
|
||||
* 1. STATION_CONFIGURE, with the current pseudonym MAC as the GN address and 802.11 source. This
|
||||
* also starts the radio, which until then neither transmits nor receives.
|
||||
* 2. If the answer reports no authorization ticket, CREDENTIALS_PROVISION of the demo bundle in
|
||||
* `assets/demo-chain.vcr` (a disposable chain, not EU-registered: receivers that verify against
|
||||
* the EU trust list will drop what it signs). The firmware keeps it in NVS from then on.
|
||||
* 3. Per message: POTI_UPDATE (the fix, which also sets the micrOBU's ITS clock for the signature
|
||||
* time), then BTP_DATA_REQUEST, secured or unsecured per the "Sign outgoing messages" setting.
|
||||
* A pseudonym change reconfigures with the new MAC before the next message goes out. A STATUS
|
||||
* saying "not configured" (the board reset) starts again at 1.
|
||||
*
|
||||
* ## Legacy firmware
|
||||
* A board still on the previous obu-firmware (0xAA55 frames 0x01-0x05, no signing, USB only)
|
||||
* keeps working for CAM exactly as before. VAM needs the current firmware.
|
||||
*
|
||||
* Everything received is surfaced the old way, as [DecodedFrame]s of [SerialFrameType.V2X_RX], so
|
||||
* the receive side of the app did not change.
|
||||
*/
|
||||
@Singleton
|
||||
class Esp32Link @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val usb: UsbSerialTransport,
|
||||
private val ble: BleLinkTransport,
|
||||
private val prefs: ObuHardwarePreferences,
|
||||
private val pseudonymManager: PseudonymManager,
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "Esp32Link"
|
||||
private const val REPLY_TIMEOUT_MS = 3_000L
|
||||
/** Applying a bundle verifies the chain and rebuilds the stack on the C5: seconds, not ms. */
|
||||
private const val PROVISION_TIMEOUT_MS = 15_000L
|
||||
private const val SEGMENT_SIZE = 240
|
||||
private const val DEMO_BUNDLE_ASSET = "demo-chain.vcr"
|
||||
/**
|
||||
* A PoTi this far behind the last one sent is a real correction of the phone's clock (e.g.
|
||||
* GNSS time taking over from a wrong system clock), not a repeated fix; it goes through
|
||||
* and the micrOBU restarts its stack at the new time once.
|
||||
*/
|
||||
private const val CLOCK_STEP_BACK_MS = 60_000L
|
||||
/** How long a refusal stays on the connection card. */
|
||||
private const val DETAIL_HOLD_MS = 10_000L
|
||||
|
||||
const val BTP_PORT_CAM = 2001
|
||||
const val BTP_PORT_VAM = 2018
|
||||
const val ITS_AID_CAM = 36L
|
||||
const val ITS_AID_VAM = 638L
|
||||
/** CAM SSP version 1, no special-vehicle permissions: what the demo ticket grants for ITS-AID 36. */
|
||||
val SSP_CAM = byteArrayOf(0x01, 0x00, 0x00)
|
||||
/** VRU SSP as the demo ticket grants for ITS-AID 638 (same as the colleague's VBS). */
|
||||
val SSP_VAM = byteArrayOf(0x01)
|
||||
}
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
val transport: StateFlow<Esp32Transport> =
|
||||
prefs.esp32TransportFlow.stateIn(scope, SharingStarted.Eagerly, Esp32Transport.USB)
|
||||
|
||||
val state: StateFlow<Esp32LinkState> = combine(transport, usb.state, ble.state) { t, u, b ->
|
||||
if (t == Esp32Transport.USB) u else b
|
||||
}.stateIn(scope, SharingStarted.Eagerly, Esp32LinkState.DISCONNECTED)
|
||||
|
||||
private val _protocol = MutableStateFlow(Esp32Protocol.UNKNOWN)
|
||||
val protocol: StateFlow<Esp32Protocol> = _protocol.asStateFlow()
|
||||
|
||||
private val _incomingFrames = MutableSharedFlow<DecodedFrame>(extraBufferCapacity = 256)
|
||||
/** Received ITS messages ([SerialFrameType.V2X_RX]) and, from legacy firmware, its heartbeats. */
|
||||
val incomingFrames: SharedFlow<DecodedFrame> = _incomingFrames.asSharedFlow()
|
||||
|
||||
private val _linkStatus = MutableStateFlow<EspLinkStatus?>(null)
|
||||
/** Heartbeat counters in the old shape, from either firmware; null before the first one. */
|
||||
val linkStatus: StateFlow<EspLinkStatus?> = _linkStatus.asStateFlow()
|
||||
|
||||
private val _stationStatus = MutableStateFlow<StationStatus?>(null)
|
||||
/** Full station-link STATUS (signing counters, tickets); null with legacy firmware. */
|
||||
val stationStatus: StateFlow<StationStatus?> = _stationStatus.asStateFlow()
|
||||
|
||||
private val _detail = MutableStateFlow<String?>(null)
|
||||
/** One line for the UI about the session: pairing, provisioning, or why it is stuck. */
|
||||
val detail: StateFlow<String?> = _detail.asStateFlow()
|
||||
|
||||
private val _consecutiveWriteFailures = MutableStateFlow(0)
|
||||
val consecutiveWriteFailures: StateFlow<Int> = _consecutiveWriteFailures.asStateFlow()
|
||||
|
||||
private val _refusedRequests = MutableStateFlow(0)
|
||||
/** BTP_DATA_REQUESTs the micrOBU answered with anything but accepted (e.g. no ticket). */
|
||||
val refusedRequests: StateFlow<Int> = _refusedRequests.asStateFlow()
|
||||
|
||||
private val sequence = AtomicInteger(0)
|
||||
private val pending = ConcurrentHashMap<Int, CompletableDeferred<LinkResult>>()
|
||||
private val sessionMutex = Mutex()
|
||||
/** The STATION_CONFIGURE the micrOBU is known to run, or null when it has to be sent again. */
|
||||
@Volatile private var configured: StationConfigure? = null
|
||||
@Volatile private var lastRefusalLogMs = 0L
|
||||
/**
|
||||
* Timestamp of the last POTI_UPDATE sent in this session, or null when the micrOBU's ITS clock
|
||||
* has to be set again (new session, board reset).
|
||||
*/
|
||||
@Volatile private var lastPotiMs: Long? = null
|
||||
/** [SystemClock.elapsedRealtime] when [lastPotiMs] was sent: with it, where the micrOBU's clock stands now. */
|
||||
@Volatile private var lastPotiElapsedMs = 0L
|
||||
/** The fix time (before any clamping) of the last PoTi sent, to send each fix only once. */
|
||||
@Volatile private var lastPotiFixMs: Long? = null
|
||||
|
||||
init {
|
||||
scope.launch { usb.incomingFrames.collect { onUsbFrame(it) } }
|
||||
scope.launch { ble.incoming.collect { onLinkMessage(it) } }
|
||||
scope.launch { usb.linkStatus.collect { if (it != null) _linkStatus.value = it } }
|
||||
// A new connection, on either transport, starts a new session.
|
||||
scope.launch {
|
||||
state.collect { s ->
|
||||
if (s != Esp32LinkState.CONNECTED) {
|
||||
configured = null; lastPotiMs = null; lastPotiFixMs = null
|
||||
_protocol.value = Esp32Protocol.UNKNOWN
|
||||
_stationStatus.value = null
|
||||
_linkStatus.value = null
|
||||
pending.values.forEach { it.cancel() }
|
||||
pending.clear()
|
||||
} else if (transport.value == Esp32Transport.BLE) {
|
||||
_protocol.value = Esp32Protocol.STATION_LINK // BLE exists only on the current firmware
|
||||
scope.launch { ensureConfigured(null) }
|
||||
}
|
||||
}
|
||||
}
|
||||
scope.launch { ble.detail.collect { if (transport.value == Esp32Transport.BLE) _detail.value = it } }
|
||||
// Switching transport in Settings closes the other one; connecting stays a user action.
|
||||
scope.launch {
|
||||
transport.collect { t ->
|
||||
if (t == Esp32Transport.USB) ble.disconnect() else usb.disconnect()
|
||||
_detail.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun connect() {
|
||||
if (transport.value == Esp32Transport.USB) usb.connect() else ble.connect()
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
usb.disconnect()
|
||||
ble.disconnect()
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands one message to the micrOBU for transmission. False when it could not be handed over
|
||||
* (no link, legacy firmware asked for a VAM, session setup failed); the next message retries.
|
||||
* Acceptance by the micrOBU is not awaited: a refusal shows up in [refusedRequests].
|
||||
*/
|
||||
suspend fun send(its: OutgoingIts): Boolean = withContext(Dispatchers.IO) {
|
||||
val ok = when (_protocol.value) {
|
||||
Esp32Protocol.LEGACY_SERIAL -> {
|
||||
if (its.kind == OutgoingMessage.CAM) {
|
||||
usb.sendCamTx(its.uper, its.positionVector)
|
||||
} else {
|
||||
noteRefusal("VAM needs the current obu-firmware; this board runs the previous one")
|
||||
false
|
||||
}
|
||||
}
|
||||
Esp32Protocol.STATION_LINK -> sendStationLink(its)
|
||||
Esp32Protocol.UNKNOWN -> false // no heartbeat yet: nothing to address
|
||||
}
|
||||
if (ok) _consecutiveWriteFailures.value = 0 else _consecutiveWriteFailures.value++
|
||||
ok
|
||||
}
|
||||
|
||||
private suspend fun sendStationLink(its: OutgoingIts): Boolean {
|
||||
val pv = its.positionVector
|
||||
if (!ensureConfigured(StationConfigure(stationType = pv.stationType, mid = pv.mac))) return false
|
||||
// The micrOBU's ITS clock must never be sent backwards: past 1 s it answers
|
||||
// time_regression and rebuilds its whole stack. Two things tried to. The transmit loops
|
||||
// re-send the latest GNSS fix every tick while fused location pauses, so an old fix time
|
||||
// arrived again and again while the micrOBU's clock ran on. And the GNSS-corrected fix
|
||||
// times themselves wobble by seconds indoors (measured 2026-09-23: -2.1 s, +4.9 s between
|
||||
// consecutive CAMs). So a fix goes over once, and its timestamp is never below where the
|
||||
// micrOBU's clock stands now, except for a real correction of the phone clock.
|
||||
val poti = potiFor(its)
|
||||
if (poti.timestampMs != lastPotiFixMs) {
|
||||
val last = lastPotiMs
|
||||
val microbuNow = last?.let { it + (SystemClock.elapsedRealtime() - lastPotiElapsedMs) }
|
||||
val timestamp = when {
|
||||
microbuNow == null -> poti.timestampMs
|
||||
poti.timestampMs < microbuNow - CLOCK_STEP_BACK_MS -> poti.timestampMs
|
||||
else -> maxOf(poti.timestampMs, microbuNow)
|
||||
}
|
||||
if (!write(LinkOpcode.POTI_UPDATE, poti.copy(timestampMs = timestamp).encode())) return false
|
||||
lastPotiFixMs = poti.timestampMs
|
||||
lastPotiMs = timestamp
|
||||
lastPotiElapsedMs = SystemClock.elapsedRealtime()
|
||||
}
|
||||
val request = BtpDataRequest(
|
||||
destinationPort = if (its.kind == OutgoingMessage.CAM) BTP_PORT_CAM else BTP_PORT_VAM,
|
||||
itsAid = if (its.kind == OutgoingMessage.CAM) ITS_AID_CAM else ITS_AID_VAM,
|
||||
securityProfile = if (its.signed) LinkSecurityProfile.SECURED else LinkSecurityProfile.UNSECURED,
|
||||
permissions = if (its.kind == OutgoingMessage.CAM) SSP_CAM else SSP_VAM,
|
||||
flSdu = its.uper,
|
||||
)
|
||||
return write(LinkOpcode.BTP_DATA_REQUEST, request.encode())
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes sure the micrOBU runs [wanted] (or, when null, any configuration: used right after a
|
||||
* BLE connect or a board reset, to start its receiver before the first message goes out).
|
||||
*/
|
||||
private suspend fun ensureConfigured(wanted: StationConfigure?): Boolean = sessionMutex.withLock {
|
||||
val current = configured
|
||||
if (current != null && (wanted == null || current == wanted)) return@withLock true
|
||||
val config = wanted ?: StationConfigure(stationType = StationType.CYCLIST, mid = pseudonymManager.current().mac)
|
||||
_detail.value = "Configuring the micrOBU"
|
||||
val result = request(LinkOpcode.STATION_CONFIGURE, config.encode(), REPLY_TIMEOUT_MS)
|
||||
if (result == null || !result.accepted) {
|
||||
_detail.value = "micrOBU did not accept the configuration (${result?.let { LinkResultCode.name(it.code) } ?: "no reply"})"
|
||||
return@withLock false
|
||||
}
|
||||
val info = StationInfo.decode(result.detail)
|
||||
Log.i(TAG, "station configured: $info")
|
||||
if (info == null || !info.credentialsLoaded || info.tickets == 0) {
|
||||
if (!provisionDemoCredentials()) return@withLock false
|
||||
}
|
||||
configured = config
|
||||
_detail.value = null
|
||||
true
|
||||
}
|
||||
|
||||
private suspend fun provisionDemoCredentials(): Boolean {
|
||||
_detail.value = "Provisioning the demo credentials"
|
||||
val bundle = runCatching { context.assets.open(DEMO_BUNDLE_ASSET).use { it.readBytes() } }.getOrElse {
|
||||
_detail.value = "Demo credential bundle missing from the app"
|
||||
return false
|
||||
}
|
||||
var offset = 0
|
||||
while (offset < bundle.size) {
|
||||
val segment = bundle.copyOfRange(offset, minOf(bundle.size, offset + SEGMENT_SIZE))
|
||||
val last = offset + segment.size == bundle.size
|
||||
val result = request(LinkOpcode.CREDENTIALS_PROVISION, credentialsSegment(bundle.size, offset, segment),
|
||||
if (last) PROVISION_TIMEOUT_MS else REPLY_TIMEOUT_MS)
|
||||
if (result == null || !result.accepted) {
|
||||
_detail.value = "micrOBU refused the demo credentials (${result?.let { LinkResultCode.name(it.code) } ?: "no reply"})"
|
||||
return false
|
||||
}
|
||||
if (last) {
|
||||
val d = result.detail
|
||||
Log.i(TAG, "demo credentials provisioned: " +
|
||||
if (d.size == 3) "${d[0]} root(s), ${d[1]} authorit(ies), ${d[2]} ticket(s)" else "no report")
|
||||
}
|
||||
offset += segment.size
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun potiFor(its: OutgoingIts): PotiUpdate {
|
||||
val pv = its.positionVector
|
||||
// Semi-axes of the 95 % ellipse from Android's 68 % radius (circular error), as GnPositionVector's PAI bound.
|
||||
val semiCm = its.accuracyM?.takeIf { it > 0f && it.isFinite() }
|
||||
?.let { (it * 1.62f * 100).roundToInt().coerceAtMost(65_535) } ?: 0
|
||||
return PotiUpdate(
|
||||
timestampMs = fullTimestampIts(pv.tstMs),
|
||||
latTenMicroDeg = pv.latTenMicroDeg,
|
||||
lonTenMicroDeg = pv.lonTenMicroDeg,
|
||||
semiMajorCm = semiCm,
|
||||
semiMinorCm = semiCm,
|
||||
speedCms = pv.speedCms.coerceAtLeast(0),
|
||||
headingDeciDeg = pv.headingDeciDeg,
|
||||
pai = pv.pai,
|
||||
)
|
||||
}
|
||||
|
||||
/** [GnPositionVector.tstMs] is already the full TimestampIts; guard against a reduced one anyway. */
|
||||
private fun fullTimestampIts(tstMs: Long): Long {
|
||||
if (tstMs > 0xFFFF_FFFFL) return tstMs
|
||||
val now = ItsTime.timestampIts(System.currentTimeMillis())
|
||||
return now - ((now - tstMs) and 0xFFFF_FFFFL)
|
||||
}
|
||||
|
||||
private suspend fun request(opcode: Int, body: ByteArray, timeoutMs: Long): LinkResult? {
|
||||
val seq = nextSequence()
|
||||
val reply = CompletableDeferred<LinkResult>()
|
||||
pending[seq] = reply
|
||||
return try {
|
||||
if (!writeMessage(LinkMessage(opcode, seq, body).encode())) null
|
||||
else withTimeoutOrNull(timeoutMs) { reply.await() }
|
||||
} finally {
|
||||
pending.remove(seq)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun write(opcode: Int, body: ByteArray): Boolean =
|
||||
writeMessage(LinkMessage(opcode, nextSequence(), body).encode())
|
||||
|
||||
private suspend fun writeMessage(message: ByteArray): Boolean =
|
||||
if (transport.value == Esp32Transport.USB) usb.sendFrame(SERIAL_FRAME_LINK, message)
|
||||
else ble.send(message)
|
||||
|
||||
private fun nextSequence(): Int = sequence.incrementAndGet() and 0xFFFF
|
||||
|
||||
private fun onUsbFrame(frame: DecodedFrame) {
|
||||
when (frame.type) {
|
||||
SerialFrameType.STATUS -> _protocol.value = Esp32Protocol.LEGACY_SERIAL
|
||||
SerialFrameType.V2X_RX -> _incomingFrames.tryEmit(frame)
|
||||
SERIAL_FRAME_LINK -> onLinkMessage(frame.payload)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onLinkMessage(octets: ByteArray) {
|
||||
val message = LinkMessage.decode(octets) ?: return
|
||||
when (message.opcode) {
|
||||
LinkOpcode.V2X_RX -> _incomingFrames.tryEmit(DecodedFrame(SerialFrameType.V2X_RX, message.body))
|
||||
LinkOpcode.RESULT -> {
|
||||
val result = LinkResult.decode(message.body) ?: return
|
||||
val waiting = pending.remove(message.sequence)
|
||||
if (waiting != null) {
|
||||
waiting.complete(result)
|
||||
} else if (result.code == LinkResultCode.TIME_REGRESSION) {
|
||||
// Only sent for a deliberate clock correction (see CLOCK_STEP_BACK_MS): the
|
||||
// micrOBU accepted the new time and restarted its stack. Not a refusal.
|
||||
Log.i(TAG, "micrOBU followed a step back of the phone's clock and restarted its stack")
|
||||
} else if (!result.accepted) {
|
||||
// A POTI_UPDATE or BTP_DATA_REQUEST the micrOBU refused (they are not awaited).
|
||||
_refusedRequests.value++
|
||||
if (result.code == LinkResultCode.NOT_CONFIGURED) { configured = null; lastPotiMs = null; lastPotiFixMs = null }
|
||||
noteRefusal("micrOBU refused a request: ${LinkResultCode.name(result.code)}")
|
||||
}
|
||||
}
|
||||
LinkOpcode.STATUS -> {
|
||||
val status = StationStatus.decode(message.body) ?: return
|
||||
val first = _protocol.value != Esp32Protocol.STATION_LINK
|
||||
_protocol.value = Esp32Protocol.STATION_LINK
|
||||
_stationStatus.value = status
|
||||
_linkStatus.value = EspLinkStatus(
|
||||
status = 0,
|
||||
oversizeDrops = 0,
|
||||
txFailures = status.radioFailed.coerceAtMost(0xFFFF).toInt(),
|
||||
rxCrcErrors = status.linkCrcErrors.coerceAtMost(0xFFFF).toInt(),
|
||||
rxQueueDrops = status.radioDropped.coerceAtMost(0xFFFF).toInt(),
|
||||
)
|
||||
// Board reset (or first contact): configure now, so its receiver runs even before
|
||||
// the first message is sent.
|
||||
if (!status.configured) { configured = null; lastPotiMs = null; lastPotiFixMs = null }
|
||||
if (first || !status.configured) scope.launch { ensureConfigured(null) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun noteRefusal(line: String) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastRefusalLogMs < 5_000) return
|
||||
lastRefusalLogMs = now
|
||||
Log.w(TAG, line)
|
||||
_detail.value = line
|
||||
// A refusal is news, not a state: it leaves the card again unless something replaced it.
|
||||
scope.launch {
|
||||
delay(DETAIL_HOLD_MS)
|
||||
_detail.compareAndSet(line, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -43,8 +57,9 @@ const val SERIAL_LINK_MAX_PAYLOAD = 512
|
||||
|
||||
/**
|
||||
* Decoded [SerialFrameType.STATUS] payload: `[status:1][oversizeDrops:2 LE][txFailures:2 LE]
|
||||
* [rxCrcErrors:2 LE]` (7 bytes). Counters are free-running totals since firmware boot and
|
||||
* saturate at 0xFFFF rather than wrapping.
|
||||
* [rxCrcErrors:2 LE][capabilities:1][rxQueueDrops:2 LE]` (10 bytes; the last two fields are an
|
||||
* optional tail — see [capabilities] and [rxQueueDrops]). Counters are free-running totals since
|
||||
* firmware boot and saturate at 0xFFFF rather than wrapping.
|
||||
*
|
||||
* Exists so the phone can tell "link alive, no traffic" from "link dead", and so firmware-side
|
||||
* drops — which otherwise only reach `ESP_LOGW` on the flashing port that the phone isn't
|
||||
@@ -59,10 +74,31 @@ 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,
|
||||
/**
|
||||
* Promiscuously-captured frames the firmware's `wifi_promisc_rx_cb` had to drop because its
|
||||
* RX queue (8 deep) was still full of frames `rx_forward_task` hadn't finished forwarding —
|
||||
* bytes 8-9 of the payload. 0 for firmware that predates this field (payload of 7 or 8 bytes),
|
||||
* which is the honest answer: such firmware drops these frames identically, it just never
|
||||
* counted them. A nonzero, growing value here — as opposed to [oversizeDrops] — points at
|
||||
* bursty RX outrunning the forward task rather than any one frame being too large.
|
||||
*/
|
||||
val rxQueueDrops: 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 +108,8 @@ 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,
|
||||
rxQueueDrops = if (payload.size >= 10) u16(8) else 0,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -161,6 +199,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 {
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
package com.hawhamburg.micr0bu.data.transport
|
||||
|
||||
/**
|
||||
* Phone side of the station-link message layer, version 1: the protocol of the colleague's
|
||||
* vanetza-idf ESP32-C5 firmware (microbu-esp32c5/station-link/README.md), which obu-firmware runs
|
||||
* since 2026-09-23. Kotlin counterpart of `obu-firmware/main/link_protocol.hpp` and of the
|
||||
* colleague's Python `microbu_link/messages.py`; the unit test pins these encoders to bytes that
|
||||
* Python module produced.
|
||||
*
|
||||
* Transport independent: over USB each message is the payload of one serial frame of type
|
||||
* [SERIAL_FRAME_LINK] (same 0xAA55 framing as before, see [SerialFrameEncoder]); over BLE each
|
||||
* message is one GATT value (see [BleLinkTransport]).
|
||||
*
|
||||
* Message: `[opcode:1][flags:1][sequence:2 LE][body]`, at most [LINK_MAX_MESSAGE] octets, all
|
||||
* integers little-endian. The phone numbers its requests; the firmware answers with a RESULT
|
||||
* carrying the same sequence.
|
||||
*
|
||||
* Only what this app uses is implemented: configure, PoTi, BTP-DATA.request, credential
|
||||
* provisioning, RESULT, STATUS, and the MicrOBU extension [LinkOpcode.V2X_RX]. The SF-SAP
|
||||
* identifier-change primitives are not used: the app owns its pseudonym (see
|
||||
* [com.hawhamburg.micr0bu.data.cam.PseudonymManager]) and reconfigures the station on a change.
|
||||
*/
|
||||
const val SERIAL_FRAME_LINK = 0x10
|
||||
const val LINK_MAX_MESSAGE = 512
|
||||
const val LINK_HEADER_SIZE = 4
|
||||
|
||||
object LinkOpcode {
|
||||
const val STATION_CONFIGURE = 0x01
|
||||
const val POTI_UPDATE = 0x02
|
||||
const val BTP_DATA_REQUEST = 0x03
|
||||
const val CREDENTIALS_PROVISION = 0x04
|
||||
const val CREDENTIALS_ERASE = 0x05
|
||||
const val STATUS_REQUEST = 0x0C
|
||||
const val RESULT = 0x80
|
||||
const val BTP_DATA_INDICATION = 0x81
|
||||
const val STATUS = 0x84
|
||||
/** MicrOBU extension: body is exactly the old [SerialFrameType.V2X_RX] payload ([V2xRxFrame]). */
|
||||
const val V2X_RX = 0x85
|
||||
}
|
||||
|
||||
/** RESULT codes: vanetza_idf::Result first, then the link's own. */
|
||||
object LinkResultCode {
|
||||
const val ACCEPTED = 0
|
||||
const val TIME_REGRESSION = 7
|
||||
const val NOT_CONFIGURED = 0x12
|
||||
|
||||
fun name(code: Int): String = when (code) {
|
||||
0 -> "accepted"; 1 -> "invalid_argument"; 2 -> "unsupported"; 3 -> "wrong_entry_point"
|
||||
4 -> "security_unavailable"; 5 -> "resource_limit"; 6 -> "rejected"; 7 -> "time_regression"
|
||||
8 -> "identity_change_pending"; 0x10 -> "unknown_opcode"; 0x11 -> "malformed"
|
||||
0x12 -> "not_configured"; 0x13 -> "busy"; 0x14 -> "no_credentials"
|
||||
else -> "code_$code"
|
||||
}
|
||||
}
|
||||
|
||||
/** GN security profile of a BTP-DATA.request (TS 103 300-3 Table 4). */
|
||||
object LinkSecurityProfile {
|
||||
const val STATION_DEFAULT = 0
|
||||
const val UNSECURED = 1
|
||||
const val SECURED = 2
|
||||
}
|
||||
|
||||
class LinkMessage(val opcode: Int, val sequence: Int, val body: ByteArray, val flags: Int = 0) {
|
||||
fun encode(): ByteArray {
|
||||
require(LINK_HEADER_SIZE + body.size <= LINK_MAX_MESSAGE) {
|
||||
"link message 0x%02x too long: %d".format(opcode, LINK_HEADER_SIZE + body.size)
|
||||
}
|
||||
return byteArrayOf(opcode.toByte(), flags.toByte(), sequence.toByte(), (sequence shr 8).toByte()) + body
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun decode(octets: ByteArray): LinkMessage? {
|
||||
if (octets.size < LINK_HEADER_SIZE || octets.size > LINK_MAX_MESSAGE) return null
|
||||
val sequence = (octets[2].toInt() and 0xFF) or ((octets[3].toInt() and 0xFF) shl 8)
|
||||
return LinkMessage(octets[0].toInt() and 0xFF, sequence,
|
||||
octets.copyOfRange(LINK_HEADER_SIZE, octets.size), octets[1].toInt() and 0xFF)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal class LinkWriter {
|
||||
private val out = java.io.ByteArrayOutputStream()
|
||||
fun u8(v: Int) = apply { out.write(v and 0xFF) }
|
||||
fun u16(v: Int) = apply { u8(v); u8(v shr 8) }
|
||||
fun u32(v: Long) = apply { for (i in 0 until 4) u8((v ushr (8 * i)).toInt()) }
|
||||
fun i32(v: Int) = u32(v.toLong())
|
||||
fun u64(v: Long) = apply { for (i in 0 until 8) u8((v ushr (8 * i)).toInt()) }
|
||||
fun bytes(b: ByteArray) = apply { out.write(b) }
|
||||
fun toByteArray(): ByteArray = out.toByteArray()
|
||||
}
|
||||
|
||||
/**
|
||||
* STATION_CONFIGURE body. (Re)creates the GeoNetworking stack and security entity on the micrOBU.
|
||||
* [mid] is the pseudonym MAC: with [addressConfiguration] 0 (AUTO) it becomes both the GN_ADDR MID
|
||||
* and the 802.11 source address, as with the old CAM_TX_PV prefix.
|
||||
*/
|
||||
data class StationConfigure(
|
||||
val stationType: Int,
|
||||
val mid: ByteArray,
|
||||
val security: Int = 1,
|
||||
val addressConfiguration: Int = 0,
|
||||
val beaconing: Int = 0,
|
||||
val channelNumber: Int = 180,
|
||||
val transmitPowerDbm: Int = 20,
|
||||
/** 0 off, 1 receive only, 2 transmit and receive. */
|
||||
val radio: Int = 2,
|
||||
/** Raw GN traffic class octet: TC-ID 2, as the previous firmware's geonet.c. */
|
||||
val defaultTrafficClass: Int = 2,
|
||||
/** Raw GN lifetime octet: 1 s. */
|
||||
val defaultLifetime: Int = 0x05,
|
||||
) {
|
||||
init { require(mid.size == 6) }
|
||||
|
||||
fun encode(): ByteArray = LinkWriter()
|
||||
.u8(stationType).u8(security).u8(addressConfiguration).bytes(mid).u8(beaconing)
|
||||
.u16(channelNumber).u8(transmitPowerDbm).u8(radio).u8(defaultTrafficClass).u8(defaultLifetime)
|
||||
.toByteArray()
|
||||
|
||||
override fun equals(other: Any?): Boolean = other is StationConfigure && encode().contentEquals(other.encode())
|
||||
override fun hashCode(): Int = encode().contentHashCode()
|
||||
}
|
||||
|
||||
/** RESULT detail of STATION_CONFIGURE. */
|
||||
data class StationInfo(val credentialsLoaded: Boolean, val tickets: Int) {
|
||||
companion object {
|
||||
fun decode(detail: ByteArray): StationInfo? =
|
||||
if (detail.size != 18) null
|
||||
else StationInfo(detail[16].toInt() != 0, detail[17].toInt() and 0xFF)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POTI_UPDATE body (EN 302 890-2 minimum data set). [timestampMs] is TimestampIts under
|
||||
* [com.hawhamburg.micr0bu.domain.asn1.ItsTime]'s convention; it also sets the micrOBU's ITS clock,
|
||||
* which the security entity stamps into every signed message's generationTime.
|
||||
*/
|
||||
data class PotiUpdate(
|
||||
val timestampMs: Long,
|
||||
val latTenMicroDeg: Int,
|
||||
val lonTenMicroDeg: Int,
|
||||
val semiMajorCm: Int = 0,
|
||||
val semiMinorCm: Int = 0,
|
||||
val orientationDeciDeg: Int = 0,
|
||||
val altitudeCm: Int? = null,
|
||||
val speedCms: Int? = null,
|
||||
val headingDeciDeg: Int? = null,
|
||||
val pai: Boolean = false,
|
||||
) {
|
||||
fun encode(): ByteArray {
|
||||
val flags = (if (altitudeCm != null) 1 else 0) or (if (speedCms != null) 2 else 0) or
|
||||
(if (headingDeciDeg != null) 4 else 0) or (if (pai) 8 else 0)
|
||||
return LinkWriter().u64(timestampMs).i32(latTenMicroDeg).i32(lonTenMicroDeg)
|
||||
.u16(semiMajorCm).u16(semiMinorCm).u16(orientationDeciDeg).u8(flags)
|
||||
.i32(altitudeCm ?: 0).u16(speedCms ?: 0).u16(headingDeciDeg ?: 0)
|
||||
.toByteArray()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* BTP_DATA_REQUEST body for a BTP-B single-hop broadcast, the only shape this app sends (CAM, VAM).
|
||||
* [permissions] is the SSP the authorization ticket must carry for [itsAid].
|
||||
*/
|
||||
data class BtpDataRequest(
|
||||
val destinationPort: Int,
|
||||
val itsAid: Long,
|
||||
val securityProfile: Int,
|
||||
val permissions: ByteArray,
|
||||
val flSdu: ByteArray,
|
||||
/** Raw GN lifetime octet; 0xFF = station default. */
|
||||
val maximumPacketLifetime: Int = 0xFF,
|
||||
) {
|
||||
fun encode(): ByteArray = LinkWriter()
|
||||
.u8(1) // BTP-B
|
||||
.u16(destinationPort)
|
||||
.u16(0) // destination port info
|
||||
.u8(1) // SHB
|
||||
.u8(1) // communication profile ITS-G5
|
||||
.u8(securityProfile)
|
||||
.u8(0xFF) // traffic class: station default
|
||||
.u8(maximumPacketLifetime)
|
||||
.u8(0) // hop limit: station default
|
||||
.u16(0).u16(0) // no repetition
|
||||
.u32(itsAid)
|
||||
.u8(permissions.size).bytes(permissions)
|
||||
.u8(0) // no SN-ENCAP context
|
||||
.u16(flSdu.size).bytes(flSdu)
|
||||
.toByteArray()
|
||||
|
||||
override fun equals(other: Any?): Boolean = other is BtpDataRequest && encode().contentEquals(other.encode())
|
||||
override fun hashCode(): Int = encode().contentHashCode()
|
||||
}
|
||||
|
||||
/** One CREDENTIALS_PROVISION segment of a `VCR1` bundle. */
|
||||
fun credentialsSegment(totalLength: Int, offset: Int, segment: ByteArray): ByteArray {
|
||||
require(segment.size <= 255)
|
||||
return LinkWriter().u16(totalLength).u16(offset).u8(segment.size).bytes(segment).toByteArray()
|
||||
}
|
||||
|
||||
data class LinkResult(val code: Int, val detail: ByteArray) {
|
||||
val accepted: Boolean get() = code == LinkResultCode.ACCEPTED
|
||||
override fun toString(): String = "LinkResult(${LinkResultCode.name(code)}, ${detail.size} B detail)"
|
||||
override fun equals(other: Any?): Boolean = other is LinkResult && code == other.code && detail.contentEquals(other.detail)
|
||||
override fun hashCode(): Int = 31 * code + detail.contentHashCode()
|
||||
|
||||
companion object {
|
||||
fun decode(body: ByteArray): LinkResult? {
|
||||
if (body.size < 2) return null
|
||||
val length = body[1].toInt() and 0xFF
|
||||
if (body.size != 2 + length) return null
|
||||
return LinkResult(body[0].toInt() and 0xFF, body.copyOfRange(2, body.size))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** STATUS body (103 octets), sent by the micrOBU every second. Counters are since the last configure. */
|
||||
data class StationStatus(
|
||||
val uptimeMs: Long,
|
||||
val configured: Boolean,
|
||||
val identifier: ByteArray,
|
||||
val tickets: Int,
|
||||
val signedMessages: Long,
|
||||
val refusedNoTicket: Long,
|
||||
val refusedChangePending: Long,
|
||||
val refusedPermission: Long,
|
||||
val signFailed: Long,
|
||||
val verified: Long,
|
||||
val rejected: Long,
|
||||
val requestsAccepted: Long,
|
||||
val requestsRefused: Long,
|
||||
val radioSubmitted: Long,
|
||||
val radioFailed: Long,
|
||||
val radioReceived: Long,
|
||||
val radioDropped: Long,
|
||||
val linkCrcErrors: Long,
|
||||
val linkMalformed: Long,
|
||||
val potiUpdates: Long,
|
||||
val itsTimeMs: Long,
|
||||
) {
|
||||
/** Signing refusals of every kind: no usable ticket, a pending id change, or a missing permission. */
|
||||
val signRefused: Long get() = refusedNoTicket + refusedChangePending + refusedPermission + signFailed
|
||||
|
||||
override fun equals(other: Any?): Boolean = other is StationStatus && toString() == other.toString() &&
|
||||
identifier.contentEquals(other.identifier)
|
||||
override fun hashCode(): Int = toString().hashCode()
|
||||
|
||||
companion object {
|
||||
const val SIZE = 103
|
||||
|
||||
fun decode(body: ByteArray): StationStatus? {
|
||||
if (body.size != SIZE) return null
|
||||
fun u8(i: Int) = body[i].toInt() and 0xFF
|
||||
fun u32(i: Int) = (0 until 4).fold(0L) { acc, k -> acc or ((body[i + k].toLong() and 0xFF) shl (8 * k)) }
|
||||
fun u64(i: Int) = (0 until 8).fold(0L) { acc, k -> acc or ((body[i + k].toLong() and 0xFF) shl (8 * k)) }
|
||||
// [0] uptime u32, [4] configured, [5] gn_address 8, [13] identifier 8, [21] change_pending,
|
||||
// [22] tickets, [23] 18 x u32 counters, [95] its_time u64
|
||||
val c = 23
|
||||
return StationStatus(
|
||||
uptimeMs = u32(0),
|
||||
configured = u8(4) != 0,
|
||||
identifier = body.copyOfRange(13, 21),
|
||||
tickets = u8(22),
|
||||
signedMessages = u32(c), refusedNoTicket = u32(c + 4), refusedChangePending = u32(c + 8),
|
||||
refusedPermission = u32(c + 12), signFailed = u32(c + 16), verified = u32(c + 20),
|
||||
rejected = u32(c + 24), requestsAccepted = u32(c + 28), requestsRefused = u32(c + 32),
|
||||
// c + 36: indications (the stack's own verified deliveries; the app uses V2X_RX)
|
||||
radioSubmitted = u32(c + 40), radioFailed = u32(c + 44), radioReceived = u32(c + 48),
|
||||
radioDropped = u32(c + 52),
|
||||
// c + 56: link_rx_frames
|
||||
linkCrcErrors = u32(c + 60), linkMalformed = u32(c + 64), potiUpdates = u32(c + 68),
|
||||
itsTimeMs = u64(95),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,9 +35,6 @@ import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** Connection lifecycle for the ESP32-C5 USB-serial link. */
|
||||
enum class UsbSerialState { DISCONNECTED, DEVICE_ATTACHED, PERMISSION_REQUESTED, CONNECTED, ERROR }
|
||||
|
||||
private const val ACTION_USB_PERMISSION = "com.hawhamburg.micr0bu.USB_SERIAL_PERMISSION"
|
||||
private const val TAG = "UsbSerialTransport"
|
||||
|
||||
@@ -63,6 +60,11 @@ private const val TAG = "UsbSerialTransport"
|
||||
* Baud rate is not applicable here — USB Serial/JTAG has no baud concept; `setParameters` below
|
||||
* is a no-op the library requires anyway for API-shape reasons but the value is otherwise unused.
|
||||
*
|
||||
* Since 2026-09-23 the rest of the app does not use this class directly but [Esp32Link], which
|
||||
* picks this or [BleLinkTransport] and speaks either the previous firmware's frames (0x01-0x05,
|
||||
* [sendCamTx]) or the current firmware's station-link messages (frame type [SERIAL_FRAME_LINK],
|
||||
* [sendFrame]) over it.
|
||||
*
|
||||
* ## Ownership
|
||||
* This is a `@Singleton` shared by the UI ([com.hawhamburg.micr0bu.viewmodel.MqttViewModel]), the
|
||||
* foreground [com.hawhamburg.micr0bu.service.TripRecordingService]'s
|
||||
@@ -111,8 +113,8 @@ class UsbSerialTransport @Inject constructor(
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
private val _state = MutableStateFlow(UsbSerialState.DISCONNECTED)
|
||||
val state: StateFlow<UsbSerialState> = _state.asStateFlow()
|
||||
private val _state = MutableStateFlow(Esp32LinkState.DISCONNECTED)
|
||||
val state: StateFlow<Esp32LinkState> = _state.asStateFlow()
|
||||
|
||||
private val _incomingFrames = MutableSharedFlow<DecodedFrame>(extraBufferCapacity = 256)
|
||||
/** Every valid frame the ESP32 sends (CAM_RX and STATUS) — callers filter by [DecodedFrame.type]. */
|
||||
@@ -150,7 +152,7 @@ class UsbSerialTransport @Inject constructor(
|
||||
} else {
|
||||
Log.w(TAG, "usbReceiver: permission denied or device null " +
|
||||
"(granted=$granted, device=$device)")
|
||||
_state.value = UsbSerialState.ERROR
|
||||
_state.value = Esp32LinkState.ERROR
|
||||
}
|
||||
}
|
||||
UsbManager.ACTION_USB_DEVICE_DETACHED -> {
|
||||
@@ -174,7 +176,7 @@ class UsbSerialTransport @Inject constructor(
|
||||
* repeatedly (e.g. from a "retry" UI action) — no-ops if already connected.
|
||||
*/
|
||||
fun connect() {
|
||||
if (_state.value == UsbSerialState.CONNECTED) {
|
||||
if (_state.value == Esp32LinkState.CONNECTED) {
|
||||
Log.i(TAG, "connect(): already connected, no-op")
|
||||
return
|
||||
}
|
||||
@@ -197,7 +199,7 @@ class UsbSerialTransport @Inject constructor(
|
||||
"(see device list logged above) - either nothing is attached at the Android " +
|
||||
"USB level, or it's attached but its VID/PID doesn't match any entry in " +
|
||||
"customProber's table")
|
||||
_state.value = UsbSerialState.DISCONNECTED
|
||||
_state.value = Esp32LinkState.DISCONNECTED
|
||||
return
|
||||
}
|
||||
if (espDriver == null) {
|
||||
@@ -211,14 +213,14 @@ class UsbSerialTransport @Inject constructor(
|
||||
Log.i(TAG, "connect(): matched device vid=0x${device.vendorId.toString(16)} " +
|
||||
"pid=0x${device.productId.toString(16)} name=${device.deviceName} " +
|
||||
"ports=${driver.ports.size}")
|
||||
_state.value = UsbSerialState.DEVICE_ATTACHED
|
||||
_state.value = Esp32LinkState.DEVICE_ATTACHED
|
||||
|
||||
if (usbManager.hasPermission(device)) {
|
||||
Log.i(TAG, "connect(): permission already granted, opening directly")
|
||||
openDevice(device)
|
||||
} else {
|
||||
Log.i(TAG, "connect(): requesting USB permission from user")
|
||||
_state.value = UsbSerialState.PERMISSION_REQUESTED
|
||||
_state.value = Esp32LinkState.PERMISSION_REQUESTED
|
||||
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0
|
||||
val permissionIntent = PendingIntent.getBroadcast(
|
||||
context, 0, Intent(ACTION_USB_PERMISSION).setPackage(context.packageName), flags,
|
||||
@@ -269,14 +271,14 @@ class UsbSerialTransport @Inject constructor(
|
||||
if (driver == null || driver.ports.isEmpty()) {
|
||||
Log.w(TAG, "openDevice(): probeDevice returned null or no ports for " +
|
||||
"vid=0x${device.vendorId.toString(16)} pid=0x${device.productId.toString(16)}")
|
||||
_state.value = UsbSerialState.ERROR
|
||||
_state.value = Esp32LinkState.ERROR
|
||||
return
|
||||
}
|
||||
val connection = usbManager.openDevice(device)
|
||||
if (connection == null) {
|
||||
Log.w(TAG, "openDevice(): usbManager.openDevice() returned null - permission not " +
|
||||
"actually granted, or Android couldn't claim the device")
|
||||
_state.value = UsbSerialState.ERROR
|
||||
_state.value = Esp32LinkState.ERROR
|
||||
return
|
||||
}
|
||||
|
||||
@@ -287,7 +289,7 @@ class UsbSerialTransport @Inject constructor(
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "openDevice(): port.open()/setParameters() threw", e)
|
||||
runCatching { newPort.close() }
|
||||
_state.value = UsbSerialState.ERROR
|
||||
_state.value = Esp32LinkState.ERROR
|
||||
return
|
||||
}
|
||||
|
||||
@@ -329,12 +331,16 @@ 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 ||
|
||||
prev.rxQueueDrops != status.rxQueueDrops
|
||||
) {
|
||||
Log.i(TAG, "ESP32 counters: status=${status.status} " +
|
||||
"oversizeDrops=${status.oversizeDrops} " +
|
||||
"txFailures=${status.txFailures} " +
|
||||
"rxCrcErrors=${status.rxCrcErrors}")
|
||||
"rxCrcErrors=${status.rxCrcErrors} " +
|
||||
"capabilities=${status.capabilities} " +
|
||||
"rxQueueDrops=${status.rxQueueDrops}")
|
||||
}
|
||||
_linkStatus.value = status
|
||||
}
|
||||
@@ -345,7 +351,7 @@ class UsbSerialTransport @Inject constructor(
|
||||
|
||||
override fun onRunError(e: Exception) {
|
||||
Log.e(TAG, "SerialInputOutputManager.onRunError()", e)
|
||||
_state.value = UsbSerialState.ERROR
|
||||
_state.value = Esp32LinkState.ERROR
|
||||
}
|
||||
})
|
||||
ioManager = manager
|
||||
@@ -358,13 +364,13 @@ class UsbSerialTransport @Inject constructor(
|
||||
_consecutiveWriteFailures.value = 0
|
||||
_linkStatus.value = null
|
||||
lastFrameAtMs = SystemClock.elapsedRealtime()
|
||||
_state.value = UsbSerialState.CONNECTED
|
||||
_state.value = Esp32LinkState.CONNECTED
|
||||
startWatchdog()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flips the link to [UsbSerialState.ERROR] once the firmware's 1 Hz STATUS heartbeat has been
|
||||
* Flips the link to [Esp32LinkState.ERROR] once the firmware's 1 Hz STATUS heartbeat has been
|
||||
* missing for [LINK_TIMEOUT_MS]. Without this, "connected" only ever means "the port opened
|
||||
* at some point in the past" — which on a bench is exactly the wrong thing to believe.
|
||||
*/
|
||||
@@ -373,19 +379,38 @@ class UsbSerialTransport @Inject constructor(
|
||||
watchdogJob = scope.launch {
|
||||
while (isActive) {
|
||||
delay(WATCHDOG_POLL_MS)
|
||||
if (_state.value != UsbSerialState.CONNECTED) continue
|
||||
if (_state.value != Esp32LinkState.CONNECTED) continue
|
||||
val silentFor = SystemClock.elapsedRealtime() - lastFrameAtMs
|
||||
if (silentFor > LINK_TIMEOUT_MS) {
|
||||
Log.w(TAG, "watchdog: no frame from ESP32 for ${silentFor}ms (heartbeat " +
|
||||
"expected at 1 Hz) - marking link ERROR. Either the firmware is wedged/" +
|
||||
"not running, or the host->device direction opened but device->host " +
|
||||
"never did (see the DTR note in openDevice()).")
|
||||
_state.value = UsbSerialState.ERROR
|
||||
_state.value = Esp32LinkState.ERROR
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 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,22 +419,57 @@ 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
|
||||
} catch (e: Exception) {
|
||||
val failures = _consecutiveWriteFailures.updateAndGet { it + 1 }
|
||||
Log.w(TAG, "sendCamTx(): write failed (consecutive failures: $failures)", e)
|
||||
_state.value = UsbSerialState.ERROR
|
||||
_state.value = Esp32LinkState.ERROR
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes one frame of any [type] (the station-link messages go as [SERIAL_FRAME_LINK]). Same
|
||||
* failure accounting as [sendCamTx]. Blocking, 200 ms timeout: call from a background dispatcher.
|
||||
*/
|
||||
fun sendFrame(type: Int, payload: ByteArray): Boolean {
|
||||
val p = port
|
||||
if (p == null) {
|
||||
_consecutiveWriteFailures.update { it + 1 }
|
||||
return false
|
||||
}
|
||||
return try {
|
||||
p.write(SerialFrameEncoder.encode(type, payload), /* timeout ms */ 200)
|
||||
_consecutiveWriteFailures.value = 0
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
val failures = _consecutiveWriteFailures.updateAndGet { it + 1 }
|
||||
Log.w(TAG, "sendFrame(0x${type.toString(16)}): write failed (consecutive failures: $failures)", e)
|
||||
_state.value = Esp32LinkState.ERROR
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -425,7 +485,7 @@ class UsbSerialTransport @Inject constructor(
|
||||
openDeviceName = null
|
||||
_linkStatus.value = null
|
||||
_consecutiveWriteFailures.value = 0
|
||||
_state.value = UsbSerialState.DISCONNECTED
|
||||
_state.value = Esp32LinkState.DISCONNECTED
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import javax.inject.Singleton
|
||||
* UPER (Unaligned Packed Encoding Rules) codec for CAM, used on the ESP32-C5 hardware path
|
||||
* (Phase 03, Section 13): the phone builds outgoing CAM itself
|
||||
* ([com.hawhamburg.micr0bu.domain.cam.PhoneCamBuilder]) and UPER-encodes it before handing bytes
|
||||
* to [com.hawhamburg.micr0bu.data.transport.UsbSerialTransport], and UPER-decodes whatever the
|
||||
* to [com.hawhamburg.micr0bu.data.transport.Esp32Link], and UPER-decodes whatever the
|
||||
* ESP32-C5 forwards back on receive (already stripped of 802.11/GeoNetworking/BTP framing by
|
||||
* the firmware's `gn_unwrap.c` — this only ever sees CAM UPER bytes, never raw radio frames).
|
||||
*
|
||||
|
||||
@@ -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,145 @@
|
||||
package com.hawhamburg.micr0bu.domain.asn1
|
||||
|
||||
import com.hawhamburg.micr0bu.domain.cam.StationType
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.roundToLong
|
||||
|
||||
/**
|
||||
* One VAM's content, in the units the phone has. Everything optional here is encoded as the ASN.1
|
||||
* "unavailable" value when null, never as a made-up number.
|
||||
*/
|
||||
data class VamContent(
|
||||
val stationId: Long,
|
||||
/** Wall-clock epoch ms of the fix, GNSS-corrected; the generationDeltaTime source. */
|
||||
val timestamp: Long,
|
||||
val latitude: Double,
|
||||
val longitude: Double,
|
||||
/** Android horizontal accuracy, metres (68 %). Null or 0: unknown. */
|
||||
val accuracyM: Float?,
|
||||
val speedMps: Double,
|
||||
val headingDeg: Double,
|
||||
/** Along-track acceleration, m/s². */
|
||||
val accelerationMps2: Double? = null,
|
||||
/** Include the low-frequency container (profile and size class). */
|
||||
val includeLowFrequency: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* UPER encoder for the VAM of ETSI TS 103 300-3 V2.3.1 (VAM-PDU-Descriptions major version 3,
|
||||
* over the CDD of TS 102 894-2 V2.4.1), for a bicyclist in VRU profile 2.
|
||||
*
|
||||
* Covers the same field set as the colleague's reference VBS (microbu-esp32c5,
|
||||
* station-link/python/microbu_link/vbs.py, `VbsLite.assemble`), which encodes with asn1tools from
|
||||
* the ETSI modules: header, basic container, the three mandatory fields of the high-frequency
|
||||
* container, and optionally the low-frequency container with profile and size class. No cluster
|
||||
* or motion-prediction containers. The unit test cross-checks the bytes against asn1tools.
|
||||
*
|
||||
* Transmit only for now: the app neither decodes received VAMs nor shows them.
|
||||
*/
|
||||
object VamUperCodec {
|
||||
|
||||
const val PROTOCOL_VERSION = 3
|
||||
const val MESSAGE_ID_VAM = 16
|
||||
|
||||
/** VruSubProfileBicyclist.bicyclist */
|
||||
private const val SUBPROFILE_BICYCLIST = 1
|
||||
/** VruSizeClass.low */
|
||||
private const val SIZE_CLASS_LOW = 1
|
||||
/** VruProfileAndSubprofile CHOICE index of bicyclistAndLightVruVehicle (root: 4 alternatives). */
|
||||
private const val PROFILE_BICYCLIST_INDEX = 1
|
||||
|
||||
private const val SEMI_AXIS_OUT_OF_RANGE = 4094
|
||||
private const val SEMI_AXIS_UNAVAILABLE = 4095
|
||||
private const val WGS84_ANGLE_UNAVAILABLE = 3601
|
||||
private const val ANGLE_CONFIDENCE_UNAVAILABLE = 127
|
||||
private const val SPEED_OUT_OF_RANGE = 16382
|
||||
private const val SPEED_CONFIDENCE_UNAVAILABLE = 127
|
||||
private const val ACCEL_UNAVAILABLE = 161
|
||||
private const val ACCEL_CONFIDENCE_UNAVAILABLE = 102
|
||||
private const val ALTITUDE_UNAVAILABLE = 800001
|
||||
private const val ALTITUDE_CONFIDENCE_UNAVAILABLE = 15
|
||||
|
||||
/**
|
||||
* Android's accuracy is a 68 % radius; the confidence ellipse is 95 %. For a circular 2-D error
|
||||
* the ratio is about 1.62, the same factor [com.hawhamburg.micr0bu.data.transport.GnPositionVector]
|
||||
* uses for its PAI bound.
|
||||
*/
|
||||
private const val ACCURACY_68_TO_95 = 1.62
|
||||
|
||||
private const val ENCODE_BUFFER_BYTES = 64
|
||||
|
||||
fun encode(vam: VamContent): ByteArray {
|
||||
val w = BitWriter(ENCODE_BUFFER_BYTES)
|
||||
|
||||
// VAM ::= SEQUENCE { header, vam } -- not extensible
|
||||
// ItsPduHeader
|
||||
w.putBits(PROTOCOL_VERSION, 8)
|
||||
w.putBits(MESSAGE_ID_VAM, 8)
|
||||
w.putBits(vam.stationId and 0xFFFFFFFFL, 32)
|
||||
|
||||
// VruAwareness ::= SEQUENCE { generationDeltaTime, vamParameters }
|
||||
w.putBits(CamUperCodec.generationDeltaTime(vam.timestamp), 16)
|
||||
|
||||
// VamParameters ::= SEQUENCE { basic, hf, lf OPT, clusterInfo OPT, clusterOp OPT, motion OPT, ... }
|
||||
w.putBits(0, 1) // extension bit
|
||||
w.putBits(if (vam.includeLowFrequency) 0b1000 else 0b0000, 4)
|
||||
|
||||
// BasicContainer ::= SEQUENCE { stationType, referencePosition, ... }
|
||||
w.putBits(0, 1)
|
||||
w.putBits(StationType.CYCLIST, 8)
|
||||
// ReferencePositionWithConfidence ::= SEQUENCE { latitude, longitude, ellipse, altitude }
|
||||
w.putBits(latitude(vam.latitude) + 900_000_000L, 31)
|
||||
w.putBits(longitude(vam.longitude) + 1_800_000_000L, 32)
|
||||
val semiAxis = semiAxisCm(vam.accuracyM)
|
||||
w.putBits(semiAxis, 12) // semiMajorAxisLength
|
||||
w.putBits(semiAxis, 12) // semiMinorAxisLength
|
||||
// Circular error: the orientation of the major axis says nothing, so it is unavailable.
|
||||
w.putBits(WGS84_ANGLE_UNAVAILABLE, 12)
|
||||
// Altitude: GnssReading carries no "has altitude" flag, so an honest unavailable.
|
||||
w.putBits(ALTITUDE_UNAVAILABLE + 100_000, 20)
|
||||
w.putBits(ALTITUDE_CONFIDENCE_UNAVAILABLE, 4)
|
||||
|
||||
// VruHighFrequencyContainer ::= SEQUENCE { heading, speed, longitudinalAcceleration, 11 OPTIONAL, ... }
|
||||
w.putBits(0, 1)
|
||||
w.putBits(0, 11)
|
||||
w.putBits(headingDeciDeg(vam.headingDeg), 12)
|
||||
w.putBits(ANGLE_CONFIDENCE_UNAVAILABLE - 1, 7) // Wgs84AngleConfidence (1..127)
|
||||
w.putBits(speedCms(vam.speedMps), 14)
|
||||
w.putBits(SPEED_CONFIDENCE_UNAVAILABLE - 1, 7) // SpeedConfidence (1..127)
|
||||
w.putBits(accelDeciMps2(vam.accelerationMps2) + 160, 9)
|
||||
w.putBits(ACCEL_CONFIDENCE_UNAVAILABLE, 7)
|
||||
|
||||
if (vam.includeLowFrequency) {
|
||||
// VruLowFrequencyContainer ::= SEQUENCE { profileAndSubprofile, sizeClass OPT, exteriorLights OPT, ... }
|
||||
w.putBits(0, 1)
|
||||
w.putBits(0b10, 2)
|
||||
// VruProfileAndSubprofile ::= CHOICE { pedestrian, bicyclistAndLightVruVehicle, motorcyclist, animal, ... }
|
||||
w.putBits(0, 1)
|
||||
w.putBits(PROFILE_BICYCLIST_INDEX, 2)
|
||||
w.putBits(SUBPROFILE_BICYCLIST, 4)
|
||||
w.putBits(SIZE_CLASS_LOW, 4)
|
||||
}
|
||||
return w.toByteArray()
|
||||
}
|
||||
|
||||
private fun latitude(deg: Double): Long =
|
||||
if (deg.isFinite()) (deg * 1e7).roundToLong().coerceIn(-900_000_000L, 900_000_000L) else 900_000_001L
|
||||
|
||||
private fun longitude(deg: Double): Long =
|
||||
if (deg.isFinite()) (deg * 1e7).roundToLong().coerceIn(-1_799_999_999L, 1_800_000_000L) else 1_800_000_001L
|
||||
|
||||
private fun semiAxisCm(accuracyM: Float?): Int {
|
||||
if (accuracyM == null || !accuracyM.isFinite() || accuracyM <= 0f) return SEMI_AXIS_UNAVAILABLE
|
||||
val cm = (accuracyM * ACCURACY_68_TO_95 * 100).roundToInt()
|
||||
return if (cm >= SEMI_AXIS_OUT_OF_RANGE) SEMI_AXIS_OUT_OF_RANGE else cm.coerceAtLeast(1)
|
||||
}
|
||||
|
||||
private fun headingDeciDeg(deg: Double): Int =
|
||||
if (deg.isFinite()) Math.floorMod((deg * 10).roundToInt(), 3600) else WGS84_ANGLE_UNAVAILABLE
|
||||
|
||||
private fun speedCms(mps: Double): Int =
|
||||
if (mps.isFinite()) (mps * 100).roundToInt().coerceIn(0, SPEED_OUT_OF_RANGE) else 16383
|
||||
|
||||
private fun accelDeciMps2(mps2: Double?): Int =
|
||||
if (mps2 == null || !mps2.isFinite()) ACCEL_UNAVAILABLE else (mps2 * 10).roundToInt().coerceIn(-160, 160)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -10,7 +10,7 @@ import kotlin.math.abs
|
||||
* This class only does the sensor-fusion-into-CAM-fields part, independent of the wire protocol
|
||||
* to the ESP32-C5. Live flow, driven by [com.hawhamburg.micr0bu.service.CamTransmitLoop]:
|
||||
*
|
||||
* `PhoneCamBuilder.build(...)` → `RealAsn1UperCodec.encodeCam(...)` → `UsbSerialTransport` (write).
|
||||
* `PhoneCamBuilder.build(...)` → `RealAsn1UperCodec.encodeCam(...)` → `Esp32Link` (write).
|
||||
*
|
||||
* Position/speed/heading come straight from GNSS. Yaw rate is derived from the gyroscope's
|
||||
* z-axis reading (rotation about the vertical axis while the phone is roughly flat/mounted
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.hawhamburg.micr0bu.domain.vam
|
||||
|
||||
import com.hawhamburg.micr0bu.domain.usecase.GeoMath
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* When to send an individual VAM: ETSI TS 103 300-3 V2.3.1 clause 6.4, items 1 to 4, with the
|
||||
* recommended values of Tables 16 and 17 — the same rules the colleague's reference VBS applies
|
||||
* (microbu-esp32c5/station-link/python/microbu_link/vbs.py, `VbsLite.due`). No clustering, no
|
||||
* redundancy mitigation, T_GenVam fixed at its minimum (no DCC input).
|
||||
*
|
||||
* Pure and clock-free (callers pass times in ms), so it can be tested without Android.
|
||||
*/
|
||||
class VamGenerationRules {
|
||||
|
||||
data class Kinematics(val latitude: Double, val longitude: Double, val speedMps: Double, val headingDeg: Double)
|
||||
|
||||
private var last: Kinematics? = null
|
||||
private var lastAtMs = 0L
|
||||
private var lastLowFrequencyAtMs: Long? = null
|
||||
|
||||
/** True if a VAM is due at [nowMs] for the VRU now at [now]. */
|
||||
fun due(nowMs: Long, now: Kinematics): Boolean {
|
||||
val previous = last ?: return true
|
||||
val elapsed = nowMs - lastAtMs
|
||||
if (elapsed < T_GEN_VAM_MIN_MS) return false
|
||||
if (elapsed > T_GEN_VAM_MAX_MS) return true // item 1
|
||||
if (GeoMath.haversineMeters(now.latitude, now.longitude, previous.latitude, previous.longitude) >
|
||||
MIN_POSITION_CHANGE_M) return true // item 2
|
||||
if (abs(now.speedMps - previous.speedMps) > MIN_SPEED_CHANGE_MPS) return true // item 3
|
||||
val headingDelta = abs(((now.headingDeg - previous.headingDeg + 180.0) % 360.0 + 360.0) % 360.0 - 180.0)
|
||||
return headingDelta > MIN_ORIENTATION_CHANGE_DEG // item 4
|
||||
}
|
||||
|
||||
/** True if the VAM sent at [nowMs] carries the low-frequency container (first VAM, then every T_GenVamLFMin). */
|
||||
fun includeLowFrequency(nowMs: Long): Boolean =
|
||||
lastLowFrequencyAtMs.let { it == null || nowMs - it >= T_GEN_VAM_LF_MIN_MS }
|
||||
|
||||
/** Records that a VAM went out at [nowMs] for [sent], with or without the low-frequency container. */
|
||||
fun onSent(nowMs: Long, sent: Kinematics, withLowFrequency: Boolean) {
|
||||
last = sent
|
||||
lastAtMs = nowMs
|
||||
if (withLowFrequency) lastLowFrequencyAtMs = nowMs
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
last = null
|
||||
lastAtMs = 0L
|
||||
lastLowFrequencyAtMs = null
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val T_GEN_VAM_MIN_MS = 100L
|
||||
const val T_GEN_VAM_MAX_MS = 5_000L
|
||||
const val T_GEN_VAM_LF_MIN_MS = 2_000L
|
||||
const val MIN_POSITION_CHANGE_M = 4.0
|
||||
const val MIN_SPEED_CHANGE_MPS = 0.5
|
||||
const val MIN_ORIENTATION_CHANGE_DEG = 4.0
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,17 @@
|
||||
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.UsbSerialTransport
|
||||
import com.hawhamburg.micr0bu.data.transport.GnPositionVector
|
||||
import com.hawhamburg.micr0bu.data.mqtt.ObuHardwarePreferences
|
||||
import com.hawhamburg.micr0bu.data.transport.Esp32Link
|
||||
import com.hawhamburg.micr0bu.data.transport.OutgoingIts
|
||||
import com.hawhamburg.micr0bu.data.transport.OutgoingMessage
|
||||
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
|
||||
@@ -17,6 +24,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
@@ -45,7 +53,8 @@ import javax.inject.Singleton
|
||||
@Singleton
|
||||
class CamPinger @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val usbSerialTransport: UsbSerialTransport,
|
||||
private val esp32Link: Esp32Link,
|
||||
private val prefs: ObuHardwarePreferences,
|
||||
private val codec: RealAsn1UperCodec,
|
||||
) {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
@@ -67,6 +76,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 +109,18 @@ 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)
|
||||
val signed = prefs.signOutgoingFlow.first()
|
||||
if (esp32Link.send(OutgoingIts(OutgoingMessage.CAM, bytes, pv, gnss.accuracyM, signed))) {
|
||||
_sentCount.update { it + 1 }
|
||||
}
|
||||
}
|
||||
@@ -119,6 +147,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 +162,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,14 +2,22 @@ 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.Esp32Link
|
||||
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.data.transport.OutgoingIts
|
||||
import com.hawhamburg.micr0bu.data.transport.OutgoingMessage
|
||||
import com.hawhamburg.micr0bu.domain.asn1.RealAsn1UperCodec
|
||||
import com.hawhamburg.micr0bu.domain.asn1.VamContent
|
||||
import com.hawhamburg.micr0bu.domain.asn1.VamUperCodec
|
||||
import com.hawhamburg.micr0bu.domain.cam.CamTransmitConfig
|
||||
import com.hawhamburg.micr0bu.domain.cam.PhoneCamBuilder
|
||||
import com.hawhamburg.micr0bu.domain.usecase.GeoMath
|
||||
import com.hawhamburg.micr0bu.domain.vam.VamGenerationRules
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -42,13 +50,21 @@ import javax.inject.Singleton
|
||||
* [com.hawhamburg.micr0bu.domain.detection.EventDetector] stream that already drives trip event
|
||||
* logging). Both rate figures are placeholders pending real-world tuning, per
|
||||
* [CamTransmitConfig]'s own disclaimer.
|
||||
*
|
||||
* ## CAM or VAM
|
||||
* Settings chooses what goes out ([OutgoingMessage]). CAM follows the rate policy above. VAM is
|
||||
* checked every [VAM_TICK_MS] against the generation rules of TS 103 300-3 clause 6.4
|
||||
* ([VamGenerationRules]) and sent when one fires, from the same GNSS fix, pseudonym and position
|
||||
* vector a CAM would use. Whether either is signed is the "Sign outgoing messages" setting; the
|
||||
* micrOBU does the signing ([Esp32Link]).
|
||||
*/
|
||||
@Singleton
|
||||
class CamTransmitLoop @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val obuHardwarePrefs: ObuHardwarePreferences,
|
||||
private val usbSerialTransport: UsbSerialTransport,
|
||||
private val esp32Link: Esp32Link,
|
||||
private val codec: RealAsn1UperCodec,
|
||||
private val pseudonymManager: PseudonymManager,
|
||||
) {
|
||||
private val config = CamTransmitConfig()
|
||||
private val sensorRepository = SensorRepository(context)
|
||||
@@ -63,13 +79,9 @@ 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
|
||||
@Volatile private var outgoing: OutgoingMessage = OutgoingMessage.CAM
|
||||
@Volatile private var signOutgoing: Boolean = true
|
||||
private val vamRules = VamGenerationRules()
|
||||
|
||||
/**
|
||||
* Call when a braking/turning/stopping event fires during an active trip — bumps the CAM
|
||||
@@ -89,8 +101,8 @@ class CamTransmitLoop @Inject constructor(
|
||||
if (job?.isActive == true) return
|
||||
elevatedUntilMs = 0L
|
||||
previousGnss = null
|
||||
vamRules.reset()
|
||||
job = scope.launch {
|
||||
stationId = obuHardwarePrefs.getOrCreateOwnStationId()
|
||||
obuHardwarePrefs.obuHardwareFlow.collectLatest { hardware ->
|
||||
if (hardware != ObuHardware.ESP32_C5) return@collectLatest
|
||||
runTransmitLoop()
|
||||
@@ -107,16 +119,64 @@ class CamTransmitLoop @Inject constructor(
|
||||
private suspend fun runTransmitLoop() = coroutineScope {
|
||||
launch { sensorRepository.gnssFlow().collect { latestGnss = it } }
|
||||
launch { sensorRepository.gyroscopeFlow().collect { latestGyroZ = it.z } }
|
||||
launch { obuHardwarePrefs.signOutgoingFlow.collect { signOutgoing = it } }
|
||||
launch {
|
||||
obuHardwarePrefs.outgoingMessageFlow.collect {
|
||||
if (it != outgoing) vamRules.reset()
|
||||
outgoing = it
|
||||
}
|
||||
}
|
||||
|
||||
while (true) {
|
||||
val gnss = latestGnss
|
||||
if (gnss != null) {
|
||||
val cam = PhoneCamBuilder.build(gnss, latestGyroZ, stationId, longitudinalAccel(gnss))
|
||||
when (outgoing) {
|
||||
OutgoingMessage.CAM -> sendCam(gnss)
|
||||
OutgoingMessage.VAM -> sendVamIfDue(gnss)
|
||||
}
|
||||
}
|
||||
delay(if (outgoing == OutgoingMessage.VAM) VAM_TICK_MS else (1000.0 / currentRateHz(gnss)).toLong())
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun sendCam(gnss: GnssReading) {
|
||||
// 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)
|
||||
}
|
||||
delay((1000.0 / currentRateHz(gnss)).toLong())
|
||||
esp32Link.send(OutgoingIts(OutgoingMessage.CAM, bytes,
|
||||
GnPositionVector.fromCam(cam, gnss.accuracyM, pseudonym.mac), gnss.accuracyM, signOutgoing))
|
||||
}
|
||||
|
||||
private suspend fun sendVamIfDue(gnss: GnssReading) {
|
||||
val now = System.currentTimeMillis()
|
||||
val kinematics = VamGenerationRules.Kinematics(gnss.latitude, gnss.longitude,
|
||||
gnss.speedMs.toDouble(), gnss.bearingDeg.toDouble())
|
||||
if (!vamRules.due(now, kinematics)) return
|
||||
val pseudonym = pseudonymManager.current()
|
||||
val fix = gnss.copy(timestamp = GnssTimeSource.correct(gnss.timestamp))
|
||||
// The CAM view of this fix is built only for its position vector, so the GN header of a VAM
|
||||
// follows exactly the rules a CAM's does. It is not transmitted.
|
||||
val cam = PhoneCamBuilder.build(fix, latestGyroZ, pseudonym.stationId, longitudinalAccel(gnss))
|
||||
val withLowFrequency = vamRules.includeLowFrequency(now)
|
||||
val bytes = VamUperCodec.encode(VamContent(
|
||||
stationId = pseudonym.stationId,
|
||||
timestamp = cam.timestamp,
|
||||
latitude = cam.latitude,
|
||||
longitude = cam.longitude,
|
||||
accuracyM = gnss.accuracyM,
|
||||
speedMps = cam.speedMps,
|
||||
headingDeg = cam.headingDeg,
|
||||
accelerationMps2 = cam.accelerationMps2,
|
||||
includeLowFrequency = withLowFrequency,
|
||||
))
|
||||
val handedOver = esp32Link.send(OutgoingIts(OutgoingMessage.VAM, bytes,
|
||||
GnPositionVector.fromCam(cam, gnss.accuracyM, pseudonym.mac), gnss.accuracyM, signOutgoing))
|
||||
if (handedOver) vamRules.onSent(now, kinematics, withLowFrequency)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -157,6 +217,9 @@ class CamTransmitLoop @Inject constructor(
|
||||
companion object {
|
||||
private const val ELEVATED_HOLD_MS = 5_000L
|
||||
|
||||
/** How often VAM generation rules are checked: T_GenVamMin, TS 103 300-3 Table 16. */
|
||||
private const val VAM_TICK_MS = VamGenerationRules.T_GEN_VAM_MIN_MS
|
||||
|
||||
/** Below this gap, GNSS speed noise divided by a tiny dt produces absurd accelerations. */
|
||||
private const val MIN_ACCEL_DT_SEC = 0.2
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ import androidx.compose.material.icons.filled.GpsOff
|
||||
import androidx.compose.material.icons.filled.Sensors
|
||||
import androidx.compose.material.icons.filled.SensorsOff
|
||||
import androidx.compose.material.icons.filled.Usb
|
||||
import androidx.compose.material.icons.filled.BluetoothDisabled
|
||||
import androidx.compose.material.icons.filled.Bluetooth
|
||||
import androidx.compose.material.icons.filled.UsbOff
|
||||
import androidx.compose.material.icons.filled.Wifi
|
||||
import androidx.compose.material.icons.filled.WifiOff
|
||||
@@ -39,7 +41,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
|
||||
import com.hawhamburg.micr0bu.data.transport.UsbSerialState
|
||||
import com.hawhamburg.micr0bu.data.transport.Esp32LinkState
|
||||
import com.hawhamburg.micr0bu.viewmodel.SensorUiState
|
||||
|
||||
private val GreenActive = Color(0xFF4CAF50)
|
||||
@@ -53,7 +55,9 @@ fun StatusTopBar(
|
||||
state: SensorUiState,
|
||||
mqttConnectionState: MqttConnectionState,
|
||||
isEsp32: Boolean = false,
|
||||
usbSerialState: UsbSerialState = UsbSerialState.DISCONNECTED,
|
||||
esp32LinkState: Esp32LinkState = Esp32LinkState.DISCONNECTED,
|
||||
/** The ESP32-C5 is reached over BLE rather than its USB port (Settings). */
|
||||
esp32Bluetooth: Boolean = false,
|
||||
) {
|
||||
TopAppBar(
|
||||
title = {
|
||||
@@ -93,7 +97,7 @@ fun StatusTopBar(
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
|
||||
ObuStatusIcon(mqttConnectionState, isEsp32, usbSerialState)
|
||||
ObuStatusIcon(mqttConnectionState, isEsp32, esp32LinkState, esp32Bluetooth = esp32Bluetooth)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
@@ -106,20 +110,21 @@ fun StatusTopBar(
|
||||
* OBU link indicator. Which transport it reflects depends on the selected hardware: the CiT One
|
||||
* reaches the phone over MQTT (Wi-Fi / USB-C tethering), the ESP32-C5 over a USB-serial link with
|
||||
* no broker at all - so on that path [mqttConnectionState] is permanently DISCONNECTED and would
|
||||
* report the OBU as offline while CAMs were streaming in. Uses a USB glyph there rather than the
|
||||
* Wi-Fi one, since that is literally what the connection is.
|
||||
* report the OBU as offline while CAMs were streaming in. Uses a USB or Bluetooth glyph there
|
||||
* rather than the Wi-Fi one, since that is literally what the connection is.
|
||||
*/
|
||||
@Composable
|
||||
private fun ObuStatusIcon(
|
||||
mqttState: MqttConnectionState,
|
||||
isEsp32: Boolean,
|
||||
usbSerialState: UsbSerialState,
|
||||
esp32LinkState: Esp32LinkState,
|
||||
esp32Bluetooth: Boolean,
|
||||
) {
|
||||
val state = if (isEsp32) usbSerialState.asConnectionState() else mqttState
|
||||
val state = if (isEsp32) esp32LinkState.asConnectionState() else mqttState
|
||||
val linkUp = state == MqttConnectionState.CONNECTED || state == MqttConnectionState.CONNECTING
|
||||
val icon = when {
|
||||
isEsp32 && state == MqttConnectionState.CONNECTED -> Icons.Default.Usb
|
||||
isEsp32 && state == MqttConnectionState.CONNECTING -> Icons.Default.Usb
|
||||
isEsp32 -> Icons.Default.UsbOff
|
||||
isEsp32 && esp32Bluetooth -> if (linkUp) Icons.Default.Bluetooth else Icons.Default.BluetoothDisabled
|
||||
isEsp32 -> if (linkUp) Icons.Default.Usb else Icons.Default.UsbOff
|
||||
state == MqttConnectionState.CONNECTED ||
|
||||
state == MqttConnectionState.CONNECTING -> Icons.Default.Wifi
|
||||
else -> Icons.Default.WifiOff
|
||||
@@ -194,10 +199,10 @@ private fun RecordingPulse() {
|
||||
* Maps the ESP32-C5 serial link's lifecycle onto the MQTT connection vocabulary this bar's colour
|
||||
* and pulse logic already speaks, so one indicator serves both transports.
|
||||
*/
|
||||
private fun UsbSerialState.asConnectionState(): MqttConnectionState = when (this) {
|
||||
UsbSerialState.CONNECTED -> MqttConnectionState.CONNECTED
|
||||
UsbSerialState.DEVICE_ATTACHED,
|
||||
UsbSerialState.PERMISSION_REQUESTED -> MqttConnectionState.CONNECTING
|
||||
UsbSerialState.ERROR -> MqttConnectionState.ERROR
|
||||
UsbSerialState.DISCONNECTED -> MqttConnectionState.DISCONNECTED
|
||||
private fun Esp32LinkState.asConnectionState(): MqttConnectionState = when (this) {
|
||||
Esp32LinkState.CONNECTED -> MqttConnectionState.CONNECTED
|
||||
Esp32LinkState.DEVICE_ATTACHED,
|
||||
Esp32LinkState.PERMISSION_REQUESTED -> MqttConnectionState.CONNECTING
|
||||
Esp32LinkState.ERROR -> MqttConnectionState.ERROR
|
||||
Esp32LinkState.DISCONNECTED -> MqttConnectionState.DISCONNECTED
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ sealed class Screen(val route: String, val labelRes: Int) {
|
||||
data object Connection : Screen("connection", R.string.nav_connection)
|
||||
data object Map : Screen("map", R.string.map_title)
|
||||
data object MqttViewer : Screen("mqtt_viewer", R.string.nav_v2x)
|
||||
/** Full-screen V2X live map, opened from the V2X Monitor's map button. */
|
||||
data object V2xMap : Screen("v2x_map", R.string.v2x_map_title)
|
||||
|
||||
// Phase A — Trip Recording
|
||||
data object TripHistory : Screen("trip_history", R.string.nav_trips)
|
||||
@@ -76,6 +78,14 @@ 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
|
||||
Screen.MqttViewer -> route == Screen.V2xMap.route
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
@@ -87,23 +97,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
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -44,7 +44,9 @@ import com.hawhamburg.micr0bu.R
|
||||
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.data.transport.Esp32LinkState
|
||||
import com.hawhamburg.micr0bu.data.transport.Esp32Transport
|
||||
import androidx.compose.material.icons.filled.Bluetooth
|
||||
import com.hawhamburg.micr0bu.viewmodel.MqttViewModel
|
||||
|
||||
private val UsbGreen = Color(0xFF4CAF50)
|
||||
@@ -65,7 +67,11 @@ fun ConnectionSetupScreen(
|
||||
val activeTransport by viewModel.activeTransport.collectAsState()
|
||||
val mqttPrefs by viewModel.mqttPrefs.collectAsState()
|
||||
val obuHardware by viewModel.obuHardware.collectAsState()
|
||||
val usbSerialState by viewModel.usbSerialState.collectAsState()
|
||||
val esp32LinkState by viewModel.esp32LinkState.collectAsState()
|
||||
val esp32Transport by viewModel.esp32Transport.collectAsState()
|
||||
val esp32Detail by viewModel.esp32Detail.collectAsState()
|
||||
val stationStatus by viewModel.stationStatus.collectAsState()
|
||||
val signOutgoing by viewModel.signOutgoing.collectAsState()
|
||||
|
||||
val isConnected = connectionState == MqttConnectionState.CONNECTED
|
||||
val isConnecting = connectionState == MqttConnectionState.CONNECTING
|
||||
@@ -229,22 +235,22 @@ fun ConnectionSetupScreen(
|
||||
}
|
||||
} else {
|
||||
// ── ESP32-C5 real connection card (Phase 03) ────────────────────────
|
||||
// Backed by UsbSerialTransport (native USB Serial/JTAG CDC-ACM link) - see that
|
||||
// class's KDoc for the VID/PID (0x303A/0x1001) and native-vs-UART-bridge port note.
|
||||
val isEspConnected = usbSerialState == UsbSerialState.CONNECTED
|
||||
val isEspBusy = usbSerialState == UsbSerialState.DEVICE_ATTACHED ||
|
||||
usbSerialState == UsbSerialState.PERMISSION_REQUESTED
|
||||
// Backed by Esp32Link: USB (UsbSerialTransport, native USB Serial/JTAG CDC-ACM - see
|
||||
// its KDoc for the VID/PID and native-vs-UART-bridge port note) or BLE (BleLinkTransport).
|
||||
val isEspConnected = esp32LinkState == Esp32LinkState.CONNECTED
|
||||
val isEspBusy = esp32LinkState == Esp32LinkState.DEVICE_ATTACHED ||
|
||||
esp32LinkState == Esp32LinkState.PERMISSION_REQUESTED
|
||||
val espContainerColor = when {
|
||||
isEspConnected -> UsbGreenBg
|
||||
isEspBusy -> UsbAmberBg
|
||||
else -> UsbGrayBg
|
||||
}
|
||||
val (espColor, espStateLabel) = when (usbSerialState) {
|
||||
UsbSerialState.CONNECTED -> UsbGreen to stringResource(R.string.conn_esp32_state_connected)
|
||||
UsbSerialState.DEVICE_ATTACHED -> UsbAmber to stringResource(R.string.conn_esp32_state_device_attached)
|
||||
UsbSerialState.PERMISSION_REQUESTED -> UsbAmber to stringResource(R.string.conn_esp32_state_permission_requested)
|
||||
UsbSerialState.ERROR -> Color(0xFFFF5252) to stringResource(R.string.conn_esp32_state_error)
|
||||
UsbSerialState.DISCONNECTED -> UsbGray to stringResource(R.string.conn_esp32_state_disconnected)
|
||||
val (espColor, espStateLabel) = when (esp32LinkState) {
|
||||
Esp32LinkState.CONNECTED -> UsbGreen to stringResource(R.string.conn_esp32_state_connected)
|
||||
Esp32LinkState.DEVICE_ATTACHED -> UsbAmber to stringResource(R.string.conn_esp32_state_device_attached)
|
||||
Esp32LinkState.PERMISSION_REQUESTED -> UsbAmber to stringResource(R.string.conn_esp32_state_permission_requested)
|
||||
Esp32LinkState.ERROR -> Color(0xFFFF5252) to stringResource(R.string.conn_esp32_state_error)
|
||||
Esp32LinkState.DISCONNECTED -> UsbGray to stringResource(R.string.conn_esp32_state_disconnected)
|
||||
}
|
||||
|
||||
Card(
|
||||
@@ -257,7 +263,7 @@ fun ConnectionSetupScreen(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Usb,
|
||||
if (esp32Transport == Esp32Transport.BLE) Icons.Default.Bluetooth else Icons.Default.Usb,
|
||||
contentDescription = null,
|
||||
tint = espColor,
|
||||
modifier = Modifier.size(20.dp),
|
||||
@@ -285,11 +291,36 @@ fun ConnectionSetupScreen(
|
||||
Text(espStateLabel, style = MaterialTheme.typography.bodySmall, color = espColor)
|
||||
}
|
||||
|
||||
Text(
|
||||
stringResource(
|
||||
if (esp32Transport == Esp32Transport.BLE) R.string.conn_esp32_via_ble else R.string.conn_esp32_via_usb
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
// Pairing passkey, provisioning progress, or why the micrOBU refused something.
|
||||
esp32Detail?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodySmall, color = UsbAmber)
|
||||
}
|
||||
if (isEspConnected) {
|
||||
stationStatus?.let { s ->
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.conn_esp32_signing,
|
||||
if (signOutgoing) stringResource(R.string.conn_esp32_signing_on) else stringResource(R.string.conn_esp32_signing_off),
|
||||
s.tickets, s.signedMessages, s.signRefused, s.radioSubmitted,
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
if (isEspConnected) {
|
||||
if (isEspConnected || isEspBusy) {
|
||||
OutlinedButton(
|
||||
onClick = { viewModel.disconnectUsbSerial() },
|
||||
onClick = { viewModel.disconnectEsp32() },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = Color(0xFFFF5252),
|
||||
@@ -297,13 +328,13 @@ fun ConnectionSetupScreen(
|
||||
) {
|
||||
Icon(Icons.Default.LinkOff, null, modifier = Modifier.size(16.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(stringResource(R.string.conn_disconnect))
|
||||
// While connecting (BLE retries until it succeeds) this cancels the attempt.
|
||||
Text(stringResource(if (isEspConnected) R.string.conn_disconnect else R.string.conn_esp32_cancel))
|
||||
}
|
||||
} else {
|
||||
Button(
|
||||
onClick = { viewModel.connectUsbSerial() },
|
||||
onClick = { viewModel.connectEsp32() },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !isEspBusy,
|
||||
) {
|
||||
Icon(Icons.Default.Link, null, modifier = Modifier.size(16.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
|
||||
@@ -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
|
||||
@@ -52,8 +60,15 @@ import com.hawhamburg.micr0bu.R
|
||||
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.data.transport.Esp32LinkState
|
||||
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)
|
||||
@@ -63,14 +78,20 @@ fun DashboardScreen(
|
||||
mqttConnectionState: MqttConnectionState,
|
||||
activeTransport: TransportType = TransportType.USB_C,
|
||||
obuHardware: ObuHardware = ObuHardware.CIT_ONE,
|
||||
usbSerialState: UsbSerialState = UsbSerialState.DISCONNECTED,
|
||||
esp32LinkState: Esp32LinkState = Esp32LinkState.DISCONNECTED,
|
||||
/** The ESP32-C5 is reached over BLE rather than its USB port (Settings). */
|
||||
esp32Bluetooth: Boolean = false,
|
||||
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
|
||||
@@ -262,14 +283,14 @@ fun DashboardScreen(
|
||||
// even once the serial link is actually up.
|
||||
val isEsp32 = obuHardware == ObuHardware.ESP32_C5
|
||||
val mqttConnected = mqttConnectionState == MqttConnectionState.CONNECTED
|
||||
val obuConnected = if (isEsp32) usbSerialState == UsbSerialState.CONNECTED else mqttConnected
|
||||
val transportIcon = when (activeTransport) {
|
||||
val obuConnected = if (isEsp32) esp32LinkState == Esp32LinkState.CONNECTED else mqttConnected
|
||||
val transportIcon = if (isEsp32 && esp32Bluetooth) Icons.Default.Bluetooth else when (activeTransport) {
|
||||
TransportType.USB_C -> Icons.Default.Usb
|
||||
TransportType.USB_SERIAL -> Icons.Default.Usb
|
||||
TransportType.WIFI -> Icons.Default.Wifi
|
||||
TransportType.BLUETOOTH -> Icons.Default.Bluetooth
|
||||
}
|
||||
val transportInactiveIcon = when (activeTransport) {
|
||||
val transportInactiveIcon = if (isEsp32 && esp32Bluetooth) Icons.Default.BluetoothDisabled else when (activeTransport) {
|
||||
TransportType.USB_C -> Icons.Default.Usb
|
||||
TransportType.USB_SERIAL -> Icons.Default.Usb
|
||||
TransportType.WIFI -> Icons.Default.WifiOff
|
||||
@@ -305,12 +326,12 @@ fun DashboardScreen(
|
||||
)
|
||||
Text(
|
||||
text = if (isEsp32) {
|
||||
when (usbSerialState) {
|
||||
UsbSerialState.CONNECTED -> stringResource(R.string.conn_esp32_state_connected)
|
||||
UsbSerialState.DEVICE_ATTACHED -> stringResource(R.string.conn_esp32_state_device_attached)
|
||||
UsbSerialState.PERMISSION_REQUESTED -> stringResource(R.string.conn_esp32_state_permission_requested)
|
||||
UsbSerialState.ERROR -> stringResource(R.string.conn_esp32_state_error)
|
||||
UsbSerialState.DISCONNECTED -> stringResource(R.string.dash_tap_to_connect)
|
||||
when (esp32LinkState) {
|
||||
Esp32LinkState.CONNECTED -> stringResource(R.string.conn_esp32_state_connected)
|
||||
Esp32LinkState.DEVICE_ATTACHED -> stringResource(R.string.conn_esp32_state_device_attached)
|
||||
Esp32LinkState.PERMISSION_REQUESTED -> stringResource(R.string.conn_esp32_state_permission_requested)
|
||||
Esp32LinkState.ERROR -> stringResource(R.string.conn_esp32_state_error)
|
||||
Esp32LinkState.DISCONNECTED -> stringResource(R.string.dash_tap_to_connect)
|
||||
}
|
||||
} else when (mqttConnectionState) {
|
||||
MqttConnectionState.CONNECTED -> stringResource(R.string.dash_mqtt_connected)
|
||||
@@ -343,20 +364,67 @@ fun DashboardScreen(
|
||||
TransportChip(
|
||||
label = stringResource(R.string.dash_transport_usb_serial),
|
||||
icon = Icons.Default.Usb,
|
||||
active = activeTransport == TransportType.USB_SERIAL,
|
||||
active = !esp32Bluetooth,
|
||||
hasCable = usbCableConnected,
|
||||
)
|
||||
}
|
||||
TransportChip(
|
||||
label = stringResource(R.string.dash_transport_bt),
|
||||
icon = Icons.Default.Bluetooth,
|
||||
active = activeTransport == TransportType.BLUETOOTH,
|
||||
dimmed = true, // Phase 03 — production BT transport still under discussion
|
||||
active = isEsp32 && esp32Bluetooth,
|
||||
// Only the ESP32-C5 has a BLE link; the CiT One has none.
|
||||
dimmed = !isEsp32,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 +553,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)
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.Circle
|
||||
import androidx.compose.material.icons.filled.Link
|
||||
import androidx.compose.material.icons.filled.LinkOff
|
||||
import androidx.compose.material.icons.filled.Map
|
||||
import androidx.compose.material.icons.filled.NotificationsActive
|
||||
import androidx.compose.material.icons.filled.VerticalAlignBottom
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
@@ -69,7 +70,7 @@ import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
|
||||
import com.hawhamburg.micr0bu.data.mqtt.MqttMessage
|
||||
import com.hawhamburg.micr0bu.data.transport.EspLinkStatus
|
||||
import com.hawhamburg.micr0bu.data.transport.ObuHardware
|
||||
import com.hawhamburg.micr0bu.data.transport.UsbSerialState
|
||||
import com.hawhamburg.micr0bu.data.transport.Esp32LinkState
|
||||
import com.hawhamburg.micr0bu.domain.cam.CamParser
|
||||
import com.hawhamburg.micr0bu.domain.cam.StationType
|
||||
import com.hawhamburg.micr0bu.domain.denm.DenmParser
|
||||
@@ -87,11 +88,15 @@ import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* View toggle for [TopicListPane]: decoded CAM/DENM traffic (LIST), the raw MQTT topic list
|
||||
* (TOPICS, CiT One only - there is no broker on the ESP32-C5 path), or the V2X live map
|
||||
* (MAP, Section 13).
|
||||
* View toggle for [TopicListPane]: decoded CAM/DENM traffic (LIST) or the raw MQTT topic list
|
||||
* (TOPICS, CiT One only - there is no broker on the ESP32-C5 path).
|
||||
*
|
||||
* The live map used to be a third mode here. It is now its own full-screen destination
|
||||
* ([V2xMapScreen]), reached from the map button in this screen's header: sharing the screen with
|
||||
* the alert panel and the TX cards left the map about a third of a phone display tall, which is
|
||||
* not enough to see where anything is relative to anything else.
|
||||
*/
|
||||
private enum class TopicViewMode { LIST, TOPICS, MAP }
|
||||
private enum class TopicViewMode { LIST, TOPICS }
|
||||
|
||||
private val timeFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.US)
|
||||
|
||||
@@ -116,6 +121,7 @@ private val WarningRedBg = Color(0xFF3A0A0A)
|
||||
@Composable
|
||||
fun MqttTopicViewerScreen(
|
||||
viewModel: MqttViewModel = hiltViewModel(),
|
||||
onOpenMap: () -> Unit = {},
|
||||
) {
|
||||
val connectionState by viewModel.connectionState.collectAsState()
|
||||
val topicMessages by viewModel.topicMessages.collectAsState()
|
||||
@@ -130,10 +136,11 @@ fun MqttTopicViewerScreen(
|
||||
val ownCamPosition by viewModel.ownCamPosition.collectAsState()
|
||||
// Engine road users PLUS roadside units - the engine deliberately does not track RSUs.
|
||||
val remoteCamPositions by viewModel.stationsInRange.collectAsState()
|
||||
val usbSerialState by viewModel.usbSerialState.collectAsState()
|
||||
val esp32LinkState by viewModel.esp32LinkState.collectAsState()
|
||||
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()
|
||||
@@ -154,7 +161,7 @@ fun MqttTopicViewerScreen(
|
||||
// DISCONNECTED and using it here made the screen report "offline" while CAMs streamed in over
|
||||
// serial. Everything on this screen that means "is the OBU link up?" follows the serial link
|
||||
// instead when that hardware is selected.
|
||||
val effectiveState = if (isEsp32) usbSerialState.asConnectionState() else connectionState
|
||||
val effectiveState = if (isEsp32) esp32LinkState.asConnectionState() else connectionState
|
||||
val isConnected = effectiveState == MqttConnectionState.CONNECTED
|
||||
val isConnecting = effectiveState == MqttConnectionState.CONNECTING
|
||||
|
||||
@@ -192,14 +199,25 @@ fun MqttTopicViewerScreen(
|
||||
Spacer(Modifier.weight(1f))
|
||||
}
|
||||
|
||||
// Full-screen live map. In the header rather than in the view-mode row below, so it
|
||||
// is reachable from the message detail pane too and does not move around as the
|
||||
// available view modes change with the selected hardware.
|
||||
IconButton(onClick = onOpenMap) {
|
||||
Icon(
|
||||
Icons.Default.Map,
|
||||
contentDescription = stringResource(R.string.v2x_map_title),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
|
||||
ConnectionChip(effectiveState)
|
||||
Spacer(Modifier.width(2.dp))
|
||||
IconButton(
|
||||
onClick = {
|
||||
// Route to whichever transport this hardware actually uses.
|
||||
if (isEsp32) {
|
||||
if (isConnected || isConnecting) viewModel.disconnectUsbSerial()
|
||||
else viewModel.connectUsbSerial()
|
||||
if (isConnected || isConnecting) viewModel.disconnectEsp32()
|
||||
else viewModel.connectEsp32()
|
||||
} else {
|
||||
if (isConnected || isConnecting) viewModel.disconnect() else viewModel.connect()
|
||||
}
|
||||
@@ -235,10 +253,11 @@ fun MqttTopicViewerScreen(
|
||||
isEsp32 = isEsp32,
|
||||
denmEvents = denmEvents,
|
||||
spatIntersections = spatIntersections,
|
||||
usbSerialState = usbSerialState,
|
||||
esp32LinkState = esp32LinkState,
|
||||
camPingerActive = camPingerActive,
|
||||
camPingerSentCount = camPingerSentCount,
|
||||
camPingerHasFix = camPingerHasFix,
|
||||
ownTxLoopback = ownTxLoopback,
|
||||
camSendFailures = camSendFailures,
|
||||
espLinkStatus = espLinkStatus,
|
||||
ownCamPosition = ownCamPosition,
|
||||
@@ -275,10 +294,11 @@ private fun TopicListPane(
|
||||
isEsp32: Boolean = false,
|
||||
denmEvents: List<com.hawhamburg.micr0bu.domain.denm.DenmEvent> = emptyList(),
|
||||
spatIntersections: List<com.hawhamburg.micr0bu.domain.spat.SpatIntersection> = emptyList(),
|
||||
usbSerialState: UsbSerialState = UsbSerialState.DISCONNECTED,
|
||||
esp32LinkState: Esp32LinkState = Esp32LinkState.DISCONNECTED,
|
||||
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,
|
||||
@@ -318,10 +338,11 @@ private fun TopicListPane(
|
||||
// ── CAM Pinger card — ESP32-C5-only manual bench test, mirrors the DENM card above ──
|
||||
if (showCamPinger) {
|
||||
CamPingerCard(
|
||||
usbConnected = usbSerialState == UsbSerialState.CONNECTED,
|
||||
usbConnected = esp32LinkState == Esp32LinkState.CONNECTED,
|
||||
pingerActive = camPingerActive,
|
||||
sentCount = camPingerSentCount,
|
||||
hasFix = camPingerHasFix,
|
||||
loopback = ownTxLoopback,
|
||||
sendFailures = camSendFailures,
|
||||
linkStatus = espLinkStatus,
|
||||
onStart = onStartCamPinger,
|
||||
@@ -331,12 +352,15 @@ private fun TopicListPane(
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f))
|
||||
}
|
||||
|
||||
// ── List / Topics / Map toggle ────────────────────────────────────────────────────
|
||||
// ── List / Topics toggle ──────────────────────────────────────────────
|
||||
// Decoded traffic is the default on BOTH hardware paths: what a tester wants to see is
|
||||
// the road users and hazards, not the transport that carried them. The raw MQTT topic
|
||||
// list stays one tap away on the CiT One path (Section 13 asks for the map "in addition
|
||||
// to", not instead of, the topic list). It is hidden on the ESP32-C5 path, where there is
|
||||
// no broker and `topics` is permanently empty.
|
||||
// to", not instead of, the topic list).
|
||||
//
|
||||
// The whole row is hidden on the ESP32-C5 path: there is no broker there, `topics` is
|
||||
// permanently empty, and a toggle offering a single choice is just noise.
|
||||
if (!isEsp32) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
@@ -346,34 +370,20 @@ private fun TopicListPane(
|
||||
selected = viewMode == TopicViewMode.LIST,
|
||||
) { viewMode = TopicViewMode.LIST }
|
||||
|
||||
if (!isEsp32) {
|
||||
ViewModeButton(
|
||||
label = stringResource(R.string.mqtt_view_topics),
|
||||
selected = viewMode == TopicViewMode.TOPICS,
|
||||
) { viewMode = TopicViewMode.TOPICS }
|
||||
}
|
||||
|
||||
ViewModeButton(
|
||||
label = stringResource(R.string.mqtt_view_map),
|
||||
selected = viewMode == TopicViewMode.MAP,
|
||||
) { viewMode = TopicViewMode.MAP }
|
||||
}
|
||||
|
||||
// ── Decoded traffic / raw topics / live map ───────────────────────────
|
||||
// ── Decoded traffic / raw topics ──────────────────────────────────────
|
||||
// TOPICS can still be the saved selection from a CiT One session after switching hardware
|
||||
// to the ESP32-C5, where that button no longer exists - fall back to the decoded list
|
||||
// rather than stranding the user on a pane they can't navigate away from.
|
||||
val shownMode = if (viewMode == TopicViewMode.TOPICS && isEsp32) TopicViewMode.LIST else viewMode
|
||||
|
||||
if (shownMode == TopicViewMode.MAP) {
|
||||
V2xLiveMapView(
|
||||
own = ownCamPosition,
|
||||
remotes = remoteCamPositions,
|
||||
alerts = useCaseAlerts,
|
||||
denms = denmEvents,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
} else if (shownMode == TopicViewMode.LIST) {
|
||||
if (shownMode == TopicViewMode.LIST) {
|
||||
ReceivedCamPane(
|
||||
own = ownCamPosition,
|
||||
remotes = remoteCamPositions,
|
||||
@@ -1108,6 +1118,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 +1187,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(
|
||||
@@ -1192,11 +1219,12 @@ private fun CamPingerCard(
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.mqtt_cam_pinger_fw_counters,
|
||||
s.txFailures, s.oversizeDrops, s.rxCrcErrors,
|
||||
s.txFailures, s.oversizeDrops, s.rxCrcErrors, s.rxQueueDrops,
|
||||
),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = if (s.txFailures > 0 || s.oversizeDrops > 0 || s.rxCrcErrors > 0)
|
||||
ErrorRed else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
color = if (s.txFailures > 0 || s.oversizeDrops > 0 || s.rxCrcErrors > 0 ||
|
||||
s.rxQueueDrops > 0
|
||||
) ErrorRed else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
}
|
||||
@@ -1445,10 +1473,10 @@ private fun prettyPrintJson(raw: String): String {
|
||||
* connection UI on this screen already speaks, so one indicator can serve both transports rather
|
||||
* than duplicating the chip and its colours per hardware type.
|
||||
*/
|
||||
private fun UsbSerialState.asConnectionState(): MqttConnectionState = when (this) {
|
||||
UsbSerialState.CONNECTED -> MqttConnectionState.CONNECTED
|
||||
UsbSerialState.DEVICE_ATTACHED,
|
||||
UsbSerialState.PERMISSION_REQUESTED -> MqttConnectionState.CONNECTING
|
||||
UsbSerialState.ERROR -> MqttConnectionState.ERROR
|
||||
UsbSerialState.DISCONNECTED -> MqttConnectionState.DISCONNECTED
|
||||
private fun Esp32LinkState.asConnectionState(): MqttConnectionState = when (this) {
|
||||
Esp32LinkState.CONNECTED -> MqttConnectionState.CONNECTED
|
||||
Esp32LinkState.DEVICE_ATTACHED,
|
||||
Esp32LinkState.PERMISSION_REQUESTED -> MqttConnectionState.CONNECTING
|
||||
Esp32LinkState.ERROR -> MqttConnectionState.ERROR
|
||||
Esp32LinkState.DISCONNECTED -> MqttConnectionState.DISCONNECTED
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -47,7 +47,9 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import com.hawhamburg.micr0bu.R
|
||||
import com.hawhamburg.micr0bu.data.mqtt.MqttPrefs
|
||||
import com.hawhamburg.micr0bu.data.transport.Esp32Transport
|
||||
import com.hawhamburg.micr0bu.data.transport.ObuHardware
|
||||
import com.hawhamburg.micr0bu.data.transport.OutgoingMessage
|
||||
import com.hawhamburg.micr0bu.domain.usecase.UseCaseDetectionConfig
|
||||
import com.hawhamburg.micr0bu.domain.usecase.UseCaseType
|
||||
import com.hawhamburg.micr0bu.viewmodel.SensorUiState
|
||||
@@ -165,13 +167,19 @@ fun ConnectionSettingsScreen(
|
||||
onMqttPrefsChange: (MqttPrefs) -> Unit,
|
||||
obuHardware: ObuHardware = ObuHardware.CIT_ONE,
|
||||
onObuHardwareChange: (ObuHardware) -> Unit = {},
|
||||
esp32Transport: Esp32Transport = Esp32Transport.USB,
|
||||
onEsp32TransportChange: (Esp32Transport) -> Unit = {},
|
||||
outgoingMessage: OutgoingMessage = OutgoingMessage.CAM,
|
||||
onOutgoingMessageChange: (OutgoingMessage) -> Unit = {},
|
||||
signOutgoing: Boolean = true,
|
||||
onSignOutgoingChange: (Boolean) -> Unit = {},
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
SubScreen(stringResource(R.string.settings_connection), onBack) {
|
||||
SectionCard {
|
||||
// OBU Hardware selector — CiT One / ESP32-C5 (Phase 03, Section 13). Everything
|
||||
// below (transport, USB-C options) only really applies to CiT One; ESP32-C5 uses
|
||||
// USB Serial exclusively and has no transport choice to make here.
|
||||
// OBU Hardware selector — CiT One / ESP32-C5 (Phase 03, Section 13). The transport
|
||||
// cards below apply to the CiT One; the ESP32-C5 has its own card (USB-C or BLE,
|
||||
// CAM or VAM, signing).
|
||||
Text(
|
||||
stringResource(R.string.settings_obu_hardware),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
@@ -212,6 +220,48 @@ fun ConnectionSettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
if (obuHardware == ObuHardware.ESP32_C5) {
|
||||
SectionCard {
|
||||
TwoWayChoice(
|
||||
label = stringResource(R.string.settings_esp32_link),
|
||||
first = stringResource(R.string.settings_transport_usbc),
|
||||
second = stringResource(R.string.settings_esp32_link_ble),
|
||||
firstSelected = esp32Transport == Esp32Transport.USB,
|
||||
onFirst = { onEsp32TransportChange(Esp32Transport.USB) },
|
||||
onSecond = { onEsp32TransportChange(Esp32Transport.BLE) },
|
||||
)
|
||||
if (esp32Transport == Esp32Transport.BLE) {
|
||||
Text(
|
||||
stringResource(R.string.settings_esp32_link_ble_note),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
Divider()
|
||||
TwoWayChoice(
|
||||
label = stringResource(R.string.settings_esp32_message),
|
||||
first = stringResource(R.string.settings_esp32_message_cam),
|
||||
second = stringResource(R.string.settings_esp32_message_vam),
|
||||
firstSelected = outgoingMessage == OutgoingMessage.CAM,
|
||||
onFirst = { onOutgoingMessageChange(OutgoingMessage.CAM) },
|
||||
onSecond = { onOutgoingMessageChange(OutgoingMessage.VAM) },
|
||||
)
|
||||
Divider()
|
||||
SettingToggleRow(
|
||||
label = stringResource(R.string.settings_esp32_sign),
|
||||
checked = signOutgoing,
|
||||
onCheckedChange = onSignOutgoingChange,
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.settings_esp32_sign_note),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (obuHardware == ObuHardware.CIT_ONE) {
|
||||
SectionCard {
|
||||
// Active transport selector
|
||||
@@ -611,6 +661,40 @@ private fun LanguageSection() {
|
||||
}
|
||||
}
|
||||
|
||||
/** A labelled pair of outlined buttons, the selected one filled — the style of the OBU hardware picker. */
|
||||
@Composable
|
||||
private fun TwoWayChoice(
|
||||
label: String,
|
||||
first: String,
|
||||
second: String,
|
||||
firstSelected: Boolean,
|
||||
onFirst: () -> Unit,
|
||||
onSecond: () -> Unit,
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
for ((text, selected, onClick) in listOf(Triple(first, firstSelected, onFirst), Triple(second, !firstSelected, onSecond))) {
|
||||
OutlinedButton(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.weight(1f),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
containerColor = if (selected) MaterialTheme.colorScheme.primaryContainer else Color.Transparent,
|
||||
contentColor = if (selected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
) { Text(text, fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RowDivider() = HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.4f))
|
||||
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
package com.hawhamburg.micr0bu.ui.screens
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.view.MotionEvent
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.GpsOff
|
||||
@@ -30,25 +31,37 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import com.hawhamburg.micr0bu.R
|
||||
import com.hawhamburg.micr0bu.domain.cam.Cam
|
||||
import com.hawhamburg.micr0bu.domain.denm.DenmEvent
|
||||
import com.hawhamburg.micr0bu.domain.spat.SpatIntersection
|
||||
import com.hawhamburg.micr0bu.domain.usecase.AlertLevel
|
||||
import com.hawhamburg.micr0bu.domain.usecase.UseCaseAlert
|
||||
import org.osmdroid.config.Configuration
|
||||
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
|
||||
import org.osmdroid.util.GeoPoint
|
||||
import org.osmdroid.views.CustomZoomButtonsController
|
||||
import org.osmdroid.views.MapView
|
||||
import org.osmdroid.views.overlay.Marker
|
||||
|
||||
/**
|
||||
* V2X Monitor live map view (Phase 03, Section 13) — plots the ego bike's own position plus
|
||||
* every currently-tracked remote road user's last-known CAM position, in addition to (not
|
||||
* replacing) the raw topic list already on this screen. Reuses the same osmdroid pattern as
|
||||
* [MapScreen]; unlike that screen, this one has no phone-GNSS-only fallback because [own] here
|
||||
* always reflects whichever ego source [com.hawhamburg.micr0bu.data.cam.CamUseCaseRepository]
|
||||
* currently trusts (obu_gnss / phone GNSS / CAM-topic-own — see that class's KDoc).
|
||||
* V2X Monitor live map (Phase 03, Section 13) — the map body behind [V2xMapScreen], plotting the
|
||||
* ego bike's own position, every currently-tracked remote road user's last-known CAM position,
|
||||
* every live hazard (DENM) and every signalised intersection heard over SPATEM.
|
||||
*
|
||||
* Remote markers are colored by that station's most severe active alert level, if any, so a
|
||||
* glance at the map shows not just "who's nearby" but "who's a warning right now" — the same
|
||||
* severity coloring already used by [UseCaseAlertPanel].
|
||||
* Marker vocabulary, one shape per message type so the map reads without a legend:
|
||||
* - CAM — teardrop pin, tinted by that station's most severe active alert level
|
||||
* - DENM — hazard warning triangle
|
||||
* - SPATEM — traffic light, with the lamp for the intersection's leading phase lit
|
||||
*
|
||||
* Unlike [MapScreen] this has no phone-GNSS-only fallback: [own] always reflects whichever ego
|
||||
* source [com.hawhamburg.micr0bu.data.cam.CamUseCaseRepository] currently trusts (obu_gnss /
|
||||
* phone GNSS / CAM-topic-own — see that class's KDoc).
|
||||
*
|
||||
* **Markers are reused across updates, not rebuilt.** CAMs arrive at up to 10 Hz per station, and
|
||||
* every arrival recomposes this view; the previous version cleared the overlay list and rebuilt
|
||||
* every Marker — decoding and mutating a fresh Drawable per marker per update — which is what
|
||||
* made panning stutter under live traffic. Drawables are now loaded once per level/phase and
|
||||
* shared (osmdroid sets the icon's bounds on each draw, so sharing one instance across markers is
|
||||
* safe), and Marker objects are cached by key. The overlay list is still reordered each update,
|
||||
* which costs nothing: it moves existing references, it does not allocate.
|
||||
*/
|
||||
@Composable
|
||||
fun V2xLiveMapView(
|
||||
@@ -56,6 +69,9 @@ fun V2xLiveMapView(
|
||||
remotes: Map<Long, Cam>,
|
||||
alerts: List<UseCaseAlert>,
|
||||
denms: List<DenmEvent> = emptyList(),
|
||||
spats: List<SpatIntersection> = emptyList(),
|
||||
followOwn: Boolean = true,
|
||||
onUserPanned: () -> Unit = {},
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
@@ -71,8 +87,17 @@ fun V2xLiveMapView(
|
||||
.mapValues { (_, a) -> a.maxByOrNull { it.alertLevel.ordinal }?.alertLevel }
|
||||
}
|
||||
|
||||
// Loaded once and shared by every marker that needs them. mutate() on the remote pin is still
|
||||
// essential: without it all four tinted copies would share one ConstantState and the last
|
||||
// tint applied would recolour every pin on the map.
|
||||
val icons = remember(context) { MapIcons(context) }
|
||||
val markers = remember { mutableMapOf<String, Marker>() }
|
||||
|
||||
val mapViewRef = remember { mutableStateOf<MapView?>(null) }
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
// Tracks whether the last update already recentred for this follow session, so re-enabling
|
||||
// follow animates once instead of fighting the rider's own panning on every frame.
|
||||
val wasFollowing = remember { mutableStateOf(false) }
|
||||
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
@@ -89,42 +114,58 @@ fun V2xLiveMapView(
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = modifier.fillMaxSize()) {
|
||||
Text(
|
||||
text = stringResource(R.string.v2x_map_remote_count, remotes.size),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
initOsmForV2xMap(ctx)
|
||||
MapView(ctx).apply {
|
||||
setTileSource(TileSourceFactory.MAPNIK)
|
||||
setMultiTouchControls(true)
|
||||
// Raster tiles are authored for ~160 dpi; without this they are upscaled by the
|
||||
// display density and labels come out soft on a modern phone.
|
||||
isTilesScaledToDpi = true
|
||||
// The floating +/- buttons sit exactly where the rider's thumb lands and
|
||||
// duplicate pinch-zoom. Pinch and double-tap still work.
|
||||
zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER)
|
||||
setMinZoomLevel(4.0)
|
||||
setMaxZoomLevel(20.0)
|
||||
controller.setZoom(17.0)
|
||||
controller.setCenter(ownGeoPoint)
|
||||
// Any touch means the rider is driving the map; follow-own hands over to them
|
||||
// until they ask for it back. false: the MapView's own gesture handling still
|
||||
// runs, this only observes.
|
||||
setOnTouchListener { _, event ->
|
||||
if (event.actionMasked == MotionEvent.ACTION_DOWN) onUserPanned()
|
||||
false
|
||||
}
|
||||
mapViewRef.value = this
|
||||
}
|
||||
},
|
||||
update = { mv ->
|
||||
mv.overlays.clear()
|
||||
val now = System.currentTimeMillis()
|
||||
|
||||
// Own position: a centred "you are here" dot, not a pin. Own position is a fact
|
||||
// about the viewer rather than one of the tracked objects, and when both used
|
||||
// osmdroid's identical default pin the two were indistinguishable at a glance.
|
||||
mv.overlays.add(
|
||||
Marker(mv).apply {
|
||||
// Intersections we can actually place: SPATEM carries signal state but no geometry
|
||||
// (that is MAPEM's job), so the only position available is the sending RSU's own CAM.
|
||||
val locatedSpats = spats.mapNotNull { spat ->
|
||||
remotes[spat.stationId]?.let { rsu -> spat to rsu }
|
||||
}
|
||||
// An RSU drawn as a traffic light must not also be drawn as a CAM pin underneath it:
|
||||
// two markers on one point, the lower one unreachable.
|
||||
val spatStationIds = locatedSpats.map { (spat, _) -> spat.stationId }.toSet()
|
||||
|
||||
val live = mutableSetOf<String>()
|
||||
|
||||
// Own position: a centred "you are here" dot, not a pin. Own position is a fact about
|
||||
// the viewer rather than one of the tracked objects, and when both used osmdroid's
|
||||
// identical default pin the two were indistinguishable at a glance.
|
||||
markers.marker(mv, KEY_OWN, live).apply {
|
||||
position = ownGeoPoint
|
||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
|
||||
icon = ContextCompat.getDrawable(context, R.drawable.ic_map_own)
|
||||
icon = icons.own
|
||||
title = context.getString(R.string.v2x_map_own_label)
|
||||
}
|
||||
)
|
||||
|
||||
remotes.forEach { (stationId, cam) ->
|
||||
if (stationId in spatStationIds) return@forEach
|
||||
val level = alertByStation[stationId]
|
||||
val label = when (level) {
|
||||
AlertLevel.WARNING -> context.getString(R.string.v2x_map_remote_warning, stationId)
|
||||
@@ -132,32 +173,23 @@ fun V2xLiveMapView(
|
||||
AlertLevel.INFO -> context.getString(R.string.v2x_map_remote_info, stationId)
|
||||
null -> context.getString(R.string.v2x_map_remote_plain, stationId)
|
||||
}
|
||||
// Teardrop pin anchored at its tip, tinted by severity. Now that these are
|
||||
// custom drawables, per-instance tinting is possible - severity no longer
|
||||
// depends on tapping the marker to read its label. mutate() is essential:
|
||||
// without it every marker shares one ConstantState and the last tint applied
|
||||
// would recolour all of them.
|
||||
val pin = ContextCompat.getDrawable(context, R.drawable.ic_map_remote_station)
|
||||
?.mutate()
|
||||
?.apply { setTint(level.toMarkerColor()) }
|
||||
mv.overlays.add(
|
||||
Marker(mv).apply {
|
||||
// Teardrop pin anchored at its tip, tinted by severity, so severity no longer
|
||||
// depends on tapping the marker to read its label.
|
||||
markers.marker(mv, "$KEY_CAM$stationId", live).apply {
|
||||
position = GeoPoint(cam.latitude, cam.longitude)
|
||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
||||
icon = pin
|
||||
icon = icons.remotePin(level)
|
||||
title = label
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// DENM hazard pins, added last so they draw on top of vehicle markers - a hazard
|
||||
// Hazards and signals are added after the vehicle pins, so they draw on top: a hazard
|
||||
// hidden behind a CAM pin defeats the point of showing it.
|
||||
denms.forEach { denm ->
|
||||
mv.overlays.add(
|
||||
Marker(mv).apply {
|
||||
markers.marker(mv, "$KEY_DENM${denm.dedupKey}", live).apply {
|
||||
position = GeoPoint(denm.latitude, denm.longitude)
|
||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
||||
icon = ContextCompat.getDrawable(context, R.drawable.ic_denm_warning)
|
||||
icon = icons.denm
|
||||
title = denm.causeCode?.let {
|
||||
context.getString(
|
||||
R.string.v2x_map_denm_labeled,
|
||||
@@ -167,10 +199,45 @@ fun V2xLiveMapView(
|
||||
)
|
||||
} ?: context.getString(R.string.v2x_map_denm_plain, denm.stationId)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
mv.controller.animateTo(ownGeoPoint)
|
||||
locatedSpats.forEach { (spat, rsu) ->
|
||||
val phase = spat.leadingPhase(now)
|
||||
markers.marker(mv, "$KEY_SPAT${spat.key}", live).apply {
|
||||
position = GeoPoint(rsu.latitude, rsu.longitude)
|
||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
||||
icon = icons.signal(phase)
|
||||
title = context.getString(
|
||||
R.string.v2x_spat_rx_title, spat.state.key, spat.stationId,
|
||||
)
|
||||
snippet = spat.state.movements.joinToString(" · ") { movement ->
|
||||
val seconds = movement.current?.secondsUntil(now)?.takeIf { it in 0.0..99.0 }
|
||||
context.getString(R.string.v2x_spat_group, movement.signalGroup) +
|
||||
(seconds?.let { " " + context.getString(R.string.v2x_spat_countdown, it) } ?: "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drop markers for stations, hazards and intersections that have expired, then rebuild
|
||||
// the overlay list in draw order from the cached Markers. Reordering moves references;
|
||||
// nothing here allocates a Marker or decodes a Drawable.
|
||||
markers.keys.retainAll { key ->
|
||||
(key in live).also { kept -> if (!kept) markers[key]?.closeInfoWindow() }
|
||||
}
|
||||
mv.overlays.clear()
|
||||
markers.entries
|
||||
.sortedBy { (key, _) -> key.drawOrder() }
|
||||
.forEach { (_, marker) -> mv.overlays.add(marker) }
|
||||
|
||||
// setCenter, not animateTo: an animation restarted on every CAM never finishes, which
|
||||
// is exactly the judder this used to show under live traffic. The one animated move is
|
||||
// the rider re-enabling follow, where the travel is worth seeing.
|
||||
if (followOwn) {
|
||||
if (wasFollowing.value) mv.controller.setCenter(ownGeoPoint)
|
||||
else mv.controller.animateTo(ownGeoPoint)
|
||||
}
|
||||
wasFollowing.value = followOwn
|
||||
|
||||
mv.invalidate()
|
||||
},
|
||||
// osmdroid's onDetach() permanently tears the MapView down: afterwards its
|
||||
@@ -186,9 +253,97 @@ fun V2xLiveMapView(
|
||||
//
|
||||
// onRelease is the callback that actually means "this View is gone": Compose
|
||||
// guarantees no further update after it.
|
||||
onRelease = { it.onDetach() },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
onRelease = {
|
||||
markers.clear()
|
||||
it.onDetach()
|
||||
},
|
||||
modifier = modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
|
||||
// ── Marker cache ──────────────────────────────────────────────────────────────
|
||||
|
||||
private const val KEY_OWN = "own"
|
||||
private const val KEY_CAM = "cam:"
|
||||
private const val KEY_DENM = "denm:"
|
||||
private const val KEY_SPAT = "spat:"
|
||||
|
||||
/** Draw order: own dot at the bottom, then vehicles, with hazards and signals on top. */
|
||||
private fun String.drawOrder(): Int = when {
|
||||
this == KEY_OWN -> 0
|
||||
startsWith(KEY_CAM) -> 1
|
||||
startsWith(KEY_DENM) -> 2
|
||||
else -> 3
|
||||
}
|
||||
|
||||
/**
|
||||
* The cached [Marker] for [key], created against [mv] on first use, recording the key in [live]
|
||||
* so the caller can drop whatever it did not ask for this update.
|
||||
*/
|
||||
private fun MutableMap<String, Marker>.marker(
|
||||
mv: MapView,
|
||||
key: String,
|
||||
live: MutableSet<String>,
|
||||
): Marker {
|
||||
live += key
|
||||
return getOrPut(key) { Marker(mv) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Marker artwork, loaded once per composition rather than per update.
|
||||
*
|
||||
* The remote pin is drawn white and tinted per severity here; [mutate] is what keeps the four
|
||||
* tinted copies independent, since without it they would share one ConstantState and the last
|
||||
* tint applied would recolour all of them.
|
||||
*/
|
||||
private class MapIcons(context: Context) {
|
||||
val own: Drawable? = ContextCompat.getDrawable(context, R.drawable.ic_map_own)
|
||||
val denm: Drawable? = ContextCompat.getDrawable(context, R.drawable.ic_denm_warning)
|
||||
|
||||
private val pins: Map<AlertLevel?, Drawable?> =
|
||||
(listOf(null) + AlertLevel.entries).associateWith { level ->
|
||||
ContextCompat.getDrawable(context, R.drawable.ic_map_remote_station)
|
||||
?.mutate()
|
||||
?.apply { setTint(level.toMarkerColor()) }
|
||||
}
|
||||
|
||||
private val signals: Map<SignalLamp, Drawable?> = SignalLamp.entries.associateWith { lamp ->
|
||||
ContextCompat.getDrawable(context, lamp.drawableRes)
|
||||
}
|
||||
|
||||
fun remotePin(level: AlertLevel?): Drawable? = pins[level]
|
||||
fun signal(lamp: SignalLamp): Drawable? = signals[lamp]
|
||||
}
|
||||
|
||||
// ── Signal phase → lamp ───────────────────────────────────────────────────────
|
||||
|
||||
/** Which lamp of the traffic-light marker is lit. */
|
||||
private enum class SignalLamp(val drawableRes: Int) {
|
||||
RED(R.drawable.ic_map_spat_red),
|
||||
AMBER(R.drawable.ic_map_spat_amber),
|
||||
GREEN(R.drawable.ic_map_spat_green),
|
||||
DARK(R.drawable.ic_map_spat_dark),
|
||||
}
|
||||
|
||||
/**
|
||||
* The lamp to light for this intersection.
|
||||
*
|
||||
* Without MAPEM there is no lane geometry, so there is no way to know which of an intersection's
|
||||
* signal groups applies to the rider's own approach. This follows the rule the Dashboard's
|
||||
* SignalCard already uses — the group changing soonest speaks for the intersection — so the same
|
||||
* intersection reads the same way in both places rather than inventing a second convention.
|
||||
*/
|
||||
private fun SpatIntersection.leadingPhase(nowMs: Long): SignalLamp {
|
||||
val leading = state.movements.minByOrNull { movement ->
|
||||
movement.current?.secondsUntil(nowMs)?.takeIf { it >= 0.0 } ?: Double.MAX_VALUE
|
||||
}
|
||||
val phase = leading?.current?.phase
|
||||
return when {
|
||||
phase == null -> SignalLamp.DARK
|
||||
phase.isGo -> SignalLamp.GREEN
|
||||
phase.isStop -> SignalLamp.RED
|
||||
phase.isTransition -> SignalLamp.AMBER
|
||||
else -> SignalLamp.DARK // UNAVAILABLE / DARK / caution
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,5 +383,10 @@ private fun initOsmForV2xMap(context: Context) {
|
||||
Configuration.getInstance().apply {
|
||||
load(context, context.getSharedPreferences("osmdroid", Context.MODE_PRIVATE))
|
||||
userAgentValue = context.packageName
|
||||
// Panning off the edge of the cache is what makes a raster map feel slow: the default
|
||||
// 600 MB cap is plenty, but the default 2 download threads are not when a pan exposes a
|
||||
// screenful of new tiles at once.
|
||||
tileDownloadThreads = 6.toShort()
|
||||
tileFileSystemThreads = 6.toShort()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
package com.hawhamburg.micr0bu.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.MyLocation
|
||||
import androidx.compose.material.icons.filled.Place
|
||||
import androidx.compose.material.icons.filled.Traffic
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hawhamburg.micr0bu.R
|
||||
import com.hawhamburg.micr0bu.viewmodel.MqttViewModel
|
||||
|
||||
/**
|
||||
* Full-screen V2X live map — the map and nothing else, reached from the map button on the V2X
|
||||
* Monitor screen.
|
||||
*
|
||||
* Identical on both hardware paths. Everything drawn here comes from
|
||||
* [com.hawhamburg.micr0bu.data.cam.CamUseCaseRepository], which already merges the CiT One's MQTT
|
||||
* feed and the ESP32-C5's serial feed into one set of flows, so this screen never has to know
|
||||
* which OBU is connected.
|
||||
*
|
||||
* The chrome is deliberately minimal and floats over the map rather than boxing it in: a back
|
||||
* button, a live count per message type, and a recentre button. The counts double as the map's
|
||||
* legend — each one carries the same icon family as the marker it counts.
|
||||
*/
|
||||
@Composable
|
||||
fun V2xMapScreen(
|
||||
viewModel: MqttViewModel,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val ownCamPosition by viewModel.ownCamPosition.collectAsState()
|
||||
// Road users from the detection engine PLUS roadside units, which it deliberately does not
|
||||
// track - an RSU is what carries the traffic lights below.
|
||||
val stations by viewModel.stationsInRange.collectAsState()
|
||||
val alerts by viewModel.useCaseAlerts.collectAsState()
|
||||
val denms by viewModel.denmEvents.collectAsState()
|
||||
val spats by viewModel.spatIntersections.collectAsState()
|
||||
|
||||
// Follow is on until the rider touches the map, and comes back when they ask for it. Without
|
||||
// the hand-over, every incoming CAM would drag the viewport back to the ego position and the
|
||||
// map could not be panned at all while traffic is flowing.
|
||||
var followOwn by remember { mutableStateOf(true) }
|
||||
|
||||
// SPATEM carries no geometry of its own, so an intersection can only be placed if its RSU has
|
||||
// also been heard over CAM. Saying so is better than silently dropping it: "the map shows two
|
||||
// of the three lights I can see in the list" is otherwise an unexplained discrepancy.
|
||||
val unlocatedSpats = spats.count { it.stationId !in stations.keys }
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
V2xLiveMapView(
|
||||
own = ownCamPosition,
|
||||
remotes = stations,
|
||||
alerts = alerts,
|
||||
denms = denms,
|
||||
spats = spats,
|
||||
followOwn = followOwn,
|
||||
onUserPanned = { followOwn = false },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
|
||||
// ── Floating header: back + live counts, which double as the legend ──
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
MapChrome {
|
||||
IconButton(onClick = onBack, modifier = Modifier.size(36.dp)) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.v2x_map_back),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.width(8.dp))
|
||||
|
||||
MapChrome {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
MapCount(
|
||||
icon = Icons.Default.Place,
|
||||
tint = CamPinBlue,
|
||||
count = stations.size,
|
||||
label = stringResource(R.string.v2x_map_legend_cam),
|
||||
)
|
||||
MapCount(
|
||||
icon = Icons.Default.Warning,
|
||||
tint = HazardAmber,
|
||||
count = denms.size,
|
||||
label = stringResource(R.string.v2x_map_legend_denm),
|
||||
)
|
||||
MapCount(
|
||||
icon = Icons.Default.Traffic,
|
||||
tint = SignalGreenDot,
|
||||
count = spats.size,
|
||||
label = stringResource(R.string.v2x_map_legend_spat),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (unlocatedSpats > 0) {
|
||||
MapChrome(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.v2x_map_spat_unlocated, unlocatedSpats),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Recentre: lit while following, so the button also reports which mode the map is in.
|
||||
FloatingActionButton(
|
||||
onClick = { followOwn = true },
|
||||
containerColor = if (followOwn) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(16.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.MyLocation,
|
||||
contentDescription = stringResource(R.string.v2x_map_follow),
|
||||
tint = if (followOwn) MaterialTheme.colorScheme.onPrimary
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A translucent pill for anything floating over the map, so chrome stays readable over tiles. */
|
||||
@Composable
|
||||
private fun MapChrome(
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.88f),
|
||||
tonalElevation = 3.dp,
|
||||
shadowElevation = 2.dp,
|
||||
modifier = modifier,
|
||||
) { content() }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MapCount(icon: ImageVector, tint: Color, count: Int, label: String) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(3.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(icon, contentDescription = label, tint = tint, modifier = Modifier.size(16.dp))
|
||||
Text(
|
||||
text = count.toString(),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Legend tints, matching the marker artwork rather than the theme: these name the drawables on
|
||||
// the map, so they must not shift with light/dark mode the way theme colours do.
|
||||
private val CamPinBlue = Color(0xFF78909C)
|
||||
private val HazardAmber = Color(0xFFFFC107)
|
||||
private val SignalGreenDot = Color(0xFF4CAF50)
|
||||
@@ -13,8 +13,11 @@ import com.hawhamburg.micr0bu.data.transport.EspLinkStatus
|
||||
import com.hawhamburg.micr0bu.data.transport.ObuHardware
|
||||
import com.hawhamburg.micr0bu.data.transport.TransportType
|
||||
import com.hawhamburg.micr0bu.data.transport.UsbNetworkDetector
|
||||
import com.hawhamburg.micr0bu.data.transport.UsbSerialState
|
||||
import com.hawhamburg.micr0bu.data.transport.UsbSerialTransport
|
||||
import com.hawhamburg.micr0bu.data.transport.Esp32LinkState
|
||||
import com.hawhamburg.micr0bu.data.transport.Esp32Link
|
||||
import com.hawhamburg.micr0bu.data.transport.Esp32Transport
|
||||
import com.hawhamburg.micr0bu.data.transport.OutgoingMessage
|
||||
import com.hawhamburg.micr0bu.data.transport.StationStatus
|
||||
import com.hawhamburg.micr0bu.domain.denm.DenmEvent
|
||||
import com.hawhamburg.micr0bu.domain.denm.DenmParser
|
||||
import com.hawhamburg.micr0bu.domain.spat.SpatIntersection
|
||||
@@ -45,7 +48,7 @@ class MqttViewModel @Inject constructor(
|
||||
private val usbDetector: UsbNetworkDetector,
|
||||
private val camUseCaseRepository: CamUseCaseRepository,
|
||||
private val obuHardwarePrefs: ObuHardwarePreferences,
|
||||
private val usbSerialTransport: UsbSerialTransport,
|
||||
private val esp32Link: Esp32Link,
|
||||
private val camPinger: CamPinger,
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -80,14 +83,42 @@ class MqttViewModel @Inject constructor(
|
||||
/** Auto-detected OBU gateway IP on the USB interface. */
|
||||
val detectedObuIp: StateFlow<String?> = usbDetector.detectedGatewayIp
|
||||
|
||||
/** ESP32-C5 USB-serial link state (Phase 03) — see [UsbSerialTransport]. */
|
||||
val usbSerialState: StateFlow<UsbSerialState> = usbSerialTransport.state
|
||||
/** ESP32-C5 link state, over USB or BLE per [esp32Transport] — see [Esp32Link]. */
|
||||
val esp32LinkState: StateFlow<Esp32LinkState> = esp32Link.state
|
||||
|
||||
/** Latest firmware heartbeat + drop counters, null until the first STATUS frame arrives. */
|
||||
val espLinkStatus: StateFlow<EspLinkStatus?> = usbSerialTransport.linkStatus
|
||||
val espLinkStatus: StateFlow<EspLinkStatus?> = esp32Link.linkStatus
|
||||
|
||||
/** Non-zero means CAMs are being built and dropped — see [UsbSerialTransport.sendCamTx]. */
|
||||
val camSendFailures: StateFlow<Int> = usbSerialTransport.consecutiveWriteFailures
|
||||
/** Non-zero means CAMs are being built and dropped — see [Esp32Link.send]. */
|
||||
val camSendFailures: StateFlow<Int> = esp32Link.consecutiveWriteFailures
|
||||
|
||||
/** Signing and radio counters of the current obu-firmware; null with the previous firmware. */
|
||||
val stationStatus: StateFlow<StationStatus?> = esp32Link.stationStatus
|
||||
|
||||
/** One line about the link session (pairing passkey, provisioning, refusals); null when quiet. */
|
||||
val esp32Detail: StateFlow<String?> = esp32Link.detail
|
||||
|
||||
// ── ESP32-C5 settings ─────────────────────────────────────────────────────
|
||||
|
||||
val esp32Transport: StateFlow<Esp32Transport> = esp32Link.transport
|
||||
|
||||
fun setEsp32Transport(transport: Esp32Transport) {
|
||||
viewModelScope.launch { obuHardwarePrefs.setEsp32Transport(transport) }
|
||||
}
|
||||
|
||||
val outgoingMessage: StateFlow<OutgoingMessage> = obuHardwarePrefs.outgoingMessageFlow
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, OutgoingMessage.CAM)
|
||||
|
||||
fun setOutgoingMessage(message: OutgoingMessage) {
|
||||
viewModelScope.launch { obuHardwarePrefs.setOutgoingMessage(message) }
|
||||
}
|
||||
|
||||
val signOutgoing: StateFlow<Boolean> = obuHardwarePrefs.signOutgoingFlow
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, true)
|
||||
|
||||
fun setSignOutgoing(sign: Boolean) {
|
||||
viewModelScope.launch { obuHardwarePrefs.setSignOutgoing(sign) }
|
||||
}
|
||||
|
||||
// ── ESP32-C5 CAM pinger (manual bench test, Phase 03) ─────────────────────
|
||||
// The ESP32-C5-path equivalent of the CiT One's manual DENM trigger below — a fixed-
|
||||
@@ -101,7 +132,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 +175,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 +203,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 +223,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 +231,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 +246,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)
|
||||
@@ -315,10 +383,10 @@ class MqttViewModel @Inject constructor(
|
||||
fun connect() = repo.connect()
|
||||
fun disconnect() = repo.disconnect()
|
||||
|
||||
/** Connect/disconnect the ESP32-C5 USB-serial link — separate from [connect]/[disconnect],
|
||||
* which drive the CiT One's MQTT-over-USB-C/Wi-Fi path. See [ConnectionSetupScreen]. */
|
||||
fun connectUsbSerial() = usbSerialTransport.connect()
|
||||
fun disconnectUsbSerial() = usbSerialTransport.disconnect()
|
||||
/** Connect/disconnect the ESP32-C5 link (USB or BLE per [esp32Transport]) — separate from
|
||||
* [connect]/[disconnect], which drive the CiT One's MQTT-over-USB-C/Wi-Fi path. */
|
||||
fun connectEsp32() = esp32Link.connect()
|
||||
fun disconnectEsp32() = esp32Link.disconnect()
|
||||
|
||||
fun selectTopic(topic: String?) { _selectedTopic.value = topic }
|
||||
fun setAutoScroll(enabled: Boolean) { _autoScroll.value = enabled }
|
||||
@@ -352,7 +420,7 @@ class MqttViewModel @Inject constructor(
|
||||
super.onCleared()
|
||||
repo.disconnect()
|
||||
camPinger.stop()
|
||||
// Deliberately NOT usbSerialTransport.disconnect(): the transport is an app-scoped
|
||||
// Deliberately NOT esp32Link.disconnect(): the link is an app-scoped
|
||||
// @Singleton also held by the foreground TripRecordingService (via CamTransmitLoop).
|
||||
// Closing it here would tear the port down when the Activity goes away — e.g. swiping
|
||||
// the app from Recents mid-recording — leaving the still-running service beaconing into
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<!--
|
||||
Live-map marker for a signalised intersection heard over SPATEM: a traffic light housing with
|
||||
the lamp for the intersection's leading phase lit and the other two dimmed.
|
||||
|
||||
One drawable per lit lamp rather than one drawable tinted at runtime: setTint recolours every
|
||||
path in a vector, so a single shared asset could not keep the unlit lamps dark while colouring
|
||||
the lit one - the whole light would turn one flat colour and stop reading as a traffic light.
|
||||
|
||||
Anchored at the bottom in V2xLiveMapView, so the housing sits above the intersection rather
|
||||
than covering it.
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="30dp"
|
||||
android:height="30dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
|
||||
<!-- White outline first, so the marker stays legible over dark map features. -->
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M4.4,0.8H19.6V23.2H4.4z" />
|
||||
|
||||
<!-- Housing. -->
|
||||
<path
|
||||
android:fillColor="#FF263238"
|
||||
android:pathData="M5.8,2.0H18.2V22.0H5.8z" />
|
||||
|
||||
<!-- Lamps, top to bottom: red, amber, green. -->
|
||||
<path
|
||||
android:fillColor="#FF5A2220"
|
||||
android:pathData="M12,6.6m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
|
||||
<path
|
||||
android:fillColor="#FFFFC107"
|
||||
android:pathData="M12,12.0m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
|
||||
<path
|
||||
android:fillColor="#FF1E4D2B"
|
||||
android:pathData="M12,17.4m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
|
||||
</vector>
|
||||
@@ -0,0 +1,38 @@
|
||||
<!--
|
||||
Live-map marker for a signalised intersection heard over SPATEM: a traffic light housing with
|
||||
the lamp for the intersection's leading phase lit and the other two dimmed.
|
||||
|
||||
One drawable per lit lamp rather than one drawable tinted at runtime: setTint recolours every
|
||||
path in a vector, so a single shared asset could not keep the unlit lamps dark while colouring
|
||||
the lit one - the whole light would turn one flat colour and stop reading as a traffic light.
|
||||
|
||||
Anchored at the bottom in V2xLiveMapView, so the housing sits above the intersection rather
|
||||
than covering it.
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="30dp"
|
||||
android:height="30dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
|
||||
<!-- White outline first, so the marker stays legible over dark map features. -->
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M4.4,0.8H19.6V23.2H4.4z" />
|
||||
|
||||
<!-- Housing. -->
|
||||
<path
|
||||
android:fillColor="#FF263238"
|
||||
android:pathData="M5.8,2.0H18.2V22.0H5.8z" />
|
||||
|
||||
<!-- Lamps, top to bottom: red, amber, green. -->
|
||||
<path
|
||||
android:fillColor="#FF5A2220"
|
||||
android:pathData="M12,6.6m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
|
||||
<path
|
||||
android:fillColor="#FF4A2E1C"
|
||||
android:pathData="M12,12.0m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
|
||||
<path
|
||||
android:fillColor="#FF1E4D2B"
|
||||
android:pathData="M12,17.4m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
|
||||
</vector>
|
||||
@@ -0,0 +1,38 @@
|
||||
<!--
|
||||
Live-map marker for a signalised intersection heard over SPATEM: a traffic light housing with
|
||||
the lamp for the intersection's leading phase lit and the other two dimmed.
|
||||
|
||||
One drawable per lit lamp rather than one drawable tinted at runtime: setTint recolours every
|
||||
path in a vector, so a single shared asset could not keep the unlit lamps dark while colouring
|
||||
the lit one - the whole light would turn one flat colour and stop reading as a traffic light.
|
||||
|
||||
Anchored at the bottom in V2xLiveMapView, so the housing sits above the intersection rather
|
||||
than covering it.
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="30dp"
|
||||
android:height="30dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
|
||||
<!-- White outline first, so the marker stays legible over dark map features. -->
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M4.4,0.8H19.6V23.2H4.4z" />
|
||||
|
||||
<!-- Housing. -->
|
||||
<path
|
||||
android:fillColor="#FF263238"
|
||||
android:pathData="M5.8,2.0H18.2V22.0H5.8z" />
|
||||
|
||||
<!-- Lamps, top to bottom: red, amber, green. -->
|
||||
<path
|
||||
android:fillColor="#FF5A2220"
|
||||
android:pathData="M12,6.6m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
|
||||
<path
|
||||
android:fillColor="#FF4A2E1C"
|
||||
android:pathData="M12,12.0m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
|
||||
<path
|
||||
android:fillColor="#FF4CAF50"
|
||||
android:pathData="M12,17.4m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
|
||||
</vector>
|
||||
@@ -0,0 +1,38 @@
|
||||
<!--
|
||||
Live-map marker for a signalised intersection heard over SPATEM: a traffic light housing with
|
||||
the lamp for the intersection's leading phase lit and the other two dimmed.
|
||||
|
||||
One drawable per lit lamp rather than one drawable tinted at runtime: setTint recolours every
|
||||
path in a vector, so a single shared asset could not keep the unlit lamps dark while colouring
|
||||
the lit one - the whole light would turn one flat colour and stop reading as a traffic light.
|
||||
|
||||
Anchored at the bottom in V2xLiveMapView, so the housing sits above the intersection rather
|
||||
than covering it.
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="30dp"
|
||||
android:height="30dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
|
||||
<!-- White outline first, so the marker stays legible over dark map features. -->
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M4.4,0.8H19.6V23.2H4.4z" />
|
||||
|
||||
<!-- Housing. -->
|
||||
<path
|
||||
android:fillColor="#FF263238"
|
||||
android:pathData="M5.8,2.0H18.2V22.0H5.8z" />
|
||||
|
||||
<!-- Lamps, top to bottom: red, amber, green. -->
|
||||
<path
|
||||
android:fillColor="#FFFF5252"
|
||||
android:pathData="M12,6.6m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
|
||||
<path
|
||||
android:fillColor="#FF4A2E1C"
|
||||
android:pathData="M12,12.0m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
|
||||
<path
|
||||
android:fillColor="#FF1E4D2B"
|
||||
android:pathData="M12,17.4m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
|
||||
</vector>
|
||||
@@ -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>
|
||||
@@ -129,12 +142,18 @@
|
||||
<string name="gnss_no_fix">Noch kein GPS-Signal - gehen Sie ins Freie</string>
|
||||
<string name="map_title">Standortkarte</string>
|
||||
<string name="map_location_label">Aktueller Standort</string>
|
||||
<string name="v2x_map_remote_count">%1$d erfasste externe Verkehrsteilnehmer</string>
|
||||
<string name="v2x_map_own_label">Eigen (Ego)</string>
|
||||
<string name="v2x_map_remote_plain">Extern #%1$d</string>
|
||||
<string name="v2x_map_remote_info">Extern #%1$d · Info</string>
|
||||
<string name="v2x_map_remote_awareness">Extern #%1$d · Aufmerksamkeit</string>
|
||||
<string name="v2x_map_remote_warning">Extern #%1$d · Warnung</string>
|
||||
<string name="v2x_map_title">V2X-Live-Karte</string>
|
||||
<string name="v2x_map_back">Zurück</string>
|
||||
<string name="v2x_map_follow">Auf eigene Position zentrieren</string>
|
||||
<string name="v2x_map_legend_cam">Verkehrsteilnehmer (CAM)</string>
|
||||
<string name="v2x_map_legend_denm">Gefahren (DENM)</string>
|
||||
<string name="v2x_map_legend_spat">Signale (SPATEM)</string>
|
||||
<string name="v2x_map_spat_unlocated">%1$d Signal(e) nicht dargestellt - Senderposition unbekannt</string>
|
||||
|
||||
<!-- Settings -->
|
||||
<string name="settings_title">Einstellungen</string>
|
||||
@@ -170,7 +189,7 @@
|
||||
<string name="settings_obu_hardware">OBU-Hardware</string>
|
||||
<string name="settings_obu_hardware_cit_one">CiT One</string>
|
||||
<string name="settings_obu_hardware_esp32">ESP32-C5</string>
|
||||
<string name="settings_obu_hardware_esp32_note">Der ESP32-C5 arbeitet als „dummer" Transceiver: CAM wird auf dem Smartphone erstellt und kodiert, über USB-Seriell an den ESP32 gesendet und über ITS-G5 gesendet. Auf diesem Pfad gibt es keinen MQTT-Broker und keine DENM-Use-Case-Engine - siehe den CAM-Pinger im V2X-Monitor für ein manuelles Testwerkzeug.</string>
|
||||
<string name="settings_obu_hardware_esp32_note">Das Smartphone erstellt CAM oder VAM und übergibt sie per USB-C oder Bluetooth an den ESP32-C5; der ESP32 ergänzt GeoNetworking, signiert (optional) und sendet über ITS-G5. Auf diesem Pfad gibt es keinen MQTT-Broker und keine DENM-Use-Case-Engine - siehe den CAM-Pinger im V2X-Monitor für ein manuelles Testwerkzeug.</string>
|
||||
<string name="settings_usb_transport">Aktiver Transport</string>
|
||||
<string name="settings_transport_usbc">USB-C</string>
|
||||
<string name="settings_transport_wifi">WLAN</string>
|
||||
@@ -184,7 +203,6 @@
|
||||
<string name="mqtt_no_topics">Noch keine Nachrichten</string>
|
||||
<string name="mqtt_view_list">Liste</string>
|
||||
<string name="mqtt_view_topics">Topics</string>
|
||||
<string name="mqtt_view_map">Karte</string>
|
||||
<string name="mqtt_no_topics_hint">Mit der OBU verbinden und auf V2X-Verkehr warten</string>
|
||||
<string name="mqtt_no_messages">Noch keine Nachrichten zu diesem Thema</string>
|
||||
|
||||
@@ -234,6 +252,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 +297,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>
|
||||
|
||||
@@ -302,4 +318,18 @@
|
||||
<string name="v2x_spat_rx_title">Kreuzung %1$s · Station %2$d</string>
|
||||
<string name="v2x_spat_group">SG%1$d</string>
|
||||
<string name="v2x_spat_countdown">%1$.0f s</string>
|
||||
<string name="settings_esp32_link">ESP32-C5-Verbindung</string>
|
||||
<string name="settings_esp32_link_ble">Bluetooth</string>
|
||||
<string name="settings_esp32_link_ble_note">Beim ersten Verbinden wird das Koppeln mit micrOBU-XXXX angefragt: Passkey 123456 eingeben. Die Platine wirbt nur, solange ihr USB-C-Port nicht benutzt wird. BLE teilt sich das Funk-Frontend mit ITS-G5; der Einfluss auf den 5,9-GHz-Empfang ist noch nicht gemessen.</string>
|
||||
<string name="settings_esp32_message">Senden während der Aufzeichnung</string>
|
||||
<string name="settings_esp32_message_cam">CAM</string>
|
||||
<string name="settings_esp32_message_vam">VAM</string>
|
||||
<string name="settings_esp32_sign">Ausgehende Nachrichten signieren</string>
|
||||
<string name="settings_esp32_sign_note">Signiert mit einer Demo-PKI, nicht der EU-Vertrauensliste: Empfänger, die dagegen prüfen, verwerfen diese Nachrichten. Aus sendet sie wie bisher unsigniert.</string>
|
||||
<string name="conn_esp32_via_usb">über USB-C (nativer Port)</string>
|
||||
<string name="conn_esp32_via_ble">über Bluetooth (Passkey 123456 beim ersten Koppeln)</string>
|
||||
<string name="conn_esp32_cancel">Abbrechen</string>
|
||||
<string name="conn_esp32_signing">Signieren %1$s · Tickets %2$d · signiert %3$d · abgelehnt %4$d · gesendet %5$d</string>
|
||||
<string name="conn_esp32_signing_on">an</string>
|
||||
<string name="conn_esp32_signing_off">aus</string>
|
||||
</resources>
|
||||
|
||||
@@ -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>
|
||||
@@ -130,12 +143,18 @@
|
||||
<string name="gnss_no_fix">No GPS fix yet - move to an open area</string>
|
||||
<string name="map_title">Location Map</string>
|
||||
<string name="map_location_label">Current Location</string>
|
||||
<string name="v2x_map_remote_count">%1$d tracked remote road user(s)</string>
|
||||
<string name="v2x_map_own_label">Own (ego)</string>
|
||||
<string name="v2x_map_remote_plain">Remote #%1$d</string>
|
||||
<string name="v2x_map_remote_info">Remote #%1$d · Info</string>
|
||||
<string name="v2x_map_remote_awareness">Remote #%1$d · Awareness</string>
|
||||
<string name="v2x_map_remote_warning">Remote #%1$d · Warning</string>
|
||||
<string name="v2x_map_title">V2X Live Map</string>
|
||||
<string name="v2x_map_back">Back</string>
|
||||
<string name="v2x_map_follow">Centre on own position</string>
|
||||
<string name="v2x_map_legend_cam">Road users (CAM)</string>
|
||||
<string name="v2x_map_legend_denm">Hazards (DENM)</string>
|
||||
<string name="v2x_map_legend_spat">Signals (SPATEM)</string>
|
||||
<string name="v2x_map_spat_unlocated">%1$d signal(s) not shown - sender position unknown</string>
|
||||
|
||||
<!-- Settings -->
|
||||
<string name="settings_title">Settings</string>
|
||||
@@ -171,7 +190,7 @@
|
||||
<string name="settings_obu_hardware">OBU Hardware</string>
|
||||
<string name="settings_obu_hardware_cit_one">CiT One</string>
|
||||
<string name="settings_obu_hardware_esp32">ESP32-C5</string>
|
||||
<string name="settings_obu_hardware_esp32_note">ESP32-C5 acts as a "dumb" transceiver: CAM is built and encoded on the phone, sent to the ESP32 over USB serial, and broadcast over ITS-G5. No MQTT broker or DENM use-case engine on this path - see the V2X Monitor screen\'s CAM Pinger for a manual test tool.</string>
|
||||
<string name="settings_obu_hardware_esp32_note">The phone builds CAM or VAM and hands it to the ESP32-C5 over USB-C or Bluetooth; the ESP32 adds GeoNetworking, signs it (optional) and broadcasts it over ITS-G5. No MQTT broker or DENM use-case engine on this path - see the V2X Monitor screen\'s CAM Pinger for a manual test tool.</string>
|
||||
<string name="settings_usb_transport">Active transport</string>
|
||||
<string name="settings_transport_usbc">USB-C</string>
|
||||
<string name="settings_transport_wifi">Wi-Fi</string>
|
||||
@@ -185,7 +204,6 @@
|
||||
<string name="mqtt_no_topics">No messages yet</string>
|
||||
<string name="mqtt_view_list">List</string>
|
||||
<string name="mqtt_view_topics">Topics</string>
|
||||
<string name="mqtt_view_map">Map</string>
|
||||
<string name="mqtt_no_topics_hint">Connect to the OBU and wait for V2X traffic</string>
|
||||
<string name="mqtt_no_messages">No messages on this topic yet</string>
|
||||
|
||||
@@ -246,7 +264,9 @@
|
||||
<string name="mqtt_cam_pinger_active">Pinging - 1 CAM/s over the serial link</string>
|
||||
<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_fw_counters">ESP32: tx fail %1$d · oversize %2$d · crc err %3$d · rx queue drop %4$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 +310,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>
|
||||
|
||||
@@ -309,4 +325,18 @@
|
||||
|
||||
<!-- Phase A: Trip Review screen -->
|
||||
<string name="trip_review_title">Trip Review</string>
|
||||
<string name="settings_esp32_link">ESP32-C5 link</string>
|
||||
<string name="settings_esp32_link_ble">Bluetooth</string>
|
||||
<string name="settings_esp32_link_ble_note">The first connection asks to pair with micrOBU-XXXX: enter passkey 123456. The board only advertises while nothing uses its USB-C port. BLE shares the radio front end with ITS-G5; its effect on 5.9 GHz reception has not been measured yet.</string>
|
||||
<string name="settings_esp32_message">Transmit while recording</string>
|
||||
<string name="settings_esp32_message_cam">CAM</string>
|
||||
<string name="settings_esp32_message_vam">VAM</string>
|
||||
<string name="settings_esp32_sign">Sign outgoing messages</string>
|
||||
<string name="settings_esp32_sign_note">Signed with a demo PKI, not the EU trust list: receivers that verify against it will drop these messages. Off sends them unsigned, as before.</string>
|
||||
<string name="conn_esp32_via_usb">via USB-C (native port)</string>
|
||||
<string name="conn_esp32_via_ble">via Bluetooth (passkey 123456 on first pairing)</string>
|
||||
<string name="conn_esp32_cancel">Cancel</string>
|
||||
<string name="conn_esp32_signing">Signing %1$s · tickets %2$d · signed %3$d · refused %4$d · on air %5$d</string>
|
||||
<string name="conn_esp32_signing_on">on</string>
|
||||
<string name="conn_esp32_signing_off">off</string>
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
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)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `firmware that predates the rx queue drop counter reports zero`() {
|
||||
// 8-byte heartbeat (status + counters + capabilities, no rx queue drops tail).
|
||||
val status = EspLinkStatus.parse("0000000000000001".hexToBytes())!!
|
||||
assertEquals(0, status.rxQueueDrops)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rx queue drops are read little-endian from the 10-byte payload`() {
|
||||
// status=0, oversize=0, txFail=0, rxCrc=0, capabilities=0x01, rxQueueDrops=0x0102 (LE: 02 01)
|
||||
val status = EspLinkStatus.parse("00000000000000010201".hexToBytes())!!
|
||||
assertEquals(0x0102, status.rxQueueDrops)
|
||||
assertTrue(status.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,119 @@
|
||||
package com.hawhamburg.micr0bu
|
||||
|
||||
import com.hawhamburg.micr0bu.data.transport.BtpDataRequest
|
||||
import com.hawhamburg.micr0bu.data.transport.LinkMessage
|
||||
import com.hawhamburg.micr0bu.data.transport.LinkOpcode
|
||||
import com.hawhamburg.micr0bu.data.transport.LinkResult
|
||||
import com.hawhamburg.micr0bu.data.transport.LinkSecurityProfile
|
||||
import com.hawhamburg.micr0bu.data.transport.PotiUpdate
|
||||
import com.hawhamburg.micr0bu.data.transport.StationConfigure
|
||||
import com.hawhamburg.micr0bu.data.transport.StationInfo
|
||||
import com.hawhamburg.micr0bu.data.transport.StationStatus
|
||||
import com.hawhamburg.micr0bu.data.transport.credentialsSegment
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
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 the phone side of the station-link message layer.
|
||||
*
|
||||
* ## Where the expected bytes come from
|
||||
* The colleague's Python implementation of the same protocol, microbu-esp32c5
|
||||
* station-link/python/microbu_link/messages.py (`encode_message` over each body's `encode`),
|
||||
* run with the same field values. That module is what their phone emulator drives the firmware
|
||||
* with, so an agreement here is agreement with a second, independent implementation.
|
||||
*/
|
||||
class StationLinkTest {
|
||||
|
||||
private fun String.hexToBytes(): ByteArray = chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
private fun ByteArray.hex(): String = joinToString("") { "%02x".format(it) }
|
||||
|
||||
@Test
|
||||
fun `STATION_CONFIGURE matches the Python encoder`() {
|
||||
val body = StationConfigure(stationType = 2, mid = "021122334455".hexToBytes()).encode()
|
||||
assertEquals(
|
||||
"0100070002010002112233445500b40014020205",
|
||||
LinkMessage(LinkOpcode.STATION_CONFIGURE, 7, body).encode().hex(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `POTI_UPDATE matches the Python encoder`() {
|
||||
val body = PotiUpdate(
|
||||
timestampMs = 717_254_800_123L, latTenMicroDeg = 535_546_667, lonTenMicroDeg = 100_223_889,
|
||||
semiMajorCm = 486, semiMinorCm = 486, speedCms = 543, headingDeciDeg = 1234, pai = true,
|
||||
).encode()
|
||||
assertEquals(
|
||||
"02000800fbb2b7ffa60000002bcbeb1f914bf905e601e60100000e000000001f02d204",
|
||||
LinkMessage(LinkOpcode.POTI_UPDATE, 8, body).encode().hex(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `BTP_DATA_REQUEST for a signed CAM matches the Python encoder`() {
|
||||
val body = BtpDataRequest(
|
||||
destinationPort = 2001, itsAid = 36, securityProfile = LinkSecurityProfile.SECURED,
|
||||
permissions = "010000".hexToBytes(), flSdu = "0102030405".hexToBytes(),
|
||||
).encode()
|
||||
assertEquals(
|
||||
"0300090001d1070000010102ffff000000000024000000030100000005000102030405",
|
||||
LinkMessage(LinkOpcode.BTP_DATA_REQUEST, 9, body).encode().hex(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CREDENTIALS_PROVISION segment matches the Python encoder`() {
|
||||
val body = credentialsSegment(totalLength = 695, offset = 240, segment = ByteArray(3) { 0xAB.toByte() })
|
||||
assertEquals("04000a00b702f00003ababab", LinkMessage(LinkOpcode.CREDENTIALS_PROVISION, 10, body).encode().hex())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `STATUS from the Python encoder decodes field by field`() {
|
||||
val message = LinkMessage.decode(
|
||||
("8400341240e20100010800021122334455b80b49387a4c12eb00010b0000000c0000000d0000000e000000" +
|
||||
"0f000000100000001100000012000000130000001400000015000000160000001700000018000000" +
|
||||
"190000001a0000001b0000001c000000fbb2b7ffa6000000").hexToBytes(),
|
||||
)
|
||||
assertNotNull(message)
|
||||
assertEquals(LinkOpcode.STATUS, message!!.opcode)
|
||||
assertEquals(0x1234, message.sequence)
|
||||
val status = StationStatus.decode(message.body)!!
|
||||
assertEquals(123_456L, status.uptimeMs)
|
||||
assertTrue(status.configured)
|
||||
assertArrayEquals("b80b49387a4c12eb".hexToBytes(), status.identifier)
|
||||
assertEquals(1, status.tickets)
|
||||
assertEquals(11L, status.signedMessages)
|
||||
assertEquals(12L, status.refusedNoTicket)
|
||||
assertEquals(13L, status.refusedChangePending)
|
||||
assertEquals(14L, status.refusedPermission)
|
||||
assertEquals(15L, status.signFailed)
|
||||
assertEquals(16L, status.verified)
|
||||
assertEquals(17L, status.rejected)
|
||||
assertEquals(18L, status.requestsAccepted)
|
||||
assertEquals(19L, status.requestsRefused)
|
||||
assertEquals(21L, status.radioSubmitted)
|
||||
assertEquals(22L, status.radioFailed)
|
||||
assertEquals(23L, status.radioReceived)
|
||||
assertEquals(24L, status.radioDropped)
|
||||
assertEquals(26L, status.linkCrcErrors)
|
||||
assertEquals(27L, status.linkMalformed)
|
||||
assertEquals(28L, status.potiUpdates)
|
||||
assertEquals(717_254_800_123L, status.itsTimeMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a STATUS of the wrong length is refused, as the Python decoder does`() {
|
||||
assertNull(StationStatus.decode(ByteArray(StationStatus.SIZE + 1)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `RESULT of STATION_CONFIGURE carries the credential state`() {
|
||||
val message = LinkMessage.decode("8000070000120800021122334455b80b49387a4c12eb0101".hexToBytes())!!
|
||||
val result = LinkResult.decode(message.body)!!
|
||||
assertTrue(result.accepted)
|
||||
assertEquals(StationInfo(credentialsLoaded = true, tickets = 1), StationInfo.decode(result.detail))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.hawhamburg.micr0bu
|
||||
|
||||
import com.hawhamburg.micr0bu.domain.vam.VamGenerationRules
|
||||
import com.hawhamburg.micr0bu.domain.vam.VamGenerationRules.Kinematics
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/** TS 103 300-3 clause 6.4 items 1 to 4, with the Table 16/17 values. */
|
||||
class VamGenerationRulesTest {
|
||||
|
||||
private val here = Kinematics(53.5546667, 10.0223889, speedMps = 3.0, headingDeg = 90.0)
|
||||
|
||||
private fun sentAt(ms: Long, k: Kinematics = here) = VamGenerationRules().apply { onSent(ms, k, withLowFrequency = true) }
|
||||
|
||||
@Test
|
||||
fun `the first VAM is always due`() {
|
||||
assertTrue(VamGenerationRules().due(0, here))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nothing is due within T_GenVamMin even after a big jump`() {
|
||||
assertFalse(sentAt(1_000).due(1_050, here.copy(latitude = here.latitude + 0.001)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a stationary VRU gets one VAM every T_GenVamMax`() {
|
||||
val rules = sentAt(1_000)
|
||||
assertFalse(rules.due(5_900, here))
|
||||
assertTrue(rules.due(6_001, here))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `position, speed and heading changes trigger past their thresholds only`() {
|
||||
val rules = sentAt(1_000)
|
||||
// ~3.3 m north: under 4 m. ~5.6 m: over.
|
||||
assertFalse(rules.due(2_000, here.copy(latitude = here.latitude + 0.00003)))
|
||||
assertTrue(rules.due(2_000, here.copy(latitude = here.latitude + 0.00005)))
|
||||
assertFalse(rules.due(2_000, here.copy(speedMps = 3.4)))
|
||||
assertTrue(rules.due(2_000, here.copy(speedMps = 3.6)))
|
||||
assertFalse(rules.due(2_000, here.copy(headingDeg = 93.0)))
|
||||
assertTrue(rules.due(2_000, here.copy(headingDeg = 95.0)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `heading change is measured the short way round north`() {
|
||||
val rules = sentAt(1_000, here.copy(headingDeg = 358.0))
|
||||
assertFalse(rules.due(2_000, here.copy(headingDeg = 1.0))) // 3 degrees across north
|
||||
assertTrue(rules.due(2_000, here.copy(headingDeg = 3.0))) // 5 degrees
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the low-frequency container goes with the first VAM, then every T_GenVamLFMin`() {
|
||||
val rules = VamGenerationRules()
|
||||
assertTrue(rules.includeLowFrequency(0))
|
||||
rules.onSent(0, here, withLowFrequency = true)
|
||||
assertFalse(rules.includeLowFrequency(1_999))
|
||||
assertTrue(rules.includeLowFrequency(2_000))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.hawhamburg.micr0bu
|
||||
|
||||
import com.hawhamburg.micr0bu.domain.asn1.VamContent
|
||||
import com.hawhamburg.micr0bu.domain.asn1.VamUperCodec
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pins the VAM encoder against bytes this code did not produce.
|
||||
*
|
||||
* ## Where the expected bytes come from
|
||||
* asn1tools 0.167, compiling the ETSI modules vanetza-idf ships (asn1/release2:
|
||||
* TS102894-2v241-CDD.asn, TS103300-3v231/VAM-PDU-Descriptions.asn and its motorcyclist container),
|
||||
* encoding the same values as a Python dict: the same toolchain the colleague's reference VBS
|
||||
* (microbu-esp32c5/station-link/python/microbu_link/vbs.py) builds its VAMs with. Station
|
||||
* 0x12345678, fix at Unix ms 1790170000123 (generationDeltaTime 45819), 53.5546667 N
|
||||
* 10.0223889 E, 5.43 m/s, heading 123.4 deg, -1.26 m/s^2; every confidence and the altitude
|
||||
* unavailable, as the encoder sends them.
|
||||
*/
|
||||
class VamUperCodecTest {
|
||||
|
||||
private fun ByteArray.hex(): String = joinToString("") { "%02x".format(it) }
|
||||
|
||||
private fun content(includeLowFrequency: Boolean, accuracyM: Float? = 3.0f) = VamContent(
|
||||
stationId = 0x12345678,
|
||||
timestamp = 1_790_170_000_123L,
|
||||
latitude = 53.5546667,
|
||||
longitude = 10.0223889,
|
||||
accuracyM = accuracyM,
|
||||
speedMps = 5.43,
|
||||
headingDeg = 123.4,
|
||||
accelerationMps2 = -1.26,
|
||||
includeLowFrequency = includeLowFrequency,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `VAM with the low-frequency container matches asn1tools`() {
|
||||
assertEquals(
|
||||
"031012345678b2fb400aac85a15b8a18ec88f30f3708eddd0f8002697e087ff24f322220",
|
||||
VamUperCodec.encode(content(includeLowFrequency = true)).hex(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `VAM without the low-frequency container matches asn1tools`() {
|
||||
assertEquals(
|
||||
"031012345678b2fb000aac85a15b8a18ec88f30f3708eddd0f8002697e087ff24f30",
|
||||
VamUperCodec.encode(content(includeLowFrequency = false)).hex(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown accuracy encodes the confidence ellipse as unavailable`() {
|
||||
assertEquals(
|
||||
"031012345678b2fb000aac85a15b8a18ec8fffffff08eddd0f8002697e087ff24f30",
|
||||
VamUperCodec.encode(content(includeLowFrequency = false, accuracyM = null)).hex(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,141 @@
|
||||
# 06 – Signed ITS messages, VAM and the BLE link (2026-09-23)
|
||||
|
||||
What changed when the ESP32-C5 OBU moved onto the colleague's vanetza-idf station, how the pieces
|
||||
fit together, how it was verified, and what is still open. Hardware checks still to do are in
|
||||
`TODO.md` ("Signed-TX firmware ...").
|
||||
|
||||
## Summary
|
||||
|
||||
- **Signing lives on the ESP32-C5.** The authorization ticket's private key is in the board's NVS;
|
||||
vanetza-idf's security entity signs every secured message there (IEEE 1609.2 / ETSI TS 103 097,
|
||||
ECDSA NIST P-256). The phone never holds a key and never signs.
|
||||
- **The phone decides what to send and when.** It builds CAM or VAM (UPER) from its own GNSS/IMU,
|
||||
hands each message to the board with the flag "signed" or "unsigned", and keeps the board's clock
|
||||
and position current.
|
||||
- **Two links, one protocol.** USB-C (native USB Serial/JTAG) or Bluetooth LE, chosen in Settings.
|
||||
Both carry the colleague's station-link protocol v1 plus one MicrOBU extension for reception.
|
||||
- **Reception is unchanged for the app.** Every ITS message heard on air reaches the phone, signed
|
||||
or not, verifiable or not, exactly as with the previous firmware.
|
||||
- **Demo PKI, not the EU trust list.** Signed messages carry a throwaway chain. Receivers that
|
||||
verify against the EU trust list drop them; unsigned sending remains available.
|
||||
|
||||
## Who does what
|
||||
|
||||
| | Phone (app) | ESP32-C5 (obu-firmware) |
|
||||
|---|---|---|
|
||||
| CAM / VAM content and UPER encoding | yes | – |
|
||||
| Send cadence (CAM 1 Hz baseline; VAM per TS 103 300-3 clause 6.4) | yes | – |
|
||||
| Pseudonym (station ID + MAC, rotated together) | yes | uses the MAC it is configured with |
|
||||
| Time and position (PoTi) | yes, per new GNSS fix | keeps an ITS clock from it |
|
||||
| GeoNetworking + BTP headers | – | yes |
|
||||
| Signing (TS 103 097), certificate handling | – | yes |
|
||||
| Credentials | ships the demo bundle, provisions it once | stores it in NVS |
|
||||
| 802.11p radio at 5 900 MHz | – | yes |
|
||||
| Reception: unwrap GN/BTP, forward | decodes CAM / DENM / SPATEM | yes (all frames) |
|
||||
|
||||
## Firmware (obu-firmware)
|
||||
|
||||
obu-firmware is now a port of `microbu-esp32c5/firmware`, from the colleague's own repository
|
||||
(not part of this one; nothing is pushed there). The C-ITS library it needs is copied into this
|
||||
repository as `obu-firmware/external/vanetza-idf` (their commit cf4b99f, unchanged), so
|
||||
obu-firmware builds from a plain clone. It builds
|
||||
with **ESP-IDF 6.0.2 only**: the raw-TX path uses private Wi-Fi driver structures that vanetza-idf
|
||||
pins to that version. The previous C firmware (IDF 6.1) is backed up as a full flash image in
|
||||
`firmware-backups/` (gitignored, restore command in its README.txt); its sources stay on disk,
|
||||
unbuilt. Setup and flashing: `obu-firmware/FLASHING.md`. Design notes and every deviation from the
|
||||
colleague's code (`MicrOBU:` in the sources): `obu-firmware/NOTES.md`.
|
||||
|
||||
Main changes against the colleague's firmware:
|
||||
|
||||
- **Raw receive path kept.** vanetza-idf decapsulates strictly and would drop unsigned frames (the
|
||||
bench car) and anything not signed under the demo root (every RSU). Every captured frame also
|
||||
goes through the previous firmware's `gn_unwrap.c` and reaches the phone as link opcode
|
||||
`V2X_RX` (0x85), whose body is the old `SERIAL_MSG_V2X_RX` payload.
|
||||
- **Unsigned sending kept.** The colleague's station refuses unsecured requests; here they go out
|
||||
with the previous firmware's `geonet.c` header.
|
||||
- **Console on UART0** (CH343, COM3 on the bench); the native USB port carries only link frames.
|
||||
- **BLE pauses advertising while USB is in use** (BLE and ITS-G5 share one RF front end).
|
||||
- **NVS 80 KB instead of 24 KB**, app at 0x20000. At 24 KB the BLE bond could not be stored and the
|
||||
phone had to pair on every connection.
|
||||
- Fixes found on the bench: radio queue drained before the first PoTi (no RX, ~177 queue drops
|
||||
before); station loop waited 0 ticks at 100 Hz and starved the idle task; 2.4 KB RX buffer moved
|
||||
off the Wi-Fi task stack; no silent truncation of BLE notifications; ATT MTU 517; serial writes
|
||||
skipped when no USB host is present.
|
||||
|
||||
## Link protocol
|
||||
|
||||
Station-link v1 (colleague's repository, `station-link/README.md`): `[opcode][flags][sequence LE][body]`,
|
||||
little-endian, at most 512 octets. Over USB each message is one `0xAA55` frame of type `0x10`
|
||||
(the old framing and CRC). Over BLE each message is one GATT value on service
|
||||
`0000C175-BA5E-4C17-8000-00805F9B34FB` (the README describes a different, Nordic-UART layout; the
|
||||
firmware is what counts).
|
||||
|
||||
| Direction | Message | Used for |
|
||||
|---|---|---|
|
||||
| phone → board | `STATION_CONFIGURE` | pseudonym MAC, station type, channel 180, 20 dBm; starts the radio |
|
||||
| phone → board | `CREDENTIALS_PROVISION` | the demo bundle, once, when the board reports no ticket |
|
||||
| phone → board | `POTI_UPDATE` | position and ITS time, once per new GNSS fix |
|
||||
| phone → board | `BTP_DATA_REQUEST` | one CAM (port 2001, psid 36) or VAM (port 2018, psid 638), signed or not |
|
||||
| board → phone | `RESULT` | answer to a request |
|
||||
| board → phone | `STATUS` | every second: counters, tickets, signed/refused counts |
|
||||
| board → phone | `V2X_RX` (0x85, MicrOBU) | every ITS message heard on air |
|
||||
|
||||
The app side is `Esp32Link.kt` (session), `StationLink.kt` (codec, pinned by unit tests to bytes
|
||||
from the colleague's Python implementation), `UsbSerialTransport.kt` and `BleLinkTransport.kt`.
|
||||
A board still on the previous firmware is recognised by its old heartbeat and keeps working for
|
||||
CAM over USB.
|
||||
|
||||
## Redundancy and recovery
|
||||
|
||||
| Situation | What notices | What happens |
|
||||
|---|---|---|
|
||||
| USB link dead (board hung, cable) | app watchdog: no frame for 3.5 s (the board's `STATUS` comes every second) | link marked ERROR on the card |
|
||||
| USB unplugged | Android detach broadcast | port closed; Connect again after re-plugging |
|
||||
| BLE link lost | BLE supervision timeout (4 s) | app reconnects by itself: 1 s after a drop, then backing off to 30 s if attempts fail |
|
||||
| Board reset / power cycle | first `STATUS` says "not configured" | app reconfigures (and re-provisions if needed) without user action |
|
||||
| App closed and reopened | new session | app configures the board again; BLE reconnects with the stored bond, no passkey (confirmed) |
|
||||
| Phone clock or GNSS time jumping | app tracks the board's clock | PoTi never moves it backwards (except a real correction of ≥ 60 s), so the board does not restart its stack |
|
||||
| Board firmware wedged | ESP task watchdog (30 s, logs on COM3) | the phone sees it as a dead link (above) |
|
||||
|
||||
## App changes
|
||||
|
||||
- Settings > Connection > ESP32-C5: **link** USB-C / Bluetooth, **transmit** CAM / VAM, **sign
|
||||
outgoing messages** (on by default). The connection card, top bar and dashboard show the link in
|
||||
use, the pairing passkey when needed, and signing counters.
|
||||
- VAM encoder (`VamUperCodec.kt`, TS 103 300-3 V2.3.1, checked against asn1tools) and the VAM
|
||||
generation rules (`VamGenerationRules.kt`).
|
||||
- `GnssTimeSource` keeps the last measured phone-clock error while GNSS time drops out indoors. The
|
||||
bench phone's clock was 14 minutes fast; falling back to it made every transmitted timestamp
|
||||
jump by 14 minutes.
|
||||
- Bluetooth permissions (Android 12+) requested at start-up.
|
||||
|
||||
## Credentials (demo PKI)
|
||||
|
||||
`app/src/main/assets/demo-chain.vcr`, generated 2026-09-23 with the colleague's `vidf_issue`: root
|
||||
`6E7D0374FB021901` → AA `B3312F29844299E0` → AT `B80B49387A4C12EB` (two years; psid 36 SSP `010000`,
|
||||
psid 638 SSP `01`). It is throwaway and not EU-registered; its private key ships with the app on
|
||||
purpose. The colleague's own demo chain only grants psid 638 and cannot sign CAMs.
|
||||
|
||||
## Verification
|
||||
|
||||
- **Unit tests** (103): VAM bytes against asn1tools, station-link messages against the colleague's
|
||||
Python encoder, VAM generation rules.
|
||||
- **Signatures on air**: `obu-firmware/test/verify_signed_pcap.py` checks a pcap with asn1tools
|
||||
and OpenSSL, sharing no code with the firmware. Pinger capture of 2026-09-23: 12/12 signed CAMs,
|
||||
psid 36, signer the demo AT, all signatures valid, chain valid.
|
||||
- **Third-party stack**: the CiT One receives the signed CAMs (~1 Hz on `v2x/rx/cam`), so its
|
||||
stack unwraps our envelope. Its MQTT interface exposes no security information, and it forwards
|
||||
unsigned and unknown-root messages alike, so it cannot tell whether it verified the signature.
|
||||
- **V2X2MAP (COM10)**: now the colleague's v2x2map 0.3.0 bridge from source with a new
|
||||
`verify.py` and `--trust demo-chain.vcr`; it shows "signature verified", "SIGNATURE INVALID" or
|
||||
"not verified" per packet. Signed CAMs and signed VAMs verified live; a one-bit change in a
|
||||
signed CAM comes out invalid. Launcher: `micrOBU_workspace/v2x-obu-esp32c5/start-v2x2map-signed.bat`.
|
||||
|
||||
## Open
|
||||
|
||||
- BLE/ITS-G5 coexistence is not measured: does an active BLE connection cost 5.9 GHz reception?
|
||||
- `time_regression` standing still indoors for several minutes, and board reset recovery over BLE,
|
||||
after the last fixes.
|
||||
- The signature's generationTime follows the app's UTC-based `ItsTime`; the colleague's VBS adds
|
||||
the 5 leap seconds (TAI). Which is right is the open question in `ItsTime.kt`.
|
||||
- Real EU PKI enrolment/authorisation (TS 102 941) instead of the demo chain.
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 660 KiB |
@@ -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.
@@ -1,7 +1,7 @@
|
||||
# OBU transmit firmware - Phase 2 (in progress: HLN-SV DENM beacon)
|
||||
|
||||
Started. See `docs/04-transmit-setup.md` in the project root for build/flash
|
||||
steps and how to validate this against your own sniffer.
|
||||
Build and flash steps are under "Build and flash" below. (`docs/04-transmit-setup.md`,
|
||||
referenced here and in the sources, is not in the repo.)
|
||||
|
||||
## Toolchain: use a dedicated terminal (ESP-IDF 5.5.4)
|
||||
|
||||
@@ -29,6 +29,123 @@ referenced an older source path under `micrOBU_workspace/v2x-obu-esp32c5/`,
|
||||
which makes `idf.py fullclean` refuse to run). If that error reappears, delete
|
||||
`build/` manually rather than fighting it.
|
||||
|
||||
## Build and flash
|
||||
|
||||
The firmware needs no button, phone or serial connection to start. On every power-up or reset,
|
||||
`app_main` sets up the radio and starts `tx_task`, which starts driving the simulated route and
|
||||
sending CAMs on 5900 MHz. A board flashed with this image starts beaconing on its own as soon as it
|
||||
gets power.
|
||||
|
||||
In a fresh PowerShell window:
|
||||
|
||||
```powershell
|
||||
$env:IDF_PYTHON_ENV_PATH = $null; $env:IDF_PATH = $null
|
||||
. C:\Espressif\frameworks\esp-idf-v5.5.4\export.ps1
|
||||
cd C:\Users\Ashin\AndroidStudioProjects\MicrOBU\obu-cam-transmistter
|
||||
```
|
||||
|
||||
1. **Check what the board is running before you flash it.** Both this project and `obu-firmware`
|
||||
name their image `obu_firmware.bin`, so the file name tells you nothing. Read the app
|
||||
descriptor instead (replace `COMx` with the board's port):
|
||||
|
||||
```powershell
|
||||
python -m esptool --chip esp32c5 -p COMx read_flash 0x10000 0x100 $env:TEMP\desc.bin
|
||||
$b = [IO.File]::ReadAllBytes("$env:TEMP\desc.bin")
|
||||
function S($o,$n){ [Text.Encoding]::ASCII.GetString($b,$o,$n).Trim([char]0) }
|
||||
"time=" + (S 0x70 16) + " date=" + (S 0x80 16) + " idf=" + (S 0x90 32)
|
||||
```
|
||||
|
||||
`idf=v6.1...` means the board runs the production OBU (`obu-firmware`). Don't flash this beacon
|
||||
over it. `idf=v5.5.4` means this transmitter, or another bench image.
|
||||
|
||||
2. **Build:**
|
||||
|
||||
```powershell
|
||||
idf.py build
|
||||
```
|
||||
|
||||
A rebuild after small changes takes about 1-2 minutes. The output is
|
||||
`build\obu_firmware.bin`.
|
||||
|
||||
3. **Flash** (the board must be on its UART bridge port or its native USB port):
|
||||
|
||||
```powershell
|
||||
idf.py -p COMx flash
|
||||
```
|
||||
|
||||
The flash is good when esptool prints `Hash of data verified.`, then resets the board.
|
||||
|
||||
4. **Check that it's transmitting:**
|
||||
|
||||
```powershell
|
||||
idf.py -p COMx monitor
|
||||
```
|
||||
|
||||
(Exit with `Ctrl+]`.) About 1.4 s after reset you should see:
|
||||
|
||||
```
|
||||
W obu-tx: OCB @ 5900 MHz - CAM beacon armed, driving a 103-point street loop
|
||||
I obu-tx: CAM sent (119 bytes) @ 5900 MHz genDeltaT=1087 pos=53.5531770,10.0220980 50.0 km/h heading 77.0 pt1
|
||||
```
|
||||
|
||||
After that, a `CAM sent` line appears about 3 times a second, with the position, speed and
|
||||
heading changing. These lines only mean each frame was
|
||||
handed to the radio. To confirm the frames actually went out, capture them with a second
|
||||
ESP32-C5 running the receiver firmware.
|
||||
|
||||
Don't open the console of the production OBU (COM3) while the phone is attached. Opening the port
|
||||
resets that board and drops the phone's USB link. The beacon boards have no phone attached, so
|
||||
this doesn't apply to them.
|
||||
|
||||
## Simulated drive
|
||||
|
||||
The beacon pretends to be a car driving a loop through St. Georg / Berliner Tor in Hamburg, on
|
||||
the real streets. The route comes from six waypoints, which you set in `tools/make_route.py`.
|
||||
|
||||
**Changing the route:** edit `WAYPOINTS` in `tools/make_route.py`, then run
|
||||
|
||||
```powershell
|
||||
py -3.11 tools/make_route.py
|
||||
```
|
||||
|
||||
The script asks the OSRM demo server (router.project-osrm.org) for a legal driving route through the
|
||||
waypoints in order and back to the first, then writes three files:
|
||||
|
||||
- `main/route_points.h`: the street geometry, thinned to points no more than 1.5 m off the line
|
||||
(currently 103 points).
|
||||
- `tools/route_osrm.json`: OSRM's raw answer. `--offline` rebuilds the header from it without the
|
||||
network.
|
||||
- `tools/route_map.html`: the route on an OpenStreetMap map. Open it in a browser and check it
|
||||
before building.
|
||||
|
||||
Then build and flash as above. Route data (c) OpenStreetMap contributors, ODbL; routing by OSRM.
|
||||
|
||||
**Things to know about the routing:**
|
||||
- OSRM follows one-way streets and turn bans, so the loop can be longer than the waypoints suggest.
|
||||
The current one is 5.2 km, with two turn-round detours: a loop via Borgfelder Straße and
|
||||
Anckelmannsplatz between wp2 and wp3, and one round Nagelsweg, Norderstraße and Repsoldstraße
|
||||
between wp5 and wp6. To avoid a detour, move the waypoint on either side of it.
|
||||
- Each waypoint is sent with the direction towards the next one. Without it, points on divided
|
||||
roads (Beim Strohhause, for example) snap to the carriageway going the other way, and the loop
|
||||
grows to 8.4 km of U-turns.
|
||||
|
||||
**How the car drives** (`main/route.c`):
|
||||
- **Speed:** it cruises at 50 km/h (`CRUISE_MPS` in `main/main.c`). Each bend gets a speed limit
|
||||
from its radius, keeping sideways acceleration at 2 m/s², so a 90° junction turn is taken at
|
||||
about 15 km/h and a gentle curve barely slows the car. It never drops below 10 km/h
|
||||
(`MIN_CORNER_MPS`). It brakes at 2 m/s² and accelerates at 1.5 m/s², planning braking across as
|
||||
many points as a bend needs. A lap takes about 7.7 min, averaging 40 km/h.
|
||||
- **Heading:** the compass bearing of the current straight piece. It changes gradually through
|
||||
curves but jumps at sharp junction turns.
|
||||
- **When CAMs are sent:** following ETSI EN 302 637-2, the state is checked every 100 ms. A CAM goes
|
||||
out when the heading changed by more than 4°, the position by more than 4 m, or the speed by more
|
||||
than 0.5 m/s since the last one, and at least once a second. That's about 3 CAMs a second at
|
||||
50 km/h.
|
||||
- **What's filled in:** position, speed and heading go into both the CAM and the GeoNetworking
|
||||
source position vector. `genDeltaT` is milliseconds since boot.
|
||||
- **Testing:** `route.c` only uses standard headers, so you can compile it on the PC with MSYS2 gcc
|
||||
and simulate a lap.
|
||||
|
||||
## CAM encoding
|
||||
|
||||
`main/cam.c` IS compiled here (unlike `obu-firmware`'s copy, which is a
|
||||
@@ -44,10 +161,12 @@ hazard-light GPIO is grounded. No location/alacarte containers.
|
||||
- `main/main.c` - entry point, the `phy_11p_set`/`phy_change_channel(5900,...)`
|
||||
register hack, GPIO polling, TX loop
|
||||
- `main/denm.c` / `.h` - ASN.1 UPER encoding of a minimal DENM
|
||||
- `main/route.c` / `.h` - simulated drive round the route loop
|
||||
- `main/route_points.h` - the route, generated by `tools/make_route.py`
|
||||
- `main/geonet.c` / `.h` - GeoNetworking Basic/Common/SHB headers + BTP-B
|
||||
- `main/dot11p.c` / `.h` - 802.11 OCB (QoS Data, broadcast) frame + LLC/SNAP
|
||||
|
||||
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
|
||||
Known gaps, tracked as TODOs in the source: no real GNSS (the CAM position comes
|
||||
from the simulated drive above), 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).
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# wifi_patches.c is intentionally NOT in this list anymore - superseded by
|
||||
# tx_custom.c (see that file for why). Left on disk, unused, for history.
|
||||
idf_component_register(
|
||||
SRCS "main.c" "denm.c" "cam.c" "geonet.c" "dot11p.c" "tx_custom.c"
|
||||
SRCS "main.c" "denm.c" "cam.c" "geonet.c" "dot11p.c" "tx_custom.c" "route.c"
|
||||
INCLUDE_DIRS "."
|
||||
REQUIRES esp_event esp_netif nvs_flash driver esp_phy
|
||||
REQUIRES esp_event esp_timer esp_netif nvs_flash driver esp_phy
|
||||
PRIV_REQUIRES esp_wifi
|
||||
)
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
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,
|
||||
uint16_t speed_cm_s, uint16_t heading_ddeg,
|
||||
uint16_t btp_dest_port,
|
||||
uint8_t *out, size_t out_len)
|
||||
{
|
||||
@@ -19,7 +20,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)
|
||||
@@ -65,12 +69,12 @@ int geonet_wrap_shb(const uint8_t *its_payload, int its_len,
|
||||
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;
|
||||
// PAI(1 bit) + Speed(15 bits, signed, 0.01 m/s), packed into 2 bytes. PAI stays 0: the
|
||||
// position has no accuracy estimate behind it.
|
||||
uint16_t spd = speed_cm_s > 0x7FFF ? 0x7FFF : speed_cm_s;
|
||||
*p++ = (uint8_t)(spd >> 8); *p++ = (uint8_t)(spd & 0xFF);
|
||||
// Heading (16 bits, 0.1 degree units, clockwise from north)
|
||||
*p++ = (uint8_t)(heading_ddeg >> 8); *p++ = (uint8_t)(heading_ddeg & 0xFF);
|
||||
// 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
|
||||
|
||||
@@ -31,6 +31,10 @@
|
||||
// working (same extended header shape as CAM). Fine for a single-vehicle
|
||||
// beacon; revisit if you need real multi-hop forwarding later.
|
||||
//
|
||||
// `speed_cm_s` (0.01 m/s) and `heading_ddeg` (0.1 deg) also go into the Source Long Position
|
||||
// Vector; pass the same values as the CAM's high-frequency container. Speed is a 15-bit field, so
|
||||
// values above 32767 are clamped.
|
||||
//
|
||||
// `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, ...
|
||||
//
|
||||
@@ -38,6 +42,7 @@
|
||||
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,
|
||||
uint16_t speed_cm_s, uint16_t heading_ddeg,
|
||||
uint16_t btp_dest_port,
|
||||
uint8_t *out, size_t out_len);
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdbool.h>
|
||||
#include <math.h>
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "esp_timer.h"
|
||||
#include "driver/gpio.h"
|
||||
#include "esp_wifi.h"
|
||||
#include "esp_event.h"
|
||||
@@ -14,13 +18,17 @@
|
||||
#include "geonet.h"
|
||||
#include "dot11p.h"
|
||||
#include "tx_custom.h"
|
||||
#include "route.h"
|
||||
#include "route_points.h"
|
||||
|
||||
static const char *TAG = "obu-tx";
|
||||
|
||||
// CAM beacon: transmit a Cooperative Awareness Message every TX_INTERVAL_MS,
|
||||
// unconditionally (no hazard-light gating - CAM is a continuous beacon, unlike
|
||||
// the event-triggered DENM). Matches the working Rust reference
|
||||
// (esp32-c_its-companion, feat/tx-cam), which beacons CAM on 5900 MHz.
|
||||
// CAM beacon for a simulated car driving round a block in Hamburg (see route.c). The CAM
|
||||
// generation rules follow ETSI EN 302 637-2 clause 6.1.3: every CHECK_INTERVAL_MS the car's state
|
||||
// is compared with the last CAM sent, and a new CAM goes out when the heading changed by more than
|
||||
// 4 degrees, the position by more than 4 m, the speed by more than 0.5 m/s, or 1 s has passed.
|
||||
// There is no hazard-light gating - CAM is a continuous beacon, unlike the event-triggered DENM.
|
||||
// Transmits on 5900 MHz like the working Rust reference (esp32-c_its-companion, feat/tx-cam).
|
||||
|
||||
// ISOLATION TEST for whether tx_custom.c is the blocker.
|
||||
// 1 = transmit via the STANDARD, well-tested esp_wifi_80211_tx() using a
|
||||
@@ -55,15 +63,19 @@ static const char *TAG = "obu-tx";
|
||||
#define VEHICLE_LENGTH_DM 40 // VehicleLengthValue, 10cm steps (4.0 m)
|
||||
#define VEHICLE_WIDTH_DM 18 // VehicleWidth, 10cm steps (1.8 m)
|
||||
#define BTP_PORT_CAM 2001 // BTP-B destination port for CAM (ETSI TS 103 248)
|
||||
#define TX_INTERVAL_MS 1000 // CAM beacon period (1 Hz; ITS allows 1-10 Hz)
|
||||
#define CHECK_INTERVAL_MS 100 // T_CheckCamGen: how often the generation rules are evaluated
|
||||
#define CAM_MAX_INTERVAL_MS 1000 // T_GenCamMax: a CAM goes out at least this often
|
||||
#define CAM_HEADING_DDEG 40 // > 4 degrees heading change triggers a CAM
|
||||
#define CAM_POSITION_M 4.0 // > 4 m position change triggers a CAM
|
||||
#define CAM_SPEED_CM_S 50 // > 0.5 m/s speed change triggers a CAM
|
||||
|
||||
// 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 (decimal_degrees * 10,000,000). Replace with real
|
||||
// GNSS output once you have a fix source; until then this beats 0/0
|
||||
// ("Null Island"), which is an obvious placeholder-tell on any map.
|
||||
#define BENCH_LATITUDE_TENMICRODEG 535546667
|
||||
#define BENCH_LONGITUDE_TENMICRODEG 100223889
|
||||
// ---- Simulated drive ----
|
||||
// route_points (main/route_points.h) is the street geometry of a driving loop through six waypoints
|
||||
// in St. Georg, generated by tools/make_route.py from OpenStreetMap via OSRM. To change the route,
|
||||
// edit WAYPOINTS in that script and rerun it. No GNSS is wired in; replace with real fixes once
|
||||
// there is one.
|
||||
#define CRUISE_MPS (50.0 / 3.6) // 50 km/h, the urban limit
|
||||
#define MIN_CORNER_MPS (10.0 / 3.6) // slowest the car goes, for hairpins and U-turns
|
||||
|
||||
// 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
|
||||
@@ -78,33 +90,28 @@ static const uint8_t pseudonym_mac[6] = {0x02, 0x00, 0x00, 0x00, 0x00, 0x01};
|
||||
extern void phy_11p_set(int enable, int unused);
|
||||
extern void phy_change_channel(int freq_mhz, int bw_mode, int sec_chan_offset, int unused);
|
||||
|
||||
static void send_cam(void)
|
||||
static void send_cam(const route_state_t *car, uint16_t gen_delta)
|
||||
{
|
||||
// GenerationDeltaTime is TimestampIts mod 65536 (ms). No RTC/GNSS time here,
|
||||
// so use a free-running ms counter that advances one beacon-interval per
|
||||
// send. It wraps at 65536, which is exactly the field's defined behaviour.
|
||||
static uint16_t gen_delta = 0;
|
||||
|
||||
uint8_t frame[300];
|
||||
cam_fields_t fields = {
|
||||
.station_id = STATION_ID,
|
||||
.station_type = STATION_TYPE,
|
||||
.generation_delta_time = gen_delta,
|
||||
.latitude_tenmicrodeg = BENCH_LATITUDE_TENMICRODEG,
|
||||
.longitude_tenmicrodeg = BENCH_LONGITUDE_TENMICRODEG,
|
||||
.speed_cm_s = 0, // stationary
|
||||
.heading_ddeg = 3601, // HeadingValue unavailable (no heading source)
|
||||
.latitude_tenmicrodeg = car->latitude_tenmicrodeg,
|
||||
.longitude_tenmicrodeg = car->longitude_tenmicrodeg,
|
||||
.speed_cm_s = car->speed_cm_s,
|
||||
.heading_ddeg = car->heading_ddeg,
|
||||
.vehicle_length_dm = VEHICLE_LENGTH_DM,
|
||||
.vehicle_width_dm = VEHICLE_WIDTH_DM,
|
||||
};
|
||||
gen_delta += TX_INTERVAL_MS;
|
||||
|
||||
uint8_t cam_payload[96];
|
||||
int cam_len = cam_encode(&fields, cam_payload, sizeof(cam_payload));
|
||||
|
||||
uint8_t gn_payload[160];
|
||||
int gn_len = geonet_wrap_shb(cam_payload, cam_len, pseudonym_mac, STATION_TYPE,
|
||||
BENCH_LATITUDE_TENMICRODEG, BENCH_LONGITUDE_TENMICRODEG,
|
||||
car->latitude_tenmicrodeg, car->longitude_tenmicrodeg,
|
||||
car->speed_cm_s, car->heading_ddeg,
|
||||
BTP_PORT_CAM, gn_payload, sizeof(gn_payload));
|
||||
|
||||
// qos=false for the standard-TX path (esp_wifi_80211_tx accepts only non-QoS
|
||||
@@ -127,7 +134,10 @@ static void send_cam(void)
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "esp_wifi_80211_tx (standard) failed: %d", err);
|
||||
} else {
|
||||
ESP_LOGI(TAG, "CAM sent via STANDARD tx (%d bytes) @ %d MHz genDeltaT=%u", frame_len, TX_FREQ_MHZ, gen_delta);
|
||||
ESP_LOGI(TAG, "CAM sent (%d bytes) @ %d MHz genDeltaT=%u pos=%.7f,%.7f %.1f km/h heading %.1f pt%d",
|
||||
frame_len, TX_FREQ_MHZ, gen_delta,
|
||||
car->latitude_tenmicrodeg / 1e7, car->longitude_tenmicrodeg / 1e7,
|
||||
car->speed_cm_s * 0.036, car->heading_ddeg / 10.0, car->segment + 1);
|
||||
}
|
||||
#else
|
||||
// tx_custom path: submits to the driver's internal HMAC TX path,
|
||||
@@ -151,12 +161,49 @@ static void send_cam(void)
|
||||
}
|
||||
}
|
||||
|
||||
static bool cam_due(const route_state_t *car, const route_state_t *last, int64_t since_last_ms)
|
||||
{
|
||||
if (since_last_ms >= CAM_MAX_INTERVAL_MS) {
|
||||
return true;
|
||||
}
|
||||
int dh = abs((int)car->heading_ddeg - (int)last->heading_ddeg);
|
||||
if (dh > 1800) {
|
||||
dh = 3600 - dh;
|
||||
}
|
||||
if (dh > CAM_HEADING_DDEG) {
|
||||
return true;
|
||||
}
|
||||
if (abs((int)car->speed_cm_s - (int)last->speed_cm_s) > CAM_SPEED_CM_S) {
|
||||
return true;
|
||||
}
|
||||
// Flat-earth distance is plenty for a 4 m threshold.
|
||||
double north_m = (car->latitude_tenmicrodeg - last->latitude_tenmicrodeg) * 0.0111194930;
|
||||
double east_m = (car->longitude_tenmicrodeg - last->longitude_tenmicrodeg) * 0.0111194930
|
||||
* cos(car->latitude_tenmicrodeg / 1e7 * M_PI / 180.0);
|
||||
return north_m * north_m + east_m * east_m > CAM_POSITION_M * CAM_POSITION_M;
|
||||
}
|
||||
|
||||
static void tx_task(void *arg)
|
||||
{
|
||||
route_state_t car;
|
||||
route_state_t last_sent;
|
||||
int64_t last_sent_ms = 0;
|
||||
bool sent_any = false;
|
||||
TickType_t wake = xTaskGetTickCount();
|
||||
|
||||
route_step(0.0, &car);
|
||||
while (1) {
|
||||
// CAM is a continuous beacon - send every interval, unconditionally.
|
||||
send_cam();
|
||||
vTaskDelay(pdMS_TO_TICKS(TX_INTERVAL_MS));
|
||||
int64_t now_ms = esp_timer_get_time() / 1000;
|
||||
if (!sent_any || cam_due(&car, &last_sent, now_ms - last_sent_ms)) {
|
||||
// GenerationDeltaTime is TimestampIts mod 65536 (ms). No real clock here, so use
|
||||
// milliseconds since boot, which advances at the right rate.
|
||||
send_cam(&car, (uint16_t)now_ms);
|
||||
last_sent = car;
|
||||
last_sent_ms = now_ms;
|
||||
sent_any = true;
|
||||
}
|
||||
vTaskDelayUntil(&wake, pdMS_TO_TICKS(CHECK_INTERVAL_MS));
|
||||
route_step(CHECK_INTERVAL_MS / 1000.0, &car);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,8 +302,13 @@ void app_main(void)
|
||||
phy_change_channel(TX_FREQ_MHZ, 1, 0, 0);
|
||||
ESP_LOGI(TAG, "phy_change_channel returned");
|
||||
|
||||
ESP_LOGW(TAG, "OCB @ %d MHz - CAM beacon armed, transmitting every %d ms",
|
||||
TX_FREQ_MHZ, TX_INTERVAL_MS);
|
||||
if (route_init(route_points, sizeof(route_points) / sizeof(route_points[0]),
|
||||
CRUISE_MPS, MIN_CORNER_MPS) != 0) {
|
||||
ESP_LOGE(TAG, "route_init failed - check route_points");
|
||||
return;
|
||||
}
|
||||
ESP_LOGW(TAG, "OCB @ %d MHz - CAM beacon armed, driving a %d-point street loop",
|
||||
TX_FREQ_MHZ, (int)(sizeof(route_points) / sizeof(route_points[0])));
|
||||
|
||||
xTaskCreate(tx_task, "tx_task", 4096, NULL, 5, NULL);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
#include "route.h"
|
||||
#include <math.h>
|
||||
|
||||
#define DEG_TO_RAD (M_PI / 180.0)
|
||||
#define METRES_PER_DEG 111194.93 // mean Earth radius 6371 km; a block is small enough for a flat projection
|
||||
#define ACCEL_MPS2 1.5 // pulling away from a corner
|
||||
#define DECEL_MPS2 2.0 // braking ahead of a corner
|
||||
#define LATERAL_MPS2 2.0 // sideways acceleration a normal driver takes a bend at
|
||||
#define STRAIGHT_DEG 3.0 // kinks gentler than this are digitising noise, not bends
|
||||
#define BEND_SPAN_M 15.0 // longest segment counted towards a bend's radius (see route_init)
|
||||
|
||||
static const route_point_t *s_pts;
|
||||
static int s_n;
|
||||
static double s_len[ROUTE_MAX_POINTS]; // segment i runs from point i to point (i+1) % n
|
||||
static double s_bearing_deg[ROUTE_MAX_POINTS];
|
||||
static double s_corner_mps[ROUTE_MAX_POINTS]; // speed limit at point i, where segment i starts
|
||||
static double s_cruise_mps;
|
||||
static int s_seg;
|
||||
static double s_pos_m; // distance along the current segment
|
||||
|
||||
static double segment_speed(int seg, double pos_m)
|
||||
{
|
||||
double v = s_cruise_mps;
|
||||
double pull_away = sqrt(s_corner_mps[seg] * s_corner_mps[seg] + 2.0 * ACCEL_MPS2 * pos_m);
|
||||
int next = (seg + 1) % s_n;
|
||||
double braking = sqrt(s_corner_mps[next] * s_corner_mps[next]
|
||||
+ 2.0 * DECEL_MPS2 * (s_len[seg] - pos_m));
|
||||
if (pull_away < v) v = pull_away;
|
||||
if (braking < v) v = braking;
|
||||
return v;
|
||||
}
|
||||
|
||||
int route_init(const route_point_t *points, int n, double cruise_mps, double min_corner_mps)
|
||||
{
|
||||
if (n < 2 || n > ROUTE_MAX_POINTS) {
|
||||
return -1;
|
||||
}
|
||||
s_pts = points;
|
||||
s_n = n;
|
||||
s_cruise_mps = cruise_mps;
|
||||
|
||||
for (int i = 0; i < n; i++) {
|
||||
const route_point_t *a = &points[i];
|
||||
const route_point_t *b = &points[(i + 1) % n];
|
||||
double mid_lat = (a->latitude_tenmicrodeg + (double)b->latitude_tenmicrodeg) / 2e7;
|
||||
double north_m = (b->latitude_tenmicrodeg - a->latitude_tenmicrodeg) / 1e7 * METRES_PER_DEG;
|
||||
double east_m = (b->longitude_tenmicrodeg - a->longitude_tenmicrodeg) / 1e7 * METRES_PER_DEG
|
||||
* cos(mid_lat * DEG_TO_RAD);
|
||||
s_len[i] = sqrt(north_m * north_m + east_m * east_m);
|
||||
if (s_len[i] < 0.01) {
|
||||
return -1;
|
||||
}
|
||||
double bearing = atan2(east_m, north_m) / DEG_TO_RAD;
|
||||
s_bearing_deg[i] = bearing < 0 ? bearing + 360.0 : bearing;
|
||||
}
|
||||
|
||||
// Speed limit at each point from how tight the bend there is. A polyline bend of angle theta
|
||||
// between segments of length L approximates an arc of radius L / theta, and a car takes a
|
||||
// radius R at sqrt(a_lat * R). L is capped at BEND_SPAN_M: at a junction the two streets can be
|
||||
// hundreds of metres long, but the car still turns within the width of the crossing.
|
||||
for (int i = 0; i < n; i++) {
|
||||
double turn = fabs(s_bearing_deg[i] - s_bearing_deg[(i + n - 1) % n]);
|
||||
if (turn > 180.0) {
|
||||
turn = 360.0 - turn;
|
||||
}
|
||||
double v = cruise_mps;
|
||||
if (turn > STRAIGHT_DEG) {
|
||||
double span = s_len[(i + n - 1) % n] < s_len[i] ? s_len[(i + n - 1) % n] : s_len[i];
|
||||
if (span > BEND_SPAN_M) {
|
||||
span = BEND_SPAN_M;
|
||||
}
|
||||
v = sqrt(LATERAL_MPS2 * span / (turn * DEG_TO_RAD));
|
||||
}
|
||||
if (v > cruise_mps) v = cruise_mps;
|
||||
if (v < min_corner_mps) v = min_corner_mps;
|
||||
s_corner_mps[i] = v;
|
||||
}
|
||||
|
||||
// A point's limit also has to respect the bends after it (the car must be able to brake for
|
||||
// them within the segments in between) and before it (it can only have sped up so much since).
|
||||
// segment_speed only looks at the two ends of a segment, so settle this here. Limits only ever
|
||||
// go down, so repeating the two passes until nothing changes terminates.
|
||||
for (int changed = 1; changed;) {
|
||||
changed = 0;
|
||||
for (int k = 0; k < 2 * n; k++) {
|
||||
int i = (2 * n - 1 - k) % n; // backwards: braking
|
||||
int next = (i + 1) % n;
|
||||
double v = sqrt(s_corner_mps[next] * s_corner_mps[next] + 2.0 * DECEL_MPS2 * s_len[i]);
|
||||
if (v < s_corner_mps[i] - 1e-9) { s_corner_mps[i] = v; changed = 1; }
|
||||
}
|
||||
for (int k = 0; k < 2 * n; k++) {
|
||||
int i = k % n; // forwards: accelerating
|
||||
int next = (i + 1) % n;
|
||||
double v = sqrt(s_corner_mps[i] * s_corner_mps[i] + 2.0 * ACCEL_MPS2 * s_len[i]);
|
||||
if (v < s_corner_mps[next] - 1e-9) { s_corner_mps[next] = v; changed = 1; }
|
||||
}
|
||||
}
|
||||
|
||||
s_seg = 0;
|
||||
s_pos_m = 0.0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
void route_step(double dt_s, route_state_t *out)
|
||||
{
|
||||
// Advance with the speed at the start of the step; at 100 ms steps the error is well under a metre.
|
||||
double d = segment_speed(s_seg, s_pos_m) * dt_s;
|
||||
while (s_pos_m + d >= s_len[s_seg]) {
|
||||
d -= s_len[s_seg] - s_pos_m;
|
||||
s_seg = (s_seg + 1) % s_n;
|
||||
s_pos_m = 0.0;
|
||||
}
|
||||
s_pos_m += d;
|
||||
|
||||
const route_point_t *a = &s_pts[s_seg];
|
||||
const route_point_t *b = &s_pts[(s_seg + 1) % s_n];
|
||||
double f = s_pos_m / s_len[s_seg];
|
||||
out->latitude_tenmicrodeg = (int32_t)lround(a->latitude_tenmicrodeg
|
||||
+ f * (b->latitude_tenmicrodeg - a->latitude_tenmicrodeg));
|
||||
out->longitude_tenmicrodeg = (int32_t)lround(a->longitude_tenmicrodeg
|
||||
+ f * (b->longitude_tenmicrodeg - a->longitude_tenmicrodeg));
|
||||
out->speed_cm_s = (uint16_t)lround(segment_speed(s_seg, s_pos_m) * 100.0);
|
||||
out->heading_ddeg = (uint16_t)(lround(s_bearing_deg[s_seg] * 10.0) % 3600);
|
||||
out->segment = s_seg;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
#ifndef ROUTE_H
|
||||
#define ROUTE_H
|
||||
#include <stdint.h>
|
||||
|
||||
// Simulated drive around a closed loop of waypoints, so the beacon looks like a
|
||||
// car going round the block instead of a parked one.
|
||||
//
|
||||
// The car follows straight lines between the points and goes from the last one
|
||||
// back to the first, forever. For street-following, the points are the street
|
||||
// geometry from tools/make_route.py (main/route_points.h). Speed is a function
|
||||
// of where the car is on a segment: it cruises, brakes ahead of each bend down to
|
||||
// the speed that bend allows, and accelerates away after it. Heading is the
|
||||
// bearing of the current segment.
|
||||
//
|
||||
// Uses only standard headers, so it also compiles on the host for testing.
|
||||
|
||||
typedef struct {
|
||||
int32_t latitude_tenmicrodeg; // 1/10 microdegree, same units as the CAM
|
||||
int32_t longitude_tenmicrodeg;
|
||||
} route_point_t;
|
||||
|
||||
typedef struct {
|
||||
int32_t latitude_tenmicrodeg;
|
||||
int32_t longitude_tenmicrodeg;
|
||||
uint16_t speed_cm_s; // SpeedValue units (0.01 m/s)
|
||||
uint16_t heading_ddeg; // HeadingValue units (0.1 deg, 0..3599, 0 = north, clockwise)
|
||||
int segment; // index of the route point the car last passed
|
||||
} route_state_t;
|
||||
|
||||
#define ROUTE_MAX_POINTS 512 // keep in step with MAX_POINTS in tools/make_route.py
|
||||
|
||||
// `points` must stay valid for as long as the route is used. Returns 0, or -1
|
||||
// if n is out of range (2..ROUTE_MAX_POINTS) or a segment has zero length.
|
||||
// min_corner_mps is the slowest the car ever goes (hairpins, U-turns).
|
||||
int route_init(const route_point_t *points, int n, double cruise_mps, double min_corner_mps);
|
||||
|
||||
// Moves the car on by dt_s seconds and writes its new position into `out`.
|
||||
void route_step(double dt_s, route_state_t *out);
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,116 @@
|
||||
// GENERATED by tools/make_route.py - do not edit by hand; change WAYPOINTS there and rerun.
|
||||
// Route data (c) OpenStreetMap contributors, ODbL. Routing by OSRM.
|
||||
//
|
||||
// Driving loop through 6 waypoints: 5179 m (legs 147 m, 1837 m, 381 m, 819 m, 1234 m, 761 m), 103 points after
|
||||
// simplifying to 1.5 m. Streets: Beim Strohhause, Berlinertordamm, Berliner Tor, Bei der Hauptfeuerwache, Westphalensweg, Berliner Tor, Berlinertordamm, Borgfelder Straße, Anckelmannstraße, Anckelmannsplatz, Bürgerweide, Wallstraße, Lübeckertordamm, Steindamm, Kreuzweg, Adenauerallee, Nagelsweg, Norderstraße, Repsoldstraße, Kurt-Schumacher-Allee, Kreuzweg, Adenauerallee, Kurt-Schumacher-Allee, Beim Strohhause.
|
||||
#ifndef ROUTE_POINTS_H
|
||||
#define ROUTE_POINTS_H
|
||||
#include "route.h"
|
||||
|
||||
static const route_point_t route_points[] = {
|
||||
{ 535531770, 100220980 },
|
||||
{ 535534470, 100240600 },
|
||||
{ 535535790, 100244160 },
|
||||
{ 535536400, 100244520 },
|
||||
{ 535537130, 100244160 },
|
||||
{ 535538480, 100242840 },
|
||||
{ 535538720, 100242140 },
|
||||
{ 535540390, 100240200 },
|
||||
{ 535548880, 100233930 },
|
||||
{ 535549470, 100235130 },
|
||||
{ 535554140, 100253280 },
|
||||
{ 535553570, 100254580 },
|
||||
{ 535546070, 100251700 },
|
||||
{ 535543130, 100250020 },
|
||||
{ 535542570, 100249130 },
|
||||
{ 535542760, 100247800 },
|
||||
{ 535539980, 100244600 },
|
||||
{ 535538720, 100242140 },
|
||||
{ 535538140, 100241960 },
|
||||
{ 535535720, 100243380 },
|
||||
{ 535535300, 100244470 },
|
||||
{ 535535090, 100246580 },
|
||||
{ 535536830, 100264460 },
|
||||
{ 535537600, 100270920 },
|
||||
{ 535538520, 100275220 },
|
||||
{ 535541110, 100290830 },
|
||||
{ 535540170, 100291550 },
|
||||
{ 535539890, 100294430 },
|
||||
{ 535539580, 100295330 },
|
||||
{ 535532290, 100299300 },
|
||||
{ 535527980, 100302450 },
|
||||
{ 535525130, 100291880 },
|
||||
{ 535524220, 100289760 },
|
||||
{ 535522900, 100287820 },
|
||||
{ 535522890, 100285300 },
|
||||
{ 535522610, 100282810 },
|
||||
{ 535520620, 100276050 },
|
||||
{ 535519820, 100271130 },
|
||||
{ 535519670, 100269030 },
|
||||
{ 535520220, 100267120 },
|
||||
{ 535520930, 100262840 },
|
||||
{ 535521850, 100260020 },
|
||||
{ 535522840, 100258210 },
|
||||
{ 535527790, 100257880 },
|
||||
{ 535538650, 100261370 },
|
||||
{ 535551660, 100266470 },
|
||||
{ 535555790, 100268750 },
|
||||
{ 535559620, 100271540 },
|
||||
{ 535561120, 100272050 },
|
||||
{ 535562430, 100271250 },
|
||||
{ 535563830, 100269390 },
|
||||
{ 535569610, 100260190 },
|
||||
{ 535579530, 100242810 },
|
||||
{ 535584090, 100237660 },
|
||||
{ 535585850, 100234670 },
|
||||
{ 535584970, 100230060 },
|
||||
{ 535584000, 100227260 },
|
||||
{ 535580730, 100222030 },
|
||||
{ 535579100, 100218590 },
|
||||
{ 535574500, 100208430 },
|
||||
{ 535570560, 100199010 },
|
||||
{ 535568130, 100194820 },
|
||||
{ 535564910, 100187620 },
|
||||
{ 535563990, 100184630 },
|
||||
{ 535562540, 100181160 },
|
||||
{ 535559780, 100176310 },
|
||||
{ 535542180, 100136840 },
|
||||
{ 535539720, 100133430 },
|
||||
{ 535537350, 100132010 },
|
||||
{ 535534760, 100131390 },
|
||||
{ 535523840, 100132690 },
|
||||
{ 535524980, 100155090 },
|
||||
{ 535524890, 100156360 },
|
||||
{ 535524440, 100157650 },
|
||||
{ 535523030, 100158530 },
|
||||
{ 535517610, 100158580 },
|
||||
{ 535504440, 100168810 },
|
||||
{ 535501360, 100156400 },
|
||||
{ 535499250, 100144880 },
|
||||
{ 535498470, 100133880 },
|
||||
{ 535498630, 100126150 },
|
||||
{ 535499110, 100121980 },
|
||||
{ 535504260, 100117230 },
|
||||
{ 535505720, 100115500 },
|
||||
{ 535507630, 100112310 },
|
||||
{ 535508400, 100111560 },
|
||||
{ 535510000, 100110940 },
|
||||
{ 535511390, 100119560 },
|
||||
{ 535512440, 100122280 },
|
||||
{ 535514510, 100132770 },
|
||||
{ 535515020, 100134120 },
|
||||
{ 535516630, 100135630 },
|
||||
{ 535518260, 100136100 },
|
||||
{ 535523950, 100134870 },
|
||||
{ 535524980, 100155090 },
|
||||
{ 535524890, 100156360 },
|
||||
{ 535524440, 100157650 },
|
||||
{ 535523470, 100158460 },
|
||||
{ 535518560, 100158480 },
|
||||
{ 535519070, 100162430 },
|
||||
{ 535522260, 100178080 },
|
||||
{ 535527880, 100197650 },
|
||||
{ 535530060, 100209020 },
|
||||
};
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Turn the beacon's waypoints into a street-following route for main/route_points.h.
|
||||
|
||||
Asks the OSRM demo server (router.project-osrm.org, OpenStreetMap data) for a driving route that
|
||||
visits WAYPOINTS in order and returns to the first, thins the street geometry out, and writes:
|
||||
|
||||
main/route_points.h the C array the firmware drives (commit this)
|
||||
tools/route_osrm.json the raw OSRM answer, so the header can be regenerated offline (--offline)
|
||||
tools/route_map.html the route over an OpenStreetMap map, to check it before flashing
|
||||
|
||||
Each waypoint also gets a bearing (the direction towards the next waypoint), so OSRM snaps it onto
|
||||
the carriageway going that way. Without it, points on divided roads land on the wrong side and
|
||||
every leg grows a U-turn detour.
|
||||
|
||||
Usage (Python 3.8+, standard library only):
|
||||
py tools/make_route.py fetch from OSRM, then write all three files
|
||||
py -3 tools/make_route.py --offline rebuild from the saved route_osrm.json
|
||||
|
||||
Route data (c) OpenStreetMap contributors, ODbL. Routing by OSRM.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import pathlib
|
||||
import urllib.request
|
||||
|
||||
# (latitude, longitude) in decimal degrees, in driving order. The route closes back to the first.
|
||||
WAYPOINTS = [
|
||||
(53.553309, 10.022043),
|
||||
(53.553611, 10.024146),
|
||||
(53.556164, 10.027121),
|
||||
(53.558310, 10.023362),
|
||||
(53.554062, 10.013515),
|
||||
(53.551540, 10.013398),
|
||||
]
|
||||
BEARING_TOLERANCE_DEG = 60 # how far the road's direction may differ from the waypoint bearing
|
||||
SIMPLIFY_M = 1.5 # drop points that move the line by less than this
|
||||
MAX_POINTS = 512 # must match ROUTE_MAX_POINTS in main/route.h
|
||||
|
||||
HERE = pathlib.Path(__file__).resolve().parent
|
||||
PROJECT = HERE.parent
|
||||
OSRM_JSON = HERE / "route_osrm.json"
|
||||
HEADER = PROJECT / "main" / "route_points.h"
|
||||
MAP_HTML = HERE / "route_map.html"
|
||||
METRES_PER_DEG = 111194.93
|
||||
|
||||
|
||||
def to_xy(lat, lon, lat0):
|
||||
return (lon * METRES_PER_DEG * math.cos(math.radians(lat0)), lat * METRES_PER_DEG)
|
||||
|
||||
|
||||
def bearing(a, b):
|
||||
north = b[0] - a[0]
|
||||
east = (b[1] - a[1]) * math.cos(math.radians(a[0]))
|
||||
return math.degrees(math.atan2(east, north)) % 360
|
||||
|
||||
|
||||
def fetch():
|
||||
n = len(WAYPOINTS)
|
||||
pts = WAYPOINTS + [WAYPOINTS[0]]
|
||||
bearings = [round(bearing(WAYPOINTS[i], WAYPOINTS[(i + 1) % n])) for i in range(n)]
|
||||
bearings.append(bearings[0])
|
||||
url = ("https://router.project-osrm.org/route/v1/driving/"
|
||||
+ ";".join(f"{lon},{lat}" for lat, lon in pts)
|
||||
+ "?overview=full&geometries=geojson&steps=true&bearings="
|
||||
+ ";".join(f"{b},{BEARING_TOLERANCE_DEG}" for b in bearings))
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "MicrOBU-route-tool"})
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
data = json.load(resp)
|
||||
if data.get("code") != "Ok":
|
||||
raise SystemExit(f"OSRM error: {data.get('code')} {data.get('message')}")
|
||||
OSRM_JSON.write_text(json.dumps(data, indent=1), encoding="utf-8")
|
||||
return data
|
||||
|
||||
|
||||
def simplify(xy, tol):
|
||||
"""Douglas-Peucker, iterative. Keeps the first and last point."""
|
||||
keep = [False] * len(xy)
|
||||
keep[0] = keep[-1] = True
|
||||
stack = [(0, len(xy) - 1)]
|
||||
while stack:
|
||||
a, b = stack.pop()
|
||||
(ax, ay), (bx, by) = xy[a], xy[b]
|
||||
dx, dy = bx - ax, by - ay
|
||||
seg2 = dx * dx + dy * dy
|
||||
worst, worst_d = -1, tol
|
||||
for i in range(a + 1, b):
|
||||
px, py = xy[i]
|
||||
if seg2 == 0:
|
||||
d = math.hypot(px - ax, py - ay)
|
||||
else:
|
||||
t = max(0.0, min(1.0, ((px - ax) * dx + (py - ay) * dy) / seg2))
|
||||
d = math.hypot(px - ax - t * dx, py - ay - t * dy)
|
||||
if d > worst_d:
|
||||
worst, worst_d = i, d
|
||||
if worst >= 0:
|
||||
keep[worst] = True
|
||||
stack += [(a, worst), (worst, b)]
|
||||
return [i for i, k in enumerate(keep) if k]
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--offline", action="store_true", help="use the saved route_osrm.json")
|
||||
args = ap.parse_args()
|
||||
data = json.loads(OSRM_JSON.read_text(encoding="utf-8")) if args.offline else fetch()
|
||||
route = data["routes"][0]
|
||||
|
||||
# GeoJSON is [lon, lat]; round to the CAM's 1/10-microdegree grid and drop repeats.
|
||||
raw = []
|
||||
for lon, lat in route["geometry"]["coordinates"]:
|
||||
p = (round(lat * 1e7), round(lon * 1e7))
|
||||
if not raw or p != raw[-1]:
|
||||
raw.append(p)
|
||||
if raw[0] == raw[-1]:
|
||||
raw.pop()
|
||||
closed = raw + [raw[0]]
|
||||
lat0 = closed[0][0] / 1e7
|
||||
xy = [to_xy(p[0] / 1e7, p[1] / 1e7, lat0) for p in closed]
|
||||
pts = [closed[i] for i in simplify(xy, SIMPLIFY_M)][:-1]
|
||||
if len(pts) > MAX_POINTS:
|
||||
raise SystemExit(f"{len(pts)} points is more than MAX_POINTS={MAX_POINTS}; raise SIMPLIFY_M")
|
||||
|
||||
streets = []
|
||||
for leg in route["legs"]:
|
||||
for step in leg["steps"]:
|
||||
if step["name"] and (not streets or streets[-1] != step["name"]):
|
||||
streets.append(step["name"])
|
||||
legs = ", ".join(f"{round(l['distance'])} m" for l in route["legs"])
|
||||
|
||||
lines = [
|
||||
"// GENERATED by tools/make_route.py - do not edit by hand; change WAYPOINTS there and rerun.",
|
||||
"// Route data (c) OpenStreetMap contributors, ODbL. Routing by OSRM.",
|
||||
"//",
|
||||
f"// Driving loop through {len(WAYPOINTS)} waypoints: {round(route['distance'])} m "
|
||||
f"(legs {legs}), {len(pts)} points after",
|
||||
f"// simplifying to {SIMPLIFY_M} m. Streets: {', '.join(streets)}.",
|
||||
"#ifndef ROUTE_POINTS_H",
|
||||
"#define ROUTE_POINTS_H",
|
||||
'#include "route.h"',
|
||||
"",
|
||||
"static const route_point_t route_points[] = {",
|
||||
]
|
||||
lines += [f" {{ {lat}, {lon} }}," for lat, lon in pts]
|
||||
lines += ["};", "", "#endif", ""]
|
||||
HEADER.write_text("\n".join(lines), encoding="utf-8", newline="\n")
|
||||
|
||||
MAP_HTML.write_text(f"""<!doctype html><meta charset="utf-8"><title>Beacon route</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<style>html,body,#m{{height:100%;margin:0}}</style><div id="m"></div><script>
|
||||
const pts={json.dumps([[p[0] / 1e7, p[1] / 1e7] for p in pts])};
|
||||
const wps={json.dumps(WAYPOINTS)};
|
||||
const m=L.map('m');
|
||||
L.tileLayer('https://tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png',{{maxZoom:19,
|
||||
attribution:'© OpenStreetMap contributors'}}).addTo(m);
|
||||
const line=L.polyline(pts.concat([pts[0]]),{{color:'#d33',weight:4}}).addTo(m);
|
||||
wps.forEach((w,i)=>L.marker(w,{{title:'wp'+(i+1)}}).bindTooltip('wp'+(i+1),{{permanent:true}}).addTo(m));
|
||||
L.circleMarker(pts[0],{{radius:7,color:'#060'}}).bindTooltip('start').addTo(m);
|
||||
m.fitBounds(line.getBounds(),{{padding:[20,20]}});
|
||||
</script>
|
||||
""", encoding="utf-8")
|
||||
|
||||
print(f"{round(route['distance'])} m, legs {legs}")
|
||||
print(f"{len(route['geometry']['coordinates'])} OSRM points -> {len(pts)} after simplifying")
|
||||
print(f"wrote {HEADER.relative_to(PROJECT)}, {OSRM_JSON.relative_to(PROJECT)}, "
|
||||
f"{MAP_HTML.relative_to(PROJECT)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,14 @@
|
||||
<!doctype html><meta charset="utf-8"><title>Beacon route</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<style>html,body,#m{height:100%;margin:0}</style><div id="m"></div><script>
|
||||
const pts=[[53.553177, 10.022098], [53.553447, 10.02406], [53.553579, 10.024416], [53.55364, 10.024452], [53.553713, 10.024416], [53.553848, 10.024284], [53.553872, 10.024214], [53.554039, 10.02402], [53.554888, 10.023393], [53.554947, 10.023513], [53.555414, 10.025328], [53.555357, 10.025458], [53.554607, 10.02517], [53.554313, 10.025002], [53.554257, 10.024913], [53.554276, 10.02478], [53.553998, 10.02446], [53.553872, 10.024214], [53.553814, 10.024196], [53.553572, 10.024338], [53.55353, 10.024447], [53.553509, 10.024658], [53.553683, 10.026446], [53.55376, 10.027092], [53.553852, 10.027522], [53.554111, 10.029083], [53.554017, 10.029155], [53.553989, 10.029443], [53.553958, 10.029533], [53.553229, 10.02993], [53.552798, 10.030245], [53.552513, 10.029188], [53.552422, 10.028976], [53.55229, 10.028782], [53.552289, 10.02853], [53.552261, 10.028281], [53.552062, 10.027605], [53.551982, 10.027113], [53.551967, 10.026903], [53.552022, 10.026712], [53.552093, 10.026284], [53.552185, 10.026002], [53.552284, 10.025821], [53.552779, 10.025788], [53.553865, 10.026137], [53.555166, 10.026647], [53.555579, 10.026875], [53.555962, 10.027154], [53.556112, 10.027205], [53.556243, 10.027125], [53.556383, 10.026939], [53.556961, 10.026019], [53.557953, 10.024281], [53.558409, 10.023766], [53.558585, 10.023467], [53.558497, 10.023006], [53.5584, 10.022726], [53.558073, 10.022203], [53.55791, 10.021859], [53.55745, 10.020843], [53.557056, 10.019901], [53.556813, 10.019482], [53.556491, 10.018762], [53.556399, 10.018463], [53.556254, 10.018116], [53.555978, 10.017631], [53.554218, 10.013684], [53.553972, 10.013343], [53.553735, 10.013201], [53.553476, 10.013139], [53.552384, 10.013269], [53.552498, 10.015509], [53.552489, 10.015636], [53.552444, 10.015765], [53.552303, 10.015853], [53.551761, 10.015858], [53.550444, 10.016881], [53.550136, 10.01564], [53.549925, 10.014488], [53.549847, 10.013388], [53.549863, 10.012615], [53.549911, 10.012198], [53.550426, 10.011723], [53.550572, 10.01155], [53.550763, 10.011231], [53.55084, 10.011156], [53.551, 10.011094], [53.551139, 10.011956], [53.551244, 10.012228], [53.551451, 10.013277], [53.551502, 10.013412], [53.551663, 10.013563], [53.551826, 10.01361], [53.552395, 10.013487], [53.552498, 10.015509], [53.552489, 10.015636], [53.552444, 10.015765], [53.552347, 10.015846], [53.551856, 10.015848], [53.551907, 10.016243], [53.552226, 10.017808], [53.552788, 10.019765], [53.553006, 10.020902]];
|
||||
const wps=[[53.553309, 10.022043], [53.553611, 10.024146], [53.556164, 10.027121], [53.55831, 10.023362], [53.554062, 10.013515], [53.55154, 10.013398]];
|
||||
const m=L.map('m');
|
||||
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png',{maxZoom:19,
|
||||
attribution:'© OpenStreetMap contributors'}).addTo(m);
|
||||
const line=L.polyline(pts.concat([pts[0]]),{color:'#d33',weight:4}).addTo(m);
|
||||
wps.forEach((w,i)=>L.marker(w,{title:'wp'+(i+1)}).bindTooltip('wp'+(i+1),{permanent:true}).addTo(m));
|
||||
L.circleMarker(pts[0],{radius:7,color:'#060'}).bindTooltip('start').addTo(m);
|
||||
m.fitBounds(line.getBounds(),{padding:[20,20]});
|
||||
</script>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,64 @@
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
cmake_minimum_required(VERSION 3.22)
|
||||
# obu-firmware: the ESP32-C5 half of the MicrOBU station, on the vanetza-idf C-ITS stack.
|
||||
#
|
||||
# Since 2026-09-23 this is a port of the colleague's standalone VRU station
|
||||
# (microbu-esp32c5/firmware, in their own repository, not part of this one). From it:
|
||||
# BTP/GeoNetworking and the TS 103 097 security entity (vanetza-idf), the station-link message
|
||||
# layer over native USB Serial/JTAG and BLE GATT, the NVS credential store, and the C5 radio adapter.
|
||||
# Added here for this project: the raw receive path of the previous firmware (gn_unwrap.c, forwarded
|
||||
# as link opcode V2X_RX) so unsigned and non-demo-signed traffic still reaches the phone, the
|
||||
# unsigned transmit path of the previous firmware (geonet.c), and BLE pausing while USB is in use.
|
||||
# See NOTES.md.
|
||||
#
|
||||
# ESP-IDF 6.0.2 exactly: the C5 radio's private Wi-Fi driver ABI (otm_tx_custom.c) is pinned to it
|
||||
# by vanetza-idf's radio_c5.cmake and has only been validated there. The previous C firmware was
|
||||
# built with IDF 6.1; see FLASHING.md for the export script of each.
|
||||
#
|
||||
# vanetza-idf lives in external/vanetza-idf, copied from the colleague's repository (their
|
||||
# external/vanetza-idf at commit cf4b99f, unchanged; see its PROVENANCE.md). Override with
|
||||
# -DVANETZA_IDF_DIR=... to build against another checkout.
|
||||
if(NOT VANETZA_IDF_DIR)
|
||||
set(VANETZA_IDF_DIR "${CMAKE_CURRENT_LIST_DIR}/external/vanetza-idf")
|
||||
endif()
|
||||
if(NOT EXISTS "${VANETZA_IDF_DIR}/idf_component.yml")
|
||||
message(FATAL_ERROR "vanetza-idf not found at ${VANETZA_IDF_DIR}: obu-firmware/external/vanetza-idf "
|
||||
"is part of this repository; check it is complete, or pass "
|
||||
"-DVANETZA_IDF_DIR=<path to a vanetza-idf checkout>")
|
||||
endif()
|
||||
|
||||
# Provenance guard for main/otm_tx_custom.c, copied unchanged from microbu-esp32c5/firmware/CMakeLists.txt:
|
||||
# this checked-in copy is what microbu::C5Radio::request() links against for every transmission.
|
||||
# Fail configure if either the pinned upstream or this file drifts from the reviewed revision.
|
||||
set(_otm_upstream "${VANETZA_IDF_DIR}/ports/esp_idf/third_party/otm/main/tx_custom.c")
|
||||
if(EXISTS "${_otm_upstream}")
|
||||
file(READ "${_otm_upstream}" _otm_upstream_text)
|
||||
string(REPLACE "\r\n" "\n" _otm_upstream_text "${_otm_upstream_text}")
|
||||
string(SHA256 _otm_upstream_hash "${_otm_upstream_text}")
|
||||
if(NOT _otm_upstream_hash STREQUAL "cb1dccfef96912ca59275e8a9102f41f56925b94a19d4a4629082aaeb779be1b")
|
||||
message(FATAL_ERROR "OpenTrafficMap upstream tx_custom.c differs from the reviewed source revision (674e3412) -- review before updating main/otm_tx_custom.c and this hash")
|
||||
endif()
|
||||
endif()
|
||||
set(_otm_checked_in "${CMAKE_CURRENT_LIST_DIR}/main/otm_tx_custom.c")
|
||||
file(READ "${_otm_checked_in}" _otm_checked_in_text)
|
||||
string(REPLACE "\r\n" "\n" _otm_checked_in_text "${_otm_checked_in_text}")
|
||||
string(SHA256 _otm_checked_in_hash "${_otm_checked_in_text}")
|
||||
if(NOT _otm_checked_in_hash STREQUAL "114693af99ce3866cfc767066d484e45822eaf15335d94a03626b60ac5777277")
|
||||
message(FATAL_ERROR "main/otm_tx_custom.c differs from the reviewed copy -- review the change, then update this hash")
|
||||
endif()
|
||||
|
||||
list(APPEND EXTRA_COMPONENT_DIRS "${VANETZA_IDF_DIR}")
|
||||
set(SDKCONFIG_DEFAULTS "${CMAKE_CURRENT_LIST_DIR}/sdkconfig.defaults")
|
||||
set(COMPONENTS main)
|
||||
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
|
||||
|
||||
# No longer need -Wl,-zmuldefs here - that was only for main/wifi_patches.c's
|
||||
# symbol-override attempt (which didn't work anyway; see docs/04-transmit-setup.md),
|
||||
# and that file is no longer part of the build. Superseded by main/tx_custom.c,
|
||||
# which bypasses the gate at a different layer instead of trying to override it.
|
||||
|
||||
project(obu_firmware)
|
||||
add_compile_options(-Wno-error -Wno-cpp -Wno-error=implicit-function-declaration)
|
||||
idf_component_get_property(vanetza_lib vanetza-idf COMPONENT_LIB)
|
||||
if(vanetza_lib)
|
||||
target_compile_options(${vanetza_lib} PRIVATE -Wno-error=implicit-function-declaration -Wno-error=cpp)
|
||||
endif()
|
||||
# GCC 15's stricter -Warray-bounds false-positives on NimBLE's fixed-size bond-store arrays
|
||||
# (upstream Apache Mynewt code); demote to a warning so the component still builds.
|
||||
idf_component_get_property(bt_lib bt COMPONENT_LIB)
|
||||
if(bt_lib)
|
||||
target_compile_options(${bt_lib} PRIVATE -Wno-error=array-bounds)
|
||||
endif()
|
||||
|
||||
+101
-27
@@ -2,35 +2,93 @@
|
||||
|
||||
## Two toolchains - use a dedicated terminal for each
|
||||
|
||||
This project builds against the receiver-firmware's pinned ESP-IDF **6.1**.
|
||||
The separate `obu-cam-transmistter` project builds against the global ESP-IDF
|
||||
**5.5.4**. Exporting both in one PowerShell window fails: the second export
|
||||
Since 2026-09-23 this project builds against ESP-IDF **6.0.2** exactly
|
||||
(`C:\Espressif\frameworks\esp-idf-v6.0.2`): it is the vanetza-idf port (see
|
||||
NOTES.md), and vanetza-idf's `radio_c5.cmake` refuses any other version because
|
||||
the raw TX path pokes private Wi-Fi driver structures only validated there. The
|
||||
previous C firmware used the receiver firmware's IDF **6.1**; the separate
|
||||
`obu-cam-transmistter` project builds against the global ESP-IDF **5.5.4**.
|
||||
Exporting two of them in one PowerShell window fails: the second export
|
||||
inherits the first's `IDF_PYTHON_ENV_PATH` and reports every Python dependency
|
||||
as unmet. Don't run `install.bat` to "fix" that - open a fresh terminal, or
|
||||
clear the state with `$env:IDF_PYTHON_ENV_PATH = $null; $env:IDF_PATH = $null`.
|
||||
|
||||
## Every new PowerShell session
|
||||
The build is self-contained: the C-ITS library comes from
|
||||
`obu-firmware/external/vanetza-idf`, a copy of `external/vanetza-idf` from the
|
||||
colleague's microbu-esp32c5 repository (their commit cf4b99f, unchanged). Pass
|
||||
`-DVANETZA_IDF_DIR=<path>` to `idf.py` to build against another checkout. The
|
||||
first build downloads `espressif/esp-boost` into `managed_components/`.
|
||||
|
||||
Activate the toolchain (obu-firmware has no esp-idf of its own — reuse the
|
||||
receiver firmware's already-installed checkout):
|
||||
Nothing is fetched from or pushed to the colleague's repository (HAW GitLab,
|
||||
urban-mobility-lab/microbu/microbu-esp32c5). To take a newer vanetza-idf from
|
||||
it, copy their `external/vanetza-idf` over this folder, rebuild and test, and
|
||||
commit it here. The rest of their tree (their own VAM firmware, PKI tooling,
|
||||
station-link Python tools, the V2X2MAP bridge) is not part of this repository.
|
||||
|
||||
## Every new PowerShell session
|
||||
|
||||
```powershell
|
||||
Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass
|
||||
C:\Users\Ashin\Documents\micrOBU_workspace\its-g5-receiver-firmware\esp-idf\export.ps1
|
||||
idf.py --version
|
||||
$env:IDF_TOOLS_PATH = "C:\Espressif"
|
||||
C:\Espressif\frameworks\esp-idf-v6.0.2\export.ps1
|
||||
idf.py --version # v6.0.2
|
||||
```
|
||||
|
||||
## Build & flash
|
||||
|
||||
Flash over the board's **UART-bridge port** (CH343; COM3 for the production OBU
|
||||
on the bench), not the native port the phone uses.
|
||||
|
||||
```powershell
|
||||
cd C:\Users\Ashin\AndroidStudioProjects\MicrOBU\obu-firmware
|
||||
idf.py set-target esp32c5 # only needed once per clean build folder
|
||||
idf.py build
|
||||
idf.py -p COM5 -b 921600 flash monitor
|
||||
idf.py -p COM3 -b 921600 flash monitor
|
||||
```
|
||||
|
||||
Swap `COM5` for whatever port the ESP32-C5 enumerates as (Device Manager →
|
||||
Swap `COM3` for whatever port the ESP32-C5's bridge enumerates as (Device Manager →
|
||||
Ports). `monitor` opens the serial console after flashing — `Ctrl+]` to exit.
|
||||
Use `flash` (bootloader + partition table + app), not `app-flash`, whenever the
|
||||
partition table may differ from what the board has.
|
||||
|
||||
### First flash onto a board with the previous firmware
|
||||
|
||||
The partition table changed with the port (NVS 24 KB → 80 KB, app 0x10000 →
|
||||
0x20000), and the old NVS is full of Wi-Fi settings the previous firmware left
|
||||
behind. Erase the whole new NVS range once, then do a full flash:
|
||||
|
||||
```powershell
|
||||
python -m esptool --chip esp32c5 -p COM3 -b 921600 erase-region 0x9000 0x15000
|
||||
idf.py -p COM3 -b 921600 flash
|
||||
```
|
||||
|
||||
Without the erase, the board cannot store the BLE bond and the phone has to pair
|
||||
on every connection (2026-09-23). After it:
|
||||
|
||||
- the boot log shows `0 bonded phone(s) in NVS`, `BLE advertising started as
|
||||
'micrOBU-XXXX'` and `station task ready`; the radio stays off until the app
|
||||
connects;
|
||||
- on the first Connect the app provisions the demo credentials once (the card
|
||||
says "Provisioning the demo credentials"); they stay in NVS from then on;
|
||||
- a phone that was paired with the board before must forget `micrOBU-XXXX` in
|
||||
Android's Bluetooth settings and pair again (passkey 123456).
|
||||
|
||||
Erasing NVS later (e.g. after a partition change) has the same effects.
|
||||
|
||||
## Going back to the previous firmware
|
||||
|
||||
The previous, unsigned C firmware (frame types 0x01-0x05, CAM over USB only)
|
||||
can come back two ways. The app detects it and falls back to its protocol.
|
||||
|
||||
- **Image:** `firmware-backups/` in the repository root (gitignored, on the lab
|
||||
laptop only) holds a full-flash image of the COM3 board as it was before the
|
||||
port, with the esptool command in its README.txt. It restores the old
|
||||
partition table and NVS too.
|
||||
- **Source:** commit `7285fa1` is the last one before the port. Check it out in
|
||||
a separate worktree and build it with the receiver firmware's IDF 6.1
|
||||
(`its-g5-receiver-firmware\esp-idf\export.ps1`). Erase the NVS range as above
|
||||
first, then `flash` (full, since the partition table differs); erasing after
|
||||
flashing would wipe the start of the old app, which sits at 0x10000.
|
||||
|
||||
## If the build fails
|
||||
|
||||
@@ -50,9 +108,9 @@ Ports). `monitor` opens the serial console after flashing — `Ctrl+]` to exit.
|
||||
The board has two USB-C ports — use the right one:
|
||||
|
||||
- **Native USB-C port** (labeled for JTAG/native USB, up to 12 Mbps) — this
|
||||
is where the phone plugs in via USB-OTG. The CAM serial link
|
||||
(`serial_link.c`) runs over the ESP32-C5's native USB Serial/JTAG
|
||||
peripheral on this port, enumerating as a CDC-ACM device under Espressif's
|
||||
is where the phone plugs in via USB-OTG. The station link
|
||||
(`serial_link.cpp`: station-link messages as frame type 0x10 in the 0xAA55
|
||||
framing) runs over the ESP32-C5's native USB Serial/JTAG peripheral on this port, enumerating as a CDC-ACM device under Espressif's
|
||||
VID/PID (0x303A/0x1001).
|
||||
- **UART-bridge port** (labeled for flashing) — this is what you use for
|
||||
`idf.py flash monitor` from your PC. Leave the phone unplugged from this
|
||||
@@ -69,10 +127,10 @@ at all — that isolates a bad/charge-only OTG cable from an app-side issue.
|
||||
|
||||
Work down this list — each step isolates the layer below it.
|
||||
|
||||
1. **Flash and install together.** `SERIAL_LINK_MAX_PAYLOAD` is 512 on both sides.
|
||||
A phone at 512 talking to firmware still at 160 (or vice versa) silently
|
||||
rejects every large frame at the `length exceeds max, resync` branch. Never
|
||||
update one side alone.
|
||||
1. **Flash and install together.** The app detects the firmware generation by
|
||||
its heartbeat and speaks either protocol, but only an app from 2026-09-23 on
|
||||
knows the station-link protocol; messages are at most 512 octets on both
|
||||
sides.
|
||||
2. **Does Android see the device at all?** Plug the phone into the **native**
|
||||
USB-C port, hit Connect, and read logcat for `UsbSerialTransport`. It logs
|
||||
every attached device *and* each device's interfaces. Empty list = cable /
|
||||
@@ -81,10 +139,12 @@ Work down this list — each step isolates the layer below it.
|
||||
composite device — expect CDC control (class 2) + CDC data (class 10) +
|
||||
vendor-specific JTAG (class 255) in that dump. Compare against the `ports=`
|
||||
count on the `matched device` line.
|
||||
4. **Is the link alive?** The firmware sends a STATUS heartbeat at 1 Hz
|
||||
regardless of radio traffic, and the app marks the link ERROR after ~3.5 s of
|
||||
silence. Connected-and-staying-connected means device→host actually works.
|
||||
5. **If it connects but no CAM_RX ever arrives** — suspect DTR. The app now
|
||||
4. **Is the link alive?** The firmware sends a STATUS at 1 Hz regardless of
|
||||
radio traffic, and the app marks the link ERROR after ~3.5 s of silence.
|
||||
Connected-and-staying-connected means device→host actually works. The board
|
||||
starts its radio only after the app's STATION_CONFIGURE, so nothing is
|
||||
received before Connect.
|
||||
5. **If it connects but no V2X_RX ever arrives** — suspect DTR. The app now
|
||||
asserts DTR/RTS on open (`openDevice()` in `UsbSerialTransport.kt`), because
|
||||
`CdcAcmSerialDriver` doesn't do it by default and the ESP32's USB Serial/JTAG
|
||||
endpoint may gate TX on the host opening the CDC line. **This is still
|
||||
@@ -93,14 +153,28 @@ Work down this list — each step isolates the layer below it.
|
||||
next to the VID/PID note, so nobody has to guess again.
|
||||
6. **Watch the counters, not just "Sent: N".** The CAM Pinger card shows
|
||||
consecutive write failures (phone side) and the firmware's tx-failure /
|
||||
oversize-drop / CRC-error totals from the heartbeat. A rising `tx fail` means
|
||||
CAMs reach the ESP32 but `esp_wifi_80211_tx` rejects them — a radio problem,
|
||||
not a link problem.
|
||||
RX-queue-drop / CRC-error totals from the heartbeat; the connection card
|
||||
shows tickets, signed and refused counts. A rising `tx fail` means messages
|
||||
reach the ESP32 but the radio refuses them — a radio problem, not a link
|
||||
problem. A rising `refused` with signing on usually means no ticket (NVS
|
||||
erased: reconnect so the app provisions again).
|
||||
|
||||
## Connecting over Bluetooth instead
|
||||
|
||||
Settings > Connection > ESP32-C5 > link: Bluetooth, then Connect. The board
|
||||
advertises as `micrOBU-XXXX` (last two bytes of its BT MAC; `micrOBU-4AFA` on
|
||||
COM3), but only while nothing uses its native USB port. Android asks to pair
|
||||
the first time: passkey **123456** (fixed in `simple_ble.cpp`). The bond is kept
|
||||
on both sides; the board keeps one bond, so pairing a second phone or a PC
|
||||
replaces the first. Log lines on the console start with `cits_ble:`.
|
||||
|
||||
## Notes
|
||||
|
||||
- No `git submodule update` needed here — obu-firmware has no pinned
|
||||
submodule of its own, unlike its-g5-receiver-firmware.
|
||||
- Don't use the global "ESP-IDF 5.5 PowerShell" shortcut — always export from
|
||||
the receiver-firmware's pinned checkout, since this firmware's undocumented
|
||||
PHY/driver internals were verified against that specific build.
|
||||
- Don't use the global "ESP-IDF 5.5 PowerShell" shortcut or the receiver
|
||||
firmware's 6.1 checkout — export from `esp-idf-v6.0.2`, the version the
|
||||
vanetza-idf radio's undocumented driver internals were verified against.
|
||||
- The console (ESP_LOG, boot messages, panics) stays on the UART-bridge port.
|
||||
Opening it resets the board; do that only while nothing else depends on the
|
||||
session.
|
||||
|
||||
+55
-1
@@ -1,4 +1,58 @@
|
||||
# OBU transmit firmware - Phase 2 (in progress: HLN-SV DENM beacon)
|
||||
# obu-firmware
|
||||
|
||||
## Since 2026-09-23: signed ITS on vanetza-idf
|
||||
|
||||
This firmware is a port of the colleague's standalone ESP32-C5 VRU station
|
||||
(`microbu-esp32c5/firmware`, in their own repository; only its `external/vanetza-idf` is copied
|
||||
here, to `obu-firmware/external/vanetza-idf`).
|
||||
From it: the vanetza-idf C-ITS stack (BTP, GeoNetworking, the TS 103 097 security entity with
|
||||
credentials in NVS), the station-link protocol v1 (`link_protocol.*`, `link_service.*`) over the
|
||||
native USB port (`serial_link.*`, frame type 0x10 in the same 0xAA55 framing as before) and over BLE
|
||||
GATT (`simple_ble.*`), and the radio adapter (`c5_radio.*`, `otm_tx_custom.c`). Build with ESP-IDF
|
||||
**6.0.2**; see FLASHING.md.
|
||||
|
||||
The phone builds CAM or VAM, configures the station, provisions credentials, sends PoTi and hands
|
||||
each message over as a BTP-DATA.request; the firmware adds GN/BTP, signs with the authorization
|
||||
ticket, and transmits. The phone side is `Esp32Link.kt` in the app.
|
||||
|
||||
Changed or added for this project (search for `MicrOBU:` in the sources):
|
||||
|
||||
- **Reception stays as it was.** vanetza-idf decapsulates with `itsGnSnDecapResultHandling =
|
||||
STRICT`, so it drops unsigned traffic (the bench sim car) and everything signed under a root other
|
||||
than the provisioned demo root (every RSU). Every captured frame therefore also goes through the
|
||||
previous firmware's `gn_unwrap.c` and reaches the phone as link opcode `V2X_RX` (0x85), whose body
|
||||
is exactly the old `SERIAL_MSG_V2X_RX` payload (`Station::forward_raw`). The app's receive side is
|
||||
unchanged.
|
||||
- **Unsigned transmission is still possible.** The colleague's station refuses unsecured requests.
|
||||
Here a request with GN security profile 1 goes out with the previous firmware's `geonet.c` header
|
||||
and the position of the last PoTi (`Station::unsecured_request`); the app's "Sign outgoing
|
||||
messages" setting decides per message.
|
||||
- **Console on UART0.** ESP_LOG stays on the CH343 bridge port (COM3 on the bench); the native port
|
||||
carries only link frames. The colleague's single-port board routes the log into LOG frames there
|
||||
(`CONFIG_MICROBU_LOG_OVER_LINK`, off here).
|
||||
- **BLE pauses while USB is in use** (`CONFIG_MICROBU_BLE_USB_IDLE_MS`, 3 s): BLE and ITS-G5 share
|
||||
the C5's one RF front end. Whether a live BLE connection disturbs 5.9 GHz is still unmeasured
|
||||
(TODO.md).
|
||||
- A serial write no longer stalls the station task when no USB host is present (BLE-only use).
|
||||
- A notification longer than the ATT MTU is dropped instead of silently truncated.
|
||||
- The Wi-Fi RX callback's 2.4 KB capture buffer is static rather than on the driver task's stack
|
||||
(the previous firmware's commit 04b0076 fixed the same risk).
|
||||
- Manual country policy and the TX-power read-back from the previous firmware's radio bring-up.
|
||||
- The activity LED (GPIO27 on the colleague's XIAO board) is off unless configured.
|
||||
|
||||
Credentials: the app ships `assets/demo-chain.vcr`, a throwaway chain generated 2026-09-23 with the
|
||||
colleague's `vidf_issue` (root `6E7D0374FB021901`, AA `B3312F29844299E0`, AT `B80B49387A4C12EB`
|
||||
valid two years, permissions psid 36 SSP `010000` and psid 638 SSP `01`). The colleague's own demo
|
||||
chain only grants psid 638 and so cannot sign CAMs. Not EU-registered: receivers that verify against
|
||||
the EU trust list drop what it signs.
|
||||
|
||||
Time: the signature's generationTime comes from the PoTi timestamp, which follows the app's
|
||||
`ItsTime` convention (UTC-based, no leap seconds). The colleague's VBS adds the 5 leap seconds.
|
||||
Which one is right is the open question documented in `ItsTime.kt`.
|
||||
|
||||
## Earlier notes (Phase 2, superseded)
|
||||
|
||||
### OBU transmit firmware - Phase 2 (in progress: HLN-SV DENM beacon)
|
||||
|
||||
Started. See `docs/04-transmit-setup.md` in the project root for build/flash
|
||||
steps and how to validate this against your own sniffer.
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
dependencies:
|
||||
espressif/esp-boost:
|
||||
component_hash: 45cfa63ade2ad7c489203ab2ddf3f33be50ec9cab752b6eb17a79f06aee8f8f0
|
||||
dependencies:
|
||||
- name: idf
|
||||
require: private
|
||||
version: '>=5.3'
|
||||
source:
|
||||
registry_url: https://components.espressif.com/
|
||||
type: service
|
||||
version: 0.4.1
|
||||
idf:
|
||||
source:
|
||||
type: idf
|
||||
version: 6.0.2
|
||||
direct_dependencies:
|
||||
- espressif/esp-boost
|
||||
- idf
|
||||
manifest_hash: 3fca1283d556ade5b08c63857e23cb3b08582f1d1c24bb7c1d5e374f438415d7
|
||||
target: esp32c5
|
||||
version: 3.0.0
|
||||
@@ -0,0 +1,14 @@
|
||||
build
|
||||
.cache/
|
||||
build-*/
|
||||
build-*.log
|
||||
sdkconfig
|
||||
sdkconfig.old
|
||||
sdkconfig.*.old
|
||||
managed_components/
|
||||
dependencies.lock
|
||||
__pycache__/
|
||||
conanfile.pyc
|
||||
doxygen/html/
|
||||
www/
|
||||
build.asn1/
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
if(ESP_PLATFORM)
|
||||
include("${CMAKE_CURRENT_LIST_DIR}/ports/esp_idf/CMakeLists.txt")
|
||||
return()
|
||||
endif()
|
||||
cmake_minimum_required(VERSION 3.14)
|
||||
cmake_policy(VERSION 3.14...3.31)
|
||||
project(Vanetza VERSION 26.02)
|
||||
set(VANETZA_SOVERSION 0)
|
||||
list(APPEND CMAKE_MODULE_PATH ${PROJECT_SOURCE_DIR}/cmake)
|
||||
|
||||
# Look up threading library (usually pthread)
|
||||
set(CMAKE_THREAD_PREFER_PTHREAD TRUE)
|
||||
set(THREADS_PREFER_PTHREAD_FLAG TRUE)
|
||||
find_package(Threads MODULE)
|
||||
|
||||
# Build configuration options
|
||||
option(BUILD_SHARED_LIBS "Build shared libraries" OFF)
|
||||
|
||||
option(BUILD_TESTS "Build unit tests" OFF)
|
||||
if(BUILD_TESTS)
|
||||
enable_testing()
|
||||
add_subdirectory(gtest)
|
||||
endif()
|
||||
|
||||
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${PROJECT_BINARY_DIR}/bin)
|
||||
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${PROJECT_BINARY_DIR}/lib)
|
||||
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${PROJECT_BINARY_DIR}/lib/static)
|
||||
|
||||
include(GNUInstallDirs)
|
||||
include(UseVanetza)
|
||||
|
||||
# Enable usage of targets' folder property (good for Visual Studio)
|
||||
set_property(GLOBAL PROPERTY USE_FOLDERS ON)
|
||||
|
||||
# Find project dependencies
|
||||
find_package(Boost 1.70 REQUIRED COMPONENTS date_time OPTIONAL_COMPONENTS program_options CONFIG)
|
||||
vanetza_optional_dependency(GeographicLib 1.37 VANETZA_WITH_GEOGRAPHICLIB "Use GeographicLib for precise geodesy")
|
||||
vanetza_optional_dependency(CapnProto 0.8 VANETZA_WITH_RPC "Enable RPC support")
|
||||
vanetza_optional_dependency(CryptoPP 5.6.1 VANETZA_WITH_CRYPTOPP "Enable Crypto++ support")
|
||||
vanetza_optional_dependency(OpenSSL 1.1.1 VANETZA_WITH_OPENSSL "Enable OpenSSL extensions")
|
||||
|
||||
option(VANETZA_WITH_PQC "Enable the experimental hybrid-PQC certificate profile" OFF)
|
||||
if(VANETZA_WITH_PQC)
|
||||
find_package(liboqs 0.14 CONFIG REQUIRED)
|
||||
endif()
|
||||
|
||||
option(VANETZA_EMBED_COUNTRY_DATA "Embed country boundary data into geodesy library" OFF)
|
||||
|
||||
add_subdirectory(vanetza/access)
|
||||
add_subdirectory(vanetza/asn1)
|
||||
add_subdirectory(vanetza/btp)
|
||||
add_subdirectory(vanetza/common)
|
||||
add_subdirectory(vanetza/dcc)
|
||||
add_subdirectory(vanetza/facilities)
|
||||
add_subdirectory(vanetza/geodesy)
|
||||
add_subdirectory(vanetza/geonet)
|
||||
add_subdirectory(vanetza/gnss)
|
||||
add_subdirectory(vanetza/net)
|
||||
add_subdirectory(vanetza/rpc)
|
||||
add_subdirectory(vanetza/security)
|
||||
add_subdirectory(vanetza/units)
|
||||
|
||||
option(BUILD_SOCKTAP "Build socktap application" OFF)
|
||||
if(BUILD_SOCKTAP)
|
||||
add_subdirectory(tools/socktap)
|
||||
endif()
|
||||
|
||||
option(BUILD_CERTIFY "Build certify application" OFF)
|
||||
if(BUILD_CERTIFY)
|
||||
add_subdirectory(tools/certify)
|
||||
if(VANETZA_WITH_PQC)
|
||||
add_subdirectory(tools/certify-pqc)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
option(BUILD_BENCHMARK "Build benchmark application" OFF)
|
||||
if(BUILD_BENCHMARK)
|
||||
add_subdirectory(tools/benchmark)
|
||||
endif()
|
||||
|
||||
option(BUILD_FUZZ "Build fuzz harness" OFF)
|
||||
if(BUILD_FUZZ)
|
||||
add_subdirectory(tools/fuzz-harness)
|
||||
endif()
|
||||
|
||||
option(BUILD_PKI "Build PKI tools" OFF)
|
||||
if(BUILD_PKI AND VANETZA_WITH_PQC)
|
||||
message(FATAL_ERROR
|
||||
"BUILD_PKI cannot be combined with VANETZA_WITH_PQC: the unchanged PKI "
|
||||
"client uses Vanetza's standard Security ASN.1 profile. Build the PKI "
|
||||
"client separately with VANETZA_WITH_PQC=OFF.")
|
||||
endif()
|
||||
if(BUILD_PKI)
|
||||
add_subdirectory(tools/pki)
|
||||
endif()
|
||||
|
||||
# interface library for convenience
|
||||
get_property(_components GLOBAL PROPERTY VANETZA_COMPONENTS)
|
||||
add_library(vanetza INTERFACE)
|
||||
add_library(Vanetza::vanetza ALIAS vanetza)
|
||||
foreach(_component IN LISTS _components)
|
||||
target_link_libraries(vanetza INTERFACE ${_component})
|
||||
endforeach()
|
||||
|
||||
# installation rules
|
||||
include(CMakePackageConfigHelpers)
|
||||
set(CMAKECONFIG_INSTALL_DIR "${CMAKE_INSTALL_LIBDIR}/cmake/Vanetza")
|
||||
set(CMAKECONFIG_BUILD_DIR "${CMAKE_CURRENT_BINARY_DIR}/cmake-config")
|
||||
write_basic_package_version_file(${CMAKECONFIG_BUILD_DIR}/VanetzaConfigVersion.cmake
|
||||
COMPATIBILITY ExactVersion)
|
||||
configure_package_config_file(cmake/VanetzaConfig.cmake.in
|
||||
${CMAKECONFIG_BUILD_DIR}/VanetzaConfig.cmake
|
||||
INSTALL_DESTINATION ${CMAKECONFIG_INSTALL_DIR}
|
||||
PATH_VARS CMAKE_INSTALL_INCLUDEDIR)
|
||||
|
||||
install(TARGETS vanetza EXPORT ${PROJECT_NAME})
|
||||
|
||||
install(FILES
|
||||
${CMAKECONFIG_BUILD_DIR}/VanetzaConfig.cmake
|
||||
${CMAKECONFIG_BUILD_DIR}/VanetzaConfigVersion.cmake
|
||||
DESTINATION ${CMAKECONFIG_INSTALL_DIR})
|
||||
|
||||
install(DIRECTORY ${PROJECT_SOURCE_DIR}/cmake/
|
||||
DESTINATION ${CMAKECONFIG_INSTALL_DIR}
|
||||
FILES_MATCHING
|
||||
PATTERN "Find*.cmake")
|
||||
|
||||
install(EXPORT ${PROJECT_NAME} NAMESPACE Vanetza:: FILE VanetzaTargets.cmake DESTINATION ${CMAKECONFIG_INSTALL_DIR})
|
||||
|
||||
# install all C++ headers except those found for tests
|
||||
file(GLOB_RECURSE _vanetza_headers
|
||||
RELATIVE ${PROJECT_SOURCE_DIR}
|
||||
CONFIGURE_DEPENDS
|
||||
vanetza/*.hpp)
|
||||
list(FILTER _vanetza_headers EXCLUDE REGEX "/tests/")
|
||||
if(NOT VANETZA_WITH_PQC)
|
||||
list(FILTER _vanetza_headers EXCLUDE REGEX "vanetza/security/pqc/")
|
||||
endif()
|
||||
foreach(_header IN LISTS _vanetza_headers)
|
||||
get_filename_component(_dir ${_header} DIRECTORY)
|
||||
install(FILES ${_header}
|
||||
DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}/${_dir})
|
||||
endforeach()
|
||||
|
||||
# export build tree (allows import by outside projects)
|
||||
option(VANETZA_EXPORT_PACKAGE "Export Vanetza build directory to CMake package registry" OFF)
|
||||
if(VANETZA_EXPORT_PACKAGE)
|
||||
export(EXPORT ${PROJECT_NAME} NAMESPACE Vanetza:: FILE VanetzaExports.cmake)
|
||||
file(COPY ${CMAKECONFIG_BUILD_DIR}/VanetzaConfigVersion.cmake
|
||||
DESTINATION ${CMAKE_CURRENT_BINARY_DIR})
|
||||
configure_file(cmake/VanetzaExportsConfig.cmake.in
|
||||
${CMAKE_CURRENT_BINARY_DIR}/VanetzaConfig.cmake @ONLY)
|
||||
export(PACKAGE ${PROJECT_NAME})
|
||||
endif()
|
||||
|
||||
# mark build directory as cache dir, see https://bford.info/cachedir
|
||||
file(WRITE ${PROJECT_BINARY_DIR}/CACHEDIR.TAG
|
||||
"Signature: 8a477f597d28d172789f06886806bc55")
|
||||
+2983
File diff suppressed because it is too large
Load Diff
+1
@@ -0,0 +1 @@
|
||||
rsource "ports/esp_idf/Kconfig"
|
||||
+853
@@ -0,0 +1,853 @@
|
||||
This file contains copies of the GNU Lesser General Public License as well
|
||||
as the GNU General Public License, both in their third version.
|
||||
|
||||
|
||||
# GNU Lesser General Public License (LGPL) v3
|
||||
|
||||
```
|
||||
GNU LESSER GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
|
||||
This version of the GNU Lesser General Public License incorporates
|
||||
the terms and conditions of version 3 of the GNU General Public
|
||||
License, supplemented by the additional permissions listed below.
|
||||
|
||||
0. Additional Definitions.
|
||||
|
||||
As used herein, "this License" refers to version 3 of the GNU Lesser
|
||||
General Public License, and the "GNU GPL" refers to version 3 of the GNU
|
||||
General Public License.
|
||||
|
||||
"The Library" refers to a covered work governed by this License,
|
||||
other than an Application or a Combined Work as defined below.
|
||||
|
||||
An "Application" is any work that makes use of an interface provided
|
||||
by the Library, but which is not otherwise based on the Library.
|
||||
Defining a subclass of a class defined by the Library is deemed a mode
|
||||
of using an interface provided by the Library.
|
||||
|
||||
A "Combined Work" is a work produced by combining or linking an
|
||||
Application with the Library. The particular version of the Library
|
||||
with which the Combined Work was made is also called the "Linked
|
||||
Version".
|
||||
|
||||
The "Minimal Corresponding Source" for a Combined Work means the
|
||||
Corresponding Source for the Combined Work, excluding any source code
|
||||
for portions of the Combined Work that, considered in isolation, are
|
||||
based on the Application, and not on the Linked Version.
|
||||
|
||||
The "Corresponding Application Code" for a Combined Work means the
|
||||
object code and/or source code for the Application, including any data
|
||||
and utility programs needed for reproducing the Combined Work from the
|
||||
Application, but excluding the System Libraries of the Combined Work.
|
||||
|
||||
1. Exception to Section 3 of the GNU GPL.
|
||||
|
||||
You may convey a covered work under sections 3 and 4 of this License
|
||||
without being bound by section 3 of the GNU GPL.
|
||||
|
||||
2. Conveying Modified Versions.
|
||||
|
||||
If you modify a copy of the Library, and, in your modifications, a
|
||||
facility refers to a function or data to be supplied by an Application
|
||||
that uses the facility (other than as an argument passed when the
|
||||
facility is invoked), then you may convey a copy of the modified
|
||||
version:
|
||||
|
||||
a) under this License, provided that you make a good faith effort to
|
||||
ensure that, in the event an Application does not supply the
|
||||
function or data, the facility still operates, and performs
|
||||
whatever part of its purpose remains meaningful, or
|
||||
|
||||
b) under the GNU GPL, with none of the additional permissions of
|
||||
this License applicable to that copy.
|
||||
|
||||
3. Object Code Incorporating Material from Library Header Files.
|
||||
|
||||
The object code form of an Application may incorporate material from
|
||||
a header file that is part of the Library. You may convey such object
|
||||
code under terms of your choice, provided that, if the incorporated
|
||||
material is not limited to numerical parameters, data structure
|
||||
layouts and accessors, or small macros, inline functions and templates
|
||||
(ten or fewer lines in length), you do both of the following:
|
||||
|
||||
a) Give prominent notice with each copy of the object code that the
|
||||
Library is used in it and that the Library and its use are
|
||||
covered by this License.
|
||||
|
||||
b) Accompany the object code with a copy of the GNU GPL and this license
|
||||
document.
|
||||
|
||||
4. Combined Works.
|
||||
|
||||
You may convey a Combined Work under terms of your choice that,
|
||||
taken together, effectively do not restrict modification of the
|
||||
portions of the Library contained in the Combined Work and reverse
|
||||
engineering for debugging such modifications, if you also do each of
|
||||
the following:
|
||||
|
||||
a) Give prominent notice with each copy of the Combined Work that
|
||||
the Library is used in it and that the Library and its use are
|
||||
covered by this License.
|
||||
|
||||
b) Accompany the Combined Work with a copy of the GNU GPL and this license
|
||||
document.
|
||||
|
||||
c) For a Combined Work that displays copyright notices during
|
||||
execution, include the copyright notice for the Library among
|
||||
these notices, as well as a reference directing the user to the
|
||||
copies of the GNU GPL and this license document.
|
||||
|
||||
d) Do one of the following:
|
||||
|
||||
0) Convey the Minimal Corresponding Source under the terms of this
|
||||
License, and the Corresponding Application Code in a form
|
||||
suitable for, and under terms that permit, the user to
|
||||
recombine or relink the Application with a modified version of
|
||||
the Linked Version to produce a modified Combined Work, in the
|
||||
manner specified by section 6 of the GNU GPL for conveying
|
||||
Corresponding Source.
|
||||
|
||||
1) Use a suitable shared library mechanism for linking with the
|
||||
Library. A suitable mechanism is one that (a) uses at run time
|
||||
a copy of the Library already present on the user's computer
|
||||
system, and (b) will operate properly with a modified version
|
||||
of the Library that is interface-compatible with the Linked
|
||||
Version.
|
||||
|
||||
e) Provide Installation Information, but only if you would otherwise
|
||||
be required to provide such information under section 6 of the
|
||||
GNU GPL, and only to the extent that such information is
|
||||
necessary to install and execute a modified version of the
|
||||
Combined Work produced by recombining or relinking the
|
||||
Application with a modified version of the Linked Version. (If
|
||||
you use option 4d0, the Installation Information must accompany
|
||||
the Minimal Corresponding Source and Corresponding Application
|
||||
Code. If you use option 4d1, you must provide the Installation
|
||||
Information in the manner specified by section 6 of the GNU GPL
|
||||
for conveying Corresponding Source.)
|
||||
|
||||
5. Combined Libraries.
|
||||
|
||||
You may place library facilities that are a work based on the
|
||||
Library side by side in a single library together with other library
|
||||
facilities that are not Applications and are not covered by this
|
||||
License, and convey such a combined library under terms of your
|
||||
choice, if you do both of the following:
|
||||
|
||||
a) Accompany the combined library with a copy of the same work based
|
||||
on the Library, uncombined with any other library facilities,
|
||||
conveyed under the terms of this License.
|
||||
|
||||
b) Give prominent notice with the combined library that part of it
|
||||
is a work based on the Library, and explaining where to find the
|
||||
accompanying uncombined form of the same work.
|
||||
|
||||
6. Revised Versions of the GNU Lesser General Public License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions
|
||||
of the GNU Lesser General Public License from time to time. Such new
|
||||
versions will be similar in spirit to the present version, but may
|
||||
differ in detail to address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Library as you received it specifies that a certain numbered version
|
||||
of the GNU Lesser General Public License "or any later version"
|
||||
applies to it, you have the option of following the terms and
|
||||
conditions either of that published version or of any later version
|
||||
published by the Free Software Foundation. If the Library as you
|
||||
received it does not specify a version number of the GNU Lesser
|
||||
General Public License, you may choose any version of the GNU Lesser
|
||||
General Public License ever published by the Free Software Foundation.
|
||||
|
||||
If the Library as you received it specifies that a proxy can decide
|
||||
whether future versions of the GNU Lesser General Public License shall
|
||||
apply, that proxy's public statement of acceptance of any version is
|
||||
permanent authorization for you to choose that version for the
|
||||
Library.
|
||||
```
|
||||
|
||||
|
||||
# GNU General Public License (GPL) v3
|
||||
|
||||
```
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<http://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<http://www.gnu.org/philosophy/why-not-lgpl.html>.
|
||||
```
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
# Vanetza Upstream Provenance
|
||||
|
||||
This directory (`external/vanetza-idf`) contains the Vanetza protocol stack and its ESP-IDF integration for the micrOBU ESP32-C5 ITS-S station.
|
||||
|
||||
## Upstream Software Basis
|
||||
|
||||
- **Project**: Vanetza (ETSI C-ITS protocol stack implementation in C++17)
|
||||
- **Upstream Repository**: `https://github.com/riebl/vanetza.git`
|
||||
- **Pinned Upstream Revision**: `a7cacc1879f3e2124dfb8ef2d83886b1e82d3b36`
|
||||
- **Author / Upstream Maintainer**: Raphael Riebl et al.
|
||||
- **Citekey**: `rieblRieblVanetza2026`
|
||||
- **License**: BSD-3-Clause (retained in `LICENSE.md`)
|
||||
|
||||
## Integrated Components & Third-Party Dependencies
|
||||
|
||||
1. **Boost C++ Libraries (v1.87.0)**:
|
||||
- Located in `ports/esp_idf/third_party/` (`circular_buffer`, `geometry`, `heap`, `iostreams`, `multiprecision`, `rational`).
|
||||
- License: Boost Software License 1.0 (`BSL-1.0`).
|
||||
|
||||
2. **OpenTrafficMap (OTM) Transmitter**:
|
||||
- Upstream: `https://codeberg.org/opentrafficmap/its-g5-receiver-firmware_txenabled.git`
|
||||
- Reviewed Revision: `674e34128279235ba34c9f8d778f43cf1d075397`
|
||||
- Core file: `ports/esp_idf/third_party/otm/main/tx_custom.c` (guarded by SHA-256 in `firmware/CMakeLists.txt`).
|
||||
|
||||
3. **ESP-IDF Port (`ports/esp_idf`)**:
|
||||
- Implements hardware-accelerated crypto (mbedTLS ECC/ECDSA/SHA), NVS credential persistence, ESP32-C5 802.11p PHY bindings (`radio_c5.cmake`), and localhost PKI verification tooling (`ports/esp_idf/tools/local_pki.py`, `pki_client.py`).
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
# vanetza-idf
|
||||
|
||||
Modular C++17 Vanetza library for ESP-IDF. Choose an access, network/transport,
|
||||
or facilities-codec profile; supply your own platform and radio adapters.
|
||||
The portable core targets the ESP32 family. The integrated C5 radio backend
|
||||
is experimental and requires separate hardware validation.
|
||||
|
||||
**Start with the [ESP-IDF integration guide](docs/idf/README.md).** It covers
|
||||
installation, Kconfig, ownership/event-loop rules, examples, and standalone
|
||||
host/device tests. [Standards traceability](docs/idf/standards.md) links the public
|
||||
interfaces to precise ETSI editions and clauses.
|
||||
The [interface assessment](docs/idf/interface-assessment.md) identifies remaining
|
||||
contract and capability gaps. [Test campaigns](docs/idf/test-campaigns.md) explains
|
||||
how the separately installed ETSI framework and two-C5 radio tests connect.
|
||||
|
||||
**This is an experimental port, not a completed ETSI-conformant stack.**
|
||||
See [implementation and conformance gaps](docs/idf/conformance.md) and
|
||||
[validation results](docs/idf/validation.md). CAM/DENM/VAM codecs and endpoints
|
||||
are distinct from complete Basic Services.
|
||||
|
||||
## Components and configurable boundaries
|
||||
|
||||
The arrangement follows the facilities, networking/transport and access layers
|
||||
of the ETSI ITS station architecture, with management and security as shared
|
||||
cross-layer services. It is an implementation map, inspired by
|
||||
[TS 102 723-11, clauses 4–5](https://www.etsi.org/deliver/etsi_ts/102700_102799/10272311/01.01.01_60/ts_10272311v010101p.pdf)
|
||||
and [EN 303 797, Annex B](https://www.etsi.org/deliver/etsi_en/303700_303799/303797/02.01.01_60/en_303797v020101p.pdf).
|
||||
It does not imply that every service in those figures is implemented.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph station["vanetza-idf"]
|
||||
direction TB
|
||||
|
||||
subgraph entry["Application / test entry points"]
|
||||
direction LR
|
||||
application["Application or upper tester"]
|
||||
f_entry["Facilities PDU entry"]
|
||||
b_entry["BTP-DATA entry"]
|
||||
a_entry["AL_DATA entry"]
|
||||
application --> f_entry
|
||||
application --> b_entry
|
||||
application --> a_entry
|
||||
end
|
||||
|
||||
subgraph dataplane["Protocol data plane"]
|
||||
direction LR
|
||||
|
||||
subgraph facilities["Facilities"]
|
||||
direction TB
|
||||
codecs["CAM R2 / DENM R2 / VAM R2\nASN.1 codecs"]
|
||||
basic_services["CA / DEN / VRU Basic Services\npending"]
|
||||
end
|
||||
|
||||
subgraph nt["Networking & Transport"]
|
||||
direction TB
|
||||
btp["BTP-A / BTP-B"]
|
||||
gn["GeoNetworking\nSHB / GBC"]
|
||||
btp --> gn
|
||||
end
|
||||
|
||||
subgraph access_layer["Access boundary"]
|
||||
direction TB
|
||||
access["AL_DATA\nparameter validation"]
|
||||
end
|
||||
|
||||
codecs --> btp
|
||||
gn --> access
|
||||
end
|
||||
|
||||
subgraph shared["Shared / cross-layer services"]
|
||||
direction LR
|
||||
position["Position + clock"]
|
||||
management["Management\nMIB + configuration"]
|
||||
security["Security entity\nTS 103 097 signing + ticket pool\nverification pending"]
|
||||
saps["SAP bindings\nSN / SF / MN / MF / MI"]
|
||||
end
|
||||
|
||||
f_entry --> codecs
|
||||
b_entry --> btp
|
||||
a_entry --> access
|
||||
|
||||
position -.-> gn
|
||||
management -.-> gn
|
||||
security -.-> gn
|
||||
saps -.-> security
|
||||
saps -.-> gn
|
||||
end
|
||||
|
||||
subgraph integration["Platform / external integration"]
|
||||
direction LR
|
||||
etsi["External ETSI TTCN-3\nframework + SUT adapter"]
|
||||
hil["Optional HIL framing\nbounded VID1 envelope"]
|
||||
adapter["External access adapter\nsoftware lower tester / radio"]
|
||||
radio["ESP32-C5 radio backend\nexperimental; DCC + HW validation pending"]
|
||||
|
||||
hil -.-> etsi
|
||||
adapter -.-> radio
|
||||
end
|
||||
|
||||
etsi -.-> application
|
||||
etsi -.-> adapter
|
||||
access <-->|"owned GNPDU + metadata"| adapter
|
||||
```
|
||||
|
||||
The main transmit path is deliberately kept horizontal: Facilities → BTP →
|
||||
GeoNetworking → Access. Management, position/time and security are shown as
|
||||
shared services because they support protocol processing rather than form another
|
||||
encapsulation stage. Reception travels back up the selected layers. The access
|
||||
profile omits the network and facilities layers; the network profile adds
|
||||
BTP/GeoNetworking; the facilities profile also enables the individually
|
||||
selectable codecs. Complete CA, DEN and VRU Basic Services are still pending.
|
||||
The optional HIL envelope carries adapter payloads; it is not an ETSI protocol.
|
||||
|
||||
## External TTCN-3 and HIL
|
||||
|
||||
Install the TTCN-3 runtime and the [official ETSI ITS framework](https://forge.etsi.org/rep/ITS/TS.ITS)
|
||||
separately. The library does not install or bundle them. The test application
|
||||
connects that framework's suite-specific SUT adapter to these points:
|
||||
|
||||
| Point | API/boundary | Intended use |
|
||||
|---|---|---|
|
||||
| Upper tester | Named NF-SAP binding, `Stack::request`, position/time/security injection | Stimulate implemented behavior and collect actual results; full service operations remain pending |
|
||||
| Software lower tester | `Access::request` and `Stack::indicate` | Inject/observe GN traffic while the protocol implementation runs on the ESP32 |
|
||||
| Physical lower tester | Independent ITS-G5 capture/injection radio | Validate transmitted frames, access behavior and RF properties |
|
||||
|
||||
Enable `CONFIG_VANETZA_IDF_HIL` for optional bounded framing. USB/UART/BLE/IP
|
||||
transports and suite-specific command decoding remain in the test application
|
||||
and host SUT adapter. Preserve the ETSI codec payload and map it to the actual
|
||||
service under test; never synthesize a successful acknowledgement.
|
||||
|
||||
The [hook-up guide](docs/idf/README.md#connect-a-separately-installed-etsi-ttcn-3-framework)
|
||||
explains payload boundaries, metadata, real-time versus injected-time tests,
|
||||
PICS/PIXIT, Release 2 ATS adaptation, and evidence retention. A mirrored SUT
|
||||
packet is software evidence; radio conformance requires independent observation.
|
||||
|
||||
## Foundation and acknowledgements
|
||||
|
||||
vanetza-idf is an independent ESP-IDF adaptation built on
|
||||
[Vanetza](https://github.com/riebl/vanetza). It is not maintained by, affiliated
|
||||
with, or endorsed by the Vanetza team. Upstream authorship and license notices
|
||||
remain with the original source files. See the
|
||||
[upstream documentation](https://www.vanetza.org/) for the original library.
|
||||
|
||||
Thank you to [OpenTrafficMap](https://codeberg.org/opentrafficmap) for the
|
||||
foundational work that established how to transmit ITS-G5 using the ESP32-C5
|
||||
radio, shared in their
|
||||
[receiver firmware with TX enabled](https://codeberg.org/opentrafficmap/its-g5-receiver-firmware_txenabled).
|
||||
The C5 backend builds on that work. Its private SDK dependencies and validation
|
||||
status are documented separately from the portable protocol core.
|
||||
|
||||
Vanetza is licensed under LGPLv3; see [LICENSE.md](LICENSE.md).
|
||||
Third-party components retain their respective notices and licensing terms.
|
||||
@@ -0,0 +1,47 @@
|
||||
These ASN.1 files are taken from their respective standardisation documents and are **not** licensed the same way as Vanetza itself.
|
||||
Please refer to the referenced standardisation documents for details about the applicable Intellectual Property Rights (IPR).
|
||||
To the best of my knowledge, ETSI does not object to bundling ASN.1 files of their published documents along with software.
|
||||
Some editorial changes may have been applied, e.g. removal of trailing whitespace etc.
|
||||
Code generated from the files located here can be found in the *vanetza/asn1/its* directory.
|
||||
|
||||
# Standardisation documents
|
||||
|
||||
- *EN302637-2v141-CAM.asn* from ETSI EN 302 637-2 v1.4.1 (2019-04)
|
||||
- *EN302637-3v131-DENM.asn* from ETSI EN 302 637-3 v1.3.1 (2019-04)
|
||||
- *TS102894-2v131-CDD.asn* from ETSI TS 102 894-2 v1.3.1 (2018-08)
|
||||
- *TS102941v131-\*.asn* from ETSI TS 102 941 v1.3.1 (2019-02)
|
||||
- *TS103097v131.asn* from ETSI TS 103 097 v1.3.1 (2017-10)
|
||||
- *TS103097v211-\*.asn* from ETSI TS 103 097 v2.1.1 (2021-10)
|
||||
- *TR103562v211.asn* from ETSI TR 103 562 v2.1.1 (2019-12)
|
||||
|
||||
- *IEEE1609dot2.asn* and *IEEE1609dot2BaseTypes.asn* from IEEE 1609.2 as printed in ETSI TS 103 097 v1.3.1
|
||||
|
||||
# ETSI WebSVN
|
||||
|
||||
You may find these ITS ASN.1 modules also in ETSI's public SVN repository: http://oldforge.etsi.org/websvn/listing.php?repname=ITS.ITS_ASN1
|
||||
While above source is still online (in March 2020), ETSI seems to have moved to https://forge.etsi.org/rep/ITS/ITS_ASN1 by now.
|
||||
This newer source also contains a license file explicitly. A copy is included below for reference.
|
||||
|
||||
> Copyright 2019 ETSI
|
||||
>
|
||||
> Redistribution and use in source and binary forms, with or without
|
||||
> modification, are permitted provided that the following conditions are met:
|
||||
> 1. Redistributions of source code must retain the above copyright notice,
|
||||
> this list of conditions and the following disclaimer.
|
||||
> 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
> this list of conditions and the following disclaimer in the documentation
|
||||
> and/or other materials provided with the distribution.
|
||||
> 3. Neither the name of the copyright holder nor the names of its contributors
|
||||
> may be used to endorse or promote products derived from this software without
|
||||
> specific prior written permission.
|
||||
>
|
||||
> THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
> ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
> WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
|
||||
> IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
|
||||
> INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
|
||||
> BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||
> DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
|
||||
> LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE
|
||||
> OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED
|
||||
> OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
@@ -0,0 +1,132 @@
|
||||
CAM-PDU-Descriptions {
|
||||
itu-t (0) identified-organization (4) etsi (0) itsDomain (5) wg1 (1) en (302637) cam (2) version (2)
|
||||
}
|
||||
|
||||
DEFINITIONS AUTOMATIC TAGS ::=
|
||||
|
||||
BEGIN
|
||||
|
||||
IMPORTS
|
||||
ItsPduHeader, CauseCode, ReferencePosition, AccelerationControl, Curvature, CurvatureCalculationMode, Heading, LanePosition, EmergencyPriority, EmbarkationStatus, Speed, DriveDirection, LongitudinalAcceleration, LateralAcceleration, VerticalAcceleration, StationType, ExteriorLights, DangerousGoodsBasic, SpecialTransportType, LightBarSirenInUse, VehicleRole, VehicleLength, VehicleWidth, PathHistory, RoadworksSubCauseCode, ClosedLanes, TrafficRule, SpeedLimit, SteeringWheelAngle, PerformanceClass, YawRate, ProtectedCommunicationZone, PtActivation, Latitude, Longitude, ProtectedCommunicationZonesRSU, CenDsrcTollingZone FROM ITS-Container {
|
||||
itu-t (0) identified-organization (4) etsi (0) itsDomain (5) wg1 (1) ts (102894) cdd (2) version (2)
|
||||
};
|
||||
|
||||
|
||||
-- The root data frame for cooperative awareness messages
|
||||
|
||||
CAM ::= SEQUENCE {
|
||||
header ItsPduHeader,
|
||||
cam CoopAwareness
|
||||
}
|
||||
|
||||
CoopAwareness ::= SEQUENCE {
|
||||
generationDeltaTime GenerationDeltaTime,
|
||||
camParameters CamParameters
|
||||
}
|
||||
|
||||
CamParameters ::= SEQUENCE {
|
||||
basicContainer BasicContainer,
|
||||
highFrequencyContainer HighFrequencyContainer,
|
||||
lowFrequencyContainer LowFrequencyContainer OPTIONAL,
|
||||
specialVehicleContainer SpecialVehicleContainer OPTIONAL,
|
||||
...
|
||||
}
|
||||
|
||||
HighFrequencyContainer ::= CHOICE {
|
||||
basicVehicleContainerHighFrequency BasicVehicleContainerHighFrequency,
|
||||
rsuContainerHighFrequency RSUContainerHighFrequency,
|
||||
...
|
||||
}
|
||||
|
||||
LowFrequencyContainer ::= CHOICE {
|
||||
basicVehicleContainerLowFrequency BasicVehicleContainerLowFrequency,
|
||||
...
|
||||
}
|
||||
|
||||
SpecialVehicleContainer ::= CHOICE {
|
||||
publicTransportContainer PublicTransportContainer,
|
||||
specialTransportContainer SpecialTransportContainer,
|
||||
dangerousGoodsContainer DangerousGoodsContainer,
|
||||
roadWorksContainerBasic RoadWorksContainerBasic,
|
||||
rescueContainer RescueContainer,
|
||||
emergencyContainer EmergencyContainer,
|
||||
safetyCarContainer SafetyCarContainer,
|
||||
...
|
||||
}
|
||||
|
||||
BasicContainer ::= SEQUENCE {
|
||||
stationType StationType,
|
||||
referencePosition ReferencePosition,
|
||||
...
|
||||
}
|
||||
|
||||
BasicVehicleContainerHighFrequency ::= SEQUENCE {
|
||||
heading Heading,
|
||||
speed Speed,
|
||||
driveDirection DriveDirection,
|
||||
vehicleLength VehicleLength,
|
||||
vehicleWidth VehicleWidth,
|
||||
longitudinalAcceleration LongitudinalAcceleration,
|
||||
curvature Curvature,
|
||||
curvatureCalculationMode CurvatureCalculationMode,
|
||||
yawRate YawRate,
|
||||
accelerationControl AccelerationControl OPTIONAL,
|
||||
lanePosition LanePosition OPTIONAL,
|
||||
steeringWheelAngle SteeringWheelAngle OPTIONAL,
|
||||
lateralAcceleration LateralAcceleration OPTIONAL,
|
||||
verticalAcceleration VerticalAcceleration OPTIONAL,
|
||||
performanceClass PerformanceClass OPTIONAL,
|
||||
cenDsrcTollingZone CenDsrcTollingZone OPTIONAL
|
||||
}
|
||||
|
||||
BasicVehicleContainerLowFrequency ::= SEQUENCE {
|
||||
vehicleRole VehicleRole,
|
||||
exteriorLights ExteriorLights,
|
||||
pathHistory PathHistory
|
||||
}
|
||||
|
||||
PublicTransportContainer ::= SEQUENCE {
|
||||
embarkationStatus EmbarkationStatus,
|
||||
ptActivation PtActivation OPTIONAL
|
||||
}
|
||||
|
||||
SpecialTransportContainer ::= SEQUENCE {
|
||||
specialTransportType SpecialTransportType,
|
||||
lightBarSirenInUse LightBarSirenInUse
|
||||
}
|
||||
|
||||
DangerousGoodsContainer ::= SEQUENCE {
|
||||
dangerousGoodsBasic DangerousGoodsBasic
|
||||
}
|
||||
|
||||
RoadWorksContainerBasic ::= SEQUENCE {
|
||||
roadworksSubCauseCode RoadworksSubCauseCode OPTIONAL,
|
||||
lightBarSirenInUse LightBarSirenInUse,
|
||||
closedLanes ClosedLanes OPTIONAL
|
||||
}
|
||||
|
||||
RescueContainer ::= SEQUENCE {
|
||||
lightBarSirenInUse LightBarSirenInUse
|
||||
}
|
||||
|
||||
EmergencyContainer ::= SEQUENCE {
|
||||
lightBarSirenInUse LightBarSirenInUse,
|
||||
incidentIndication CauseCode OPTIONAL,
|
||||
emergencyPriority EmergencyPriority OPTIONAL
|
||||
}
|
||||
|
||||
SafetyCarContainer ::= SEQUENCE {
|
||||
lightBarSirenInUse LightBarSirenInUse,
|
||||
incidentIndication CauseCode OPTIONAL,
|
||||
trafficRule TrafficRule OPTIONAL,
|
||||
speedLimit SpeedLimit OPTIONAL
|
||||
}
|
||||
|
||||
RSUContainerHighFrequency ::= SEQUENCE {
|
||||
protectedCommunicationZonesRSU ProtectedCommunicationZonesRSU OPTIONAL,
|
||||
...
|
||||
}
|
||||
|
||||
GenerationDeltaTime ::= INTEGER { oneMilliSec(1) } (0..65535)
|
||||
|
||||
END
|
||||
@@ -0,0 +1,109 @@
|
||||
DENM-PDU-Descriptions {
|
||||
itu-t (0) identified-organization (4) etsi (0) itsDomain (5) wg1 (1) en (302637) denm (1) version (2)
|
||||
}
|
||||
|
||||
DEFINITIONS AUTOMATIC TAGS ::=
|
||||
|
||||
BEGIN
|
||||
|
||||
IMPORTS
|
||||
ItsPduHeader, CauseCode, Speed, InformationQuality, ReferencePosition, ClosedLanes, DangerousGoodsExtended, Heading, LanePosition, LightBarSirenInUse, RoadType, HeightLonCarr, PosLonCarr, PosCentMass, PositioningSolutionType, RequestResponseIndication, StationType, SpeedLimit, StationarySince, TimestampIts, WheelBaseVehicle, TurningRadius, PosFrontAx, PositionOfOccupants, Temperature, VehicleMass, VehicleIdentification, EnergyStorageType, ActionID, ItineraryPath, NumberOfOccupants, PositionOfPillars, RelevanceTrafficDirection, RestrictedTypes, Traces, TransmissionInterval, ValidityDuration, RelevanceDistance, EventHistory, TrafficRule, DeltaReferencePosition FROM ITS-Container {
|
||||
itu-t (0) identified-organization (4) etsi (0) itsDomain (5) wg1 (1) ts (102894) cdd (2) version (2)
|
||||
};
|
||||
|
||||
|
||||
DENM ::= SEQUENCE {
|
||||
header ItsPduHeader,
|
||||
denm DecentralizedEnvironmentalNotificationMessage
|
||||
}
|
||||
|
||||
DecentralizedEnvironmentalNotificationMessage ::= SEQUENCE {
|
||||
management ManagementContainer,
|
||||
situation SituationContainer OPTIONAL,
|
||||
location LocationContainer OPTIONAL,
|
||||
alacarte AlacarteContainer OPTIONAL
|
||||
}
|
||||
|
||||
ManagementContainer ::= SEQUENCE {
|
||||
actionID ActionID,
|
||||
detectionTime TimestampIts,
|
||||
referenceTime TimestampIts,
|
||||
termination Termination OPTIONAL,
|
||||
eventPosition ReferencePosition,
|
||||
relevanceDistance RelevanceDistance OPTIONAL,
|
||||
relevanceTrafficDirection RelevanceTrafficDirection OPTIONAL,
|
||||
validityDuration ValidityDuration DEFAULT defaultValidity,
|
||||
transmissionInterval TransmissionInterval OPTIONAL,
|
||||
stationType StationType,
|
||||
...
|
||||
}
|
||||
|
||||
SituationContainer ::= SEQUENCE {
|
||||
informationQuality InformationQuality,
|
||||
eventType CauseCode,
|
||||
linkedCause CauseCode OPTIONAL,
|
||||
eventHistory EventHistory OPTIONAL,
|
||||
...
|
||||
}
|
||||
|
||||
LocationContainer ::= SEQUENCE {
|
||||
eventSpeed Speed OPTIONAL,
|
||||
eventPositionHeading Heading OPTIONAL,
|
||||
traces Traces,
|
||||
roadType RoadType OPTIONAL,
|
||||
...
|
||||
}
|
||||
|
||||
ImpactReductionContainer ::= SEQUENCE {
|
||||
heightLonCarrLeft HeightLonCarr,
|
||||
heightLonCarrRight HeightLonCarr,
|
||||
posLonCarrLeft PosLonCarr,
|
||||
posLonCarrRight PosLonCarr,
|
||||
positionOfPillars PositionOfPillars,
|
||||
posCentMass PosCentMass,
|
||||
wheelBaseVehicle WheelBaseVehicle,
|
||||
turningRadius TurningRadius,
|
||||
posFrontAx PosFrontAx,
|
||||
positionOfOccupants PositionOfOccupants,
|
||||
vehicleMass VehicleMass,
|
||||
requestResponseIndication RequestResponseIndication
|
||||
}
|
||||
|
||||
RoadWorksContainerExtended ::= SEQUENCE {
|
||||
lightBarSirenInUse LightBarSirenInUse OPTIONAL,
|
||||
closedLanes ClosedLanes OPTIONAL,
|
||||
restriction RestrictedTypes OPTIONAL,
|
||||
speedLimit SpeedLimit OPTIONAL,
|
||||
incidentIndication CauseCode OPTIONAL,
|
||||
recommendedPath ItineraryPath OPTIONAL,
|
||||
startingPointSpeedLimit DeltaReferencePosition OPTIONAL,
|
||||
trafficFlowRule TrafficRule OPTIONAL,
|
||||
referenceDenms ReferenceDenms OPTIONAL
|
||||
}
|
||||
|
||||
StationaryVehicleContainer ::= SEQUENCE {
|
||||
stationarySince StationarySince OPTIONAL,
|
||||
stationaryCause CauseCode OPTIONAL,
|
||||
carryingDangerousGoods DangerousGoodsExtended OPTIONAL,
|
||||
numberOfOccupants NumberOfOccupants OPTIONAL,
|
||||
vehicleIdentification VehicleIdentification OPTIONAL,
|
||||
energyStorageType EnergyStorageType OPTIONAL
|
||||
}
|
||||
|
||||
AlacarteContainer ::= SEQUENCE {
|
||||
lanePosition LanePosition OPTIONAL,
|
||||
impactReduction ImpactReductionContainer OPTIONAL,
|
||||
externalTemperature Temperature OPTIONAL,
|
||||
roadWorks RoadWorksContainerExtended OPTIONAL,
|
||||
positioningSolution PositioningSolutionType OPTIONAL,
|
||||
stationaryVehicle StationaryVehicleContainer OPTIONAL,
|
||||
...
|
||||
}
|
||||
|
||||
defaultValidity INTEGER ::= 600
|
||||
|
||||
Termination ::= ENUMERATED {isCancellation(0), isNegation (1)}
|
||||
|
||||
ReferenceDenms ::= SEQUENCE (SIZE(1..8, ...)) OF ActionID
|
||||
|
||||
END
|
||||
@@ -0,0 +1,306 @@
|
||||
IEEE1609dot2 {
|
||||
iso(1) identified-organization(3) ieee(111) standards-association-numbered-series-standards(2) wave-stds(1609) dot2(2) base (1) schema (1) major-version-2(2)
|
||||
}
|
||||
|
||||
--******************************************************************************
|
||||
--
|
||||
-- IEEE P1609.2 Data Types
|
||||
--
|
||||
--******************************************************************************
|
||||
|
||||
DEFINITIONS AUTOMATIC TAGS ::= BEGIN
|
||||
|
||||
EXPORTS ALL;
|
||||
|
||||
IMPORTS
|
||||
CrlSeries,
|
||||
EccP256CurvePoint,
|
||||
EciesP256EncryptedKey,
|
||||
EncryptionKey,
|
||||
GeographicRegion,
|
||||
GroupLinkageValue,
|
||||
HashAlgorithm,
|
||||
HashedId3,
|
||||
HashedId8,
|
||||
Hostname,
|
||||
IValue,
|
||||
LinkageValue,
|
||||
Opaque,
|
||||
Psid,
|
||||
PsidSsp,
|
||||
PsidSspRange,
|
||||
PublicEncryptionKey,
|
||||
PublicVerificationKey,
|
||||
SequenceOfHashedId3,
|
||||
SequenceOfPsidSsp,
|
||||
SequenceOfPsidSspRange,
|
||||
ServiceSpecificPermissions,
|
||||
Signature,
|
||||
SubjectAssurance,
|
||||
SymmetricEncryptionKey,
|
||||
ThreeDLocation,
|
||||
Time64,
|
||||
Uint3,
|
||||
Uint8,
|
||||
Uint16,
|
||||
Uint32,
|
||||
ValidityPeriod
|
||||
FROM IEEE1609dot2BaseTypes {
|
||||
iso(1) identified-organization(3) ieee(111) standards-association-numbered-series-standards(2) wave-stds(1609) dot2(2) base(1) base-types(2) major-version-2 (2)
|
||||
};
|
||||
|
||||
--
|
||||
--*********************************************************************
|
||||
--
|
||||
-- Structures for describing secured data
|
||||
--
|
||||
--*********************************************************************
|
||||
|
||||
-- Necessary to get certain tools to generate sample PDUs
|
||||
-- TestIeee1609Dot2Data ::= Ieee1609Dot2Data
|
||||
-- TestCertificate ::= Certificate
|
||||
|
||||
-- this structure belongs later in the file but putting it here avoids
|
||||
-- compiler errors with certain tools
|
||||
SignedDataPayload ::= SEQUENCE {
|
||||
data Ieee1609Dot2Data OPTIONAL,
|
||||
extDataHash HashedData OPTIONAL,
|
||||
...
|
||||
}
|
||||
(WITH COMPONENTS {..., data PRESENT} |
|
||||
WITH COMPONENTS {..., extDataHash PRESENT})
|
||||
|
||||
Ieee1609Dot2Data ::= SEQUENCE {
|
||||
protocolVersion Uint8(3),
|
||||
content Ieee1609Dot2Content
|
||||
}
|
||||
|
||||
Ieee1609Dot2Content ::= CHOICE {
|
||||
unsecuredData Opaque,
|
||||
signedData SignedData,
|
||||
encryptedData EncryptedData,
|
||||
signedCertificateRequest Opaque,
|
||||
...
|
||||
}
|
||||
|
||||
SignedData ::= SEQUENCE {
|
||||
hashId HashAlgorithm,
|
||||
tbsData ToBeSignedData,
|
||||
signer SignerIdentifier,
|
||||
signature Signature
|
||||
}
|
||||
|
||||
SignerIdentifier ::= CHOICE {
|
||||
digest HashedId8,
|
||||
certificate SequenceOfCertificate,
|
||||
self NULL,
|
||||
...
|
||||
}
|
||||
|
||||
ToBeSignedData ::= SEQUENCE {
|
||||
payload SignedDataPayload,
|
||||
headerInfo HeaderInfo
|
||||
}
|
||||
|
||||
HashedData::= CHOICE {
|
||||
sha256HashedData OCTET STRING (SIZE(32)),
|
||||
...
|
||||
}
|
||||
|
||||
HeaderInfo ::= SEQUENCE {
|
||||
psid Psid,
|
||||
generationTime Time64 OPTIONAL,
|
||||
expiryTime Time64 OPTIONAL,
|
||||
generationLocation ThreeDLocation OPTIONAL,
|
||||
p2pcdLearningRequest HashedId3 OPTIONAL,
|
||||
missingCrlIdentifier MissingCrlIdentifier OPTIONAL,
|
||||
encryptionKey EncryptionKey OPTIONAL,
|
||||
...,
|
||||
inlineP2pcdRequest SequenceOfHashedId3 OPTIONAL,
|
||||
requestedCertificate Certificate OPTIONAL
|
||||
}
|
||||
|
||||
MissingCrlIdentifier ::= SEQUENCE {
|
||||
cracaId HashedId3,
|
||||
crlSeries CrlSeries,
|
||||
...
|
||||
}
|
||||
|
||||
Countersignature ::= Ieee1609Dot2Data (WITH COMPONENTS {...,
|
||||
content (WITH COMPONENTS {...,
|
||||
signedData (WITH COMPONENTS {...,
|
||||
tbsData (WITH COMPONENTS {...,
|
||||
payload (WITH COMPONENTS {...,
|
||||
data ABSENT,
|
||||
extDataHash PRESENT
|
||||
}),
|
||||
headerInfo(WITH COMPONENTS {...,
|
||||
generationTime PRESENT,
|
||||
expiryTime ABSENT,
|
||||
generationLocation ABSENT,
|
||||
p2pcdLearningRequest ABSENT,
|
||||
missingCrlIdentifier ABSENT,
|
||||
encryptionKey ABSENT
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
--**********************************************************************
|
||||
--
|
||||
-- Structures for describing encrypted data
|
||||
--
|
||||
--**********************************************************************
|
||||
|
||||
EncryptedData ::= SEQUENCE {
|
||||
recipients SequenceOfRecipientInfo,
|
||||
ciphertext SymmetricCiphertext
|
||||
}
|
||||
RecipientInfo ::= CHOICE {
|
||||
pskRecipInfo PreSharedKeyRecipientInfo,
|
||||
symmRecipInfo SymmRecipientInfo,
|
||||
certRecipInfo PKRecipientInfo,
|
||||
signedDataRecipInfo PKRecipientInfo,
|
||||
rekRecipInfo PKRecipientInfo
|
||||
}
|
||||
|
||||
SequenceOfRecipientInfo ::= SEQUENCE OF RecipientInfo
|
||||
|
||||
PreSharedKeyRecipientInfo ::= HashedId8
|
||||
SymmRecipientInfo ::= SEQUENCE {
|
||||
recipientId HashedId8,
|
||||
encKey SymmetricCiphertext
|
||||
}
|
||||
|
||||
PKRecipientInfo ::= SEQUENCE {
|
||||
recipientId HashedId8,
|
||||
encKey EncryptedDataEncryptionKey
|
||||
}
|
||||
|
||||
EncryptedDataEncryptionKey ::= CHOICE {
|
||||
eciesNistP256 EciesP256EncryptedKey,
|
||||
eciesBrainpoolP256r1 EciesP256EncryptedKey,
|
||||
...
|
||||
}
|
||||
|
||||
SymmetricCiphertext ::= CHOICE {
|
||||
aes128ccm AesCcmCiphertext,
|
||||
...
|
||||
}
|
||||
|
||||
AesCcmCiphertext ::= SEQUENCE {
|
||||
nonce OCTET STRING (SIZE (12)),
|
||||
ccmCiphertext Opaque -- 16 bytes longer than plaintext
|
||||
}
|
||||
|
||||
|
||||
--**********************************************************************
|
||||
--
|
||||
-- Certificates and other security management data structures
|
||||
--
|
||||
--**********************************************************************
|
||||
|
||||
-- Certificates are implicit (type = implicit, toBeSigned includes
|
||||
-- reconstruction value, signature absent) or explicit (type = explicit,
|
||||
-- toBeSigned includes verification key, signature present).
|
||||
|
||||
Certificate ::= CertificateBase (ImplicitCertificate | ExplicitCertificate)
|
||||
|
||||
SequenceOfCertificate ::= SEQUENCE OF Certificate
|
||||
|
||||
CertificateBase ::= SEQUENCE {
|
||||
version Uint8(3),
|
||||
type CertificateType,
|
||||
issuer IssuerIdentifier,
|
||||
toBeSigned ToBeSignedCertificate,
|
||||
signature Signature OPTIONAL
|
||||
}
|
||||
|
||||
CertificateType ::= ENUMERATED {
|
||||
explicit,
|
||||
implicit,
|
||||
...
|
||||
}
|
||||
|
||||
ImplicitCertificate ::= CertificateBase (WITH COMPONENTS {...,
|
||||
type(implicit),
|
||||
toBeSigned (WITH COMPONENTS {...,
|
||||
verifyKeyIndicator(WITH COMPONENTS {reconstructionValue})
|
||||
}),
|
||||
signature ABSENT
|
||||
})
|
||||
|
||||
ExplicitCertificate ::= CertificateBase (WITH COMPONENTS {...,
|
||||
type(explicit),
|
||||
toBeSigned(WITH COMPONENTS {...,
|
||||
verifyKeyIndicator(WITH COMPONENTS {verificationKey})
|
||||
}),
|
||||
signature PRESENT
|
||||
})
|
||||
|
||||
IssuerIdentifier ::= CHOICE {
|
||||
sha256AndDigest HashedId8,
|
||||
self HashAlgorithm,
|
||||
...,
|
||||
sha384AndDigest HashedId8
|
||||
}
|
||||
|
||||
ToBeSignedCertificate ::= SEQUENCE {
|
||||
id CertificateId,
|
||||
cracaId HashedId3,
|
||||
crlSeries CrlSeries,
|
||||
validityPeriod ValidityPeriod,
|
||||
region GeographicRegion OPTIONAL,
|
||||
assuranceLevel SubjectAssurance OPTIONAL,
|
||||
appPermissions SequenceOfPsidSsp OPTIONAL,
|
||||
certIssuePermissions SequenceOfPsidGroupPermissions OPTIONAL,
|
||||
certRequestPermissions SequenceOfPsidGroupPermissions OPTIONAL,
|
||||
canRequestRollover NULL OPTIONAL,
|
||||
encryptionKey PublicEncryptionKey OPTIONAL,
|
||||
verifyKeyIndicator VerificationKeyIndicator,
|
||||
...
|
||||
}
|
||||
(WITH COMPONENTS { ..., appPermissions PRESENT} |
|
||||
WITH COMPONENTS { ..., certIssuePermissions PRESENT} |
|
||||
WITH COMPONENTS { ..., certRequestPermissions PRESENT})
|
||||
|
||||
CertificateId ::= CHOICE {
|
||||
linkageData LinkageData,
|
||||
name Hostname,
|
||||
binaryId OCTET STRING(SIZE(1..64)),
|
||||
none NULL,
|
||||
...
|
||||
}
|
||||
|
||||
LinkageData ::= SEQUENCE {
|
||||
iCert IValue,
|
||||
linkage-value LinkageValue,
|
||||
group-linkage-value GroupLinkageValue OPTIONAL
|
||||
}
|
||||
|
||||
EndEntityType ::= BIT STRING { app (0), enrol (1) } (SIZE (8)) (ALL EXCEPT {})
|
||||
|
||||
PsidGroupPermissions ::= SEQUENCE {
|
||||
subjectPermissions SubjectPermissions,
|
||||
minChainLength INTEGER DEFAULT 1,
|
||||
chainLengthRange INTEGER DEFAULT 0,
|
||||
eeType EndEntityType DEFAULT {app} -- vanetza-idf: IEEE Std 1609.2-2022 6.4.28 (was '00'H)
|
||||
}
|
||||
|
||||
SequenceOfPsidGroupPermissions ::= SEQUENCE OF PsidGroupPermissions
|
||||
|
||||
SubjectPermissions ::= CHOICE {
|
||||
explicit SequenceOfPsidSspRange,
|
||||
all NULL,
|
||||
...
|
||||
}
|
||||
|
||||
VerificationKeyIndicator ::= CHOICE {
|
||||
verificationKey PublicVerificationKey,
|
||||
reconstructionValue EccP256CurvePoint,
|
||||
...
|
||||
}
|
||||
|
||||
END
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user