Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73d4477f1e | ||
|
|
277c6b20f4 | ||
|
|
0f06cdb339 | ||
|
|
75d6d3b85c | ||
|
|
1baae2c5f6 | ||
|
|
8871708a98 | ||
|
|
d7043bb04d | ||
|
|
e908f7fae1 | ||
|
|
8f397eea20 | ||
|
|
cc395771ea | ||
|
|
83153a0971 | ||
|
|
3eeccfb268 | ||
|
|
5ec3619cbe | ||
|
|
1ad123a6f8 | ||
|
|
83ccf335bb | ||
|
|
034ef22336 | ||
|
|
ebe1c9edfd | ||
|
|
312f094909 | ||
|
|
b6a687982d | ||
|
|
3da60d3a99 | ||
|
|
081347f31b | ||
|
|
16998bf478 | ||
|
|
cc35994e68 | ||
|
|
04b0076b8b | ||
|
|
d0701ccea4 | ||
|
|
b2b57fa39e | ||
|
|
eb6150260b | ||
|
|
a5ad3dcc5d | ||
|
|
0ccb867228 | ||
|
|
f1770e11dd | ||
|
|
f507a8a9fd | ||
|
|
b91eb460dc |
+32
@@ -17,3 +17,35 @@ local.properties
|
||||
*.jks
|
||||
*.keystore
|
||||
secrets.properties
|
||||
|
||||
# --- local IDE state, not project configuration -------------------------------
|
||||
# Which device you last deployed to, your emulator list, and Studio's assistant
|
||||
# settings are per-machine. deploymentTargetSelector.xml in particular rewrites
|
||||
# itself on every run, so tracking it means a dirty tree after every deploy.
|
||||
/.idea/deploymentTargetSelector.xml
|
||||
/.idea/deviceManager.xml
|
||||
/.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
|
||||
|
||||
# Claude Code local settings (permissions, per-machine). The skills alongside it
|
||||
# are project knowledge and may be committed deliberately.
|
||||
.claude/settings.local.json
|
||||
|
||||
# Third-party working copies kept beside the project, not part of it. The ASN.1
|
||||
# modules this project actually needs are vendored under asn1/ instead.
|
||||
/C-ITS-Parser/
|
||||
/vanetza/
|
||||
|
||||
# Office lock files. Word/Excel create these beside a document while it is open
|
||||
# and remove them on close, so they are transient and machine-local.
|
||||
~$*
|
||||
|
||||
# Captures are large data files, not source (see capture/README.md).
|
||||
/capture/recordings/
|
||||
|
||||
Generated
-6
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="AppInsightsSettings">
|
||||
<option name="selectedTabId" value="Android vitals" />
|
||||
</component>
|
||||
</project>
|
||||
Generated
-31
@@ -1,31 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="deploymentTargetSelector">
|
||||
<selectionStates>
|
||||
<SelectionState runConfigName="app">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
<DropdownSelection timestamp="2026-08-05T15:07:39.343414600Z">
|
||||
<Target type="DEFAULT_BOOT">
|
||||
<handle>
|
||||
<DeviceId pluginId="PhysicalDevice" identifier="serial=56211FDAP0015L" />
|
||||
</handle>
|
||||
</Target>
|
||||
</DropdownSelection>
|
||||
<DialogSelection>
|
||||
<targets>
|
||||
<Target type="DEFAULT_BOOT">
|
||||
<handle>
|
||||
<DeviceId pluginId="PhysicalDevice" identifier="serial=56211FDAP0015L" />
|
||||
</handle>
|
||||
</Target>
|
||||
<Target type="DEFAULT_BOOT">
|
||||
<handle>
|
||||
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\Ashin\.android\avd\Pixel_7_Pro.avd" />
|
||||
</handle>
|
||||
</Target>
|
||||
</targets>
|
||||
</DialogSelection>
|
||||
</SelectionState>
|
||||
</selectionStates>
|
||||
</component>
|
||||
</project>
|
||||
Generated
-13
@@ -1,13 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DeviceTable">
|
||||
<option name="columnSorters">
|
||||
<list>
|
||||
<ColumnSorterState>
|
||||
<option name="column" value="Name" />
|
||||
<option name="order" value="ASCENDING" />
|
||||
</ColumnSorterState>
|
||||
</list>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
Generated
-6
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="StudioBotProjectSettings">
|
||||
<option name="shareContext" value="OptedOut" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -0,0 +1,280 @@
|
||||
# Step 4 - Build, flash, and validate the HLN-SV DENM transmitter
|
||||
|
||||
## Radio-config fix (2026-07-15) - read this first
|
||||
|
||||
Symptom: nothing on air. Proof it was a radio-config problem, not a
|
||||
frame-format problem: the 2026-07-15 sniffer capture (`recordings/
|
||||
its5_20260715_131448.pcap`) contains 13,584 ITS-G5 frames on 5.9 GHz but
|
||||
**zero** from our source MAC `02:00:00:00:00:01`. A sniffer records a
|
||||
station's frames even if the payload is malformed, so the frame was never
|
||||
leaving the radio - the DENM/GeoNet/802.11 encoding was never the issue.
|
||||
|
||||
Three stacking causes, all in `main.c`'s Wi-Fi init, now fixed:
|
||||
|
||||
1. **Plain STA, no promiscuous, power-save on.** ESP-IDF only actually emits
|
||||
raw frames when the MAC is in promiscuous mode or associated to an AP, and
|
||||
default STA power-save sleeps the radio between beacons and drops outbound
|
||||
frames. The *working sniffer* runs promiscuous - the OBU didn't. Fixed:
|
||||
`esp_wifi_set_ps(WIFI_PS_NONE)` + `esp_wifi_set_promiscuous(true)` after
|
||||
`esp_wifi_start()`.
|
||||
2. **Never entered 5 GHz band mode.** The stuck `esp_wifi_get_channel()
|
||||
primary=1` was the dual-band C5 still in 2.4 GHz band mode, so
|
||||
`esp_wifi_set_channel(<5G channel>)` failed silently and the HMAC TX path
|
||||
keyed the 2.4 GHz PHY on ch1 - inaudible to a 5.9 GHz sniffer. Fixed:
|
||||
`esp_wifi_set_band_mode(WIFI_BAND_MODE_5G_ONLY)` before start. Channel prime
|
||||
changed from 140 (5700 MHz) to **177 (5885 MHz)** - see cause 3.
|
||||
3. **5900 MHz is out of the C5's spec range.** The datasheet 5 GHz range is
|
||||
**5180-5885 MHz**; our target 5900 MHz (ITS-G5 G5-CCH) is 15 MHz above it.
|
||||
RX tolerates 15 MHz over (that's why the sniffer works at 5900); TX may be
|
||||
PA-calibration-gated at an uncalibrated frequency. Priming the driver to
|
||||
channel 177 (5885, the top legal channel) keeps TX on the closest real
|
||||
power table before `phy_change_channel(5900,...)` nudges the LO.
|
||||
|
||||
### If it STILL shows nothing after these fixes - the 5900 MHz isolation test
|
||||
|
||||
Causes 1 and 2 are high-confidence. Cause 3 is the remaining unknown, so
|
||||
isolate it before touching anything else. Temporarily change **both** ends to
|
||||
a channel inside the C5's spec range and see if the OBU appears:
|
||||
|
||||
- In `main.c`, change the target frequency from `5900` to `5885` in *both*
|
||||
`phy_change_channel()` calls (boot + per-TX), matching the channel-177 prime.
|
||||
- Put your sniffer on 5885 MHz too.
|
||||
- Ground GPIO4 and watch the dashboard/PCAP for source MAC
|
||||
`02:00:00:00:00:01`.
|
||||
|
||||
If the OBU now appears at 5885 but not at 5900: TX genuinely can't key the PA
|
||||
at the out-of-spec 5900 MHz, and no amount of frame-format work will change
|
||||
that - you'd need a chip whose 5 GHz range covers the ITS band, or to accept
|
||||
operating one channel down. If it appears at neither, the problem is still in
|
||||
the TX-enable path (recheck that the three calls above returned ESP_OK in the
|
||||
boot log), not the frequency.
|
||||
|
||||
Also note: **nothing transmits until GPIO4 is grounded** (active-low hazard
|
||||
input). If you were bench-testing without grounding GPIO4, the TX path was
|
||||
never even entered - ground it (jumper GPIO4 to GND) before concluding
|
||||
anything.
|
||||
|
||||
---
|
||||
|
||||
Profile: **HLN-SV** (aftermarket stationary recovery vehicle) - causeCode 94
|
||||
(stationaryVehicle), subCauseCode 0, transmits only while the hazard-light
|
||||
GPIO reads active. No location/alacarte containers, no optional fields.
|
||||
|
||||
Code lives in `obu-firmware/main/`: `main.c` (entry, PHY hack, GPIO, TX loop),
|
||||
`denm.c/.h` (ASN.1 UPER encoding), `geonet.c/.h` (GeoNetworking + BTP-B
|
||||
wrapping), `dot11p.c/.h` (802.11 OCB frame assembly), `tx_custom.c/.h` (the
|
||||
actual raw-frame transmit path - see "The TX bypass" below).
|
||||
|
||||
## The TX bypass (tx_custom.c)
|
||||
|
||||
`esp_wifi_80211_tx()` - ESP-IDF's public raw-frame API - rejects QoS Data
|
||||
frames outright (`esp_err 258` / "unsupport QoS frame type"), and there's no
|
||||
supported way to override that from application code (a linker-level symbol
|
||||
override attempt, `main/wifi_patches.c`, is kept around only for history -
|
||||
confirmed not to work).
|
||||
|
||||
`tx_custom.c` was pulled from
|
||||
[opentrafficmap/its-g5-receiver-firmware_txenabled](https://codeberg.org/opentrafficmap/its-g5-receiver-firmware_txenabled)
|
||||
- a TX-enabled fork of the exact receiver firmware (V2X2MAP) already used on
|
||||
the RX side of this project, same authors, same chip. Instead of trying to
|
||||
disable the gate, it skips the code path that contains it entirely: it calls
|
||||
`ic_ebuf_alloc()` and `ieee80211_post_hmac_tx()` - undocumented internal
|
||||
driver functions - directly, submitting straight to the MAC. `main.c` now
|
||||
calls `esp_wifi_80211_tx_custom()` instead of `esp_wifi_80211_tx()`, with the
|
||||
one proven-working parameter set (`WIFI_PHY_MODE_11A`, `WIFI_PHY_RATE_12M`,
|
||||
`WIFI_BAND_5G`, `WIFI_BW20`) copied from the only call site in the upstream
|
||||
repo (`mqtt.cpp`, triggered by an incoming MQTT message there - ours is
|
||||
triggered by the GPIO4 hazard-light logic instead).
|
||||
|
||||
**What this unlocks**: `dot11p.c` is back to real QoS Data frames (subtype 8)
|
||||
since the gate that forced the non-QoS downgrade no longer applies. Frame
|
||||
size is now 117 bytes (was 115 with non-QoS Data's 2-byte-shorter header) -
|
||||
expect `sent (117 bytes)` in the log now, not 115.
|
||||
|
||||
**What's NOT yet independently verified** - things worth checking as you go:
|
||||
- Whether `ieee80211_post_hmac_tx`, `ic_ebuf_alloc`, `ic_get_default_sched`,
|
||||
`g_osi_funcs_p`, and `g_wifi_global_lock` actually exist as symbols in
|
||||
*your* IDF version's `libnet80211.a`/`libpp.a` for esp32c5 - we only
|
||||
previously confirmed `ieee80211_raw_frame_sanity_check` exists via `nm`,
|
||||
not these. If the build fails to link with `undefined reference`, this is
|
||||
the first thing to check - same `nm` approach as the `phy_11p_set` section
|
||||
below, just against these symbol names.
|
||||
- `tx_custom.c`'s internal struct layouts (`x_eb_txdesc_t`, `x_ebuf_t`) are
|
||||
reverse-engineered from the closed WiFi driver, pinned only by a `sizeof()`
|
||||
assert - that catches a total-size mismatch across IDF versions but not a
|
||||
field-order mismatch that happens to keep the same total size. If your IDF
|
||||
version differs meaningfully from whatever the upstream repo has pinned
|
||||
(check their `esp-idf` git submodule commit vs. `idf.py --version`), this
|
||||
could compile and link cleanly but write to the wrong internal offsets.
|
||||
Worth keeping in mind as a possible explanation if you get a crash/hang
|
||||
right when TX fires rather than a clean error.
|
||||
- Same "skips ALL sanity checking" risk as the old override attempt: a
|
||||
malformed frame from a bug anywhere in `denm.c`/`geonet.c`/`dot11p.c` could
|
||||
now behave worse (crash, silent corruption) than a clean rejection.
|
||||
- `esp_wifi_set_channel(140, ...)` was added in `main.c` right before the
|
||||
`phy_11p_set`/`phy_change_channel` pair, copied from upstream's
|
||||
`cmd_sniffer.c` (their own comment on it: "not sure if strictly needed").
|
||||
Motivation: our own `esp_wifi_get_channel()` diagnostic was reporting a
|
||||
stuck `primary=1` regardless of what the PHY hack was told, consistent
|
||||
with the driver's channel bookkeeping never being touched by anything it
|
||||
tracks. This is a "worth trying," not a confirmed fix - watch whether
|
||||
`esp_wifi_get_channel()`'s reported value changes at all now.
|
||||
|
||||
## Confidence levels - read this before debugging blind
|
||||
|
||||
Updated after pulling the actual specs (EN 302 636-4-1, EN 302 636-5-1) and
|
||||
the real ASN.1 modules from forge.etsi.org (EN 302 637-3, TS 102 894-2 CDD) -
|
||||
this isn't guesswork anymore for the parts listed as "verified" below.
|
||||
|
||||
- **GeoNetworking Basic/Common/SHB headers, BTP-B header** (`geonet.c`):
|
||||
verified field-by-field against EN 302 636-4-1. This caught three real
|
||||
bugs in the previous version: wrong header type (was encoded as
|
||||
GeoUnicast, HT=2 - now correctly TSB/SINGLE_HOP, HT=5/HST=0), wrong
|
||||
payload-length calculation (was including the 24-byte extended header,
|
||||
which it shouldn't), and GN_ADDR being an arbitrary byte string instead of
|
||||
its actual structure (M-flag + 5-bit station type + reserved + 48-bit
|
||||
MID = the same link-layer address used in the 802.11 header).
|
||||
- **BTP-B header** (`geonet.c`): verified against EN 302 636-5-1 - unchanged
|
||||
from before, structure was already correct.
|
||||
- **802.11 header, LLC/SNAP** (`dot11p.c`): back to real QoS Data (subtype 8,
|
||||
26-byte header with a QoS Control field), matching actual ITS-G5 hardware.
|
||||
This required abandoning `esp_wifi_80211_tx()` entirely in favor of
|
||||
`esp_wifi_80211_tx_custom()` (`tx_custom.c`) - see "The TX bypass" above for
|
||||
the full story and the list of things about it that aren't independently
|
||||
verified yet for our exact toolchain.
|
||||
- **DENM ASN.1 UPER payload** (`denm.c`): verified against the real ASN.1
|
||||
modules (DENM-PDU-Descriptions.asn, ITS-Container.asn). This caught real
|
||||
bugs too: `ManagementContainer`, `SituationContainer`, and the inner
|
||||
`CauseCode` SEQUENCE are all declared with a trailing `...` (extensible),
|
||||
each of which needs its own leading extension bit that the previous
|
||||
version omitted entirely; `SituationContainer`'s optional-presence bits
|
||||
were encoded in the wrong position (at the end instead of the start); and
|
||||
three field widths were wrong (latitude is 31 bits not 32, the two
|
||||
position-confidence fields and orientation are 12 bits not 16, altitude
|
||||
value is 20 bits not 24). All fixed now, with the exact ASN.1 type and
|
||||
constraint range cited in comments next to each field.
|
||||
- **Known-missing, by design, not bugs**: `detectionTime`/`referenceTime`/GN
|
||||
timestamp are hardcoded to 0 (no RTC/NTP wired up - will decode as
|
||||
2004-01-01), and `latitude`/`longitude` are hardcoded to 0 (no GNSS wired
|
||||
up). Both are called out with `TODO` comments in the source.
|
||||
- **Privacy pseudonym**: the source MAC/GN_ADDR MID is a fixed placeholder,
|
||||
not rotated. Fine for bench testing; real stacks rotate this every 5-15 min.
|
||||
- **Unsecured** (no IEEE 1609.2 signing) - matches "no additional
|
||||
parameters"/easiest, and your sniffer already handles unsecured frames fine
|
||||
(that's how it decodes RSU SPATEM/MAPEM today).
|
||||
- **Still a deliberate simplification, not a bug**: single-hop broadcast
|
||||
(TSB/SINGLE_HOP) instead of GeoBroadcast. Real DENM dissemination
|
||||
typically uses GeoBroadcast so RSUs/OBUs can forward it across an area -
|
||||
upgrading to that needs a sequence number + circular geo-area fields in
|
||||
the GN extended header that this skeleton doesn't build. Fine for a
|
||||
single-vehicle beacon; revisit if you need multi-hop forwarding.
|
||||
|
||||
## Build
|
||||
|
||||
```powershell
|
||||
. $env:IDF_PATH\export.ps1 # or use the "ESP-IDF PowerShell" Start Menu shortcut instead
|
||||
cd C:\Users\Ashin\Documents\micrOBU_workspace\v2x-obu-esp32c5\obu-firmware
|
||||
idf.py set-target esp32c5
|
||||
idf.py build
|
||||
```
|
||||
|
||||
### If the linker fails on `phy_11p_set` / `phy_change_channel`
|
||||
|
||||
These are undocumented, reverse-engineered symbols pulled straight from
|
||||
`libphy.a` - not a public API, so exact names/signatures can shift between
|
||||
ESP-IDF versions. If you get `undefined reference`, check what's actually
|
||||
exported for your IDF version:
|
||||
|
||||
```powershell
|
||||
riscv32-esp-elf-nm $env:IDF_PATH\components\esp_phy\lib\esp32c5\libphy.a | Select-String -Pattern "11p|change_channel"
|
||||
```
|
||||
|
||||
Adjust the `extern` declarations at the top of `main.c` to match whatever you
|
||||
find.
|
||||
|
||||
## Flash
|
||||
|
||||
```powershell
|
||||
idf.py -p COM5 -b 921600 flash monitor
|
||||
```
|
||||
|
||||
You should see in the log:
|
||||
```
|
||||
OCB mode requested @ 5900 MHz - HLN-SV DENM beacon armed, waiting on GPIO4
|
||||
```
|
||||
Nothing transmits yet - GPIO4 is pulled up (inactive) until you ground it.
|
||||
|
||||
## Wire up the hazard-light input
|
||||
|
||||
For bench testing: a jumper wire or push button between GPIO4 and GND is
|
||||
enough (active-low - grounding it = "hazard lights on" = beacon active).
|
||||
For the real thing later: tap whatever signal your recovery vehicle's hazard
|
||||
switch drives (through a level shifter / opto-isolator if it's 12V vehicle
|
||||
wiring - don't feed vehicle voltage directly into a GPIO).
|
||||
|
||||
## Event lifecycle (added after cross-checking the Cohda UCA MQTT schema)
|
||||
|
||||
The Cohda "Use Case App" schema (`v2x-uca/output/json/denm`) documents a
|
||||
`termination` key: "present if the DENM is cancelled or negated." The
|
||||
firmware now implements this properly:
|
||||
|
||||
- `actionID` (station ID + sequence number) is assigned once per event, on
|
||||
the rising edge of the hazard-light GPIO, and stays constant across every
|
||||
repeat of that same event - it does **not** increment every second like an
|
||||
earlier version of this code did.
|
||||
- On the falling edge (hazard lights go off), exactly one DENM is sent with
|
||||
`termination = isCancellation`, referencing that same actionID, then
|
||||
transmission stops until the next rising edge.
|
||||
|
||||
Expect your sniffer to show the same station+sequence number repeating for
|
||||
the duration of the event, then one final frame with a termination flag.
|
||||
|
||||
## Validate against your own sniffer
|
||||
|
||||
This is the important part, since a few of the payload bit widths are
|
||||
best-effort: with the transmitter running (GPIO4 grounded) and your Phase 1
|
||||
sniffer board powered on nearby, check the V2X2MAP dashboard.
|
||||
|
||||
Expect to see:
|
||||
- A new station appear, sending DENM (and only DENM - this skeleton doesn't
|
||||
send CAM).
|
||||
- `causeCode` decoding to **stationaryVehicle**, `subCauseCode` **0**.
|
||||
- `stationType` decoding to **passengerCar**.
|
||||
|
||||
If instead you get "unknown message type," garbage station type, or the
|
||||
dashboard just doesn't show anything: capture a PCAP from the dashboard's
|
||||
record button and open it in Wireshark - compare byte-by-byte against a real
|
||||
captured DENM (like the one from your Cohda OBU) to see exactly where the
|
||||
two diverge. That's a much faster debug loop than staring at the C code.
|
||||
|
||||
## Validate against the real RSU
|
||||
|
||||
Your own sniffer receiving the frame only tells you the RF/PHY side works -
|
||||
it doesn't tell you whether a standards-strict stack (Cohda) will accept it.
|
||||
With the transmitter running and both your sniffer and the Cohda-based RSU
|
||||
mqtt monitor watching:
|
||||
|
||||
- If **both** see it, and it decodes as a real DENM (not "Type ?"): done,
|
||||
move on to the "once this round-trips cleanly" list below.
|
||||
- If your sniffer sees it but doesn't classify it, or the RSU doesn't see it
|
||||
at all: worth first ruling out the receiver's own reliability (checked
|
||||
separately - the receiver has shown signs of hanging independent of the
|
||||
transmitter) before concluding anything about frame format.
|
||||
- If **neither** sees it: still worth re-checking `esp_wifi_get_channel()`'s
|
||||
reported value (logged before every TX) - if it's still stuck regardless of
|
||||
the new `esp_wifi_set_channel(140, ...)` call, that's a stronger signal
|
||||
the PHY genuinely isn't moving to 5900MHz, independent of frame format.
|
||||
|
||||
Note the receiver firmware (V2X2MAP) never faces the frame-type problem this
|
||||
project spent a while on: receiving never calls `esp_wifi_80211_tx()`, so
|
||||
there was never a gate on that side to work around. The `phy_11p_set`/
|
||||
`phy_change_channel` PHY setup in `main.c` already mirrors what the receiver
|
||||
does for getting onto 5.9GHz OCB mode.
|
||||
|
||||
## Once this round-trips cleanly
|
||||
|
||||
Next reasonable steps, in rough order: wire in real GNSS (replaces the 0/0
|
||||
lat-long and the GN timestamp), wire in SNTP or GNSS-derived UTC time
|
||||
(replaces detectionTime/referenceTime), then decide whether you actually
|
||||
need GeoBroadcast/multi-hop forwarding instead of SHB, then - only if you
|
||||
need it - look at IEEE 1609.2 signing.
|
||||
@@ -0,0 +1,290 @@
|
||||
# ESP32-C5 OBU firmware — bench test against live ITS-G5 traffic
|
||||
|
||||
**Date:** 2026-08-25
|
||||
**Firmware:** `obu-firmware` @ commit `b2b57fa`, flashed to COM3 (CH343 UART bridge, 921600 baud)
|
||||
**App:** `app-debug.apk`, installed 14:33:45, Pixel 9 Pro on the C5's native USB-C port
|
||||
**Verdict:** the receive path works against all three live message types with zero failures.
|
||||
Two blocking items remain before real-world use, both known and both outside what this bench can
|
||||
exercise. See [Readiness](#readiness).
|
||||
|
||||
## Test environment
|
||||
|
||||
| role | device | notes |
|
||||
|---|---|---|
|
||||
| Device under test | ESP32-C5 OBU, COM3 | our firmware; phone attached to its native USB port |
|
||||
| Traffic source | RSU, broker `192.168.3.202` | transmits SPATEM + CAM |
|
||||
| Traffic source | CiT One OBU, broker `192.168.3.201` | transmits DENM + CAM |
|
||||
| Independent witness | both brokers' `v2x/rx/*` topics | each hears the *other* device |
|
||||
|
||||
Both brokers publish raw UPER (protobuf-wrapped, field 3), so their counts are directly comparable
|
||||
with what the OBU forwarded. That is what makes this a cross-check rather than a self-report: the
|
||||
OBU's output is measured against two receivers that share none of its code.
|
||||
|
||||
Station IDs observed today (`3983312873` RSU, `3257224191` CiT One) differ from those seen this
|
||||
morning (`968482441`, `2880458775`). **Station IDs rotate**, so nothing may treat one as a durable
|
||||
identity for a physical unit.
|
||||
|
||||
## T1 — Message type coverage and rate
|
||||
|
||||
305-second continuous capture, phone logcat.
|
||||
|
||||
| type | frames | rate | stations |
|
||||
|---|---|---|---|
|
||||
| CAM | 1244 | 4.08/s | 2 |
|
||||
| SPATEM | 978 | 3.20/s | 1 |
|
||||
| DENM | 646 | 2.12/s | 3 |
|
||||
| **total** | **2868** | **9.40/s** | |
|
||||
|
||||
Per station, with received signal strength:
|
||||
|
||||
| type | station | frames | RSSI min/median/max |
|
||||
|---|---|---|---|
|
||||
| SPATEM | 3983312873 | 978 | −65 / −60 / −48 dBm |
|
||||
| CAM | 3257224191 | 633 | −58 / −52 / −48 dBm |
|
||||
| DENM | 3257224191 | 616 | −65 / −58 / −48 dBm |
|
||||
| CAM | 3983312873 | 611 | −65 / −62 / −60 dBm |
|
||||
| DENM | 2908440021 | 20 | −64 / −61 / −53 dBm |
|
||||
| DENM | 1220851972 | 10 | −63 / −52 / −49 dBm |
|
||||
|
||||
All three types decoded concurrently, from both transmitters plus two additional DENM sources that
|
||||
happened to be on air. **PASS.**
|
||||
|
||||
## T2 — Decode integrity
|
||||
|
||||
Over the same window:
|
||||
|
||||
| check | result |
|
||||
|---|---|
|
||||
| Decode failures (CAM / DENM / SPATEM) | **0** |
|
||||
| Unexpected BTP ports from firmware | **0** |
|
||||
| USB I/O errors | **0** |
|
||||
| Device detach events | **0** |
|
||||
| Fatal exceptions | **0** |
|
||||
|
||||
Zero decode failures across 2868 frames. Since the decoders return null rather than guessing
|
||||
whenever an extension bit or unsupported optional appears, a zero here means every frame matched
|
||||
the bit layouts exactly — not that failures were being swallowed. **PASS.**
|
||||
|
||||
## T3 — Cross-check against independent receivers
|
||||
|
||||
60-second simultaneous capture from both brokers, counting the same UPER the OBU sees.
|
||||
|
||||
| stream | independent witness | our OBU |
|
||||
|---|---|---|
|
||||
| RSU SPATEM (3983312873) | 2.00/s | 3.20/s |
|
||||
| RSU CAM (3983312873) | 2.00/s | 2.00/s |
|
||||
| CiT One CAM (3257224191) | 2.05/s | 2.08/s |
|
||||
| CiT One DENM (3257224191) | 1.00/s | 2.02/s |
|
||||
|
||||
CAM matches on both transmitters. SPATEM and DENM read high — explained in T4, not a defect.
|
||||
|
||||
## T4 — Duplicate transmission (finding, not a fault)
|
||||
|
||||
The DENM and SPATEM discrepancies above are **real duplicate transmissions**, not double-counting
|
||||
in our firmware. Inter-arrival analysis of the captured stream:
|
||||
|
||||
| type | gaps < 150 ms | median of those | identical content, different RSSI |
|
||||
|---|---|---|---|
|
||||
| SPATEM | 50% (485/977) | 7 ms | **480 / 485** |
|
||||
| DENM | 50% (308/615) | 5 ms | **282 / 308** |
|
||||
| CAM | 2% (11/632) | 100 ms | 1 / 11 |
|
||||
|
||||
Each SPATEM and DENM goes out **twice, ~5–7 ms apart, with different RSSI** — two antennas. CAM is
|
||||
sent once. This also reconciles the broker figures: the CiT One reports one copy on `v2x/rx/*` and
|
||||
the other on `v2x/rx-red/*` ("red" = redundant), and only the primary was counted.
|
||||
|
||||
Our firmware is a promiscuous receiver, so forwarding both copies is correct behaviour. The app
|
||||
deduplicates downstream — DENM on ETSI actionID, SPATEM on intersection key — so the UI shows one
|
||||
entry per event. The cost is serial bandwidth: **38% of the bytes carried are duplicate copies.**
|
||||
|
||||
## T5 — Serial link load
|
||||
|
||||
Measured over the 305 s window, using UPER sizes taken from the brokers:
|
||||
|
||||
- **9.40 frames/s, ~1550 B/s (12.4 kbit/s)**
|
||||
- Largest frame: DENM at 402 B UPER → 416 B payload (81% of the 512 B cap, 96 B headroom)
|
||||
- On the wire that frame is 423 B, 41% of the 1024 B TX ring
|
||||
- Suppressing duplicate copies would cut this to ~961 B/s (7.7 kbit/s)
|
||||
|
||||
No frame in this session exceeded the payload cap.
|
||||
|
||||
## T6 — Firmware drop counters
|
||||
|
||||
Read directly off the app at 14:45, after the capture:
|
||||
|
||||
```
|
||||
ESP32: tx fail 0 · oversize 0 · crc err 0
|
||||
```
|
||||
|
||||
These are free-running totals **since firmware boot**, so all three being zero covers the whole
|
||||
session, not just the test window — no oversize drops, no `esp_wifi_80211_tx` failures, no CRC
|
||||
errors at any point since the C5 was flashed. **PASS.**
|
||||
|
||||
The app also now logs these counters whenever one changes (added for this test; they previously
|
||||
reached only the UI), so a future bench run captured through logcat records drops as they happen.
|
||||
|
||||
## T7 — End-to-end UI verification
|
||||
|
||||
Screenshot at 14:45 confirms the full chain reaches the display:
|
||||
|
||||
| element | shown |
|
||||
|---|---|
|
||||
| Link state | CONNECTED |
|
||||
| Hazard (DENM) | `stationaryVehicle · station 3257224191`, 30 m, 1000 m radius, −63 dBm |
|
||||
| Signals (SPATEM) | `Intersection -1/23 · station 3983312873`, SG1 red / SG2 amber, −63 dBm |
|
||||
| Station (CAM) | `Station 3257224191 · Car`, 1.4 km/h, heading 19°, 25 m, −50 dBm |
|
||||
|
||||
Signal-group colouring, the DENM relevance radius from the GeoNetworking header, and per-station
|
||||
RSSI all render correctly.
|
||||
|
||||
### Finding: the RSU's CAM decodes but is never displayed
|
||||
|
||||
The station list reads **"1 station(s) in range"** — only the CiT One (`3257224191`). The RSU
|
||||
(`3983312873`) is absent, despite **611 of its CAMs decoding successfully** during the capture.
|
||||
|
||||
Cause: RSU CAMs are deliberately excluded from `UseCaseDetectionEngine` (a permanently stationary
|
||||
station at a fixed point otherwise trips the stopped-vehicle use case continuously) — but
|
||||
`remoteCamPositions`, which feeds both the station list and the map, is populated *by that engine*.
|
||||
So the exclusion removes them from the display as well as from detection.
|
||||
|
||||
The RSU is not entirely invisible: it appears in the SPAT section as the intersection's station. But
|
||||
its CAM-reported position is dropped on the floor. This is a defect introduced with the RSU CAM
|
||||
decode fix earlier today, not a firmware problem — the firmware forwarded all 611 correctly.
|
||||
|
||||
**Fixed and re-verified the same session.** RSU CAMs are now tracked in a separate
|
||||
`rsuStations` flow in the repository, merged with the engine's road users for display only, with a
|
||||
15 s staleness window and a clear on link-down. Screenshot at 15:01 confirms:
|
||||
|
||||
```
|
||||
2 station(s) in range - latest CAM per station
|
||||
Station 2199514753 · Car 0.8 km/h · heading 192° 28 m -52 dBm
|
||||
Station 440624502 · Roadside unit roadside unit - no kinematics reported
|
||||
46 m -61 dBm
|
||||
```
|
||||
|
||||
The kinematics line is suppressed for RSUs: their CAM carries none, so the zeroes in the model are
|
||||
placeholders and printing "0.0 km/h · heading 0" would assert a stationary vehicle facing north.
|
||||
The same station also drives the SPAT row, so the two views agree.
|
||||
|
||||
## T8 — Station ID rotation
|
||||
|
||||
Station IDs rotated **twice within one session**:
|
||||
|
||||
| time | RSU | CiT One |
|
||||
|---|---|---|
|
||||
| ~09:00 | 968482441 | 2880458775 |
|
||||
| 14:45 | 3983312873 | 3257224191 |
|
||||
| 15:01 | 440624502 | 2199514753 |
|
||||
|
||||
That is a rotation inside 16 minutes. Consequences for the app, none of them currently handled:
|
||||
|
||||
- **DENM dedup keys on ETSI actionID**, which contains the originating station ID. A hazard that
|
||||
outlives a rotation will appear as a second, independent pin rather than an update of the first.
|
||||
Both then persist until the 60 s TTL expires them.
|
||||
- **The station list and map key on station ID**, so a rotation shows the same physical vehicle
|
||||
twice for up to the 15 s window.
|
||||
- **SPATEM is unaffected**, because it dedups on the intersection reference (`region/id`), which is
|
||||
a property of the junction rather than the sender. That is the right key and it survives rotation.
|
||||
|
||||
Nothing here is a firmware issue, and pseudonym rotation is the intended privacy behaviour of the
|
||||
transmitters. But any future logic that assumes a station ID identifies a physical unit over time
|
||||
will be wrong.
|
||||
|
||||
## T9 — Stack fix and re-verification
|
||||
|
||||
`rx_item_t` was moved off both task stacks (`static` in the promiscuous callback and in
|
||||
`rx_forward_task`), firmware reflashed, and the campaign re-run:
|
||||
|
||||
| | before fix (305 s) | after fix (125 s) |
|
||||
|---|---|---|
|
||||
| Total | 9.40/s | 8.73/s |
|
||||
| CAM | 4.08/s | 4.11/s |
|
||||
| SPATEM | 3.20/s | **3.98/s** |
|
||||
| DENM | 2.12/s | 0.64/s |
|
||||
| Decode failures / IO errors / crashes | 0 | 0 |
|
||||
| Mutex timeouts | — | 0 |
|
||||
|
||||
SPATEM capture rose from ~80% to ~100% of the theoretical 4.00/s (2 Hz × two antennas). Not
|
||||
attributable to the fix with confidence — RF geometry moves between runs — but it is the direction
|
||||
stack pressure relief would produce, and worth re-checking on the next run. The DENM drop is the
|
||||
CiT One's trigger being intermittent, not a receive problem.
|
||||
|
||||
### Finding: no automatic reconnect after re-enumeration
|
||||
|
||||
Reflashing resets the C5, which re-enumerates its USB device. The app did **not** recover: it went
|
||||
to `Connection error - check the cable and native USB-C port, then try again` and stayed there until
|
||||
Connect was tapped manually, followed by a fresh USB permission grant.
|
||||
|
||||
This matters more for the intersection use case than SPATEM does. On a bike, a jostled cable that
|
||||
re-enumerates leaves the link dead until the rider notices and taps a button — a silent loss of the
|
||||
CAM stream the use case runs on. The permission grant is a genuine one-time consent and cannot be
|
||||
automated, but retrying automatically when a matching device is already attached would cover the
|
||||
common case.
|
||||
|
||||
## Readiness
|
||||
|
||||
### Working
|
||||
|
||||
- All three received message types decode correctly from live over-the-air traffic
|
||||
- Concurrent multi-station, multi-type reception with no interference between streams
|
||||
- Sustained 5-minute run with no link drop, no I/O error, no crash
|
||||
- RSSI plausible and discriminating between transmitters (−48 to −65 dBm at bench distance)
|
||||
- No frame exceeded the serial payload cap under this traffic mix
|
||||
|
||||
### Scope decision (2026-08-25): Phase B dropped
|
||||
|
||||
Raising the payload cap was considered and **deliberately rejected**. The project goal is V2X
|
||||
communication with at least one white-paper use case — incoming car at an intersection — working on
|
||||
the ESP32. That use case is `IMA-B`/`IMA-S`, which `UseCaseDetectionEngine` drives entirely from CAM
|
||||
kinematics; the engine contains **zero references to SPATEM or MAPEM**. Everything the goal needs
|
||||
fits the current cap with margin: CAM 26–211 B, DENM 402 B, bench SPATEM 58 B, against a 498 B
|
||||
budget.
|
||||
|
||||
Phase B would buy only road-RSU SPATEM/MAPEM — the add-on, not the goal — while putting a measured,
|
||||
zero-failure chain at risk. The one component of it that *reduces* risk, moving `rx_item_t` off the
|
||||
WiFi callback stack, was done separately (T9).
|
||||
|
||||
### Known ceiling, accepted
|
||||
|
||||
1. **Serial payload cap (512 B).** The bench RSU sends 58-byte SPATEMs, but the 2026-03-18 drive
|
||||
measured real road RSUs at 555 B median and 1243 B max — **roughly 70% would be dropped as
|
||||
oversize**. Raising `SERIAL_LINK_MAX_PAYLOAD` and `RX_FRAME_MAX_LEN` to ~1536 is required, and
|
||||
forces item 2.
|
||||
2. ~~**`rx_item_t` on the WiFi driver's callback stack**~~ — **fixed 2026-08-25**, see T9.
|
||||
|
||||
3. **DENM headroom is 96 B.** DENM matters to this project in a way SPATEM does not, and at 402 B
|
||||
it is the closest message to the cap. A DENM carrying more optional containers than the CiT One's
|
||||
HLN-SV currently sends would be silently dropped and counted as oversize. The `oversize` counter
|
||||
on the CAM Pinger card is the thing to check if hazards ever stop appearing.
|
||||
|
||||
### Defect found and fixed during this test
|
||||
|
||||
- **RSU CAM positions were decoded but never displayed** (see T7). App-side; the firmware forwarded
|
||||
all 611 correctly. Fixed and re-verified in the same session.
|
||||
|
||||
### Open, found by this test
|
||||
|
||||
- **Station ID rotation** (see T8) fragments DENM and CAM identity across a rotation. Not yet
|
||||
handled.
|
||||
|
||||
### Untested here
|
||||
|
||||
- ~~**Link recovery**~~ — exercised by the reflash in T9: it does **not** auto-recover. See T9.
|
||||
- **Sustained load at road rates.** This bench ran at 9.4 frames/s. The drive data implies 24–32
|
||||
frames/s with frames 3× larger, where the TX-mutex interaction (400 ms worst-case hold vs the
|
||||
1 Hz heartbeat and the phone's 3-beat dead-link timeout) becomes the thing to watch.
|
||||
- **The link's actual ceiling**, which has never been saturated and so is unmeasured.
|
||||
- **MAPEM** — nothing on air is transmitting it; no decoder written.
|
||||
- **Secured messages** — 75 frames with GN `NextHeader=2` appeared in earlier pcaps; these are
|
||||
rejected by design. The bench runs with `ItsGnSecurity = 0`.
|
||||
|
||||
### Recommendation
|
||||
|
||||
Ready for continued bench and short-range field work as it stands. **Not ready for a road drive
|
||||
past real RSUs** until items 1 and 2 land, because the failure there is silent: oversize SPATEMs are
|
||||
counted and dropped, so the symptom is "the intersection never appears" rather than an error.
|
||||
|
||||
## Reproducing
|
||||
|
||||
Capture: `adb logcat -d` filtered on `CamUseCaseRepo` while subscribed to `v2x/rx/#` on both
|
||||
brokers. Decode cross-checks use `asn1tools` with the modules in `asn1/` — see `asn1/README.md`.
|
||||
@@ -2,78 +2,156 @@
|
||||
|
||||
Android companion app for the micrOBU; a compact V2X on-board unit developed by HAW Hamburg and consider it GmbH for vulnerable road users (cyclists, e-bike riders, pedestrians).
|
||||
|
||||
The app serves as the HMI for the micrOBU hardware, handling V2X message display, sensor data collection, trip recording, and OBU communication over USB-C, Wi-Fi (dev), and Bluetooth (upcoming).
|
||||
The app is the HMI for the OBU hardware and, on one of the two supported hardware paths, the entire V2X protocol stack. It handles V2X message display, use case detection, sensor collection, trip recording, and OBU communication over USB-C.
|
||||
|
||||
**Platform:** Android (Kotlin) · **Min SDK:** 29 (Android 10) · **Target SDK:** 36
|
||||
**Platform:** Android (Kotlin) / **Min SDK:** 29 (Android 10) / **Target SDK:** 36 / **Version:** 0.5.0 (Phase 03)
|
||||
|
||||
## Project goal
|
||||
|
||||
Demonstrate V2X communication with at least one C2C-CC bicycle safety use case working on the ESP32-C5, specifically intersection movement assist: a car approaching an intersection on a path that conflicts with the rider's.
|
||||
|
||||
That use case is driven entirely from the periodic position and kinematics vehicles broadcast. It needs no traffic light state and no intersection lane geometry, which is why several scope decisions in this repository look deliberately narrow.
|
||||
|
||||
## Two hardware paths
|
||||
|
||||
The project began against the consider it CiT One and later added the ESP32-C5 as a second option. Both paths are supported at runtime and selected by the rider in Settings > OBU Hardware.
|
||||
|
||||
| | consider it CiT One | ESP32-C5 |
|
||||
|---|---|---|
|
||||
| What it is | Complete V2X on-board unit | Development board acting as a plain radio |
|
||||
| Transport | IP over USB tethering, MQTT | Framed binary protocol over USB CDC serial |
|
||||
| Reaches the phone as | Processed JSON | Raw ASN.1 UPER bytes |
|
||||
| V2X stack lives | On the OBU | On the phone, except the radio and GeoNetworking |
|
||||
| Own CAM generated by | The OBU, autonomously | The phone, transmitted on the phone's clock |
|
||||
| DENM trigger | Available (manual test tool) | Not available |
|
||||
| Needs a network | Yes, internally | No |
|
||||
|
||||
Both paths converge at `CamUseCaseRepository`, which normalises whatever arrived into the domain `Cam` type. Everything above that point, including the entire use case detection engine and all UI, is shared and transport-agnostic. That is what makes the ESP32-C5 a drop-in second OBU rather than a fork of the application.
|
||||
|
||||
## What it does
|
||||
|
||||
**Real-time V2X monitoring**; subscribes to the OBU's MQTT broker and displays live CAM, DENM, SPAT, MAP, and CPM messages grouped by topic with pretty-printed JSON and TX/RX badges.
|
||||
**Real-time V2X monitoring**; live CAM, DENM and SPATEM with a station list, active hazards, live signal phase, and a map view. On the CiT One path the topic viewer additionally shows whatever the broker publishes, grouped by topic with pretty-printed JSON and TX/RX badges.
|
||||
|
||||
**DENM transmission**; triggers DENM use cases (e.g. stationary vehicle warning `hln-sv`) on the OBU via the consider it Use Case API (`v2x-uca/input/denmtrg`) with a single tap.
|
||||
**CAM-based use case detection**; correlates the rider's own state with a short per-station history of received CAMs to evaluate five C2C-CC bicycle safety use cases (IMA-B, IMA-S, RTW-B, LTW-B, SMVA/BCW-B) and raises alerts under the three-tier Info / Awareness / Warning model. None of these use cases generates a DENM.
|
||||
|
||||
**Sensor monitoring**; live readout of phone GNSS, accelerometer, gyroscope, magnetometer, and barometer alongside OBU GNSS for cross-reference.
|
||||
**Phone-generated CAM**; on the ESP32-C5 path the app builds a CAM from live GNSS and IMU, UPER-encodes it, and pushes it down the serial link for the board to broadcast over ITS-G5.
|
||||
|
||||
**Trip recording**; foreground service records all sensor streams and detects cycling events (braking, turning, stopping) using orientation-independent signal processing. Works fully offline with no OBU connected.
|
||||
**DENM transmission**; CiT One path only. Triggers the stationary vehicle profile (`hln-sv`, causeCode 94) via the consider it Use Case API. This is a manual antenna and range test tool. It is never triggered by a detected event or a use case alert, and the control is hidden entirely on the ESP32-C5 path.
|
||||
|
||||
**Trip review**; past trips displayed on an OpenStreetMap layer with detected events overlaid as coloured pins. Tap any pin for event details.
|
||||
**Trip recording**; foreground service records all sensor streams and detects cycling manoeuvres (braking, turning, stopping) using orientation-independent signal processing. Works fully offline with no OBU connected. The detected manoeuvres are neither shown nor stored - their only effect is to raise the CAM transmit rate through the manoeuvre on the ESP32-C5 path.
|
||||
|
||||
**CSV export**; every sensor sample written to a timestamped CSV in real time during a session. Shareable via the standard Android share sheet.
|
||||
**Trip review**; past trips displayed as a route on an OpenStreetMap layer, with duration and distance.
|
||||
|
||||
**CSV export**; every sensor sample written to a timestamped CSV in real time. Trip exports additionally include the V2X messages received and their RSSI. Shareable via the standard Android share sheet.
|
||||
|
||||
## Architecture
|
||||
|
||||
MVVM with Repository pattern throughout. Jetpack Compose for all UI (no XML layouts). Hilt for dependency injection.
|
||||
|
||||
```
|
||||
ui/screens/ Compose screens (Dashboard, V2X Monitor, Sensors, Recording, Trip History, Settings…)
|
||||
ui/screens/ Compose screens (Dashboard, Record, Trips, V2X Monitor, Settings...)
|
||||
ui/navigation/ Navigation graph and bottom nav bar
|
||||
viewmodel/ MqttViewModel, SensorViewModel, TripRecordingViewModel
|
||||
data/mqtt/ MQTT repository, Paho client, exponential-backoff reconnection
|
||||
data/transport/ USB tethering detection and gateway IP resolution
|
||||
data/db/ Room database (sessions, trips, detected events)
|
||||
data/ SensorRepository, TripRepository, CsvExporter
|
||||
data/transport/ UsbSerialTransport, SerialFrame, UsbNetworkDetector, ObuHardware
|
||||
data/cam/ CamUseCaseRepository; where both hardware paths converge
|
||||
data/db/ Room database (sessions, trips, detected events, V2X messages)
|
||||
data/ SensorRepository, TripRepository, CsvExporter, TripExporter
|
||||
domain/asn1/ BitReader/BitWriter and the CAM, DENM and SPATEM UPER codecs
|
||||
domain/usecase/ UseCaseDetectionEngine, UseCaseDetectionConfig, AlertLevel, GeoMath
|
||||
domain/detection/ EventDetector, RunningStats sliding window (orientation-independent)
|
||||
service/ TripRecordingService (foreground service)
|
||||
domain/cam/ Cam, CamParser, PhoneCamBuilder, CamTransmitConfig
|
||||
service/ TripRecordingService, CamTransmitLoop, CamPinger
|
||||
obu-firmware/ ESP32-C5 firmware (serial link, GeoNetworking, 802.11 OCB, raw TX)
|
||||
asn1/ Vendored ETSI ASN.1 modules the codecs are verified against
|
||||
```
|
||||
|
||||
The `domain/` packages contain no Android imports. That is what makes the 41-test JVM suite possible without an emulator or instrumentation.
|
||||
|
||||
## Connectivity
|
||||
|
||||
The app uses a phased transport strategy. The MQTT client, topic subscriptions, and all UI are identical across transports; only the underlying network path changes.
|
||||
USB-C on both hardware paths. Bluetooth is **not implemented** and remains an open question in the requirements.
|
||||
|
||||
| Phase | Transport | Status |
|
||||
|---|---|----------|
|
||||
| Phase 01 | Wi-Fi | Complete |
|
||||
| Phase 02 | USB-C tethering | Active |
|
||||
| Phase 03 | Bluetooth BLE | Future |
|
||||
| Transport | Path | Status |
|
||||
|---|---|---|
|
||||
| USB-C tethering (IP + MQTT) | CiT One | Active |
|
||||
| USB-C serial (framed binary) | ESP32-C5 | Active |
|
||||
| Wi-Fi | CiT One | Developer builds only |
|
||||
| Bluetooth | Either | Not implemented |
|
||||
|
||||
The MQTT broker runs on the OBU hardware (Mosquitto 2.0.11, port 1883). In Phase 02, Android USB tethering exposes the OBU as a virtual Ethernet interface at `192.168.42.x`. The app auto-detects the gateway IP on plug-in.
|
||||
On the CiT One path the MQTT broker runs on the OBU (Mosquitto 2.0.11, port 1883); Android USB tethering exposes it as a virtual Ethernet interface at `192.168.42.x` and the app auto-detects the gateway IP on plug-in. On the ESP32-C5 path there is no network layer at all: a private framed protocol runs over the board's native USB-C port as a CDC-ACM device.
|
||||
|
||||
## Verification
|
||||
|
||||
The app hand-encodes and decodes ETSI messages bit by bit on the ESP32-C5 path, which is the highest-risk code in the project. Round-trip tests through the project's own codecs structurally cannot catch a shared mistake about a field's bit width, and this project shipped exactly that bug three times (`CurvatureCalculationMode`, the GeoNetworking reserved bytes, `yawRateConfidence`). Phone and ESP32 agreed with each other and with nothing else.
|
||||
|
||||
Verification therefore uses an independent oracle: `asn1tools` compiled from the ETSI modules vendored in `asn1/`.
|
||||
|
||||
- **Golden-byte fixtures** assert exact encoder output, with expected values produced by the oracle rather than by this encoder.
|
||||
- **Bulk replay** compares every field over real captures: 79,042 SPATEMs and 1,885 DENMs, zero mismatches.
|
||||
- **Off-air confirmation**: 26 of this project's own CAMs captured back by an independent receiver, all accepted.
|
||||
|
||||
Never regenerate a golden fixture from this project's own encoder output. See `asn1/README.md`.
|
||||
|
||||
## Documentation
|
||||
|
||||
| Document | Audience |
|
||||
|---|---|
|
||||
| [docs/MicrOBU-User-Guide.docx](docs/MicrOBU-User-Guide.docx) | Riders. Setup, screens, what the alerts mean, troubleshooting |
|
||||
| [docs/MicrOBU-Technical-Documentation.docx](docs/MicrOBU-Technical-Documentation.docx) | Supervisors and stakeholders. Architecture, message path, verification, results, decisions |
|
||||
| [docs/01-requirements-traceability.md](docs/01-requirements-traceability.md) | Requirements chapters 0 to 13 mapped to implementation and evidence |
|
||||
| [05-obu-bench-test-2026-08-25.md](05-obu-bench-test-2026-08-25.md) | Bench campaign T1 to T9, measured results |
|
||||
| [04-transmit-setup.md](04-transmit-setup.md) | Transmitter bring-up, radio configuration diagnosis, the TX bypass |
|
||||
| [obu-firmware/FLASHING.md](obu-firmware/FLASHING.md) | Toolchain setup, flashing, phone-to-board bring-up checklist |
|
||||
| [asn1/README.md](asn1/README.md) | ASN.1 module provenance and the fixture regeneration rule |
|
||||
| [docs/references.bib](docs/references.bib) | Standards references as BibTeX |
|
||||
|
||||
## Key dependencies
|
||||
|
||||
| Library | Purpose |
|
||||
|---|---|
|
||||
| Jetpack Compose + Material3 | UI |
|
||||
| Eclipse Paho MQTT | OBU communication |
|
||||
| Eclipse Paho MQTT | CiT One path communication |
|
||||
| usb-serial-for-android | ESP32-C5 path communication (custom probe table for Espressif VID/PID) |
|
||||
| Room | Local database |
|
||||
| Hilt | Dependency injection |
|
||||
| OSMDroid | Trip review map |
|
||||
| OSMDroid | Trip review and live V2X map |
|
||||
| DataStore | Settings persistence |
|
||||
| FusedLocationProviderClient | GNSS |
|
||||
|
||||
## Getting started
|
||||
|
||||
1. Open in Android Studio (Hedgehog or newer).
|
||||
1. Open in Android Studio and build the `app` module. Gradle 8.10.2.
|
||||
2. Connect a device running Android 10+ (API 29).
|
||||
3. Build and run the `app` module.
|
||||
4. For Phase 02 testing: plug the phone into the OBU via USB-C, enable USB tethering on the phone, and the app will detect the interface and connect automatically. Broker IP can be overridden manually in Settings → Connection.
|
||||
5. For standalone trip recording: no OBU required. Go to the Record tab and tap Record.
|
||||
3. Choose your hardware in Settings > OBU Hardware.
|
||||
|
||||
The Wi-Fi transport (Phase 01 broker at `192.168.3.202`) remains available in developer builds and can be toggled in Settings → Developer.
|
||||
**CiT One path.** Plug the phone into the OBU via USB-C, enable USB tethering on the phone, and the app detects the interface and connects automatically. Broker IP can be overridden in Settings > Connection.
|
||||
|
||||
**ESP32-C5 path.** Flash `obu-firmware/` (see `obu-firmware/FLASHING.md`), then plug the phone into the board's **native** USB-C port, not the UART bridge port used for flashing. Tap Connect and grant the USB permission. Use the CAM Pinger on the V2X screen to verify the link and radio without starting a trip.
|
||||
|
||||
**Standalone trip recording.** No OBU required. Go to the Record tab and tap REC.
|
||||
|
||||
The Wi-Fi transport (broker at `192.168.3.202`) remains available in developer builds via Settings > Developer.
|
||||
|
||||
Firmware and app must be flashed and installed together: `SERIAL_LINK_MAX_PAYLOAD` is 512 on both sides and a mismatch silently rejects every large frame.
|
||||
|
||||
## Status and known limitations
|
||||
|
||||
Bench verified against live ITS-G5 traffic on 2026-08-25: 2868 frames over 305 seconds, zero decode failures, zero USB errors, zero crashes. Not yet road validated.
|
||||
|
||||
- **Requirement 11.6, Phase A success criteria, is not met.** The bench proves reception. It cannot prove the use case behaves correctly with two genuinely moving stations, because nothing on the bench moves. This is the main open evidence gap for the project's central claim.
|
||||
- **No message signing.** ETSI TS 103 097 is out of scope. Secured frames are rejected rather than mis-parsed.
|
||||
- **Detection thresholds are untuned engineering estimates**, not calibrated against real intersection data.
|
||||
- **512-byte serial payload cap.** Roughly 70% of real road RSU SPATEMs would be dropped as oversize. Accepted deliberately: the intersection use case is CAM-driven and needs none of it.
|
||||
- **No automatic reconnect** after USB re-enumeration; requires a manual Connect.
|
||||
- **Station IDs rotate**, so they cannot identify a physical unit over time.
|
||||
- **No backend, no login.** Everything is on-device.
|
||||
- **No MAPEM decoder**, so signal groups cannot yet be associated with the rider's lane.
|
||||
|
||||
## Project context
|
||||
|
||||
The micrOBU project is funded under the ZIM program (BMWK) and targets micromobility users in Hamburg. The companion app offloads processing from the compact OBU hardware to the smartphone; GNSS fusion, event detection, and future antenna coordination all run on the phone to keep the OBU lightweight and power-efficient.
|
||||
The micrOBU project is funded under the ZIM program (BMWK) and targets micromobility users in Hamburg. The companion app offloads processing from the compact OBU hardware to the smartphone; GNSS fusion, event detection, and on the ESP32-C5 path the full ASN.1 encoding and decoding all run on the phone to keep the OBU lightweight and power-efficient.
|
||||
|
||||
V2X communication uses ITS-G5 (IEEE 802.11p / DSRC) at 5.9 GHz. The app communicates with the OBU exclusively via the consider it MQTT API v6 (processed JSON messages); no ASN.1 encoding in the app.
|
||||
V2X communication uses ITS-G5 (IEEE 802.11p) at 5.9 GHz. On the CiT One path the app communicates via the consider it MQTT API v6 (processed JSON). On the ESP32-C5 path the app performs its own ASN.1 UPER encoding and decoding against the ETSI modules vendored in `asn1/`.
|
||||
|
||||
**Owner:** HAW Hamburg
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
# TODO
|
||||
|
||||
Engineering to-do list. The reviewer-facing open items live in
|
||||
`docs/01-requirements-traceability.md` ("Open items"); this file is the working list behind them.
|
||||
|
||||
## Waiting on hardware
|
||||
|
||||
### Over-the-air check of the GN lifetime fix (added 2026-09-11)
|
||||
|
||||
`geonet.c` now writes GN lifetime `0x05` (1 s) instead of `0x83`, which decoded to 3200 s. Changed
|
||||
in both `obu-firmware` and `obu-cam-transmistter`. Both still build (IDF 6.1 / 5.5.4), and the
|
||||
compiled `geonet_wrap_shb` stores the new byte. Confirmed on air 2026-09-14. Nothing else
|
||||
reads this byte (`gn_unwrap.c` ignores it, the app never sees GN headers), so the app does not
|
||||
need updating alongside the firmware.
|
||||
|
||||
Needs: the phone with the app, the OBU ESP32-C5, and a **second** ESP32-C5 running
|
||||
`its-g5-receiver-firmware` to capture with.
|
||||
|
||||
- [x] Flash `obu-firmware` (done 2026-09-14 on COM3; flash backed up first to
|
||||
`Documents/micrOBU_workspace/firmware-backups/COM3-2026-09-14-before-secured-rx.bin`).
|
||||
- [x] Capture with the receiver (COM8) into `its-g5-receiver-firmware/recordings/`.
|
||||
- [x] `pcap_gn_tally.py` on capture_20260914_132126.pcap: our station sends SHB, port 2001,
|
||||
lifetime `0x05`, same as both bench stations. It was `0x83` in the August captures.
|
||||
- [x] Real-station CAMs/DENMs/SPATEM still reach the app (logcat: `handleCamUper`,
|
||||
`handleDenmUper`, `handleSpatUper` all decoding, 2026-09-14).
|
||||
- [x] Our own CAMs decode on air: 397 frames from station 999999 decode with asn1tools and
|
||||
re-encode byte-identically.
|
||||
- [ ] Confirm the CAM Pinger card's `tx fail` / oversize / CRC counters are 0 (needs a look at the
|
||||
phone; not readable from the PC).
|
||||
|
||||
Partial check possible with one board and no phone: flash it, `idf.py -p COMx monitor`, and look
|
||||
for `OCB @ 5900 MHz - TX/RX armed`. That proves the new build boots and brings the radio up, not
|
||||
that it transmits correctly.
|
||||
|
||||
### obu-cam-transmistter yawRateConfidence fix (added 2026-09-11)
|
||||
|
||||
Its `cam.c` (compiled into that firmware) wrote `yawRateConfidence` as 3 bits / 7 instead of
|
||||
4 bits / unavailable(8), the bug the app fixed on 2026-08-20. Fixed in it and in obu-firmware's
|
||||
reference copy; asn1tools now decodes the CAM and re-encodes it byte-identically, and it builds on
|
||||
IDF 5.5.4. Since 2026-09-14 the spare board on COM10 runs it as a bench beacon:
|
||||
|
||||
- [x] Done 2026-09-14: flashed on COM10 and captured on COM8. All 72 CAMs from station
|
||||
195936478 (0x0BADC0DE) decode with asn1tools and re-encode byte-identically, so the
|
||||
4-bit yawRateConfidence is right on air. COM10 now runs this beacon rather than
|
||||
obu-firmware - reflash it if the spare is needed as an OBU again.
|
||||
|
||||
### Signed-message reception and exact payloads (added 2026-09-11)
|
||||
|
||||
obu-firmware's `gn_unwrap.c` now unwraps TS 103 097 signed packets (signature not verified,
|
||||
reported as V2X_RX flags bit1) and cuts every message to the length its header declares, dropping
|
||||
the 8 bytes the chip's RX appends to each frame, which were forwarded to the phone until now.
|
||||
Verified on the host (`obu-firmware/test/host`: chain, replay of all recordings against asn1tools,
|
||||
50M-iteration fuzz) and flashed to the production OBU on 2026-09-14. The remaining gap is signed
|
||||
traffic to receive: real vehicles or
|
||||
RSUs, since the bench CiT One sends unsigned. A second ESP32 running the receiver firmware is
|
||||
optional, but shows what was on air at the time.
|
||||
|
||||
- [x] Flash obu-firmware (done 2026-09-14, COM3).
|
||||
- [x] Unsigned bench traffic still decodes in the app, with messages now cut to their declared
|
||||
length (CAM, DENM and SPATEM all decoding in logcat after the flash).
|
||||
- [ ] Near signed traffic: signed CAMs/DENMs appear in the app, and a simultaneous capture shows
|
||||
them on air (`pcap_gn_tally.py` lists them as `secured`). NOT possible at this bench: the
|
||||
CiT One transmits unsigned (`ItsGnSecurity = 0`) and nothing else here signs. Needs a drive
|
||||
past real RSUs, the CiT One switched to signed mode if its API allows, or a replay firmware
|
||||
on a spare board that re-transmits the recorded signed frames.
|
||||
- [ ] The heartbeat's oversize counter still counts over-long messages. Not exercised at the
|
||||
bench: the SPATEMs here are ~340 bytes on air, far below the cap.
|
||||
|
||||
## Set up host testing
|
||||
|
||||
- [x] Install MSYS2 UCRT64 gcc (done 2026-09-11: gcc 16.2.0, GNU Make 4.4.1; chosen over WSL,
|
||||
vanetza is not going to be built). Setup and the PATH gotcha: `obu-firmware/test/host/README.md`.
|
||||
- [x] Host round-trip test `obu-firmware/test/host/test_chain.c` (`geonet_wrap_shb` ->
|
||||
`dot11p_build_frame` -> `gn_unwrap_its`, byte-checked against the standard). Done
|
||||
2026-09-11: 491 checks, 0 failed. Run `make` in that folder before flashing any firmware fix.
|
||||
- [x] Replay of the recorded captures (`test_replay.c` + `check_replay.py`, independent asn1tools
|
||||
check). Done 2026-09-11: C and Python agree on all 15 145 records.
|
||||
- [x] Mutation fuzzer `fuzz_gn_unwrap.c`, inputs against a no-access guard page. Done 2026-09-11:
|
||||
50 000 000 iterations, no crash. `make` runs a 2 000 000-iteration pass every time.
|
||||
|
||||
## Firmware ideas from the vanetza review (2026-09-11, not started)
|
||||
|
||||
Suggested order after the host tests exist:
|
||||
|
||||
- [x] **Read secured packets (GN NextHeader=2) without verifying them.** Done 2026-09-11 in
|
||||
`gn_unwrap.c`, host-verified; flagged to the phone as V2X_RX flags bit1. On-air check under
|
||||
"Waiting on hardware".
|
||||
- [ ] **Forward the full GeoBroadcast area**: shape (circle/rectangle/ellipse), DistanceB, angle,
|
||||
appended to the V2X_RX prefix behind a capability bit. Port vanetza's `geonet/areas.cpp`
|
||||
`inside_or_at_border` to the app, which currently treats every area as a circle.
|
||||
- [ ] **RX filtering before the serial link**: duplicate detection for GBC (last 8 sequence numbers
|
||||
per source, as vanetza does), drop our own frames, reject GN version != 1.
|
||||
- [ ] **Read the DCC-MCO field** (the 4 "reserved" bytes of an SHB header): neighbours' channel
|
||||
busy ratio for free.
|
||||
- [ ] **Minimum TX gap in firmware** as a DCC safety net (vanetza reactive table: 60 ms relaxed ...
|
||||
460 ms restrictive), with a CBR estimate in the heartbeat.
|
||||
- [ ] **Generic V2X_TX message** (BTP port, SHB/GBC, traffic class, lifetime, area) so the phone can
|
||||
send DENM and VAM without reflashing. Consider QoS Data frames: vanetza's Cohda receive path
|
||||
drops non-QoS ones.
|
||||
|
||||
Dropped: building vanetza as a GN/BTP oracle. Real captures (`pcap_gn_tally.py`), the host
|
||||
round-trip test and `asn1tools` for UPER cover what it would have checked.
|
||||
|
||||
## Follow-ups found 2026-09-14
|
||||
|
||||
- [x] **Capture tooling moved into this repo** (`capture/`), with the CR-insertion fix. The
|
||||
sniffer's console inserts a CR before every LF, which also hits every 0x0a byte of the binary
|
||||
pcap stream, shifting pcap record headers and frames. A 787 KB capture parsed cleanly for
|
||||
only 82 of ~2000 records, and DENMs showed up on nonsense BTP ports. `undo_crlf()` reverses
|
||||
it on the raw stream before framing; afterwards a capture parsed to EOF and DENMs read as
|
||||
port 2002. **Every capture taken before 2026-09-14 is truncated at its first corrupted
|
||||
record** - re-measure anything derived from them.
|
||||
- [ ] `capture/dump_pcap.py` reads the same console and still needs the same treatment.
|
||||
- [ ] **Do not open COM3's console while the phone is attached.** Opening it toggles DTR/RTS on the
|
||||
CH343 and resets the OBU, which drops the phone's USB link and needs a manual Connect.
|
||||
|
||||
## Follow-ups found 2026-09-11
|
||||
|
||||
- [ ] **App: show the signed flag.** `V2xRxFrame.parse` in `SerialFrame.kt` only reads bit0 of
|
||||
the flags byte; read bit1 (signed, not verified) and show it where messages are listed.
|
||||
- [ ] **Messages that do not decode with asn1tools.** In the recordings, 56 from the CiT One
|
||||
(`aa:f8:76:7d:bd:ad`: 54 CAMs of 245 bytes, 2 DENMs of 402 bytes) and one 218-byte CAM from
|
||||
`6e:94:03:1b:05:26` fail against `cam_1_4_1`/`denm_1_3_1` + `cdd_1_3_1_1`, with or without the
|
||||
old trailing bytes. A newer module version on the sender, or a sender bug; check what the
|
||||
app's decoders make of them (`check_replay.py` lists the records).
|
||||
@@ -80,5 +80,8 @@ dependencies {
|
||||
|
||||
// ── Unit tests (JVM, no emulator required) ────────────────────────────────
|
||||
testImplementation(libs.junit)
|
||||
// Real org.json for JVM unit tests: the android.jar stub throws "not mocked" on every
|
||||
// JSONObject call, which would make the MQTT payload parsers untestable off-device.
|
||||
testImplementation(libs.json)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
}
|
||||
|
||||
@@ -28,6 +28,9 @@ import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
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.ObuHardware
|
||||
import com.hawhamburg.micr0bu.ui.components.StatusTopBar
|
||||
import com.hawhamburg.micr0bu.ui.navigation.BottomNavBar
|
||||
import com.hawhamburg.micr0bu.ui.navigation.Screen
|
||||
@@ -84,7 +87,12 @@ class MainActivity : AppCompatActivity() {
|
||||
val useCaseEnabledMap by mqttViewModel.useCaseEnabledMap.collectAsState()
|
||||
val obuHardware by mqttViewModel.obuHardware.collectAsState()
|
||||
val usbSerialState by mqttViewModel.usbSerialState.collectAsState()
|
||||
val espRxMode by mqttViewModel.espRxMode.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
|
||||
@@ -123,7 +131,14 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = { StatusTopBar(state, mqttConnectionState) },
|
||||
topBar = {
|
||||
StatusTopBar(
|
||||
state = state,
|
||||
mqttConnectionState = mqttConnectionState,
|
||||
isEsp32 = obuHardware == ObuHardware.ESP32_C5,
|
||||
usbSerialState = usbSerialState,
|
||||
)
|
||||
},
|
||||
bottomBar = { BottomNavBar(navController) },
|
||||
) { innerPadding ->
|
||||
NavHost(
|
||||
@@ -141,20 +156,29 @@ class MainActivity : AppCompatActivity() {
|
||||
usbCableConnected = usbConnected,
|
||||
obuStationTypeWarning = obuStationTypeWarning,
|
||||
obuStationType = obuStationType,
|
||||
hazards = denmEvents,
|
||||
signals = spatIntersections,
|
||||
ownPosition = ownCamPosition,
|
||||
onNavigateToConnection = { navController.navigate(Screen.Connection.route) },
|
||||
onNavigateToSensors = {
|
||||
navController.navigate(Screen.Sensors.route) {
|
||||
popUpTo(Screen.Dashboard.route) { saveState = true }
|
||||
popUpTo(Screen.Dashboard.route)
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
},
|
||||
onNavigateToMap = { navController.navigate(Screen.Map.route) },
|
||||
onNavigateToRecord = {
|
||||
navController.navigate(Screen.Record.route) {
|
||||
popUpTo(Screen.Dashboard.route) { saveState = true }
|
||||
popUpTo(Screen.Dashboard.route)
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
// Same options the bottom bar uses, so arriving at V2X from a
|
||||
// Dashboard card leaves the same back stack as tapping the tab.
|
||||
onNavigateToV2x = {
|
||||
navController.navigate(Screen.MqttViewer.route) {
|
||||
popUpTo(Screen.Dashboard.route)
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -166,12 +190,19 @@ class MainActivity : AppCompatActivity() {
|
||||
RecordingScreen(
|
||||
state = state,
|
||||
mqttConnectionState = mqttConnectionState,
|
||||
obuConnected = if (obuHardware == ObuHardware.ESP32_C5)
|
||||
usbSerialState == UsbSerialState.CONNECTED
|
||||
else
|
||||
mqttConnectionState == MqttConnectionState.CONNECTED,
|
||||
tripServiceState = tripServiceState,
|
||||
showBatteryOptPrompt = showBatteryOptPrompt,
|
||||
onToggleRecording = {
|
||||
// Start / stop both CSV recording and the event detection service
|
||||
// Start / stop both CSV recording and the event detection
|
||||
// service. Order matters on start: the CSV session must exist
|
||||
// before the trip row is written, so the trip can store its
|
||||
// id and later delete the CSV along with itself.
|
||||
viewModel.toggleRecording()
|
||||
tripViewModel.toggleRecording()
|
||||
tripViewModel.toggleRecording(viewModel.activeSessionId)
|
||||
},
|
||||
onOpenSessionLog = {
|
||||
navController.navigate(Screen.Log.route)
|
||||
@@ -217,10 +248,7 @@ class MainActivity : AppCompatActivity() {
|
||||
val trip = trips.firstOrNull { it.id == tripId }
|
||||
|
||||
if (trip != null) {
|
||||
TripReviewScreen(
|
||||
trip = trip,
|
||||
viewModel = tripViewModel,
|
||||
)
|
||||
TripReviewScreen(trip = trip)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,8 +281,6 @@ class MainActivity : AppCompatActivity() {
|
||||
onMqttPrefsChange = mqttViewModel::updatePrefs,
|
||||
obuHardware = obuHardware,
|
||||
onObuHardwareChange = mqttViewModel::setObuHardware,
|
||||
espRxMode = espRxMode,
|
||||
onEspRxModeChange = mqttViewModel::setEspRxMode,
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
@@ -297,6 +323,7 @@ class MainActivity : AppCompatActivity() {
|
||||
DeveloperSettingsScreen(
|
||||
state = state,
|
||||
onDeveloperMode = viewModel::setDeveloperMode,
|
||||
onOpenSensorMonitor = { navController.navigate(Screen.Sensors.route) },
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
@@ -309,7 +336,7 @@ class MainActivity : AppCompatActivity() {
|
||||
// hiltViewModel() — that would create a separate instance scoped to
|
||||
// this NavBackStackEntry, whose onCleared() (fired the moment you
|
||||
// navigate away) would disconnect the shared UsbSerialTransport out
|
||||
// from under every other screen still using it.
|
||||
// from under every other screen still using.
|
||||
ConnectionSetupScreen(viewModel = mqttViewModel)
|
||||
}
|
||||
composable(Screen.Map.route) {
|
||||
|
||||
@@ -51,7 +51,7 @@ suspend fun shareSessionCsv(context: Context, session: RecordingSession) {
|
||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/csv"
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
putExtra(Intent.EXTRA_SUBJECT, "MicrOBU Session Export — $fileName")
|
||||
putExtra(Intent.EXTRA_SUBJECT, "MicrOBU Session Export - $fileName")
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
context.startActivity(Intent.createChooser(intent, "Export session"))
|
||||
@@ -82,7 +82,7 @@ suspend fun saveSessionCsvToUri(context: Context, session: RecordingSession, uri
|
||||
*/
|
||||
fun buildSessionCsv(s: RecordingSession): String = buildString {
|
||||
appendLine("# MicrOBU Session Export")
|
||||
appendLine("# Generated by MicrOBU v0.2.0 — HAW Hamburg / Project MicrOBU")
|
||||
appendLine("# Generated by MicrOBU v0.2.0 - HAW Hamburg / Project MicrOBU")
|
||||
appendLine("# Session ID,${s.id}")
|
||||
appendLine("# Start,${iso.format(Date(s.startTime))}")
|
||||
appendLine("# End,${iso.format(Date(s.endTime))}")
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.hawhamburg.micr0bu.data
|
||||
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import com.hawhamburg.micr0bu.domain.asn1.ItsTime
|
||||
import java.time.DateTimeException
|
||||
|
||||
/**
|
||||
* Puts the timestamps this phone transmits on GNSS time instead of its own wall clock.
|
||||
*
|
||||
* ## Why
|
||||
* Every CAM carries a generationDeltaTime and every GeoNetworking header a TST, and receivers use
|
||||
* them to judge how fresh a message is and in what order messages came. Both used to come straight
|
||||
* from `System.currentTimeMillis()`, so they were only as good as the phone's clock setting. On
|
||||
* 2026-09-10 the bench phone was 24 minutes fast: automatic time had no source (no SIM, and the
|
||||
* lab Wi-Fi has no internet time), so it had not set the clock once in 69 hours, and every CAM
|
||||
* went out stamped 24 minutes in the future. A bike-mounted phone on the road is in exactly that
|
||||
* position. GNSS time depends on none of it.
|
||||
*
|
||||
* ## How
|
||||
* [SystemClock.currentGnssTimeClock] (API 29, this app's minSdk) is a UTC clock the platform keeps
|
||||
* synchronised from GNSS fixes. One reading of it taken alongside the wall clock gives the wall
|
||||
* clock's error, which is then applied to the fix's own timestamp. When GNSS time is unavailable,
|
||||
* typically indoors before any satellite fix since boot, the wall clock is used unchanged.
|
||||
*
|
||||
* Which clock is in use is logged whenever it changes, with the measured error, so a capture shows
|
||||
* where a given run's timestamps came from.
|
||||
*
|
||||
* Only the transmit path uses this. Everything else in the app stays on the wall clock, because
|
||||
* received messages, sensor samples and trip records are all stamped with it and must stay
|
||||
* comparable with one another.
|
||||
*/
|
||||
object GnssTimeSource {
|
||||
private const val TAG = "GnssTimeSource"
|
||||
|
||||
/** Whether the last correction used GNSS time; null before the first. For change-only logging. */
|
||||
@Volatile private var lastUsedGnss: Boolean? = null
|
||||
|
||||
/** [systemMs], a wall-clock reading, moved onto GNSS time where GNSS time is available. */
|
||||
fun correct(systemMs: Long): Long {
|
||||
val systemNow = System.currentTimeMillis()
|
||||
val gnssNow = try {
|
||||
SystemClock.currentGnssTimeClock().millis()
|
||||
} catch (e: DateTimeException) {
|
||||
null
|
||||
}
|
||||
noteSource(gnssNow, systemNow)
|
||||
return ItsTime.onGnssTime(systemMs, gnssNow, systemNow)
|
||||
}
|
||||
|
||||
private fun noteSource(gnssNow: Long?, systemNow: Long) {
|
||||
val usingGnss = gnssNow != null
|
||||
if (lastUsedGnss == usingGnss) return
|
||||
lastUsedGnss = usingGnss
|
||||
if (gnssNow != null) {
|
||||
Log.i(TAG, "transmit timestamps now on GNSS time; phone clock is " +
|
||||
"${"%+.1f".format((systemNow - gnssNow) / 1000.0)} s off")
|
||||
} else {
|
||||
Log.w(TAG, "GNSS time unavailable, transmit timestamps fall back to the phone clock, " +
|
||||
"which has no automatic time source without a SIM or internet")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package com.hawhamburg.micr0bu.data
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.content.FileProvider
|
||||
import com.hawhamburg.micr0bu.data.db.RecordedTripEntity
|
||||
import com.hawhamburg.micr0bu.data.db.V2xMessageEntity
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONArray
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
private val isoUtc = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US)
|
||||
private val humanLocal = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
|
||||
|
||||
fun tripFileName(trip: RecordedTripEntity): String =
|
||||
"micr0bu_trip_${humanLocal.format(Date(trip.startTime))
|
||||
.replace(" ", "_").replace(":", "-")}.csv"
|
||||
|
||||
/**
|
||||
* Builds a single combined CSV for one trip: the raw sensor samples recorded alongside it, the
|
||||
* 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. 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.
|
||||
*
|
||||
* Columns are the union of what the row types need; a row leaves the fields that don't apply to it
|
||||
* empty rather than inventing values. That is deliberately wide and sparse — spreadsheets and
|
||||
* pandas both handle it fine, and it keeps every value under a self-describing header instead of
|
||||
* a positional one that means different things per row.
|
||||
*
|
||||
* Sensor rows are copied through verbatim from the session CSV where one exists. Trips recorded
|
||||
* before the trip↔session link existed (schema v3 and earlier) have no `sessionId`, so their
|
||||
* export contains everything except the raw sensor stream.
|
||||
*/
|
||||
suspend fun buildTripCsv(
|
||||
context: Context,
|
||||
trip: RecordedTripEntity,
|
||||
v2xMessages: List<V2xMessageEntity>,
|
||||
): String = withContext(Dispatchers.IO) {
|
||||
buildString {
|
||||
appendLine("# MicrOBU Trip Export")
|
||||
appendLine("# Trip ID,${trip.id}")
|
||||
appendLine("# Start,${isoUtc.format(Date(trip.startTime))}")
|
||||
appendLine("# End,${isoUtc.format(Date(trip.endTime))}")
|
||||
appendLine("# Distance_m,${trip.distanceMetres}")
|
||||
appendLine("# Events,${trip.eventCount}")
|
||||
appendLine("# V2X_messages,${v2xMessages.size}")
|
||||
appendLine("# Session_ID,${trip.sessionId ?: "(none - recorded before trip/session linking)"}")
|
||||
appendLine()
|
||||
appendLine(
|
||||
"type,timestamp_ms,timestamp_iso,lat,lon,speed_ms,heading_deg," +
|
||||
"station_id,station_type,is_own,yaw_rate_dps,rssi_dbm"
|
||||
)
|
||||
|
||||
// GPS track: stored on the trip row as a JSON array of points, not in a table.
|
||||
for (point in trip.gpsTrackJson.toTrackPoints()) {
|
||||
appendLine(
|
||||
"gps,${point.timestamp},${isoUtc.format(Date(point.timestamp))}," +
|
||||
"${point.lat},${point.lon},,," +
|
||||
",,,,"
|
||||
)
|
||||
}
|
||||
|
||||
for (m in v2xMessages) {
|
||||
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) 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()) {
|
||||
appendLine()
|
||||
appendLine("# --- raw sensor samples (from session ${trip.sessionId}) ---")
|
||||
sessionCsv.forEachLine { line ->
|
||||
if (line.isNotBlank() && !line.startsWith("#")) appendLine(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Shares a trip's combined CSV via Android's share sheet. */
|
||||
suspend fun shareTripCsv(
|
||||
context: Context,
|
||||
trip: RecordedTripEntity,
|
||||
v2xMessages: List<V2xMessageEntity>,
|
||||
) {
|
||||
val fileName = tripFileName(trip)
|
||||
val cacheFile = File(context.cacheDir, fileName)
|
||||
val csv = buildTripCsv(context, trip, v2xMessages)
|
||||
|
||||
withContext(Dispatchers.IO) { cacheFile.writeText(csv) }
|
||||
|
||||
val uri = FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.fileprovider",
|
||||
cacheFile,
|
||||
)
|
||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/csv"
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
putExtra(Intent.EXTRA_SUBJECT, "MicrOBU Trip Export - $fileName")
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
context.startActivity(Intent.createChooser(intent, "Export trip"))
|
||||
}
|
||||
|
||||
private data class TrackPoint(val timestamp: Long, val lat: Double, val lon: Double)
|
||||
|
||||
/** Parses [RecordedTripEntity.gpsTrackJson]; returns empty on anything malformed. */
|
||||
private fun String.toTrackPoints(): List<TrackPoint> = runCatching {
|
||||
val arr = JSONArray(this)
|
||||
(0 until arr.length()).mapNotNull { i ->
|
||||
val o = arr.optJSONObject(i) ?: return@mapNotNull null
|
||||
// Keys match TripRecordingService.appendGpsPoint: {"lat":..,"lon":..,"ts":..}
|
||||
TrackPoint(
|
||||
timestamp = o.optLong("ts", 0L),
|
||||
lat = o.optDouble("lat", Double.NaN),
|
||||
lon = o.optDouble("lon", Double.NaN),
|
||||
).takeIf { !it.lat.isNaN() && !it.lon.isNaN() }
|
||||
}
|
||||
}.getOrDefault(emptyList())
|
||||
@@ -1,22 +1,27 @@
|
||||
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
|
||||
|
||||
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.
|
||||
*/
|
||||
class TripRepository(db: AppDatabase) {
|
||||
class TripRepository(db: AppDatabase, private val context: Context) {
|
||||
|
||||
private val dao = db.tripDao()
|
||||
private val sessionDao = db.sessionDao()
|
||||
|
||||
// ── Trips ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -30,6 +35,7 @@ class TripRepository(db: AppDatabase) {
|
||||
distanceMetres: Float = 0f,
|
||||
eventCount: Int = 0,
|
||||
gpsTrackJson: String = "[]",
|
||||
sessionId: String? = null,
|
||||
): Long = dao.insertTrip(
|
||||
RecordedTripEntity(
|
||||
startTime = startTime,
|
||||
@@ -37,6 +43,7 @@ class TripRepository(db: AppDatabase) {
|
||||
distanceMetres = distanceMetres,
|
||||
eventCount = eventCount,
|
||||
gpsTrackJson = gpsTrackJson,
|
||||
sessionId = sessionId,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -65,33 +72,37 @@ class TripRepository(db: AppDatabase) {
|
||||
/** Emits all trips ordered by startTime DESC, updating whenever the DB changes. */
|
||||
fun getAllTrips(): Flow<List<RecordedTripEntity>> = dao.getAllTrips()
|
||||
|
||||
suspend fun deleteTrip(tripId: Long) = dao.deleteTripById(tripId)
|
||||
|
||||
// ── Events ────────────────────────────────────────────────────────────────
|
||||
/** One-shot read of a single trip row, or null if it no longer exists. */
|
||||
suspend fun getTrip(tripId: Long): RecordedTripEntity? = dao.getTripById(tripId)
|
||||
|
||||
/**
|
||||
* Persists a domain [DetectedEvent] for the given [tripId].
|
||||
* Converts the domain model to the Room entity.
|
||||
* 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 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,
|
||||
)
|
||||
)
|
||||
suspend fun getV2xMessagesForTripOnce(tripId: Long): List<V2xMessageEntity> =
|
||||
dao.getV2xMessagesForTrip(tripId).first()
|
||||
|
||||
/** Emits events for [tripId] ordered by timestamp, updating whenever the DB changes. */
|
||||
fun getEventsForTrip(tripId: Long): Flow<List<DetectedEventEntity>> =
|
||||
dao.getEventsForTrip(tripId)
|
||||
/**
|
||||
* 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
|
||||
* this, every deleted trip left one behind and the user had to clear them by hand.
|
||||
*/
|
||||
suspend fun deleteTrip(tripId: Long) {
|
||||
val sessionId = dao.getTripById(tripId)?.sessionId
|
||||
dao.deleteTripById(tripId)
|
||||
if (sessionId == null) return
|
||||
|
||||
// The CSV lives in two places as far as the user is concerned: a file on disk, and a row
|
||||
// in `sessions` that makes it visible on the Session Log screen. Deleting only the file
|
||||
// left a phantom entry there pointing at nothing, so both go.
|
||||
sessionDao.deleteById(sessionId)
|
||||
val csv = File(File(context.filesDir, "sessions"), "$sessionId.csv")
|
||||
if (csv.exists() && !csv.delete()) {
|
||||
Log.w(TAG, "deleteTrip: failed to delete CSV for session $sessionId")
|
||||
}
|
||||
}
|
||||
|
||||
// ── V2X messages (Phase 03) ──────────────────────────────────────────────────
|
||||
// Retention policy: only ever called while a trip is actively recording — see
|
||||
@@ -112,6 +123,7 @@ class TripRepository(db: AppDatabase) {
|
||||
speedMps = cam.speedMps,
|
||||
headingDeg = cam.headingDeg,
|
||||
yawRateDps = cam.yawRateDps,
|
||||
rssiDbm = cam.rssiDbm,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -1,23 +1,38 @@
|
||||
package com.hawhamburg.micr0bu.data.cam
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.hawhamburg.micr0bu.data.GnssReading
|
||||
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.EspRxMode
|
||||
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.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
|
||||
@@ -32,10 +47,12 @@ 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
|
||||
|
||||
private const val TAG = "CamUseCaseRepo"
|
||||
private const val CAM_TOPIC = "v2x-uca/output/json/cam"
|
||||
private const val OBU_GNSS_TOPIC = "v2x/rx/obu_gnss"
|
||||
private const val PRUNE_INTERVAL_MS = 1_000L
|
||||
@@ -46,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
|
||||
@@ -63,7 +90,13 @@ 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.
|
||||
*
|
||||
* No DENM is generated or consumed anywhere in this class.
|
||||
* **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(
|
||||
@@ -72,13 +105,16 @@ class CamUseCaseRepository @Inject constructor(
|
||||
private val usbSerialTransport: UsbSerialTransport,
|
||||
private val camCodec: RealAsn1UperCodec,
|
||||
private val obuHardwarePrefs: ObuHardwarePreferences,
|
||||
private val pseudonymManager: PseudonymManager,
|
||||
private val camPinger: CamPinger,
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val engine = UseCaseDetectionEngine()
|
||||
private val sensorRepository = SensorRepository(context)
|
||||
|
||||
@Volatile private var espRxMode: EspRxMode = EspRxMode.SEND_AND_RECEIVE
|
||||
/** Latest selected OBU hardware, so serial-link events only act on the ESP32-C5 path. */
|
||||
@Volatile private var currentHardware: ObuHardware = ObuHardware.CIT_ONE
|
||||
|
||||
private val _ownStationId = MutableStateFlow<Long?>(null)
|
||||
/** The ego OBU's own station ID, learned from `v2x/rx/obu_gnss`. Null until known. */
|
||||
@@ -87,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 },
|
||||
@@ -117,6 +156,55 @@ class CamUseCaseRepository @Inject constructor(
|
||||
*/
|
||||
val processedCam: SharedFlow<Cam> = _processedCam.asSharedFlow()
|
||||
|
||||
private val _rsuStations = MutableStateFlow<Map<Long, Cam>>(emptyMap())
|
||||
/**
|
||||
* Latest CAM per roadside unit heard over the air.
|
||||
*
|
||||
* Separate from [remotePositions] because an RSU is infrastructure, not a road user: it has no
|
||||
* kinematics, sits at a fixed point forever, and would trip the stopped-vehicle and
|
||||
* intersection-movement use cases for as long as it is in range. It still belongs on the map
|
||||
* and in the station list, which is what this flow is for. Consumers should apply their own
|
||||
* staleness window - nothing prunes this map except a link drop.
|
||||
*/
|
||||
val rsuStations: StateFlow<Map<Long, Cam>> = _rsuStations.asStateFlow()
|
||||
|
||||
private val _ownTxLoopback = MutableStateFlow<OwnTxLoopback?>(null)
|
||||
/**
|
||||
* 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 ownTxLoopback: StateFlow<OwnTxLoopback?> = _ownTxLoopback.asStateFlow()
|
||||
|
||||
/** 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)
|
||||
/**
|
||||
* 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 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 {
|
||||
mqttRepository.messages.collect { msg ->
|
||||
@@ -127,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
|
||||
@@ -167,14 +286,53 @@ class CamUseCaseRepository @Inject constructor(
|
||||
// just never emits CAM_RX frames if nothing's plugged in over serial).
|
||||
scope.launch {
|
||||
usbSerialTransport.incomingFrames.collect { frame ->
|
||||
if (frame.type != SerialFrameType.CAM_RX) return@collect
|
||||
if (espRxMode == EspRxMode.SEND_ONLY) return@collect
|
||||
handleCamFromSerial(frame.payload)
|
||||
if (frame.type != SerialFrameType.V2X_RX) return@collect
|
||||
if (usbSerialTransport.state.value != UsbSerialState.CONNECTED) return@collect
|
||||
val v2x = V2xRxFrame.parse(frame.payload) ?: return@collect
|
||||
when (v2x.btpPort) {
|
||||
BtpPort.CAM -> handleCamFromSerial(v2x)
|
||||
BtpPort.DENM -> handleDenmFromSerial(v2x)
|
||||
BtpPort.SPATEM -> handleSpatFromSerial(v2x)
|
||||
// The firmware only forwards ports it was told to accept, so anything else
|
||||
// means the two sides have drifted out of sync.
|
||||
else -> Log.w(TAG, "unexpected BTP port ${v2x.btpPort} from firmware")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drop everything the serial link taught us the moment it goes down. Without this, the
|
||||
// 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 {
|
||||
obuHardwarePrefs.espRxModeFlow.collect { espRxMode = it }
|
||||
usbSerialTransport.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) {
|
||||
engine.reset()
|
||||
_rsuStations.value = emptyMap()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 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
|
||||
// off the air in promiscuous mode, were tracked as a *remote* station: a ghost vehicle
|
||||
// sitting exactly on top of the ego position, fed into the detection engine as a
|
||||
// collision partner for itself.
|
||||
scope.launch {
|
||||
combine(obuHardwarePrefs.obuHardwareFlow, pseudonymManager.currentFlow) { hardware, pseudonym ->
|
||||
hardware to pseudonym
|
||||
}.collect { (hardware, pseudonym) ->
|
||||
currentHardware = hardware
|
||||
if (hardware == ObuHardware.ESP32_C5) {
|
||||
// currentFlow re-emits on every rotation, so this tracks the live identity.
|
||||
_ownStationId.value = (pseudonym ?: pseudonymManager.current()).stationId
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,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
|
||||
@@ -226,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)
|
||||
}
|
||||
|
||||
@@ -250,12 +438,141 @@ 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(payload: ByteArray) {
|
||||
if (payload.isEmpty()) return
|
||||
val camBytes = payload.copyOfRange(1, payload.size) // payload[0] is RSSI, not part of the CAM
|
||||
val cam = camCodec.decodeCam(camBytes, System.currentTimeMillis()) ?: return
|
||||
if (_ownStationId.value != null && cam.stationId == _ownStationId.value) return // self-heard TX
|
||||
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
|
||||
// causes - frames not arriving at all, versus arriving and failing to decode - and
|
||||
// without this line they're indistinguishable from the outside.
|
||||
Log.w(
|
||||
TAG,
|
||||
"handleCamUper[${v2x.source}]: decode FAILED for ${v2x.uper.size}-byte CAM " +
|
||||
"(rssi=${v2x.rssiDbm} dBm) - first bytes: ${v2x.uper.toHexPreview()}",
|
||||
)
|
||||
return
|
||||
}
|
||||
Log.d(TAG, "handleCamUper[${v2x.source}]: decoded station=${cam.stationId} " +
|
||||
"lat=${cam.latitude} lon=${cam.longitude} speed=${cam.speedMps} rssi=${v2x.rssiDbm} dBm")
|
||||
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
|
||||
// stationary station at a fixed point - which is precisely the shape the stopped-vehicle
|
||||
// and intersection-movement use cases look for. Feeding it to the engine would raise a
|
||||
// standing false alert for as long as the RSU is in range.
|
||||
if (cam.stationType == StationType.ROAD_SIDE_UNIT) {
|
||||
// Tracked here rather than in the engine, so an RSU still shows on the map and in the
|
||||
// station list without being evaluated for alerts. Keeping it out of the engine
|
||||
// entirely - as the first version of this did - also removed it from the display,
|
||||
// because remotePositions is the engine's map: 611 RSU CAMs decoded during the
|
||||
// 2026-08-25 bench run and none of them were ever shown.
|
||||
_rsuStations.value = _rsuStations.value + (cam.stationId to cam)
|
||||
_processedCam.tryEmit(cam)
|
||||
return
|
||||
}
|
||||
|
||||
engine.onRemoteCam(cam)
|
||||
_processedCam.tryEmit(cam)
|
||||
}
|
||||
|
||||
/**
|
||||
* A DENM heard over the air. Deliberately NOT fed to [UseCaseDetectionEngine] - that engine
|
||||
* 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) = 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 = relevanceRadiusM,
|
||||
)
|
||||
if (denm == null) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"handleDenmUper[${v2x.source}]: decode FAILED for ${v2x.uper.size}-byte DENM " +
|
||||
"(rssi=${v2x.rssiDbm} dBm) - first bytes: ${v2x.uper.toHexPreview()}",
|
||||
)
|
||||
return
|
||||
}
|
||||
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")
|
||||
_decodedDenm.tryEmit(denm)
|
||||
}
|
||||
|
||||
/**
|
||||
* A SPATEM heard over the air: the live signal phase for one or more intersections.
|
||||
*
|
||||
* Like DENM, this is deliberately kept out of [UseCaseDetectionEngine] - a traffic light is
|
||||
* not a moving road user, and the CAM-based use cases reason about kinematics.
|
||||
*
|
||||
* Note the firmware drops any SPATEM whose UPER exceeds the 512-byte serial payload cap and
|
||||
* 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) =
|
||||
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(),
|
||||
rssiDbm = v2x.rssiDbm,
|
||||
)
|
||||
if (spat == null) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"handleSpatUper[${v2x.source}]: decode FAILED for ${v2x.uper.size}-byte SPATEM " +
|
||||
"(rssi=${v2x.rssiDbm} dBm) - first bytes: ${v2x.uper.toHexPreview()}",
|
||||
)
|
||||
return
|
||||
}
|
||||
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")
|
||||
_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 = 3,
|
||||
version = 5,
|
||||
exportSchema = false,
|
||||
)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
@@ -34,13 +33,46 @@ abstract class AppDatabase : RoomDatabase() {
|
||||
AppDatabase::class.java,
|
||||
"micr0bu.db",
|
||||
)
|
||||
.addMigrations(MIGRATION_1_2, MIGRATION_2_3)
|
||||
.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
|
||||
* deleting a trip can also delete its `filesDir/sessions/<id>.csv`. Nullable, because
|
||||
* trips recorded before this column existed have no way to identify their CSV - those
|
||||
* files stay orphaned and have to be cleared by hand once.
|
||||
* - `v2x_messages.rssiDbm` stores the received signal strength the ESP32-C5 firmware
|
||||
* already sends in every CAM_RX frame but which the app previously discarded, so
|
||||
* range-vs-signal can be analysed from a recorded ride.
|
||||
*/
|
||||
private val MIGRATION_3_4 = object : Migration(3, 4) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
database.execSQL("ALTER TABLE `trips` ADD COLUMN `sessionId` TEXT")
|
||||
database.execSQL("ALTER TABLE `v2x_messages` ADD COLUMN `rssiDbm` INTEGER")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the `trips` and `detected_events` tables introduced in Phase A.
|
||||
* The existing `sessions` table is left untouched.
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -18,4 +18,16 @@ data class RecordedTripEntity(
|
||||
val distanceMetres: Float,
|
||||
val eventCount: Int,
|
||||
val gpsTrackJson: String,
|
||||
|
||||
/**
|
||||
* ID of the CSV recording session captured alongside this trip, or null for trips recorded
|
||||
* before this column existed (schema v3 and earlier).
|
||||
*
|
||||
* Trips and CSV sessions are written by two independent subsystems - [TripRecordingService]
|
||||
* and SensorViewModel - that the Recording screen happens to start together. Without this
|
||||
* link, deleting a trip left its `filesDir/sessions/<id>.csv` behind forever with nothing in
|
||||
* the UI referencing it. Matching them by timestamp instead was rejected: two recordings
|
||||
* close together would silently delete the wrong file.
|
||||
*/
|
||||
val sessionId: String? = null,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -48,4 +48,7 @@ data class V2xMessageEntity(
|
||||
val speedMps: Double,
|
||||
val headingDeg: Double,
|
||||
val yawRateDps: Double?,
|
||||
|
||||
/** Received signal strength, dBm, for CAMs heard over the air. Null on the CiT One path. */
|
||||
val rssiDbm: Int? = null,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,12 +2,14 @@ package com.hawhamburg.micr0bu.data.mqtt
|
||||
|
||||
import android.content.Context
|
||||
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.EspRxMode
|
||||
import com.hawhamburg.micr0bu.data.transport.ObuHardware
|
||||
import com.hawhamburg.micr0bu.domain.cam.Pseudonym
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
@@ -25,7 +27,11 @@ class ObuHardwarePreferences @Inject constructor(
|
||||
) {
|
||||
private object Keys {
|
||||
val OBU_HARDWARE = stringPreferencesKey("obu_hardware")
|
||||
val ESP_RX_MODE = stringPreferencesKey("esp_rx_mode")
|
||||
// The current transmit pseudonym. Three keys, but only ever read or written together;
|
||||
// see loadPseudonym.
|
||||
val OWN_STATION_ID = longPreferencesKey("own_station_id")
|
||||
val OWN_MAC = stringPreferencesKey("own_mac")
|
||||
val OWN_PSEUDONYM_CREATED_MS = longPreferencesKey("own_pseudonym_created_ms")
|
||||
}
|
||||
|
||||
val obuHardwareFlow: Flow<ObuHardware> = context.obuHardwareDataStore.data.map { prefs ->
|
||||
@@ -37,15 +43,35 @@ class ObuHardwarePreferences @Inject constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the ESP32-C5 path processes received CAM traffic or only transmits — see
|
||||
* [EspRxMode]'s KDoc for what this does and doesn't actually control. Defaults to
|
||||
* [EspRxMode.SEND_AND_RECEIVE] (full duplex, today's existing behavior).
|
||||
* 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.
|
||||
*/
|
||||
val espRxModeFlow: Flow<EspRxMode> = context.obuHardwareDataStore.data.map { prefs ->
|
||||
EspRxMode.entries.firstOrNull { it.id == prefs[Keys.ESP_RX_MODE] } ?: EspRxMode.SEND_AND_RECEIVE
|
||||
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)
|
||||
}
|
||||
|
||||
suspend fun setEspRxMode(mode: EspRxMode) {
|
||||
context.obuHardwareDataStore.edit { prefs -> prefs[Keys.ESP_RX_MODE] = mode.id }
|
||||
/** 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
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -25,8 +25,9 @@ enum class ObuHardware(val id: String) {
|
||||
*
|
||||
* Real serial link + CAM UPER codec are implemented on both sides — see
|
||||
* [com.hawhamburg.micr0bu.data.transport.UsbSerialTransport] (phone) and
|
||||
* `obu-firmware/main/serial_link.c` (firmware). See also [EspRxMode] for the send-only vs
|
||||
* send-and-receive toggle (Settings > Connection).
|
||||
* `obu-firmware/main/serial_link.c` (firmware). Reception is always on: the ESP32 must keep
|
||||
* its receiver enabled for raw TX to work at all (ESP-IDF only emits raw 802.11 frames while
|
||||
* promiscuous or associated), so there is nothing meaningful for the app to toggle.
|
||||
*/
|
||||
ESP32_C5("esp32_c5"),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -14,13 +19,24 @@ object SerialFrameType {
|
||||
/** Phone -> ESP32: raw CAM UPER bytes to GeoNetworking-wrap and transmit immediately. */
|
||||
const val CAM_TX: Int = 0x01
|
||||
|
||||
/** ESP32 -> phone: payload is `[rssi: 1 signed][CAM UPER bytes...]`, already stripped of
|
||||
* 802.11/LLC-SNAP/GeoNetworking/BTP-B framing by the firmware's `gn_unwrap.c`. */
|
||||
/** Superseded by [V2X_RX]; the firmware no longer sends this. Kept so the number isn't reused. */
|
||||
const val CAM_RX: Int = 0x02
|
||||
|
||||
/** ESP32 -> phone: any received ITS message — see [V2xRxFrame] for the payload layout. */
|
||||
const val V2X_RX: Int = 0x04
|
||||
|
||||
/** 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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -57,10 +73,22 @@ data class EspLinkStatus(
|
||||
val txFailures: Int,
|
||||
/** Frames from the phone the firmware dropped on CRC mismatch. */
|
||||
val rxCrcErrors: Int,
|
||||
/**
|
||||
* What the firmware accepts, as `SERIAL_CAP_*` bits from `serial_link.h`. Byte 7 of the
|
||||
* payload; 0 for firmware that predates it and sends only 7 bytes, which is exactly the answer
|
||||
* the phone needs from such firmware: it accepts nothing beyond the original messages.
|
||||
*/
|
||||
val capabilities: Int = 0,
|
||||
) {
|
||||
/** True when the firmware accepts [SerialFrameType.CAM_TX_PV]. */
|
||||
val supportsCamTxPv: Boolean get() = capabilities and CAP_CAM_TX_PV != 0
|
||||
|
||||
companion object {
|
||||
const val PAYLOAD_SIZE = 7
|
||||
|
||||
/** Mirrors `SERIAL_CAP_CAM_TX_PV` in `serial_link.h`. */
|
||||
const val CAP_CAM_TX_PV = 0x01
|
||||
|
||||
/** Returns null if [payload] isn't a well-formed status payload (e.g. older firmware). */
|
||||
fun parse(payload: ByteArray): EspLinkStatus? {
|
||||
if (payload.size < PAYLOAD_SIZE) return null
|
||||
@@ -70,6 +98,7 @@ data class EspLinkStatus(
|
||||
oversizeDrops = u16(1),
|
||||
txFailures = u16(3),
|
||||
rxCrcErrors = u16(5),
|
||||
capabilities = if (payload.size > PAYLOAD_SIZE) payload[7].toInt() and 0xFF else 0,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -93,6 +122,182 @@ object Crc16CcittFalse {
|
||||
}
|
||||
}
|
||||
|
||||
/** BTP-B destination ports (ETSI TS 103 248) the firmware forwards. */
|
||||
object BtpPort {
|
||||
const val CAM = 2001
|
||||
const val DENM = 2002
|
||||
|
||||
/**
|
||||
* Watch the crossover: SPATEM is BTP port **2004** but ItsPduHeader messageID **4**, while
|
||||
* MAPEM is port 2003 and messageID 5. The two numbering schemes are unrelated, and swapping
|
||||
* them routes messages to the wrong decoder.
|
||||
*/
|
||||
const val SPATEM = 2004
|
||||
}
|
||||
|
||||
/**
|
||||
* Decoded [SerialFrameType.V2X_RX] payload: a 14-byte little-endian prefix followed by the UPER
|
||||
* message. Must stay in lockstep with `serial_link.h`'s `SERIAL_V2X_RX_PREFIX_LEN` and the layout
|
||||
* documented there.
|
||||
*
|
||||
* Deliberately generic — [btpPort] says what [uper] is, so adding MAPEM or SPATEM later needs a
|
||||
* decoder here and one accepted port in the firmware's `gn_unwrap.c`, but no protocol change.
|
||||
*/
|
||||
data class V2xRxFrame(
|
||||
/** 2001 = CAM, 2002 = DENM. See [BtpPort]. */
|
||||
val btpPort: Int,
|
||||
/** Received signal strength, dBm, from the firmware's promiscuous RX metadata. */
|
||||
val rssiDbm: Int,
|
||||
/**
|
||||
* GeoBroadcast destination area, or null when the source frame was single-hop broadcast and
|
||||
* carried none. For a DENM this is the hazard's relevance circle — "applies within
|
||||
* [GeoArea.radiusMeters] of this point" — which is more useful on a map than the sender's own
|
||||
* position, since the sender may be relaying for someone else.
|
||||
*/
|
||||
val geoArea: GeoArea?,
|
||||
/** The raw UPER message bytes. */
|
||||
val uper: ByteArray,
|
||||
) {
|
||||
data class GeoArea(val latitude: Double, val longitude: Double, val radiusMeters: Int)
|
||||
|
||||
companion object {
|
||||
const val PREFIX_SIZE = 14
|
||||
|
||||
/** Returns null if [payload] is too short to be a well-formed V2X_RX payload. */
|
||||
fun parse(payload: ByteArray): V2xRxFrame? {
|
||||
if (payload.size <= PREFIX_SIZE) return null
|
||||
fun u8(i: Int) = payload[i].toInt() and 0xFF
|
||||
fun u16(i: Int) = u8(i) or (u8(i + 1) shl 8)
|
||||
fun i32(i: Int) = u8(i) or (u8(i + 1) shl 8) or (u8(i + 2) shl 16) or (u8(i + 3) shl 24)
|
||||
|
||||
val hasArea = (u8(3) and 0x01) != 0
|
||||
return V2xRxFrame(
|
||||
btpPort = u16(0),
|
||||
rssiDbm = payload[2].toInt(), // signed
|
||||
geoArea = if (hasArea) {
|
||||
GeoArea(
|
||||
// GeoNetworking carries these in 1/10 microdegree.
|
||||
latitude = i32(4) / 1e7,
|
||||
longitude = i32(8) / 1e7,
|
||||
radiusMeters = u16(12),
|
||||
)
|
||||
} else null,
|
||||
uper = payload.copyOfRange(PREFIX_SIZE, payload.size),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
|
||||
@@ -318,7 +318,28 @@ class UsbSerialTransport @Inject constructor(
|
||||
val frames = decoder.onBytes(data)
|
||||
frames.forEach { frame ->
|
||||
if (frame.type == SerialFrameType.STATUS) {
|
||||
EspLinkStatus.parse(frame.payload)?.let { _linkStatus.value = it }
|
||||
EspLinkStatus.parse(frame.payload)?.let { status ->
|
||||
// Logged only when a counter moves, not on every 1 Hz beat: the
|
||||
// interesting event is a drop appearing, and a per-second line
|
||||
// would bury it. Without this the firmware's own drop counters are
|
||||
// visible only on the CAM Pinger card, so a bench run captured
|
||||
// through logcat has no record of whether anything was dropped.
|
||||
val prev = _linkStatus.value
|
||||
if (prev == null ||
|
||||
prev.oversizeDrops != status.oversizeDrops ||
|
||||
prev.txFailures != status.txFailures ||
|
||||
prev.rxCrcErrors != status.rxCrcErrors ||
|
||||
prev.status != status.status ||
|
||||
prev.capabilities != status.capabilities
|
||||
) {
|
||||
Log.i(TAG, "ESP32 counters: status=${status.status} " +
|
||||
"oversizeDrops=${status.oversizeDrops} " +
|
||||
"txFailures=${status.txFailures} " +
|
||||
"rxCrcErrors=${status.rxCrcErrors} " +
|
||||
"capabilities=${status.capabilities}")
|
||||
}
|
||||
_linkStatus.value = status
|
||||
}
|
||||
}
|
||||
_incomingFrames.tryEmit(frame)
|
||||
}
|
||||
@@ -367,6 +388,25 @@ class UsbSerialTransport @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/** Which frame type the last CAM went out as, so a change of path is logged once, not per CAM. */
|
||||
@Volatile private var lastTxWithPositionVector: Boolean? = null
|
||||
|
||||
/**
|
||||
* Logs whenever CAMs switch between [SerialFrameType.CAM_TX_PV] and legacy
|
||||
* [SerialFrameType.CAM_TX]. Without it, "the GN header still says bench" has no visible cause
|
||||
* in a logcat capture: it looks identical whether the firmware is old or the phone is.
|
||||
*/
|
||||
private fun noteTxPath(withPositionVector: Boolean, requested: Boolean) {
|
||||
if (lastTxWithPositionVector == withPositionVector) return
|
||||
lastTxWithPositionVector = withPositionVector
|
||||
Log.i(TAG, when {
|
||||
withPositionVector -> "CAM TX path: CAM_TX_PV, GN position vector supplied by the phone"
|
||||
requested -> "CAM TX path: legacy CAM_TX, firmware has not advertised CAM_TX_PV yet; " +
|
||||
"GN position vector is the firmware's bench placeholder"
|
||||
else -> "CAM TX path: legacy CAM_TX, no position vector supplied"
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes [camUperBytes] as a [SerialFrameType.CAM_TX] frame and writes it to the port.
|
||||
* No-op (returns false) if not currently connected — callers (the CAM transmit loop) should
|
||||
@@ -375,15 +415,28 @@ class UsbSerialTransport @Inject constructor(
|
||||
* [consecutiveWriteFailures] so they can't stay invisible.
|
||||
*
|
||||
* Blocking: writes with a 200 ms timeout, so call from a background dispatcher.
|
||||
*
|
||||
* [positionVector], when given, travels with the CAM as a [SerialFrameType.CAM_TX_PV] frame so
|
||||
* the ESP32 builds the GeoNetworking Source Position Vector from real values. It is used only
|
||||
* once the heartbeat advertises [EspLinkStatus.supportsCamTxPv]. Until then, and against
|
||||
* firmware that predates it, the CAM goes out as a plain [SerialFrameType.CAM_TX] exactly as
|
||||
* before and the GN header carries the firmware's bench placeholders. Neither mixed-version
|
||||
* combination breaks transmission; `serial_link.h` explains why.
|
||||
*/
|
||||
fun sendCamTx(camUperBytes: ByteArray): Boolean {
|
||||
fun sendCamTx(camUperBytes: ByteArray, positionVector: GnPositionVector? = null): Boolean {
|
||||
val p = port
|
||||
if (p == null) {
|
||||
_consecutiveWriteFailures.update { it + 1 }
|
||||
return false
|
||||
}
|
||||
return try {
|
||||
val frame = SerialFrameEncoder.encode(SerialFrameType.CAM_TX, camUperBytes)
|
||||
val pv = positionVector?.takeIf { _linkStatus.value?.supportsCamTxPv == true }
|
||||
noteTxPath(withPositionVector = pv != null, requested = positionVector != null)
|
||||
val frame = if (pv != null) {
|
||||
SerialFrameEncoder.encode(SerialFrameType.CAM_TX_PV, pv.toSerialPrefix() + camUperBytes)
|
||||
} else {
|
||||
SerialFrameEncoder.encode(SerialFrameType.CAM_TX, camUperBytes)
|
||||
}
|
||||
p.write(frame, /* timeout ms */ 200)
|
||||
_consecutiveWriteFailures.value = 0
|
||||
true
|
||||
|
||||
@@ -28,12 +28,12 @@ import kotlin.math.roundToLong
|
||||
*/
|
||||
object CamUperCodec {
|
||||
|
||||
/** HighFrequencyContainer CHOICE index for rsuContainerHighFrequency. */
|
||||
private const val HF_CONTAINER_RSU = 1
|
||||
|
||||
/** 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
|
||||
@@ -44,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()
|
||||
@@ -128,13 +132,33 @@ object CamUperCodec {
|
||||
|
||||
bw.putBits(1023 - (-1023), 11) // curvatureValue: unavailable (not derived - see class KDoc)
|
||||
bw.putBits(7, 3) // curvatureConfidence: unavailable
|
||||
bw.putBits(2, 2) // curvatureCalculationMode: unavailable
|
||||
|
||||
// CurvatureCalculationMode is the ONE extensible ENUMERATED in this message:
|
||||
// ENUMERATED {yawRateUsed(0), yawRateNotUsed(1), unavailable(2), ...}
|
||||
// UPER encodes an extensible ENUMERATED as an extension bit followed by the index into
|
||||
// the root list - 1 + 2 = 3 bits, not 2. Both this encoder and its `cam.c` ancestor wrote
|
||||
// only the 2-bit index, which shifted yawRate and the whole low-frequency container one
|
||||
// bit early for any standards-compliant receiver. It went unnoticed because both ends of
|
||||
// this project shared the same mistake; phone <-> ESP32 agreed perfectly with each other
|
||||
// and with nothing else.
|
||||
bw.putBits(0, 1) // extension bit: value is in the root list
|
||||
bw.putBits(2, 2) // curvatureCalculationMode: unavailable(2)
|
||||
|
||||
val yawRateCentiDegS = cam.yawRateDps
|
||||
?.let { (it * 100.0).roundToInt().coerceIn(-32766, 32766) }
|
||||
?: YAW_RATE_UNAVAILABLE
|
||||
bw.putBits(yawRateCentiDegS - (-32766), 16)
|
||||
bw.putBits(7, 3) // yawRateConfidence: unavailable
|
||||
// YawRateConfidence has NINE enumerands (degSec-000-01(0) .. unavailable(8), see
|
||||
// cdd_1_3_1_1.asn), so UPER needs 4 bits and "unavailable" is 8. This wrote 3 bits with
|
||||
// value 7 - which is both one bit short and the wrong symbol (7 is outOfRange), shifting
|
||||
// every field after yawRate for any standards-compliant receiver.
|
||||
//
|
||||
// Exactly the same failure mode as the CurvatureCalculationMode note above, and caught
|
||||
// the same way it should have been the first time: asn1tools, decoding this project's own
|
||||
// transmitted CAM with the real ETSI modules, rejected it with
|
||||
// "yawRateConfidence: Expected enumeration index ...". The decoder below read 3 bits too,
|
||||
// so phone <-> ESP32 agreed with each other and with nothing else.
|
||||
bw.putBits(8, 4) // yawRateConfidence: unavailable(8)
|
||||
|
||||
// ---- LowFrequencyContainer CHOICE ---- extension(0) -> basicVehicleContainerLowFrequency
|
||||
bw.putBits(0, 1)
|
||||
@@ -148,11 +172,18 @@ object CamUperCodec {
|
||||
/**
|
||||
* Decodes a UPER CAM byte string into a domain [Cam] (always `isOwn = false` — this is only
|
||||
* used for CAMs received from other stations; the ego's own CAM never round-trips through
|
||||
* this). Returns null if the bytes aren't a CAM this codec understands: wrong
|
||||
* protocolVersion/messageID, a CamParameters/HighFrequencyContainer/LowFrequencyContainer
|
||||
* shape we don't decode (extension in use, RSU container instead of vehicle, or a
|
||||
* specialVehicleContainer present — none of those are things this project transmits or
|
||||
* currently needs to receive), or a truncated frame.
|
||||
* this).
|
||||
*
|
||||
* Accepts any CAM whose BasicContainer + BasicVehicleContainerHighFrequency are non-extended,
|
||||
* regardless of which optional high-frequency fields the sender includes or whether it carries
|
||||
* a lowFrequencyContainer or a specialVehicleContainer — all of that is declared after the
|
||||
* fields read here, so it neither shifts them nor needs parsing. A bus, an emergency vehicle,
|
||||
* or a car sending lanePosition and steering-wheel angle all decode normally.
|
||||
*
|
||||
* Returns null only when the bytes genuinely can't be read as vehicle kinematics: wrong
|
||||
* protocolVersion/messageID, an extension marker in use on a container this parses,
|
||||
* rsuContainerHighFrequency (roadside infrastructure — carries no heading/speed/yaw at all,
|
||||
* so there is nothing for the detection engine to consume), or a truncated frame.
|
||||
*
|
||||
* [receivedAtEpochMs] becomes [Cam.timestamp] (wall-clock receipt time) — GenerationDeltaTime
|
||||
* alone (a value mod 65536 ms) isn't enough on its own to reconstruct an absolute timestamp
|
||||
@@ -179,9 +210,12 @@ object CamUperCodec {
|
||||
|
||||
val camParamsExt = br.getBitsInt(1)
|
||||
if (camParamsExt != 0) return null // extension in use - unsupported shape
|
||||
val lowFreqPresent = br.getBitsInt(1) == 1
|
||||
val specialVehiclePresent = br.getBitsInt(1) == 1
|
||||
if (specialVehiclePresent) return null // different container shape we don't parse
|
||||
// lowFrequencyContainer / specialVehicleContainer presence bits. Both containers are
|
||||
// declared after highFrequencyContainer, so everything this decoder reads comes first and
|
||||
// neither needs parsing - a public-transport bus or an emergency vehicle now decodes for
|
||||
// its position and kinematics like any other station, instead of being dropped.
|
||||
br.getBits(1)
|
||||
br.getBits(1)
|
||||
|
||||
val basicContainerExt = br.getBitsInt(1)
|
||||
if (basicContainerExt != 0) return null
|
||||
@@ -197,11 +231,46 @@ object CamUperCodec {
|
||||
br.getBits(20) // altitudeValue
|
||||
br.getBits(4) // altitudeConfidence
|
||||
|
||||
// HighFrequencyContainer is an extensible CHOICE: extension bit, then a 1-bit index
|
||||
// selecting basicVehicleContainerHighFrequency(0) or rsuContainerHighFrequency(1).
|
||||
val highFreqExt = br.getBitsInt(1)
|
||||
val highFreqIndex = br.getBitsInt(1)
|
||||
if (highFreqExt != 0 || highFreqIndex != 0) return null // extension, or rsuContainerHighFrequency
|
||||
if (highFreqExt != 0) return null // an alternative added in a later revision
|
||||
|
||||
br.getBits(7) // 7 optional-presence bits
|
||||
if (highFreqIndex == HF_CONTAINER_RSU) {
|
||||
// An RSU's CAM carries no kinematics at all - RSUContainerHighFrequency holds only an
|
||||
// optional protected-zone list. Everything meaningful (position, stationType) has
|
||||
// already been read from the basicContainer above, so return that rather than
|
||||
// dropping the message: an RSU is exactly the station a rider wants to see, and the
|
||||
// roadside unit at this bench sends CAM and SPATEM from the same station id.
|
||||
//
|
||||
// speed/heading are reported as zero because the model has no "unknown" for them.
|
||||
// That is safe only because RSU CAMs are kept out of UseCaseDetectionEngine - a
|
||||
// permanently stationary "vehicle" would otherwise trip the stopped-vehicle use case
|
||||
// forever. See CamUseCaseRepository.handleCamFromSerial.
|
||||
return Cam(
|
||||
stationId = stationId,
|
||||
stationType = stationType,
|
||||
latitude = latitude,
|
||||
longitude = longitude,
|
||||
speedMps = 0.0,
|
||||
headingDeg = 0.0,
|
||||
timestamp = receivedAtEpochMs,
|
||||
isOwn = false,
|
||||
)
|
||||
}
|
||||
|
||||
// Optional-presence bitmap for BasicVehicleContainerHighFrequency's 7 trailing OPTIONAL
|
||||
// fields: accelerationControl, lanePosition, steeringWheelAngle, lateralAcceleration,
|
||||
// verticalAcceleration, performanceClass, cenDsrcTollingZone (see
|
||||
// asn1/cam_1_4_1.asn).
|
||||
//
|
||||
// Consumed but not acted on, and that is correct: UPER writes a SEQUENCE's presence
|
||||
// bitmap up front but each field's VALUE in declaration order, and all seven of these are
|
||||
// declared AFTER yawRate. Everything this decoder extracts (heading..yawRate) therefore
|
||||
// sits between the bitmap and the optionals, at a fixed offset no matter which optionals
|
||||
// a sender includes. Do not "skip" the optional values here - they are not here.
|
||||
br.getBits(7)
|
||||
|
||||
val headingRaw = br.getBitsInt(12)
|
||||
br.getBits(7) // headingConfidence
|
||||
@@ -227,20 +296,27 @@ object CamUperCodec {
|
||||
|
||||
br.getBits(11) // curvatureValue
|
||||
br.getBits(3) // curvatureConfidence
|
||||
br.getBits(2) // curvatureCalculationMode
|
||||
|
||||
// CurvatureCalculationMode: extensible ENUMERATED - extension bit, then the root index.
|
||||
// See the matching comment in [encode]. If the extension bit is set the sender used a
|
||||
// value added in a later spec revision, encoded as a length-prefixed extension addition
|
||||
// this decoder can't skip reliably - bail rather than misread everything after it.
|
||||
if (br.getBitsInt(1) != 0) return null
|
||||
br.getBits(2) // curvatureCalculationMode root index
|
||||
|
||||
val yawRateRaw = br.getBitsInt(16) + (-32766)
|
||||
br.getBits(3) // yawRateConfidence
|
||||
br.getBits(4) // yawRateConfidence: 9 enumerands -> 4 bits, see the note in encode()
|
||||
val yawRateDps = if (yawRateRaw == YAW_RATE_UNAVAILABLE) null else yawRateRaw / 100.0
|
||||
|
||||
if (lowFreqPresent) {
|
||||
val lowFreqExt = br.getBitsInt(1)
|
||||
if (lowFreqExt != 0) return null
|
||||
br.getBits(4) // vehicleRole
|
||||
br.getBits(8) // exteriorLights
|
||||
br.getBits(6) // pathHistory count (0..40) - not decoded into path points, just consumed
|
||||
}
|
||||
|
||||
// Everything after yawRate is deliberately left unread: the 7 optional high-frequency
|
||||
// fields, then lowFrequencyContainer (vehicleRole / exteriorLights / pathHistory), then
|
||||
// specialVehicleContainer. None of it maps onto [Cam], and because it all follows the
|
||||
// fields above, not parsing it cannot misalign anything already extracted.
|
||||
//
|
||||
// IMPORTANT: if a future change needs any of those - path history is the likely one - the
|
||||
// 7 optionals must be parsed and consumed first, in declaration order, or every read after
|
||||
// them lands at the wrong bit offset. At that point this hand-written decoder stops being
|
||||
// the right tool; use a generated codec (see asn1/README.md) instead.
|
||||
return Cam(
|
||||
stationId = stationId,
|
||||
stationType = stationType,
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
package com.hawhamburg.micr0bu.domain.asn1
|
||||
|
||||
import com.hawhamburg.micr0bu.domain.denm.DenmEvent
|
||||
|
||||
/**
|
||||
* ASN.1 UPER **decoder** for DENM (ETSI EN 302 637-3 v1.3.1 DENM-PDU-Descriptions +
|
||||
* TS 102 894-2 v1.3.1 ITS-Container), for messages received over the air on the ESP32-C5 path.
|
||||
*
|
||||
* Decode-only by design: this project transmits CAM, not DENM, so there is no encode direction to
|
||||
* keep symmetric. (`obu-cam-transmistter/main/denm.c` does encode DENM, but that's a separate
|
||||
* firmware with its own purpose.)
|
||||
*
|
||||
* ## Scope
|
||||
* Decodes the `ManagementContainer` and the `SituationContainer`'s `eventType` — that is, *what*
|
||||
* the hazard is, *where* it is, and *when* it was detected, which is everything the map and list
|
||||
* need. It deliberately stops after `causeCode`/`subCauseCode` and does not parse `linkedCause`,
|
||||
* `eventHistory`, the `LocationContainer` (traces, road type) or the `AlacarteContainer`. Those are
|
||||
* large, deeply nested, and nothing consumes them yet.
|
||||
*
|
||||
* ## Field widths
|
||||
* Every width below is taken from the ETSI ASN.1 modules in `asn1/` (`denm_1_3_1.asn`, `cdd_1_3_1_1.asn`), and every extension marker was
|
||||
* cross-checked against how `rasn` renders the same type (`#[non_exhaustive]` marks an extensible
|
||||
* SEQUENCE). That cross-check matters: hand-derived widths are exactly how this project shipped a
|
||||
* one-bit `CurvatureCalculationMode` bug in CAM that was invisible until measured against real
|
||||
* traffic. Two traps worth naming here:
|
||||
*
|
||||
* - **`ValidityDuration` is 17 bits**, not 16. It's `INTEGER (0..86400)`, and 86401 values need
|
||||
* 17 bits. A hand-decode of a real frame landed on `causeCode` 47 instead of 94 purely from
|
||||
* getting this one wrong — a single bit doubles or halves everything after it.
|
||||
* - **`ManagementContainer`, `SituationContainer` and `CauseCode` are all extensible**, so each
|
||||
* needs its own leading extension bit before its optional-presence bitmap. The DENM body
|
||||
* SEQUENCE is *not* extensible and has no extension bit — only the three optional bits.
|
||||
*
|
||||
* Verified end-to-end against a live capture (2026-08-17): a CiT One HLN-SV trigger decodes as
|
||||
* `causeCode` 94 (stationaryVehicle), `subCauseCode` 0.
|
||||
*
|
||||
* Returns null rather than guessing whenever an extension bit is set or a field is out of range —
|
||||
* a dropped hazard is recoverable (they repeat at 1 Hz), a misplaced one is not.
|
||||
*/
|
||||
object DenmUperCodec {
|
||||
|
||||
private const val MESSAGE_ID_DENM = 1
|
||||
private const val PROTOCOL_VERSION = 2
|
||||
|
||||
/** TimestampIts epoch: 2004-01-01T00:00:00Z in Unix epoch milliseconds. */
|
||||
private const val TS_ITS_EPOCH_MS = 1_072_915_200_000L
|
||||
|
||||
/**
|
||||
* Decodes a UPER DENM into a [DenmEvent].
|
||||
*
|
||||
* @param receivedAtEpochMs wall-clock receipt time, used only as a fallback if the message's
|
||||
* own detectionTime is unusable.
|
||||
* @param rssiDbm signal strength from the serial frame, carried through for range analysis.
|
||||
* @param relevanceRadiusM the GeoBroadcast destination-area radius from the GeoNetworking
|
||||
* header, if the frame carried one. Not part of the DENM payload itself.
|
||||
*/
|
||||
fun decode(
|
||||
bytes: ByteArray,
|
||||
receivedAtEpochMs: Long,
|
||||
rssiDbm: Int? = null,
|
||||
relevanceRadiusM: Int? = null,
|
||||
): DenmEvent? = try {
|
||||
decodeOrThrow(bytes, receivedAtEpochMs, rssiDbm, relevanceRadiusM)
|
||||
} catch (e: IndexOutOfBoundsException) {
|
||||
null // truncated frame
|
||||
}
|
||||
|
||||
private fun decodeOrThrow(
|
||||
bytes: ByteArray,
|
||||
receivedAtEpochMs: Long,
|
||||
rssiDbm: Int?,
|
||||
relevanceRadiusM: Int?,
|
||||
): DenmEvent? {
|
||||
val br = BitReader(bytes)
|
||||
|
||||
// ---- ItsPduHeader ---- no extension marker, no optionals, so no preamble.
|
||||
if (br.getBitsInt(8) != PROTOCOL_VERSION) return null
|
||||
if (br.getBitsInt(8) != MESSAGE_ID_DENM) return null
|
||||
br.getBits(32) // header stationID - actionID.originatingStationID below is the identity
|
||||
|
||||
// ---- DecentralizedEnvironmentalNotificationMessage ----
|
||||
// NOT extensible (rasn renders it without #[non_exhaustive]), so three optional bits only
|
||||
// and no leading extension bit.
|
||||
val situationPresent = br.getBitsInt(1) == 1
|
||||
br.getBits(1) // location container present - not parsed
|
||||
br.getBits(1) // alacarte container present - not parsed
|
||||
|
||||
// ---- ManagementContainer ---- extensible: 1 extension bit + 5 optional/DEFAULT bits.
|
||||
if (br.getBitsInt(1) != 0) return null // extension in use - can't trust later offsets
|
||||
val terminationPresent = br.getBitsInt(1) == 1
|
||||
val relevanceDistancePresent = br.getBitsInt(1) == 1
|
||||
val relevanceTrafficDirectionPresent = br.getBitsInt(1) == 1
|
||||
val validityDurationPresent = br.getBitsInt(1) == 1
|
||||
val transmissionIntervalPresent = br.getBitsInt(1) == 1
|
||||
|
||||
// actionID: the real ETSI identity of an event. Successive repetitions of the same hazard
|
||||
// reuse it, and GeoBroadcast means several stations may relay the same DENM - so this, not
|
||||
// the radio source, is what dedup must key on.
|
||||
val originatingStationId = br.getBits(32)
|
||||
val sequenceNumber = br.getBitsInt(16)
|
||||
|
||||
val detectionTimeIts = br.getBits(42) // TimestampIts (0..4398046511103) -> 42 bits
|
||||
br.getBits(42) // referenceTime - not used
|
||||
|
||||
val isTermination = if (terminationPresent) {
|
||||
// Termination ::= ENUMERATED {isCancellation(0), isNegation(1)} - 2 values, not
|
||||
// extensible, so a single bit. Either value means "this event is over".
|
||||
br.getBits(1); true
|
||||
} else false
|
||||
|
||||
// ---- eventPosition: ReferencePosition ---- same layout as CAM's, see CamUperCodec.
|
||||
val latitude = (br.getBits(31) + (-900000000L)) / 1e7
|
||||
val longitude = (br.getBits(32) + (-1800000000L)) / 1e7
|
||||
br.getBits(12) // semiMajorConfidence
|
||||
br.getBits(12) // semiMinorConfidence
|
||||
br.getBits(12) // semiMajorOrientation
|
||||
br.getBits(20) // altitudeValue
|
||||
br.getBits(4) // altitudeConfidence
|
||||
|
||||
if (relevanceDistancePresent) br.getBits(3) // ENUMERATED, 8 values
|
||||
if (relevanceTrafficDirectionPresent) br.getBits(2) // ENUMERATED, 4 values
|
||||
if (validityDurationPresent) br.getBits(17) // INTEGER (0..86400) -> 17 bits
|
||||
if (transmissionIntervalPresent) br.getBits(14) // INTEGER (1..10000) -> 14 bits
|
||||
|
||||
val stationType = br.getBitsInt(8)
|
||||
|
||||
// ---- SituationContainer ---- carries what the hazard actually is. Optional in the
|
||||
// grammar; without it there is no causeCode and the event is not worth showing.
|
||||
var causeCode: Int? = null
|
||||
var subCauseCode: Int? = null
|
||||
if (situationPresent) {
|
||||
if (br.getBitsInt(1) != 0) return null // extensible: extension in use
|
||||
br.getBits(1) // linkedCause present - not parsed
|
||||
br.getBits(1) // eventHistory present - not parsed
|
||||
br.getBits(3) // informationQuality (0..7)
|
||||
|
||||
// CauseCode is itself an extensible SEQUENCE, so it has its own extension bit before
|
||||
// its two 8-bit fields. Omitting this bit is what made a real frame read 47 instead
|
||||
// of 94.
|
||||
if (br.getBitsInt(1) != 0) return null
|
||||
causeCode = br.getBitsInt(8)
|
||||
subCauseCode = br.getBitsInt(8)
|
||||
}
|
||||
|
||||
// Everything after this point - the rest of the SituationContainer, the LocationContainer
|
||||
// and the AlacarteContainer - is deliberately unread. Safe because nothing above depends
|
||||
// on it; if any of it is ever needed, the unparsed optionals must be consumed in order
|
||||
// first or every later read lands at the wrong bit offset.
|
||||
|
||||
val detectionTimeMs = detectionTimeIts + TS_ITS_EPOCH_MS
|
||||
|
||||
return DenmEvent(
|
||||
stationId = originatingStationId,
|
||||
sequenceNumber = sequenceNumber,
|
||||
latitude = latitude,
|
||||
longitude = longitude,
|
||||
causeCode = causeCode,
|
||||
subCauseCode = subCauseCode,
|
||||
stationType = stationType,
|
||||
isTermination = isTermination,
|
||||
detectionTimeMs = detectionTimeMs.takeIf { it in 0..(receivedAtEpochMs + DAY_MS) },
|
||||
relevanceRadiusM = relevanceRadiusM,
|
||||
rssiDbm = rssiDbm,
|
||||
timestamp = receivedAtEpochMs,
|
||||
)
|
||||
}
|
||||
|
||||
private const val DAY_MS = 86_400_000L
|
||||
}
|
||||
@@ -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,198 @@
|
||||
package com.hawhamburg.micr0bu.domain.asn1
|
||||
|
||||
import com.hawhamburg.micr0bu.domain.spat.IntersectionSignalState
|
||||
import com.hawhamburg.micr0bu.domain.spat.SignalMovement
|
||||
import com.hawhamburg.micr0bu.domain.spat.SignalPhase
|
||||
import com.hawhamburg.micr0bu.domain.spat.SignalPhaseEvent
|
||||
import com.hawhamburg.micr0bu.domain.spat.SpatEvent
|
||||
|
||||
/**
|
||||
* ASN.1 UPER **decoder** for SPATEM (ETSI TS 103 301 / SAE J2735 DSRC), for messages received over
|
||||
* the air on the ESP32-C5 path.
|
||||
*
|
||||
* Decode-only: this project never transmits SPATEM, that is an RSU's job.
|
||||
*
|
||||
* ## Field widths
|
||||
* Every width is taken from the ETSI ASN.1 modules in `asn1/` (`dsrc_2_2_1.asn`, `cdd_2_2_1.asn`):
|
||||
*
|
||||
* - `MinuteOfTheYear` (0..527040) = 20 bits
|
||||
* - `DSecond` (0..65535) = 16 bits
|
||||
* - `MsgCount` (0..127) = 7 bits
|
||||
* - `SignalGroupID` / `LaneID` / `LaneConnectionID` (0..255) = 8 bits
|
||||
* - `IntersectionID` / `RoadRegulatorID` (0..65535) = 16 bits
|
||||
* - `TimeMark` (0..36001) = 16 bits
|
||||
* - `TimeIntervalConfidence` (0..15) = 4 bits
|
||||
* - `ZoneLength` (0..10000) = 14 bits
|
||||
* - `IntersectionStatusObject` BIT STRING SIZE(16) = 16 bits
|
||||
*
|
||||
* A `SEQUENCE (SIZE(lo..hi)) OF` writes its count in `ceil(log2(hi-lo+1))` bits holding `n-lo`:
|
||||
* intersections 1..32 gives 5 bits, movements 1..255 gives 8, events 1..16 gives 4.
|
||||
*
|
||||
* Two traps worth naming, both of which silently shift every later field:
|
||||
* - `TimeChangeDetails` is **not** extensible, so it has 5 optional bits and no extension bit,
|
||||
* unlike almost every other SEQUENCE here, which all carry one.
|
||||
* - `maneuverAssistList` cannot be skipped when present. It is variable-length, so the only way
|
||||
* to reach the next movement is to walk it, even though nothing here consumes it.
|
||||
*
|
||||
* ## Verification
|
||||
* The layout was validated by replaying **79,042 real SPATEMs**, the entire 2026-03-18 drive
|
||||
* (7+ RSUs) plus the live bench trigger, through a port of this decoder and comparing every field
|
||||
* against `asn1tools` decoding the same bytes with the real ETSI modules. All 79,042 matched
|
||||
* exactly, with no message hitting an unsupported branch.
|
||||
*
|
||||
* Returns null rather than guessing whenever an extension bit is set or an unsupported optional
|
||||
* appears: a dropped SPATEM is recoverable (they repeat at ~2 Hz), a misread one shows a driver
|
||||
* the wrong light.
|
||||
*/
|
||||
object SpatemUperCodec {
|
||||
|
||||
private const val MESSAGE_ID_SPATEM = 4
|
||||
private const val PROTOCOL_VERSION = 2
|
||||
|
||||
/** Guards against a malformed length field turning into a long decode loop. */
|
||||
private const val MAX_INTERSECTIONS = 32
|
||||
private const val MAX_MOVEMENTS = 255
|
||||
|
||||
fun decode(bytes: ByteArray, receivedAtEpochMs: Long, rssiDbm: Int? = null): SpatEvent? = try {
|
||||
decodeOrNull(bytes, receivedAtEpochMs, rssiDbm)
|
||||
} catch (e: IndexOutOfBoundsException) {
|
||||
null // truncated frame
|
||||
}
|
||||
|
||||
private fun decodeOrNull(bytes: ByteArray, receivedAtEpochMs: Long, rssiDbm: Int?): SpatEvent? {
|
||||
val br = BitReader(bytes)
|
||||
|
||||
// ---- ItsPduHeader ---- not extensible, no optionals, so no preamble.
|
||||
if (br.getBitsInt(8) != PROTOCOL_VERSION) return null
|
||||
if (br.getBitsInt(8) != MESSAGE_ID_SPATEM) return null
|
||||
val stationId = br.getBits(32)
|
||||
|
||||
// ---- SPAT ---- extensible: extension bit, then timeStamp/name/regional optional bits.
|
||||
if (br.getBitsInt(1) != 0) return null
|
||||
val hasTimeStamp = br.getBitsInt(1) == 1
|
||||
val hasName = br.getBitsInt(1) == 1
|
||||
val hasRegional = br.getBitsInt(1) == 1
|
||||
val minuteOfYear = if (hasTimeStamp) br.getBitsInt(20) else null
|
||||
// DescriptiveName is a variable-length IA5String. Nothing in 79k real messages uses it,
|
||||
// and guessing its length would desynchronise everything after it.
|
||||
if (hasName) return null
|
||||
|
||||
val intersectionCount = br.getBitsInt(5) + 1
|
||||
if (intersectionCount > MAX_INTERSECTIONS) return null
|
||||
val intersections = ArrayList<IntersectionSignalState>(intersectionCount)
|
||||
repeat(intersectionCount) {
|
||||
intersections.add(readIntersection(br) ?: return null)
|
||||
}
|
||||
if (hasRegional) return null
|
||||
|
||||
return SpatEvent(
|
||||
stationId = stationId,
|
||||
minuteOfYear = minuteOfYear,
|
||||
intersections = intersections,
|
||||
rssiDbm = rssiDbm,
|
||||
timestamp = receivedAtEpochMs,
|
||||
)
|
||||
}
|
||||
|
||||
private fun readIntersection(br: BitReader): IntersectionSignalState? {
|
||||
if (br.getBitsInt(1) != 0) return null // IntersectionState extension
|
||||
val hasName = br.getBitsInt(1) == 1
|
||||
val hasMoy = br.getBitsInt(1) == 1
|
||||
val hasTimeStamp = br.getBitsInt(1) == 1
|
||||
val hasEnabledLanes = br.getBitsInt(1) == 1
|
||||
val hasManeuvers = br.getBitsInt(1) == 1
|
||||
val hasRegional = br.getBitsInt(1) == 1
|
||||
if (hasName) return null
|
||||
|
||||
// IntersectionReferenceID - not extensible, one optional bit for region.
|
||||
val region = if (br.getBitsInt(1) == 1) br.getBitsInt(16) else null
|
||||
val id = br.getBitsInt(16)
|
||||
val revision = br.getBitsInt(7)
|
||||
br.getBits(16) // IntersectionStatusObject - not surfaced yet
|
||||
val moy = if (hasMoy) br.getBitsInt(20) else null
|
||||
val timeStampMs = if (hasTimeStamp) br.getBitsInt(16) else null
|
||||
if (hasEnabledLanes) {
|
||||
repeat(br.getBitsInt(4) + 1) { br.getBits(8) } // EnabledLaneList SIZE(1..16) OF LaneID
|
||||
}
|
||||
|
||||
val movementCount = br.getBitsInt(8) + 1
|
||||
if (movementCount > MAX_MOVEMENTS) return null
|
||||
val movements = ArrayList<SignalMovement>(movementCount)
|
||||
repeat(movementCount) {
|
||||
movements.add(readMovement(br) ?: return null)
|
||||
}
|
||||
|
||||
if (hasManeuvers && !skipManeuverAssistList(br)) return null
|
||||
if (hasRegional) return null
|
||||
|
||||
return IntersectionSignalState(region, id, revision, moy, timeStampMs, movements)
|
||||
}
|
||||
|
||||
private fun readMovement(br: BitReader): SignalMovement? {
|
||||
if (br.getBitsInt(1) != 0) return null // MovementState extension
|
||||
val hasName = br.getBitsInt(1) == 1
|
||||
val hasManeuvers = br.getBitsInt(1) == 1
|
||||
val hasRegional = br.getBitsInt(1) == 1
|
||||
if (hasName) return null
|
||||
|
||||
val signalGroup = br.getBitsInt(8)
|
||||
val eventCount = br.getBitsInt(4) + 1
|
||||
val events = ArrayList<SignalPhaseEvent>(eventCount)
|
||||
repeat(eventCount) {
|
||||
if (br.getBitsInt(1) != 0) return null // MovementEvent extension
|
||||
val hasTiming = br.getBitsInt(1) == 1
|
||||
val hasSpeeds = br.getBitsInt(1) == 1
|
||||
val hasRegionalEvent = br.getBitsInt(1) == 1
|
||||
val phase = SignalPhase.fromWire(br.getBitsInt(4)) ?: return null
|
||||
|
||||
var minEnd: Int? = null
|
||||
var maxEnd: Int? = null
|
||||
var likely: Int? = null
|
||||
if (hasTiming) {
|
||||
// TimeChangeDetails: NOT extensible - 5 optional bits, no extension bit.
|
||||
val hasStart = br.getBitsInt(1) == 1
|
||||
val hasMaxEnd = br.getBitsInt(1) == 1
|
||||
val hasLikely = br.getBitsInt(1) == 1
|
||||
val hasConfidence = br.getBitsInt(1) == 1
|
||||
val hasNext = br.getBitsInt(1) == 1
|
||||
if (hasStart) br.getBits(16)
|
||||
minEnd = br.getBitsInt(16)
|
||||
if (hasMaxEnd) maxEnd = br.getBitsInt(16)
|
||||
if (hasLikely) likely = br.getBitsInt(16)
|
||||
if (hasConfidence) br.getBits(4)
|
||||
if (hasNext) br.getBits(16)
|
||||
}
|
||||
if (hasSpeeds || hasRegionalEvent) return null
|
||||
events.add(SignalPhaseEvent(phase, minEnd, maxEnd, likely))
|
||||
}
|
||||
|
||||
if (hasManeuvers && !skipManeuverAssistList(br)) return null
|
||||
if (hasRegional) return null
|
||||
return SignalMovement(signalGroup, events)
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks a `ManeuverAssistList` without keeping it. Nothing consumes queue lengths yet, but the
|
||||
* list is variable-length, so it has to be parsed to find where the next field starts.
|
||||
*
|
||||
* Returns false if it contains something this decoder cannot size, in which case the whole
|
||||
* message must be abandoned - the bit position is no longer trustworthy.
|
||||
*/
|
||||
private fun skipManeuverAssistList(br: BitReader): Boolean {
|
||||
repeat(br.getBitsInt(4) + 1) { // SIZE(1..16)
|
||||
if (br.getBitsInt(1) != 0) return false // ConnectionManeuverAssist extension
|
||||
val hasQueue = br.getBitsInt(1) == 1
|
||||
val hasStorage = br.getBitsInt(1) == 1
|
||||
val hasWaitOnStop = br.getBitsInt(1) == 1
|
||||
val hasPedBicycle = br.getBitsInt(1) == 1
|
||||
val hasRegional = br.getBitsInt(1) == 1
|
||||
br.getBits(8) // connectionID
|
||||
if (hasQueue) br.getBits(14) // ZoneLength (0..10000)
|
||||
if (hasStorage) br.getBits(14)
|
||||
if (hasWaitOnStop) br.getBits(1) // BOOLEAN
|
||||
if (hasPedBicycle) br.getBits(1) // BOOLEAN
|
||||
if (hasRegional) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,12 @@ package com.hawhamburg.micr0bu.domain.cam
|
||||
object StationType {
|
||||
const val CYCLIST = 2
|
||||
const val PASSENGER_CAR = 5
|
||||
|
||||
/**
|
||||
* Roadside infrastructure. Note the gap: the enumeration runs 0..11 then jumps to 15, so this
|
||||
* is 15 and not 12 - mapping by list position mislabels every RSU.
|
||||
*/
|
||||
const val ROAD_SIDE_UNIT = 15
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,6 +62,17 @@ data class Cam(
|
||||
/** Optional longitudinal acceleration control field, m/s², where available. */
|
||||
val accelerationMps2: Double? = null,
|
||||
|
||||
/**
|
||||
* Received signal strength in dBm for a CAM heard over the air, or null when there is no such
|
||||
* measurement.
|
||||
*
|
||||
* Only the ESP32-C5 path populates this: the firmware reads it from the promiscuous RX
|
||||
* callback's packet metadata (`wifi_pkt_rx_ctrl_t.rssi`) and puts it in byte 0 of every
|
||||
* CAM_RX frame. Null for own CAMs and for everything on the CiT One path, whose Use Case API
|
||||
* JSON carries no equivalent field.
|
||||
*/
|
||||
val rssiDbm: Int? = null,
|
||||
|
||||
/** Wall-clock ms this CAM was received/processed. */
|
||||
val timestamp: Long,
|
||||
|
||||
|
||||
@@ -47,8 +47,12 @@ internal object JsonFieldReader {
|
||||
if (lat != null && lon != null) return lat to lon
|
||||
}
|
||||
|
||||
val geoJson = obj.optJSONObject("position")
|
||||
val coords = geoJson?.optJSONArray("coordinates")
|
||||
// A GeoJSON Point may be nested under "position", or `obj` may BE the Point itself - the
|
||||
// Use Case API's DENM sends `"eventPosition": {"type":"Point","coordinates":[...]}`, so the
|
||||
// caller passes eventPosition in directly. Missing this second case meant every DENM was
|
||||
// rejected for having no position.
|
||||
val coords = obj.optJSONObject("position")?.optJSONArray("coordinates")
|
||||
?: obj.optJSONArray("coordinates")
|
||||
if (coords != null && coords.length() >= 2) {
|
||||
// GeoJSON coordinate order is [longitude, latitude, altitude?]
|
||||
val lon = coords.optDouble(0, Double.NaN)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -7,13 +7,10 @@ import kotlin.math.abs
|
||||
* Builds an outgoing [Cam] from the phone's own GNSS + gyroscope, for the ESP32-C5 hardware
|
||||
* path (Phase 03, Section 13) where the OBU itself generates no CAM at all — the phone must.
|
||||
*
|
||||
* This class only does the sensor-fusion-into-CAM-fields part, which is independent of the
|
||||
* (not yet defined) wire protocol to the ESP32-C5. It is **not yet wired into any transmit
|
||||
* pipeline** — nothing calls this today. Once the ESP32 firmware protocol is translated to
|
||||
* Kotlin and [com.hawhamburg.micr0bu.domain.asn1.Asn1UperCodec] has a real implementation, the
|
||||
* intended flow is:
|
||||
* 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(...)` → `Asn1UperCodec.encodeCam(...)` → `UsbSerialTransport` (write).
|
||||
* `PhoneCamBuilder.build(...)` → `RealAsn1UperCodec.encodeCam(...)` → `UsbSerialTransport` (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
|
||||
@@ -22,22 +19,27 @@ import kotlin.math.abs
|
||||
*/
|
||||
object PhoneCamBuilder {
|
||||
|
||||
/** Placeholder station ID until real station-ID assignment/config exists for this path. */
|
||||
private const val PLACEHOLDER_OWN_STATION_ID = 0L
|
||||
|
||||
/**
|
||||
* @param gnss latest phone GNSS fix.
|
||||
* @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. Defaults to a placeholder until Phase 03
|
||||
* defines how the phone learns/assigns an ID on the ESP32-C5 path (the CiT One path
|
||||
* currently learns this from `v2x/rx/obu_gnss`'s own_info, which doesn't exist here).
|
||||
* @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,
|
||||
* and the raw accelerometer is in the device frame with gravity mixed in, so it can't supply
|
||||
* that without full orientation estimation. Null when it can't be computed (no previous fix,
|
||||
* stale sample), which encodes as the ASN.1 `unavailable` sentinel.
|
||||
*/
|
||||
fun build(
|
||||
gnss: GnssReading,
|
||||
gyroZRadPerSec: Float?,
|
||||
stationId: Long = PLACEHOLDER_OWN_STATION_ID,
|
||||
stationId: Long,
|
||||
longitudinalAccelMps2: Double? = null,
|
||||
): Cam {
|
||||
val yawRateDps = gyroZRadPerSec?.let { -it * RAD_TO_DEG } // negate: CCW+ -> CW+ convention
|
||||
|
||||
@@ -49,6 +51,7 @@ object PhoneCamBuilder {
|
||||
speedMps = gnss.speedMs.toDouble(),
|
||||
headingDeg = normalizeHeading(gnss.bearingDeg.toDouble()),
|
||||
yawRateDps = yawRateDps?.let { if (abs(it) < YAW_RATE_NOISE_FLOOR_DPS) 0.0 else it },
|
||||
accelerationMps2 = longitudinalAccelMps2,
|
||||
timestamp = gnss.timestamp,
|
||||
isOwn = true,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package com.hawhamburg.micr0bu.domain.denm
|
||||
|
||||
import com.hawhamburg.micr0bu.domain.cam.JsonFieldReader
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* A decentralized environmental notification received from another station — a hazard at a fixed
|
||||
* place, as opposed to [com.hawhamburg.micr0bu.domain.cam.Cam]'s "here I am, moving" beacon.
|
||||
*
|
||||
* Only the fields needed to put a pin on the live map are modelled. DENM carries a great deal
|
||||
* more (validity duration, relevance area, traffic direction, trace paths); none of it is used
|
||||
* yet, and inventing a fuller model before there's a consumer for it would just be guesswork.
|
||||
*
|
||||
* **Availability:** both hardware paths. On the CiT One path DENM arrives as processed JSON on
|
||||
* the Use Case API's `v2x-uca/output/json/denm` topic ([DenmParser]); on the ESP32-C5 path it is
|
||||
* decoded from over-the-air GeoBroadcast traffic on BTP-B port 2002
|
||||
* ([com.hawhamburg.micr0bu.domain.asn1.DenmUperCodec]). Fields sourced from the GeoNetworking
|
||||
* header - [relevanceRadiusM], [rssiDbm] - exist only on the ESP32-C5 path, since the Use Case
|
||||
* API never exposes the GN layer.
|
||||
*/
|
||||
data class DenmEvent(
|
||||
/** Originating station ID — `actionID.originatingStationID`, not the radio source. */
|
||||
val stationId: Long,
|
||||
|
||||
/**
|
||||
* `actionID.sequenceNumber`. Together with [stationId] this is ETSI's real event identity:
|
||||
* repetitions of one hazard reuse it, and under GeoBroadcast several stations may relay the
|
||||
* same DENM, so this pair is what dedup must key on. Both transports supply it - the Use Case
|
||||
* API as a `sequenceNumber` key - so the fallback below is for malformed payloads only.
|
||||
*/
|
||||
val sequenceNumber: Int? = null,
|
||||
|
||||
/** Event position (WGS84 degrees) — where the hazard is, not where the sender is. */
|
||||
val latitude: Double,
|
||||
val longitude: Double,
|
||||
|
||||
/** ETSI TS 102 894-2 CauseCode, or null if the payload didn't carry one. */
|
||||
val causeCode: Int?,
|
||||
|
||||
/** SubCauseCode qualifying [causeCode], or null. */
|
||||
val subCauseCode: Int?,
|
||||
|
||||
/** Originating station's ETSI stationType, where known. */
|
||||
val stationType: Int? = null,
|
||||
|
||||
/**
|
||||
* True when this DENM cancels or negates the event (`termination` present). A terminated event
|
||||
* should be removed from the map rather than drawn — the hazard is over.
|
||||
*/
|
||||
val isTermination: Boolean = false,
|
||||
|
||||
/** Event detection time in epoch ms, where the message carried a usable one. */
|
||||
val detectionTimeMs: Long? = null,
|
||||
|
||||
/**
|
||||
* Radius of the GeoBroadcast destination area in metres, i.e. how far the warning is meant to
|
||||
* apply. Comes from the GeoNetworking header rather than the DENM payload, so it's only
|
||||
* available on the ESP32-C5 path (the MQTT path never exposes the GN layer).
|
||||
*/
|
||||
val relevanceRadiusM: Int? = null,
|
||||
|
||||
/** Received signal strength, dBm — ESP32-C5 path only. */
|
||||
val rssiDbm: Int? = null,
|
||||
|
||||
/** Wall-clock ms this DENM was received. */
|
||||
val timestamp: Long,
|
||||
) {
|
||||
/**
|
||||
* Stable identity for map/list dedup. Prefers ETSI's actionID (`stationId` + `sequenceNumber`),
|
||||
* which both transports carry; falls back to station + cause only when a payload omits the
|
||||
* sequence number. The fallback is weaker than it looks: a terminating DENM carries no
|
||||
* SituationContainer, so its cause is null and it would NOT collide with the event it ends.
|
||||
*/
|
||||
val dedupKey: String
|
||||
get() = if (sequenceNumber != null) "$stationId/$sequenceNumber"
|
||||
else "$stationId/${causeCode ?: -1}/${subCauseCode ?: -1}"
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the processed DENM JSON published by the consider it Use Case API on
|
||||
* `v2x-uca/output/json/denm`.
|
||||
*
|
||||
* Field names follow `CI-CiT-MQTT_API_Documentation-v6-20250221.pdf` section 2.2.4 / listing 2.6,
|
||||
* which is the contract for this topic; `DenmParserMqttTest` pins this parser to that worked
|
||||
* example. Alternative spellings are still accepted via [JsonFieldReader] as fallbacks.
|
||||
*
|
||||
* Returns null rather than a half-populated event when position is missing: a DENM with no
|
||||
* position is useless to a map and worse than absent on a hazard display.
|
||||
*/
|
||||
object DenmParser {
|
||||
|
||||
/**
|
||||
* The Use Case API's `causeCode` string enum mapped back to its ITS-G5 integer, so a DENM from
|
||||
* the MQTT path and one decoded off the air are directly comparable. Values are from
|
||||
* CauseCodeType in the ETSI CDD; the names are the API's spelling.
|
||||
*/
|
||||
private val CAUSE_CODE_BY_NAME = mapOf(
|
||||
"trafficCondition" to 1, "accident" to 2, "roadworks" to 3, "impassability" to 5,
|
||||
"adverseWeatherCondition_Adhesion" to 6,
|
||||
// Three n's: that is how ETSI's CauseCodeType spells it, and the API follows.
|
||||
// The correctly-spelled variant is accepted too, in case that is ever fixed.
|
||||
"aquaplannning" to 7, "aquaplanning" to 7,
|
||||
"hazardousLocation_SurfaceCondition" to 9, "hazardousLocation_ObstacleOnTheRoad" to 10,
|
||||
"hazardousLocation_AnimalOnTheRoad" to 11, "humanPresenceOnTheRoad" to 12,
|
||||
"wrongWayDriving" to 14, "rescueAndRecoveryWorkInProgress" to 15,
|
||||
"adverseWeatherCondition_ExtremeWeatherCondition" to 17,
|
||||
"adverseWeatherCondition_Visibility" to 18,
|
||||
"adverseWeatherCondition_Precipitation" to 19, "slowVehicle" to 26,
|
||||
"dangerousEndOfQueue" to 27, "vehicleBreakdown" to 91, "postCrash" to 92,
|
||||
"humanProblem" to 93, "stationaryVehicle" to 94, "emergencyVehicleApproaching" to 95,
|
||||
"hazardousLocation_DangerousCurve" to 96, "collisionRisk" to 97,
|
||||
"signalViolation" to 98, "dangerousSituation" to 99,
|
||||
)
|
||||
|
||||
private val NAME_BY_CAUSE_CODE = CAUSE_CODE_BY_NAME.entries.associate { (n, c) -> c to n }
|
||||
|
||||
/**
|
||||
* The ETSI CauseCode name for [causeCode], or null for a code this table doesn't cover.
|
||||
*
|
||||
* Deliberately the API's own camelCase spelling ("stationaryVehicle") rather than prose: it's
|
||||
* the vocabulary the MQTT payloads, the V2X2MAP dashboard and the sniffer all use, so a bench
|
||||
* operator can compare what the app says against what those show without translating.
|
||||
*/
|
||||
fun causeCodeName(causeCode: Int?): String? = causeCode?.let { NAME_BY_CAUSE_CODE[it] }
|
||||
|
||||
/**
|
||||
* The Use Case API's `stationType` string enum mapped to its ITS-G5 integer, per StationType in
|
||||
* the ETSI CDD. Note roadSideUnit is **15**, not 12 - the enumeration has a gap after tram(11),
|
||||
* so mapping by list position would silently mislabel every RSU.
|
||||
*/
|
||||
private val STATION_TYPE_BY_NAME = mapOf(
|
||||
"unknown" to 0, "pedestrian" to 1, "cyclist" to 2, "moped" to 3, "motorcycle" to 4,
|
||||
"passengerCar" to 5, "bus" to 6, "lightTruck" to 7, "heavyTruck" to 8, "trailer" to 9,
|
||||
"specialVehicles" to 10, "tram" to 11, "roadSideUnit" to 15,
|
||||
)
|
||||
|
||||
/**
|
||||
* Parses the API's RFC3339 timestamps ("2021-05-11T12:01:02+00:00") to epoch millis. The air
|
||||
* path carries a binary TimestampIts instead, so the two transports arrive here in completely
|
||||
* different formats and both end up as epoch ms on [DenmEvent].
|
||||
*/
|
||||
private fun parseRfc3339(value: String?): Long? {
|
||||
if (value.isNullOrBlank()) return null
|
||||
return runCatching { java.time.OffsetDateTime.parse(value).toInstant().toEpochMilli() }
|
||||
.recoverCatching { java.time.Instant.parse(value).toEpochMilli() }
|
||||
.getOrNull()
|
||||
}
|
||||
|
||||
fun parse(json: String, timestamp: Long = System.currentTimeMillis()): DenmEvent? {
|
||||
val obj = runCatching { JSONObject(json) }.getOrNull() ?: return null
|
||||
|
||||
// Event position may sit at the top level or nested under an eventPosition/
|
||||
// situation-style object, depending on how the API flattens the ASN.1.
|
||||
val (lat, lon) = JsonFieldReader.firstLatLon(obj)
|
||||
?: obj.optJSONObject("eventPosition")?.let { JsonFieldReader.firstLatLon(it) }
|
||||
?: obj.optJSONObject("management")?.optJSONObject("eventPosition")
|
||||
?.let { JsonFieldReader.firstLatLon(it) }
|
||||
?: return null
|
||||
|
||||
// "originatingStationId" is what the Use Case API actually sends (API doc listing 2.6);
|
||||
// without it every DENM from the CiT One path was dropped here, before anything else in
|
||||
// this function ran. The other spellings are kept as fallbacks.
|
||||
val stationId = JsonFieldReader.firstLong(
|
||||
obj, "originatingStationId", "originatingStationID", "stationId", "stationID", "station_id",
|
||||
)
|
||||
?: obj.optJSONObject("management")?.let {
|
||||
JsonFieldReader.firstLong(
|
||||
it, "originatingStationId", "originatingStationID", "stationId", "stationID", "station_id",
|
||||
)
|
||||
}
|
||||
?: return null
|
||||
|
||||
val situation = obj.optJSONObject("situation")
|
||||
// The Use Case API sends causeCode as a STRING enum ("stationaryVehicle", "roadworks", ...),
|
||||
// per CI-CiT-MQTT_API_Documentation-v6 section 2.2.4 - not the ITS-G5 integer. An earlier
|
||||
// version of this parser read it as an Int and therefore always got null. Both forms are
|
||||
// accepted: the air path (DenmUperCodec) produces the integer.
|
||||
val causeCode = JsonFieldReader.firstInt(obj, "causeCode", "cause_code", "cause")
|
||||
?: situation?.let { JsonFieldReader.firstInt(it, "causeCode", "cause_code", "cause") }
|
||||
?: CAUSE_CODE_BY_NAME[obj.optString("causeCode").takeIf { it.isNotBlank() }]
|
||||
val subCauseCode = JsonFieldReader.firstInt(obj, "subCauseCode", "sub_cause_code", "subCause")
|
||||
?: situation?.let { JsonFieldReader.firstInt(it, "subCauseCode", "sub_cause_code", "subCause") }
|
||||
|
||||
// "Key is present, if the DENM is cancelled or negated" (API doc 2.2.4) - so presence is
|
||||
// the signal, not the value. An explicit `false` is still honoured in case the API ever
|
||||
// starts always emitting the key.
|
||||
val isTermination = obj.has("termination") && obj.optBoolean("termination", true)
|
||||
|
||||
val stationType = JsonFieldReader.firstInt(obj, "stationType", "station_type")
|
||||
?: STATION_TYPE_BY_NAME[obj.optString("stationType").takeIf { it.isNotBlank() }]
|
||||
|
||||
return DenmEvent(
|
||||
stationId = stationId,
|
||||
sequenceNumber = JsonFieldReader.firstInt(obj, "sequenceNumber", "sequence_number"),
|
||||
latitude = lat,
|
||||
longitude = lon,
|
||||
causeCode = causeCode,
|
||||
subCauseCode = subCauseCode,
|
||||
stationType = stationType,
|
||||
isTermination = isTermination,
|
||||
detectionTimeMs = parseRfc3339(obj.optString("detectionTime").takeIf { it.isNotBlank() })
|
||||
?: JsonFieldReader.firstLong(obj, "detectionTime", "detection_time"),
|
||||
timestamp = timestamp,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,141 @@
|
||||
package com.hawhamburg.micr0bu.domain.spat
|
||||
|
||||
/**
|
||||
* Signal phase and timing for one or more intersections, decoded from a SPATEM heard over the air.
|
||||
*
|
||||
* A SPATEM is the live counterpart to MAPEM's static geometry: MAPEM says where the lanes are,
|
||||
* SPATEM says what the lights are doing right now. The two join on [IntersectionSignalState.key].
|
||||
* Only SPATEM is decoded today - without MAPEM there is no lane geometry, so a signal group is
|
||||
* shown as a bare number rather than "the left-turn lane you are in".
|
||||
*
|
||||
* Repetition is ~2 Hz per intersection, so consumers should key on [IntersectionSignalState.key]
|
||||
* and keep the latest rather than accumulating a log.
|
||||
*/
|
||||
data class SpatEvent(
|
||||
/** Originating RSU's station ID, from the ItsPduHeader. */
|
||||
val stationId: Long,
|
||||
|
||||
/** `SPAT.timeStamp`, minute of the year, when present. */
|
||||
val minuteOfYear: Int?,
|
||||
|
||||
val intersections: List<IntersectionSignalState>,
|
||||
|
||||
/** Received signal strength, dBm - ESP32-C5 path only. */
|
||||
val rssiDbm: Int? = null,
|
||||
|
||||
/** Wall-clock ms this SPATEM was received. */
|
||||
val timestamp: Long,
|
||||
)
|
||||
|
||||
/** One intersection's current signal state. */
|
||||
data class IntersectionSignalState(
|
||||
/** `RoadRegulatorID`, when the sender qualifies its intersection id with one. */
|
||||
val region: Int?,
|
||||
/** `IntersectionID` - only unique *within* [region]. */
|
||||
val id: Int,
|
||||
/** `MsgCount`, bumped when the intersection's MAP geometry changes. */
|
||||
val revision: Int,
|
||||
/** Minute of the year this state refers to, when present. */
|
||||
val moy: Int?,
|
||||
/** `DSecond` - milliseconds within [moy]'s minute, when present. */
|
||||
val timeStampMs: Int?,
|
||||
val movements: List<SignalMovement>,
|
||||
) {
|
||||
/**
|
||||
* Identity for dedup and for joining against MAPEM. `IntersectionID` alone is NOT unique -
|
||||
* it is only unique within a `RoadRegulatorID`, and the recorded drive contains the same id
|
||||
* under different regions - so the region must be part of the key.
|
||||
*/
|
||||
val key: String get() = "${region ?: -1}/$id"
|
||||
}
|
||||
|
||||
/** The signal state of one signal group (one movement through the intersection). */
|
||||
data class SignalMovement(
|
||||
/** `SignalGroupID` - the number MAPEM's lane connections refer to. */
|
||||
val signalGroup: Int,
|
||||
/**
|
||||
* Predicted phases, in order. The first entry is the state now; later entries are the
|
||||
* upcoming sequence, which is what makes a countdown possible.
|
||||
*/
|
||||
val events: List<SignalPhaseEvent>,
|
||||
) {
|
||||
val current: SignalPhaseEvent? get() = events.firstOrNull()
|
||||
}
|
||||
|
||||
data class SignalPhaseEvent(
|
||||
val phase: SignalPhase,
|
||||
/**
|
||||
* `TimeMark`: tenths of a second within the current or next UTC hour, so it wraps hourly.
|
||||
* 36001 means "unknown". Use [secondsUntil] rather than comparing these directly.
|
||||
*/
|
||||
val minEndTimeDs: Int?,
|
||||
val maxEndTimeDs: Int?,
|
||||
val likelyTimeDs: Int?,
|
||||
) {
|
||||
/**
|
||||
* Seconds from [nowEpochMs] until [minEndTimeDs], or null if unknown.
|
||||
*
|
||||
* TimeMark counts tenths of a second from the top of the hour and wraps, so a mark that looks
|
||||
* like it is in the past is really in the next hour - hence the wrap correction. Without it, a
|
||||
* countdown reads as a large negative number for the seconds either side of the hour.
|
||||
*/
|
||||
fun secondsUntil(nowEpochMs: Long): Double? {
|
||||
val mark = minEndTimeDs ?: return null
|
||||
if (mark >= UNKNOWN_TIME_MARK) return null
|
||||
val msIntoHour = nowEpochMs % 3_600_000L
|
||||
var deltaMs = mark * 100L - msIntoHour
|
||||
if (deltaMs < -HALF_HOUR_MS) deltaMs += 3_600_000L // mark is in the next hour
|
||||
return deltaMs / 1000.0
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val UNKNOWN_TIME_MARK = 36001
|
||||
const val HALF_HOUR_MS = 1_800_000L
|
||||
}
|
||||
}
|
||||
|
||||
/** `MovementPhaseState` (ETSI/SAE J2735), in enumeration order - the ordinal IS the wire value. */
|
||||
enum class SignalPhase {
|
||||
UNAVAILABLE,
|
||||
DARK,
|
||||
STOP_THEN_PROCEED,
|
||||
STOP_AND_REMAIN,
|
||||
PRE_MOVEMENT,
|
||||
PERMISSIVE_MOVEMENT_ALLOWED,
|
||||
PROTECTED_MOVEMENT_ALLOWED,
|
||||
PERMISSIVE_CLEARANCE,
|
||||
PROTECTED_CLEARANCE,
|
||||
CAUTION_CONFLICTING_TRAFFIC;
|
||||
|
||||
/** True for the two "you may go" states. */
|
||||
val isGo: Boolean
|
||||
get() = this == PERMISSIVE_MOVEMENT_ALLOWED || this == PROTECTED_MOVEMENT_ALLOWED
|
||||
|
||||
/** True for the two "you must stop" states. */
|
||||
val isStop: Boolean
|
||||
get() = this == STOP_AND_REMAIN || this == STOP_THEN_PROCEED
|
||||
|
||||
/** True while the light is changing - amber, or red-amber before green. */
|
||||
val isTransition: Boolean
|
||||
get() = this == PRE_MOVEMENT || this == PERMISSIVE_CLEARANCE || this == PROTECTED_CLEARANCE
|
||||
|
||||
companion object {
|
||||
fun fromWire(value: Int): SignalPhase? = entries.getOrNull(value)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One intersection's signal state as the UI consumes it: the decoded state plus who sent it and
|
||||
* when, flattened out of the [SpatEvent] that carried it.
|
||||
*
|
||||
* A single SPATEM may describe several intersections, and the same intersection may be heard from
|
||||
* more than one RSU, so the UI keys on the intersection rather than on the message.
|
||||
*/
|
||||
data class SpatIntersection(
|
||||
val state: IntersectionSignalState,
|
||||
val stationId: Long,
|
||||
val rssiDbm: Int?,
|
||||
val timestamp: Long,
|
||||
) {
|
||||
val key: String get() = state.key
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package com.hawhamburg.micr0bu.service
|
||||
|
||||
import android.content.Context
|
||||
import android.os.SystemClock
|
||||
import com.hawhamburg.micr0bu.data.GnssReading
|
||||
import com.hawhamburg.micr0bu.data.GnssTimeSource
|
||||
import com.hawhamburg.micr0bu.data.SensorRepository
|
||||
import com.hawhamburg.micr0bu.data.transport.GnPositionVector
|
||||
import com.hawhamburg.micr0bu.data.transport.UsbSerialTransport
|
||||
import com.hawhamburg.micr0bu.domain.asn1.RealAsn1UperCodec
|
||||
import com.hawhamburg.micr0bu.domain.cam.OwnStationIds
|
||||
import com.hawhamburg.micr0bu.domain.cam.PhoneCamBuilder
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Manual bench-test CAM transmitter for the ESP32-C5 path (Phase 03) — a fixed-rate (1 Hz) CAM
|
||||
* ping built from the phone's real GNSS and gyroscope, independent of [CamTransmitLoop] and not
|
||||
* tied to an active trip recording. Purpose: verify the phone <-> ESP32-C5 serial link and the
|
||||
* ESP32's TX/RX radio path end-to-end without needing a full recording session — the CAM
|
||||
* equivalent of the CiT One path's manual DENM trigger
|
||||
* ([com.hawhamburg.micr0bu.data.mqtt.MqttRepository.activateDenm]).
|
||||
*
|
||||
* Uses the same [PhoneCamBuilder] as the real transmit path, so what goes on air here is a
|
||||
* properly populated CAM — real position, speed, heading, yaw rate and along-track acceleration —
|
||||
* not a synthetic frame. Previously this beaconed a hardcoded bench coordinate with speed and
|
||||
* heading pinned to zero, which exercised the link but told you nothing about whether the sensor
|
||||
* pipeline produced sane CAM content.
|
||||
*
|
||||
* Requires a GNSS fix: with no fix there is no position to put in a CAM, so the loop sends
|
||||
* nothing and reports that via [hasFix] rather than transmitting a placeholder.
|
||||
*
|
||||
* Entirely user-triggered (Start/Stop in the V2X Monitor screen) — never started automatically,
|
||||
* and does not interact with [CamTransmitLoop]'s recording-gated loop. Both could in theory run at
|
||||
* once (nothing prevents it); they use distinct station IDs so the two streams stay separable.
|
||||
*/
|
||||
@Singleton
|
||||
class CamPinger @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val usbSerialTransport: UsbSerialTransport,
|
||||
private val codec: RealAsn1UperCodec,
|
||||
) {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val sensorRepository = SensorRepository(context)
|
||||
private var job: Job? = null
|
||||
|
||||
@Volatile private var latestGnss: GnssReading? = null
|
||||
@Volatile private var latestGyroZ: Float? = null
|
||||
@Volatile private var previousGnss: GnssReading? = null
|
||||
|
||||
private val _isActive = MutableStateFlow(false)
|
||||
val isActive: StateFlow<Boolean> = _isActive.asStateFlow()
|
||||
|
||||
private val _sentCount = MutableStateFlow(0)
|
||||
/** Number of CAM pings sent since [start] was last called. Reset to 0 on each [start]. */
|
||||
val sentCount: StateFlow<Int> = _sentCount.asStateFlow()
|
||||
|
||||
private val _hasFix = MutableStateFlow(false)
|
||||
/** 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
|
||||
_hasFix.value = false
|
||||
previousGnss = null
|
||||
latestGnss = null
|
||||
_isActive.value = true
|
||||
job = scope.launch { runPingLoop() }
|
||||
}
|
||||
|
||||
private suspend fun runPingLoop() = coroutineScope {
|
||||
launch { sensorRepository.gnssFlow().collect { latestGnss = it } }
|
||||
launch { sensorRepository.gyroscopeFlow().collect { latestGyroZ = it.z } }
|
||||
|
||||
while (true) {
|
||||
val gnss = latestGnss
|
||||
_hasFix.value = gnss != null
|
||||
if (gnss != null) {
|
||||
val cam = PhoneCamBuilder.build(
|
||||
// Stamped on GNSS time rather than the phone clock; see GnssTimeSource.
|
||||
gnss = gnss.copy(timestamp = GnssTimeSource.correct(gnss.timestamp)),
|
||||
gyroZRadPerSec = latestGyroZ,
|
||||
stationId = OwnStationIds.BENCH_PING,
|
||||
longitudinalAccelMps2 = longitudinalAccel(gnss),
|
||||
)
|
||||
val bytes = codec.encodeCam(cam)
|
||||
// Fixed bench identity on every layer, the link-layer address included, so a ping
|
||||
// stays recognisable in a capture and never rotates.
|
||||
val pv = GnPositionVector.fromCam(cam, gnss.accuracyM, OwnStationIds.BENCH_PING_MAC)
|
||||
if (usbSerialTransport.sendCamTx(bytes, pv)) {
|
||||
_sentCount.update { it + 1 }
|
||||
}
|
||||
}
|
||||
delay(PING_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Along-track acceleration from successive GNSS speed samples — same derivation and same
|
||||
* reasoning as [CamTransmitLoop.longitudinalAccel] (the accelerometer reads in the device
|
||||
* frame with gravity mixed in, so it can't give signed along-track acceleration without a
|
||||
* full orientation estimate). Null outside a usable sample gap, which encodes as the ASN.1
|
||||
* `unavailable` sentinel.
|
||||
*/
|
||||
private fun longitudinalAccel(gnss: GnssReading): Double? {
|
||||
val prev = previousGnss
|
||||
previousGnss = gnss
|
||||
if (prev == null) return null
|
||||
|
||||
val dtSec = (gnss.timestamp - prev.timestamp) / 1000.0
|
||||
if (dtSec < MIN_ACCEL_DT_SEC || dtSec > MAX_ACCEL_DT_SEC) return null
|
||||
|
||||
return (gnss.speedMs.toDouble() - prev.speedMs.toDouble()) / dtSec
|
||||
}
|
||||
|
||||
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
|
||||
_hasFix.value = false
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PING_INTERVAL_MS = 1_000L
|
||||
|
||||
private const val MIN_ACCEL_DT_SEC = 0.2
|
||||
private const val MAX_ACCEL_DT_SEC = 3.0
|
||||
|
||||
// The station id these pings go out under lives in
|
||||
// [com.hawhamburg.micr0bu.domain.cam.OwnStationIds.BENCH_PING], not here. It is not a
|
||||
// private detail of this class: the ESP32 hears these frames back off the air, so the
|
||||
// receive path has to recognise the same value, and a second copy of it is exactly how
|
||||
// the two sides would drift apart.
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,11 @@ package com.hawhamburg.micr0bu.service
|
||||
|
||||
import android.content.Context
|
||||
import com.hawhamburg.micr0bu.data.GnssReading
|
||||
import com.hawhamburg.micr0bu.data.GnssTimeSource
|
||||
import com.hawhamburg.micr0bu.data.SensorRepository
|
||||
import com.hawhamburg.micr0bu.data.cam.PseudonymManager
|
||||
import com.hawhamburg.micr0bu.data.mqtt.ObuHardwarePreferences
|
||||
import com.hawhamburg.micr0bu.data.transport.GnPositionVector
|
||||
import com.hawhamburg.micr0bu.data.transport.ObuHardware
|
||||
import com.hawhamburg.micr0bu.data.transport.UsbSerialTransport
|
||||
import com.hawhamburg.micr0bu.domain.asn1.RealAsn1UperCodec
|
||||
@@ -49,6 +52,7 @@ class CamTransmitLoop @Inject constructor(
|
||||
private val obuHardwarePrefs: ObuHardwarePreferences,
|
||||
private val usbSerialTransport: UsbSerialTransport,
|
||||
private val codec: RealAsn1UperCodec,
|
||||
private val pseudonymManager: PseudonymManager,
|
||||
) {
|
||||
private val config = CamTransmitConfig()
|
||||
private val sensorRepository = SensorRepository(context)
|
||||
@@ -60,8 +64,8 @@ class CamTransmitLoop @Inject constructor(
|
||||
@Volatile private var latestGyroZ: Float? = null
|
||||
@Volatile private var elevatedUntilMs: Long = 0L
|
||||
|
||||
/** Own station id for the ESP32-C5 path — see [PhoneCamBuilder]'s KDoc on why this is a placeholder. */
|
||||
@Volatile var stationId: Long = 0L
|
||||
/** Previous GNSS fix, kept only to derive along-track acceleration — see [longitudinalAccel]. */
|
||||
@Volatile private var previousGnss: GnssReading? = null
|
||||
|
||||
/**
|
||||
* Call when a braking/turning/stopping event fires during an active trip — bumps the CAM
|
||||
@@ -80,6 +84,7 @@ class CamTransmitLoop @Inject constructor(
|
||||
fun start() {
|
||||
if (job?.isActive == true) return
|
||||
elevatedUntilMs = 0L
|
||||
previousGnss = null
|
||||
job = scope.launch {
|
||||
obuHardwarePrefs.obuHardwareFlow.collectLatest { hardware ->
|
||||
if (hardware != ObuHardware.ESP32_C5) return@collectLatest
|
||||
@@ -101,14 +106,46 @@ class CamTransmitLoop @Inject constructor(
|
||||
while (true) {
|
||||
val gnss = latestGnss
|
||||
if (gnss != null) {
|
||||
val cam = PhoneCamBuilder.build(gnss, latestGyroZ, stationId)
|
||||
// Asked for per CAM rather than once per trip: that is what lets a pseudonym
|
||||
// rotation fall cleanly between two frames instead of inside one.
|
||||
val pseudonym = pseudonymManager.current()
|
||||
// Stamped on GNSS time rather than the phone clock; see GnssTimeSource. Only the
|
||||
// outgoing CAM is: acceleration below still differences wall-clock samples.
|
||||
val fix = gnss.copy(timestamp = GnssTimeSource.correct(gnss.timestamp))
|
||||
val cam = PhoneCamBuilder.build(fix, latestGyroZ, pseudonym.stationId, longitudinalAccel(gnss))
|
||||
val bytes = codec.encodeCam(cam)
|
||||
usbSerialTransport.sendCamTx(bytes)
|
||||
usbSerialTransport.sendCamTx(bytes, GnPositionVector.fromCam(cam, gnss.accuracyM, pseudonym.mac))
|
||||
}
|
||||
delay((1000.0 / currentRateHz(gnss)).toLong())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Along-track acceleration in m/s², from the change in GNSS speed since the previous fix.
|
||||
*
|
||||
* Deliberately not from the accelerometer: CAM's `longitudinalAcceleration` is acceleration
|
||||
* along the direction of travel, while the raw accelerometer reads in the device frame with
|
||||
* gravity included — extracting the along-track component from it needs a full orientation
|
||||
* estimate, which this path doesn't have (the detection engine sidesteps the same problem by
|
||||
* working on orientation-independent magnitudes, which is not what CAM wants here).
|
||||
*
|
||||
* Returns null — encoded as ASN.1 `unavailable` — when there's no usable previous fix, when
|
||||
* the gap is too short to divide by safely, or when it's long enough that the two samples
|
||||
* aren't really consecutive. Better an honest "unavailable" than a fabricated number a
|
||||
* receiving vehicle might brake on.
|
||||
*/
|
||||
private fun longitudinalAccel(gnss: GnssReading): Double? {
|
||||
val prev = previousGnss
|
||||
previousGnss = gnss
|
||||
if (prev == null) return null
|
||||
|
||||
val dtSec = (gnss.timestamp - prev.timestamp) / 1000.0
|
||||
if (dtSec < MIN_ACCEL_DT_SEC || dtSec > MAX_ACCEL_DT_SEC) return null
|
||||
|
||||
val dv = gnss.speedMs.toDouble() - prev.speedMs.toDouble()
|
||||
return dv / dtSec
|
||||
}
|
||||
|
||||
private fun currentRateHz(gnss: GnssReading?): Double {
|
||||
val now = System.currentTimeMillis()
|
||||
val inGeofence = gnss != null && config.geofences.any { fence ->
|
||||
@@ -120,5 +157,11 @@ class CamTransmitLoop @Inject constructor(
|
||||
|
||||
companion object {
|
||||
private const val ELEVATED_HOLD_MS = 5_000L
|
||||
|
||||
/** Below this gap, GNSS speed noise divided by a tiny dt produces absurd accelerations. */
|
||||
private const val MIN_ACCEL_DT_SEC = 0.2
|
||||
|
||||
/** Above this gap the two fixes aren't consecutive enough to call the result acceleration. */
|
||||
private const val MAX_ACCEL_DT_SEC = 3.0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -65,6 +63,13 @@ class TripRecordingService : Service() {
|
||||
|
||||
companion object {
|
||||
const val ACTION_START = "com.hawhamburg.micr0bu.TRIP_START"
|
||||
|
||||
/**
|
||||
* Optional string extra on [ACTION_START]: the ID of the CSV recording session started
|
||||
* alongside this trip, so deleting the trip can also delete its CSV. Absent if the trip
|
||||
* was started without CSV recording.
|
||||
*/
|
||||
const val EXTRA_SESSION_ID = "com.hawhamburg.micr0bu.EXTRA_SESSION_ID"
|
||||
const val ACTION_STOP = "com.hawhamburg.micr0bu.TRIP_STOP"
|
||||
|
||||
private const val NOTIFICATION_ID = 9001
|
||||
@@ -101,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 ───────────────────────────────────────────────────
|
||||
|
||||
@@ -146,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 ───────────────────────────────────────────────────
|
||||
|
||||
@@ -215,13 +212,13 @@ class TripRecordingService : Service() {
|
||||
super.onCreate()
|
||||
sensorManager = getSystemService(SENSOR_SERVICE) as SensorManager
|
||||
fusedLocation = LocationServices.getFusedLocationProviderClient(this)
|
||||
repository = TripRepository(AppDatabase.getInstance(applicationContext))
|
||||
repository = TripRepository(AppDatabase.getInstance(applicationContext), applicationContext)
|
||||
createNotificationChannel()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
ACTION_START -> startTrip()
|
||||
ACTION_START -> startTrip(intent.getStringExtra(EXTRA_SESSION_ID))
|
||||
ACTION_STOP -> stopTrip()
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
@@ -236,7 +233,7 @@ class TripRecordingService : Service() {
|
||||
|
||||
// ── Trip control ─────────────────────────────────────────────────────────
|
||||
|
||||
private fun startTrip() {
|
||||
private fun startTrip(sessionId: String?) {
|
||||
// Keep the CPU awake for the duration of the trip so sensor and GPS callbacks
|
||||
// are delivered reliably when the screen is off. Released in stopTrip().
|
||||
val pm = getSystemService(POWER_SERVICE) as PowerManager
|
||||
@@ -246,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
|
||||
@@ -260,41 +255,26 @@ class TripRecordingService : Service() {
|
||||
|
||||
// Insert placeholder trip row and get the auto-generated ID
|
||||
serviceScope.launch {
|
||||
currentTripId = repository.insertTrip(startTime = startTime)
|
||||
currentTripId = repository.insertTrip(startTime = startTime, sessionId = sessionId)
|
||||
TripServiceBus.update {
|
||||
copy(
|
||||
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++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,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("]")
|
||||
@@ -447,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,
|
||||
)
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ import androidx.compose.material.icons.filled.GpsFixed
|
||||
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.UsbOff
|
||||
import androidx.compose.material.icons.filled.Wifi
|
||||
import androidx.compose.material.icons.filled.WifiOff
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -37,6 +39,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.viewmodel.SensorUiState
|
||||
|
||||
private val GreenActive = Color(0xFF4CAF50)
|
||||
@@ -46,7 +49,12 @@ private val GrayInactive = Color(0xFF555E6A)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun StatusTopBar(state: SensorUiState, mqttConnectionState: MqttConnectionState) {
|
||||
fun StatusTopBar(
|
||||
state: SensorUiState,
|
||||
mqttConnectionState: MqttConnectionState,
|
||||
isEsp32: Boolean = false,
|
||||
usbSerialState: UsbSerialState = UsbSerialState.DISCONNECTED,
|
||||
) {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
@@ -85,7 +93,7 @@ fun StatusTopBar(state: SensorUiState, mqttConnectionState: MqttConnectionState)
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
|
||||
ObuStatusIcon(mqttConnectionState)
|
||||
ObuStatusIcon(mqttConnectionState, isEsp32, usbSerialState)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
@@ -94,13 +102,27 @@ fun StatusTopBar(state: SensorUiState, mqttConnectionState: MqttConnectionState)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
@Composable
|
||||
private fun ObuStatusIcon(state: MqttConnectionState) {
|
||||
val icon = when (state) {
|
||||
MqttConnectionState.CONNECTED,
|
||||
MqttConnectionState.CONNECTING -> Icons.Default.Wifi
|
||||
MqttConnectionState.ERROR,
|
||||
MqttConnectionState.DISCONNECTED -> Icons.Default.WifiOff
|
||||
private fun ObuStatusIcon(
|
||||
mqttState: MqttConnectionState,
|
||||
isEsp32: Boolean,
|
||||
usbSerialState: UsbSerialState,
|
||||
) {
|
||||
val state = if (isEsp32) usbSerialState.asConnectionState() else mqttState
|
||||
val icon = when {
|
||||
isEsp32 && state == MqttConnectionState.CONNECTED -> Icons.Default.Usb
|
||||
isEsp32 && state == MqttConnectionState.CONNECTING -> Icons.Default.Usb
|
||||
isEsp32 -> Icons.Default.UsbOff
|
||||
state == MqttConnectionState.CONNECTED ||
|
||||
state == MqttConnectionState.CONNECTING -> Icons.Default.Wifi
|
||||
else -> Icons.Default.WifiOff
|
||||
}
|
||||
val tint = when (state) {
|
||||
MqttConnectionState.CONNECTED -> GreenActive
|
||||
@@ -167,3 +189,15 @@ private fun RecordingPulse() {
|
||||
.background(Color(0xFFFF5252).copy(alpha = alpha)),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
@@ -52,15 +52,41 @@ sealed class Screen(val route: String, val labelRes: Int) {
|
||||
data object SettingsAbout : Screen("settings/about", R.string.settings_about)
|
||||
}
|
||||
|
||||
// Sensors is deliberately absent: a live phone-sensor feed isn't something a rider needs, and the
|
||||
// Dashboard already reports whether GNSS/IMU are active. The screen and its route still exist and
|
||||
// are reachable from Settings > Developer for bench diagnosis - see DeveloperSettingsScreen.
|
||||
private val bottomNavItems = listOf(
|
||||
Screen.Dashboard,
|
||||
Screen.Sensors,
|
||||
Screen.Record,
|
||||
Screen.TripHistory,
|
||||
Screen.MqttViewer,
|
||||
Screen.Settings,
|
||||
)
|
||||
|
||||
/**
|
||||
* True if [route] is this tab's own screen or one of its sub-screens.
|
||||
*
|
||||
* The graph is flat, so ownership is derived from the route naming convention: every Settings
|
||||
* sub-screen is "settings/...", and a trip review is "trip_review/{tripId}" belonging to Trips.
|
||||
* Without this, a tab stops looking selected the moment you open anything inside it.
|
||||
*/
|
||||
private fun Screen.ownsRoute(route: String?): Boolean {
|
||||
if (route == null) return false
|
||||
if (route == this.route) return true
|
||||
return when (this) {
|
||||
Screen.Settings -> route.startsWith("settings/")
|
||||
Screen.TripHistory -> route.startsWith("trip_review")
|
||||
// Connection, Map and Sensors are only reachable from the Dashboard's own cards, and
|
||||
// the session log only from Record, so those tabs stay lit while the rider is inside
|
||||
// one of them. Without this the bar goes blank on screens that clearly belong to a tab.
|
||||
Screen.Dashboard -> route == Screen.Connection.route ||
|
||||
route == Screen.Map.route ||
|
||||
route == Screen.Sensors.route
|
||||
Screen.Record -> route == Screen.Log.route
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BottomNavBar(navController: NavController) {
|
||||
val backStackEntry by navController.currentBackStackEntryAsState()
|
||||
@@ -69,12 +95,26 @@ fun BottomNavBar(navController: NavController) {
|
||||
NavigationBar {
|
||||
bottomNavItems.forEach { screen ->
|
||||
NavigationBarItem(
|
||||
selected = currentRoute == screen.route,
|
||||
selected = screen.ownsRoute(currentRoute),
|
||||
onClick = {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
icon = {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package com.hawhamburg.micr0bu.ui.screens
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -28,6 +31,7 @@ import androidx.compose.material.icons.filled.GpsOff
|
||||
import androidx.compose.material.icons.filled.Map
|
||||
import androidx.compose.material.icons.filled.Sensors
|
||||
import androidx.compose.material.icons.filled.SensorsOff
|
||||
import androidx.compose.material.icons.filled.Traffic
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -37,14 +41,18 @@ import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
@@ -53,7 +61,14 @@ import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
|
||||
import com.hawhamburg.micr0bu.data.transport.ObuHardware
|
||||
import com.hawhamburg.micr0bu.data.transport.TransportType
|
||||
import com.hawhamburg.micr0bu.data.transport.UsbSerialState
|
||||
import com.hawhamburg.micr0bu.domain.cam.Cam
|
||||
import com.hawhamburg.micr0bu.domain.denm.DenmEvent
|
||||
import com.hawhamburg.micr0bu.domain.denm.DenmParser
|
||||
import com.hawhamburg.micr0bu.domain.spat.SignalPhase
|
||||
import com.hawhamburg.micr0bu.domain.spat.SpatIntersection
|
||||
import com.hawhamburg.micr0bu.domain.usecase.GeoMath
|
||||
import com.hawhamburg.micr0bu.viewmodel.SensorUiState
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlin.math.sqrt
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -67,10 +82,14 @@ fun DashboardScreen(
|
||||
usbCableConnected: Boolean = false,
|
||||
obuStationTypeWarning: Boolean = false,
|
||||
obuStationType: Int? = null,
|
||||
hazards: List<DenmEvent> = emptyList(),
|
||||
signals: List<SpatIntersection> = emptyList(),
|
||||
ownPosition: Cam? = null,
|
||||
onNavigateToConnection: () -> Unit,
|
||||
onNavigateToSensors: () -> Unit,
|
||||
onNavigateToMap: () -> Unit,
|
||||
onNavigateToRecord: () -> Unit = {},
|
||||
onNavigateToV2x: () -> Unit = {},
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
@@ -357,6 +376,52 @@ fun DashboardScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Live V2X, below the status cards: the hazard that matters most and the signalised
|
||||
// intersection about to change. Both are summaries of what the V2X screen shows in full,
|
||||
// so tapping either opens that screen rather than repeating its detail here. One of each
|
||||
// is shown deliberately: a dashboard read from a bike mount has room for the single most
|
||||
// relevant thing, not for a list.
|
||||
val ownLatLon = ownPosition?.let { it.latitude to it.longitude }
|
||||
?: state.gnss?.let { it.latitude to it.longitude }
|
||||
|
||||
val rankedHazards = remember(hazards, ownLatLon) {
|
||||
hazards
|
||||
.map { denm ->
|
||||
val distance = ownLatLon?.let { (lat, lon) ->
|
||||
GeoMath.haversineMeters(lat, lon, denm.latitude, denm.longitude)
|
||||
}
|
||||
denm to distance
|
||||
}
|
||||
// Closest first. A hazard whose distance cannot be worked out, because there is
|
||||
// no fix yet, sorts last rather than being dropped: it is still a real hazard,
|
||||
// we just cannot say how far away it is.
|
||||
.sortedBy { (_, d) -> d ?: Double.MAX_VALUE }
|
||||
}
|
||||
rankedHazards.firstOrNull()?.let { (denm, distance) ->
|
||||
HazardCard(
|
||||
hazard = denm,
|
||||
distanceMeters = distance,
|
||||
additionalCount = rankedHazards.size - 1,
|
||||
onClick = onNavigateToV2x,
|
||||
)
|
||||
}
|
||||
|
||||
// Signals cannot be ranked by distance: SPATEM carries no position at all. The geometry
|
||||
// that would place an intersection lives in MAPEM, which nothing on the air is currently
|
||||
// sending. So the one shown is the one changing soonest, which is in any case the one a
|
||||
// rider approaching a junction needs to see.
|
||||
val nextSignal = remember(signals) {
|
||||
val now = System.currentTimeMillis()
|
||||
signals.minByOrNull { it.secondsToNextChange(now) ?: Double.MAX_VALUE }
|
||||
}
|
||||
nextSignal?.let { signal ->
|
||||
SignalCard(
|
||||
signal = signal,
|
||||
additionalCount = signals.size - 1,
|
||||
onClick = onNavigateToV2x,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
if (state.pressureHpa != null)
|
||||
@@ -485,3 +550,208 @@ private fun QuickStatRow(label: String, value: String) {
|
||||
Text(value, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
|
||||
}
|
||||
}
|
||||
|
||||
// Hazard red and the three signal states. Kept local to this file for the same reason the V2X
|
||||
// screen keeps its own: these are traffic-light and warning semantics, not theme roles, and
|
||||
// tying them to the colour scheme would let a theme change turn a red light amber.
|
||||
private val HazardRed = Color(0xFFE53935)
|
||||
private val HazardRedBg = Color(0xFF3A0A0A)
|
||||
private val SignalGreen = Color(0xFF4CAF50)
|
||||
private val SignalAmber = Color(0xFFFFC107)
|
||||
private val SignalGray = Color(0xFF8B949E)
|
||||
|
||||
/** How many signal groups fit on the dashboard before the rest are summarised as a count. */
|
||||
private const val DASH_MAX_SIGNAL_GROUPS = 6
|
||||
|
||||
/**
|
||||
* Seconds until the first of this intersection's signal groups changes, or null when no group
|
||||
* supplies a usable countdown. Marks already in the past are excluded: a change that has already
|
||||
* happened says nothing about what the light will do next.
|
||||
*/
|
||||
private fun SpatIntersection.secondsToNextChange(nowMs: Long): Double? =
|
||||
state.movements
|
||||
.mapNotNull { it.current?.secondsUntil(nowMs) }
|
||||
.filter { it >= 0.0 }
|
||||
.minOrNull()
|
||||
|
||||
/**
|
||||
* The nearest received hazard, as a glanceable summary.
|
||||
*
|
||||
* Deliberately says less than the V2X screen's row: what it is, how far away, and whether there
|
||||
* are others behind it. Anything more detailed belongs on the screen this card opens.
|
||||
*/
|
||||
@Composable
|
||||
private fun HazardCard(
|
||||
hazard: DenmEvent,
|
||||
distanceMeters: Double?,
|
||||
additionalCount: Int,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val title = DenmParser.causeCodeName(hazard.causeCode)
|
||||
?: hazard.causeCode?.let {
|
||||
stringResource(R.string.v2x_denm_rx_cause_code, it, hazard.subCauseCode ?: 0)
|
||||
}
|
||||
?: stringResource(R.string.v2x_map_denm_plain, hazard.stationId)
|
||||
|
||||
val detail = listOfNotNull(
|
||||
distanceMeters?.let { stringResource(R.string.v2x_cam_rx_distance, it) }
|
||||
?: stringResource(R.string.v2x_cam_rx_distance_unknown),
|
||||
stringResource(R.string.dash_hazard_station, hazard.stationId),
|
||||
if (additionalCount > 0) stringResource(R.string.dash_more_count, additionalCount) else null,
|
||||
).joinToString(" · ")
|
||||
|
||||
androidx.compose.material3.Card(
|
||||
modifier = Modifier.fillMaxWidth().clickable { onClick() },
|
||||
colors = androidx.compose.material3.CardDefaults.cardColors(containerColor = HazardRedBg),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Icon(Icons.Default.Warning, null, tint = HazardRed, modifier = Modifier.size(28.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
stringResource(R.string.dash_hazard_warning),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = HazardRed,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
detail,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The signalised intersection changing soonest: its leading phase with a countdown, then every
|
||||
* signal group as a coloured chip.
|
||||
*
|
||||
* Signal groups are bare numbers because that is all the app knows. Calling one "your lane" needs
|
||||
* MAPEM geometry, and a friendlier label would claim knowledge that is not there.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun SignalCard(
|
||||
signal: SpatIntersection,
|
||||
additionalCount: Int,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
// The countdown has to advance on its own clock. SPATEM repeats at about 2 Hz, so
|
||||
// recomposition would roughly keep pace while the RSU is transmitting, but the moment it
|
||||
// stops, a frozen "3 s" would go on claiming the light is about to change.
|
||||
val nowMs = remember { mutableLongStateOf(System.currentTimeMillis()) }
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
nowMs.longValue = System.currentTimeMillis()
|
||||
delay(500L)
|
||||
}
|
||||
}
|
||||
val now = nowMs.longValue
|
||||
|
||||
val leading = signal.state.movements.minByOrNull { movement ->
|
||||
movement.current?.secondsUntil(now)?.takeIf { it >= 0.0 } ?: Double.MAX_VALUE
|
||||
}
|
||||
val phase = leading?.current?.phase
|
||||
val tint = phaseTint(phase)
|
||||
val countdown = leading?.current?.secondsUntil(now)?.takeIf { it in 0.0..99.0 }
|
||||
|
||||
val hiddenGroups = signal.state.movements.size - DASH_MAX_SIGNAL_GROUPS
|
||||
val footer = listOfNotNull(
|
||||
if (hiddenGroups > 0) stringResource(R.string.dash_more_count, hiddenGroups) else null,
|
||||
if (additionalCount > 0) stringResource(R.string.dash_signal_more, additionalCount) else null,
|
||||
).joinToString(" · ")
|
||||
|
||||
androidx.compose.material3.Card(
|
||||
modifier = Modifier.fillMaxWidth().clickable { onClick() },
|
||||
colors = androidx.compose.material3.CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Icon(Icons.Default.Traffic, null, tint = tint, modifier = Modifier.size(28.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
stringResource(R.string.dash_signal_title, signal.state.key),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = tint,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
text = countdown
|
||||
?.let { stringResource(R.string.dash_signal_countdown, phaseLabel(phase), it) }
|
||||
?: phaseLabel(phase),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
|
||||
// Wraps rather than scrolls: a horizontal scroller inside a scrolling dashboard
|
||||
// is awkward to drive one-handed, and the chip row is short by construction.
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
signal.state.movements.take(DASH_MAX_SIGNAL_GROUPS).forEach { movement ->
|
||||
val groupTint = phaseTint(movement.current?.phase)
|
||||
val groupCountdown =
|
||||
movement.current?.secondsUntil(now)?.takeIf { it in 0.0..99.0 }
|
||||
Text(
|
||||
text = stringResource(R.string.v2x_spat_group, movement.signalGroup) +
|
||||
(groupCountdown?.let { " " + stringResource(R.string.v2x_spat_countdown, it) } ?: ""),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = groupTint,
|
||||
modifier = Modifier
|
||||
.padding(vertical = 2.dp)
|
||||
.clip(androidx.compose.foundation.shape.RoundedCornerShape(4.dp))
|
||||
.background(groupTint.copy(alpha = 0.15f))
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (footer.isNotEmpty()) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
footer,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Traffic-light colour for a phase: go is green, stop is red, anything in between is amber. */
|
||||
@Composable
|
||||
private fun phaseTint(phase: SignalPhase?): Color = when {
|
||||
phase == null -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
phase.isGo -> SignalGreen
|
||||
phase.isStop -> HazardRed
|
||||
phase.isTransition -> SignalAmber
|
||||
else -> SignalGray
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun phaseLabel(phase: SignalPhase?): String = when {
|
||||
phase == null -> stringResource(R.string.dash_signal_phase_unknown)
|
||||
phase.isGo -> stringResource(R.string.dash_signal_phase_go)
|
||||
phase.isStop -> stringResource(R.string.dash_signal_phase_stop)
|
||||
phase.isTransition -> stringResource(R.string.dash_signal_phase_changing)
|
||||
phase == SignalPhase.DARK -> stringResource(R.string.dash_signal_phase_dark)
|
||||
else -> stringResource(R.string.dash_signal_phase_unknown)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -30,6 +31,7 @@ import androidx.compose.material.icons.filled.Link
|
||||
import androidx.compose.material.icons.filled.LinkOff
|
||||
import androidx.compose.material.icons.filled.NotificationsActive
|
||||
import androidx.compose.material.icons.filled.VerticalAlignBottom
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.Badge
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
@@ -69,8 +71,11 @@ 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.domain.cam.CamParser
|
||||
import com.hawhamburg.micr0bu.domain.cam.StationType
|
||||
import com.hawhamburg.micr0bu.domain.denm.DenmParser
|
||||
import com.hawhamburg.micr0bu.domain.denm.DenmUseCase
|
||||
import com.hawhamburg.micr0bu.domain.usecase.AlertLevel
|
||||
import com.hawhamburg.micr0bu.domain.usecase.GeoMath
|
||||
import com.hawhamburg.micr0bu.domain.usecase.UseCaseAlert
|
||||
import com.hawhamburg.micr0bu.domain.usecase.UseCaseType
|
||||
import com.hawhamburg.micr0bu.viewmodel.MqttViewModel
|
||||
@@ -81,8 +86,12 @@ import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/** List (raw topics) vs Map (V2X live map, Section 13) toggle for [TopicListPane]. */
|
||||
private enum class TopicViewMode { LIST, MAP }
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
private enum class TopicViewMode { LIST, TOPICS, MAP }
|
||||
|
||||
private val timeFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.US)
|
||||
|
||||
@@ -119,12 +128,17 @@ fun MqttTopicViewerScreen(
|
||||
val ownStationId by viewModel.ownStationId.collectAsState()
|
||||
val obuHardware by viewModel.obuHardware.collectAsState()
|
||||
val ownCamPosition by viewModel.ownCamPosition.collectAsState()
|
||||
val remoteCamPositions by viewModel.remoteCamPositions.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 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()
|
||||
val spatIntersections by viewModel.spatIntersections.collectAsState()
|
||||
|
||||
// Sort: sys/ topics first (heartbeat/health), then alphabetical
|
||||
val sortedTopics = topicMessages.keys.sortedWith(
|
||||
@@ -135,8 +149,15 @@ fun MqttTopicViewerScreen(
|
||||
viewModel.selectTopic(null)
|
||||
}
|
||||
|
||||
val isConnected = connectionState == MqttConnectionState.CONNECTED
|
||||
val isConnecting = connectionState == MqttConnectionState.CONNECTING
|
||||
val isEsp32 = obuHardware == ObuHardware.ESP32_C5
|
||||
|
||||
// On the ESP32-C5 path there is no MQTT broker, so `connectionState` is permanently
|
||||
// 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 isConnected = effectiveState == MqttConnectionState.CONNECTED
|
||||
val isConnecting = effectiveState == MqttConnectionState.CONNECTING
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
|
||||
@@ -172,10 +193,18 @@ fun MqttTopicViewerScreen(
|
||||
Spacer(Modifier.weight(1f))
|
||||
}
|
||||
|
||||
ConnectionChip(connectionState)
|
||||
ConnectionChip(effectiveState)
|
||||
Spacer(Modifier.width(2.dp))
|
||||
IconButton(
|
||||
onClick = { if (isConnected || isConnecting) viewModel.disconnect() else viewModel.connect() },
|
||||
onClick = {
|
||||
// Route to whichever transport this hardware actually uses.
|
||||
if (isEsp32) {
|
||||
if (isConnected || isConnecting) viewModel.disconnectUsbSerial()
|
||||
else viewModel.connectUsbSerial()
|
||||
} else {
|
||||
if (isConnected || isConnecting) viewModel.disconnect() else viewModel.connect()
|
||||
}
|
||||
},
|
||||
colors = IconButtonDefaults.iconButtonColors(
|
||||
contentColor = if (isConnected) ErrorRed else ConnectedGreen,
|
||||
),
|
||||
@@ -197,16 +226,21 @@ fun MqttTopicViewerScreen(
|
||||
TopicListPane(
|
||||
topics = sortedTopics,
|
||||
topicMessages = topicMessages,
|
||||
connectionState = connectionState,
|
||||
connectionState = effectiveState,
|
||||
denmActive = denmActive,
|
||||
lastDenmPayload = lastDenmPayload,
|
||||
activeDenmUseCase = activeDenmUseCase,
|
||||
useCaseAlerts = useCaseAlerts,
|
||||
showDenmTrigger = obuHardware == ObuHardware.CIT_ONE,
|
||||
showCamPinger = obuHardware == ObuHardware.ESP32_C5,
|
||||
showCamPinger = isEsp32,
|
||||
isEsp32 = isEsp32,
|
||||
denmEvents = denmEvents,
|
||||
spatIntersections = spatIntersections,
|
||||
usbSerialState = usbSerialState,
|
||||
camPingerActive = camPingerActive,
|
||||
camPingerSentCount = camPingerSentCount,
|
||||
camPingerHasFix = camPingerHasFix,
|
||||
ownTxLoopback = ownTxLoopback,
|
||||
camSendFailures = camSendFailures,
|
||||
espLinkStatus = espLinkStatus,
|
||||
ownCamPosition = ownCamPosition,
|
||||
@@ -240,9 +274,14 @@ private fun TopicListPane(
|
||||
useCaseAlerts: List<UseCaseAlert>,
|
||||
showDenmTrigger: Boolean = true,
|
||||
showCamPinger: Boolean = false,
|
||||
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,
|
||||
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,
|
||||
@@ -285,6 +324,8 @@ private fun TopicListPane(
|
||||
usbConnected = usbSerialState == UsbSerialState.CONNECTED,
|
||||
pingerActive = camPingerActive,
|
||||
sentCount = camPingerSentCount,
|
||||
hasFix = camPingerHasFix,
|
||||
loopback = ownTxLoopback,
|
||||
sendFailures = camSendFailures,
|
||||
linkStatus = espLinkStatus,
|
||||
onStart = onStartCamPinger,
|
||||
@@ -294,34 +335,55 @@ private fun TopicListPane(
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f))
|
||||
}
|
||||
|
||||
// ── List / Map toggle — the raw topic list stays available either way (Section 13
|
||||
// asks for the map "in addition to", not instead of, the topic list). ──────────────
|
||||
// ── List / Topics / Map 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.
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = { viewMode = TopicViewMode.LIST },
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
containerColor = if (viewMode == TopicViewMode.LIST) MaterialTheme.colorScheme.primaryContainer else Color.Transparent,
|
||||
contentColor = if (viewMode == TopicViewMode.LIST) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
) { Text(stringResource(R.string.mqtt_view_list)) }
|
||||
OutlinedButton(
|
||||
onClick = { viewMode = TopicViewMode.MAP },
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
containerColor = if (viewMode == TopicViewMode.MAP) MaterialTheme.colorScheme.primaryContainer else Color.Transparent,
|
||||
contentColor = if (viewMode == TopicViewMode.MAP) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
) { Text(stringResource(R.string.mqtt_view_map)) }
|
||||
ViewModeButton(
|
||||
label = stringResource(R.string.mqtt_view_list),
|
||||
selected = viewMode == TopicViewMode.LIST,
|
||||
) { viewMode = TopicViewMode.LIST }
|
||||
|
||||
if (!isEsp32) {
|
||||
ViewModeButton(
|
||||
label = stringResource(R.string.mqtt_view_topics),
|
||||
selected = viewMode == TopicViewMode.TOPICS,
|
||||
) { viewMode = TopicViewMode.TOPICS }
|
||||
}
|
||||
|
||||
// ── Topic rows / live map ─────────────────────────────────────────────
|
||||
if (viewMode == TopicViewMode.MAP) {
|
||||
ViewModeButton(
|
||||
label = stringResource(R.string.mqtt_view_map),
|
||||
selected = viewMode == TopicViewMode.MAP,
|
||||
) { viewMode = TopicViewMode.MAP }
|
||||
}
|
||||
|
||||
// ── Decoded traffic / raw topics / live map ───────────────────────────
|
||||
// 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) {
|
||||
ReceivedCamPane(
|
||||
own = ownCamPosition,
|
||||
remotes = remoteCamPositions,
|
||||
alerts = useCaseAlerts,
|
||||
denms = denmEvents,
|
||||
spats = spatIntersections,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
} else if (topics.isEmpty()) {
|
||||
@@ -358,6 +420,357 @@ private fun TopicListPane(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Received-CAM list for the ESP32-C5 path — one row per remote station, showing that station's
|
||||
* latest decoded CAM.
|
||||
*
|
||||
* **Deliberately one row per station, not one per message.** CAMs arrive at 1-10 Hz *per
|
||||
* station*; rendering a scrolling log of individual messages would repaint constantly, bury the
|
||||
* useful information, and tell you nothing a per-station summary doesn't. The underlying
|
||||
* [com.hawhamburg.micr0bu.data.cam.CamUseCaseRepository] already keeps only the latest CAM per
|
||||
* station, so this pane is bounded by the number of road users nearby, not by traffic rate or
|
||||
* session length.
|
||||
*
|
||||
* Sorted nearest-first: on a bike, the closest station is the one that matters. Rows are tinted
|
||||
* by that station's most severe active alert, matching [UseCaseAlertPanel] and the map markers.
|
||||
*
|
||||
* Hazards ([denms]) are listed above the stations rather than mixed in: a DENM is a warning about
|
||||
* a place, a CAM is a report about a moving road user, and a hazard outranks a neighbour even when
|
||||
* the neighbour is closer. Both sections live in one [LazyColumn] so the pane scrolls as a whole.
|
||||
*/
|
||||
@Composable
|
||||
private fun ReceivedCamPane(
|
||||
own: com.hawhamburg.micr0bu.domain.cam.Cam?,
|
||||
remotes: Map<Long, com.hawhamburg.micr0bu.domain.cam.Cam>,
|
||||
alerts: List<UseCaseAlert>,
|
||||
denms: List<com.hawhamburg.micr0bu.domain.denm.DenmEvent>,
|
||||
spats: List<com.hawhamburg.micr0bu.domain.spat.SpatIntersection>,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (remotes.isEmpty() && denms.isEmpty() && spats.isEmpty()) {
|
||||
Box(modifier = modifier, contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
stringResource(R.string.v2x_cam_rx_none),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
stringResource(R.string.v2x_cam_rx_none_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
|
||||
)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val alertByStation = remember(alerts) {
|
||||
alerts.groupBy { it.remoteStationId }
|
||||
.mapValues { (_, a) -> a.maxByOrNull { it.alertLevel.ordinal }?.alertLevel }
|
||||
}
|
||||
|
||||
// Distance is computed once per recomposition per station rather than inside each row, so
|
||||
// sorting and display agree and the haversine isn't run twice per station.
|
||||
val rows = remember(remotes, own) {
|
||||
remotes.values
|
||||
.map { cam ->
|
||||
val distance = own?.let {
|
||||
GeoMath.haversineMeters(it.latitude, it.longitude, cam.latitude, cam.longitude)
|
||||
}
|
||||
cam to distance
|
||||
}
|
||||
.sortedBy { (_, d) -> d ?: Double.MAX_VALUE }
|
||||
}
|
||||
|
||||
// Same treatment as the CAM rows: distance resolved once here so sort order and the displayed
|
||||
// value can't disagree. A DENM's position is the hazard's, not the sender's.
|
||||
val hazards = remember(denms, own) {
|
||||
denms
|
||||
.map { denm ->
|
||||
val distance = own?.let {
|
||||
GeoMath.haversineMeters(it.latitude, it.longitude, denm.latitude, denm.longitude)
|
||||
}
|
||||
denm to distance
|
||||
}
|
||||
.sortedBy { (_, d) -> d ?: Double.MAX_VALUE }
|
||||
}
|
||||
|
||||
LazyColumn(modifier = modifier) {
|
||||
if (hazards.isNotEmpty()) {
|
||||
item {
|
||||
PaneSectionHeader(stringResource(R.string.v2x_denm_rx_count, hazards.size))
|
||||
}
|
||||
// Keys can't collide with the CAM rows below - dedupKey is a String, stationId a Long.
|
||||
items(hazards, key = { (denm, _) -> denm.dedupKey }) { (denm, distance) ->
|
||||
ReceivedDenmRow(denm, distance)
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f))
|
||||
}
|
||||
}
|
||||
|
||||
if (spats.isNotEmpty()) {
|
||||
item { PaneSectionHeader(stringResource(R.string.v2x_spat_rx_count, spats.size)) }
|
||||
items(spats, key = { "spat/" + it.key }) { spat ->
|
||||
ReceivedSpatRow(spat)
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f))
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
PaneSectionHeader(
|
||||
if (rows.isEmpty()) stringResource(R.string.v2x_cam_rx_none_stations)
|
||||
else stringResource(R.string.v2x_cam_rx_count, rows.size)
|
||||
)
|
||||
}
|
||||
items(rows, key = { (cam, _) -> cam.stationId }) { (cam, distance) ->
|
||||
ReceivedCamRow(cam, distance, alertByStation[cam.stationId])
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ViewModeButton(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||
OutlinedButton(
|
||||
onClick = onClick,
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
containerColor = if (selected) MaterialTheme.colorScheme.primaryContainer else Color.Transparent,
|
||||
contentColor = if (selected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
) { Text(label) }
|
||||
}
|
||||
|
||||
/**
|
||||
* One signalised intersection: every signal group's current phase, with a countdown where the RSU
|
||||
* supplies one.
|
||||
*
|
||||
* Signal groups are shown as bare numbers because that is genuinely all we know - mapping a group
|
||||
* to "your lane" needs MAPEM geometry, which nothing on the air is currently sending. Inventing a
|
||||
* friendlier label would imply knowledge the app does not have.
|
||||
*/
|
||||
@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun ReceivedSpatRow(spat: com.hawhamburg.micr0bu.domain.spat.SpatIntersection) {
|
||||
val now = System.currentTimeMillis()
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.v2x_spat_rx_title, spat.state.key, spat.stationId),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
// Wraps rather than scrolls: a busy intersection has 20+ groups and a horizontal
|
||||
// scroller inside a vertical list is awkward to drive one-handed on a bike mount.
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
spat.state.movements.forEach { movement ->
|
||||
val phase = movement.current?.phase
|
||||
val tint = when {
|
||||
phase == null -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
phase.isGo -> ConnectedGreen
|
||||
phase.isStop -> WarningRed
|
||||
phase.isTransition -> AwarenessAmber
|
||||
else -> DisconnectedGray
|
||||
}
|
||||
val countdown = movement.current?.secondsUntil(now)?.takeIf { it in 0.0..99.0 }
|
||||
Text(
|
||||
text = stringResource(R.string.v2x_spat_group, movement.signalGroup) +
|
||||
(countdown?.let { " " + stringResource(R.string.v2x_spat_countdown, it) } ?: ""),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = tint,
|
||||
modifier = Modifier
|
||||
.padding(vertical = 2.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(tint.copy(alpha = 0.15f))
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
spat.rssiDbm?.let {
|
||||
Spacer(Modifier.height(3.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.v2x_cam_rx_rssi, it),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PaneSectionHeader(text: String) {
|
||||
Column {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One hazard row: what it is, where it is, and how well it was heard.
|
||||
*
|
||||
* [DenmEvent.relevanceRadiusM] and [DenmEvent.rssiDbm] come from the GeoNetworking header and the
|
||||
* serial prefix rather than the DENM payload, so they're only populated on the ESP32-C5 path and
|
||||
* are omitted rather than shown as zeroes when absent.
|
||||
*/
|
||||
@Composable
|
||||
private fun ReceivedDenmRow(
|
||||
denm: com.hawhamburg.micr0bu.domain.denm.DenmEvent,
|
||||
distanceMeters: Double?,
|
||||
) {
|
||||
val title = DenmParser.causeCodeName(denm.causeCode)
|
||||
?: denm.causeCode?.let {
|
||||
stringResource(R.string.v2x_denm_rx_cause_code, it, denm.subCauseCode ?: 0)
|
||||
}
|
||||
?: stringResource(R.string.v2x_map_denm_plain, denm.stationId)
|
||||
|
||||
val detail = listOfNotNull(
|
||||
distanceMeters?.let { stringResource(R.string.v2x_cam_rx_distance, it) }
|
||||
?: stringResource(R.string.v2x_cam_rx_distance_unknown),
|
||||
denm.relevanceRadiusM?.let { stringResource(R.string.v2x_denm_rx_radius, it) },
|
||||
denm.rssiDbm?.let { stringResource(R.string.v2x_cam_rx_rssi, it) },
|
||||
).joinToString(" · ")
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Warning,
|
||||
contentDescription = null,
|
||||
tint = DenmRed,
|
||||
modifier = Modifier.size(14.dp),
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = stringResource(R.string.v2x_denm_rx_hazard, title, denm.stationId),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = DenmRed,
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
text = detail,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReceivedCamRow(
|
||||
cam: com.hawhamburg.micr0bu.domain.cam.Cam,
|
||||
distanceMeters: Double?,
|
||||
alertLevel: AlertLevel?,
|
||||
) {
|
||||
val accent = when (alertLevel) {
|
||||
AlertLevel.WARNING -> WarningRed
|
||||
AlertLevel.AWARENESS -> AwarenessAmber
|
||||
AlertLevel.INFO -> InfoBlue
|
||||
null -> MaterialTheme.colorScheme.primary
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Circle,
|
||||
contentDescription = null,
|
||||
tint = accent,
|
||||
modifier = Modifier.size(8.dp),
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = stringResource(
|
||||
R.string.v2x_cam_rx_station,
|
||||
cam.stationId,
|
||||
stationTypeLabel(cam.stationType),
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = accent,
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
// An RSU's CAM carries no kinematics at all (rsuContainerHighFrequency), so the
|
||||
// zeroes in the model are placeholders, not measurements. Printing "0.0 km/h -
|
||||
// heading 0" would assert a stationary vehicle pointing due north.
|
||||
text = if (cam.stationType == StationType.ROAD_SIDE_UNIT) {
|
||||
stringResource(R.string.v2x_cam_rx_no_kinematics)
|
||||
} else stringResource(
|
||||
R.string.v2x_cam_rx_kinematics,
|
||||
cam.speedMps * 3.6,
|
||||
cam.headingDeg,
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
}
|
||||
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
Text(
|
||||
text = distanceMeters
|
||||
?.let { stringResource(R.string.v2x_cam_rx_distance, it) }
|
||||
?: stringResource(R.string.v2x_cam_rx_distance_unknown),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
cam.rssiDbm?.let { rssi ->
|
||||
Text(
|
||||
text = stringResource(R.string.v2x_cam_rx_rssi, rssi),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = timeFormat.format(Date(cam.timestamp)),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Human label for the ETSI stationType values this project is likely to actually see. */
|
||||
@Composable
|
||||
private fun stationTypeLabel(stationType: Int): String = when (stationType) {
|
||||
1 -> stringResource(R.string.station_type_pedestrian)
|
||||
2 -> stringResource(R.string.station_type_cyclist)
|
||||
5 -> stringResource(R.string.station_type_car)
|
||||
6 -> stringResource(R.string.station_type_bus)
|
||||
8 -> stringResource(R.string.station_type_truck)
|
||||
15 -> stringResource(R.string.station_type_rsu)
|
||||
else -> stringResource(R.string.station_type_other, stationType)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TopicRow(
|
||||
topic: String,
|
||||
@@ -698,6 +1111,8 @@ private fun CamPingerCard(
|
||||
usbConnected: Boolean,
|
||||
pingerActive: Boolean,
|
||||
sentCount: Int,
|
||||
hasFix: Boolean,
|
||||
loopback: com.hawhamburg.micr0bu.domain.cam.OwnTxLoopback?,
|
||||
sendFailures: Int,
|
||||
linkStatus: EspLinkStatus?,
|
||||
onStart: () -> Unit,
|
||||
@@ -752,10 +1167,38 @@ private fun CamPingerCard(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
// No GNSS fix means the loop is running but has no position to build a CAM from, so
|
||||
// nothing is going out - without this the card would just sit at "Sent: 0".
|
||||
if (pingerActive && !hasFix) {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
stringResource(R.string.mqtt_cam_pinger_no_fix),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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(
|
||||
@@ -1017,3 +1460,16 @@ private fun prettyPrintJson(raw: String): String {
|
||||
raw
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps the ESP32-C5 serial link's lifecycle onto the MQTT connection vocabulary the shared
|
||||
* 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
|
||||
}
|
||||
|
||||
@@ -45,6 +45,13 @@ import com.hawhamburg.micr0bu.viewmodel.SensorUiState
|
||||
fun RecordingScreen(
|
||||
state: SensorUiState,
|
||||
mqttConnectionState: MqttConnectionState,
|
||||
/**
|
||||
* Whether the OBU link is up, whichever transport this hardware uses - MQTT for the CiT One,
|
||||
* the USB-serial link for the ESP32-C5. Passed in rather than derived from
|
||||
* [mqttConnectionState] because that is permanently DISCONNECTED on the ESP32 path, which
|
||||
* showed the OBU stream as offline during a recording that was actively beaconing CAMs.
|
||||
*/
|
||||
obuConnected: Boolean = mqttConnectionState == MqttConnectionState.CONNECTED,
|
||||
tripServiceState: TripServiceBus.State,
|
||||
showBatteryOptPrompt: Boolean,
|
||||
onToggleRecording: () -> Unit,
|
||||
@@ -134,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(
|
||||
@@ -213,7 +180,7 @@ fun RecordingScreen(
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
|
||||
StreamRow(stringResource(R.string.stream_baro), state.barometerEnabled && state.pressureHpa != null)
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
|
||||
StreamRow(stringResource(R.string.stream_obu), mqttConnectionState == MqttConnectionState.CONNECTED)
|
||||
StreamRow(stringResource(R.string.stream_obu), obuConnected)
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
|
||||
StreamRow(stringResource(R.string.rec_stream_event_detection), tripServiceState.isRecording)
|
||||
}
|
||||
@@ -221,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,6 @@ 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.EspRxMode
|
||||
import com.hawhamburg.micr0bu.data.transport.ObuHardware
|
||||
import com.hawhamburg.micr0bu.domain.usecase.UseCaseDetectionConfig
|
||||
import com.hawhamburg.micr0bu.domain.usecase.UseCaseType
|
||||
@@ -166,8 +165,6 @@ fun ConnectionSettingsScreen(
|
||||
onMqttPrefsChange: (MqttPrefs) -> Unit,
|
||||
obuHardware: ObuHardware = ObuHardware.CIT_ONE,
|
||||
onObuHardwareChange: (ObuHardware) -> Unit = {},
|
||||
espRxMode: EspRxMode = EspRxMode.SEND_AND_RECEIVE,
|
||||
onEspRxModeChange: (EspRxMode) -> Unit = {},
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
SubScreen(stringResource(R.string.settings_connection), onBack) {
|
||||
@@ -212,47 +209,6 @@ fun ConnectionSettingsScreen(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
|
||||
// CAM reception mode — see EspRxMode's KDoc for the caveat that this is purely
|
||||
// an app-side filter, not a physical radio-receiver toggle (the ESP32 must keep
|
||||
// its receiver on for TX to keep working at all).
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
stringResource(R.string.settings_esp32_rx_mode),
|
||||
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 = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
val isSendOnly = espRxMode == EspRxMode.SEND_ONLY
|
||||
OutlinedButton(
|
||||
onClick = { onEspRxModeChange(EspRxMode.SEND_ONLY) },
|
||||
modifier = Modifier.weight(1f),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
containerColor = if (isSendOnly) MaterialTheme.colorScheme.primaryContainer else Color.Transparent,
|
||||
contentColor = if (isSendOnly) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
) { Text(stringResource(R.string.settings_esp32_rx_mode_send_only), fontWeight = if (isSendOnly) FontWeight.Bold else FontWeight.Normal) }
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { onEspRxModeChange(EspRxMode.SEND_AND_RECEIVE) },
|
||||
modifier = Modifier.weight(1f),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
containerColor = if (!isSendOnly) MaterialTheme.colorScheme.primaryContainer else Color.Transparent,
|
||||
contentColor = if (!isSendOnly) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
) { Text(stringResource(R.string.settings_esp32_rx_mode_send_and_receive), fontWeight = if (!isSendOnly) FontWeight.Bold else FontWeight.Normal) }
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.settings_esp32_rx_mode_desc),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f),
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -441,11 +397,16 @@ fun UseCaseAlertsSettingsScreen(
|
||||
fun DeveloperSettingsScreen(
|
||||
state: SensorUiState,
|
||||
onDeveloperMode: (Boolean) -> Unit,
|
||||
onOpenSensorMonitor: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
SubScreen(stringResource(R.string.settings_developer), onBack) {
|
||||
SectionCard {
|
||||
SettingToggleRow(stringResource(R.string.settings_dev_mode), state.developerMode, onDeveloperMode)
|
||||
Divider()
|
||||
// Sensor Monitor lives here rather than in the bottom nav: a live phone-sensor feed is
|
||||
// a bench-diagnosis tool, not something a rider needs mid-ride.
|
||||
MenuRow(stringResource(R.string.sensor_monitor_title), onOpenSensorMonitor)
|
||||
if (state.developerMode) {
|
||||
Divider()
|
||||
DisabledRow(stringResource(R.string.settings_wifi), stringResource(R.string.settings_wifi_val))
|
||||
|
||||
@@ -21,6 +21,7 @@ import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
@@ -33,6 +34,7 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -51,6 +53,7 @@ fun TripHistoryScreen(
|
||||
onOpenTrip: (Long) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val trips by viewModel.trips.collectAsState(initial = emptyList())
|
||||
var pendingDelete by remember { mutableStateOf<RecordedTripEntity?>(null) }
|
||||
|
||||
@@ -99,6 +102,7 @@ fun TripHistoryScreen(
|
||||
index = trips.size - index,
|
||||
onOpen = { onOpenTrip(trip.id) },
|
||||
onDelete = { pendingDelete = trip },
|
||||
onExport = { viewModel.exportTrip(context, trip.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -112,6 +116,7 @@ private fun TripCard(
|
||||
index: Int,
|
||||
onOpen: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
onExport: () -> Unit,
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
@@ -140,12 +145,18 @@ 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) {
|
||||
Icon(Icons.Default.Map, contentDescription = stringResource(R.string.trip_review_title), tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
IconButton(onClick = onExport) {
|
||||
Icon(
|
||||
Icons.Default.Share,
|
||||
contentDescription = stringResource(R.string.trip_export_cd),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onDelete) {
|
||||
Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.log_delete_cd), tint = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -23,11 +23,13 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
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.usecase.AlertLevel
|
||||
import com.hawhamburg.micr0bu.domain.usecase.UseCaseAlert
|
||||
import org.osmdroid.config.Configuration
|
||||
@@ -53,6 +55,7 @@ fun V2xLiveMapView(
|
||||
own: Cam?,
|
||||
remotes: Map<Long, Cam>,
|
||||
alerts: List<UseCaseAlert>,
|
||||
denms: List<DenmEvent> = emptyList(),
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
@@ -82,7 +85,7 @@ fun V2xLiveMapView(
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose {
|
||||
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||
mapViewRef.value?.onDetach()
|
||||
// onDetach() deliberately NOT called here - see AndroidView's onRelease below.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,13 +112,14 @@ fun V2xLiveMapView(
|
||||
update = { mv ->
|
||||
mv.overlays.clear()
|
||||
|
||||
// Own marker — distinct from remotes via a dedicated title prefix; osmdroid
|
||||
// doesn't tint default pins per-instance without a custom drawable, so color
|
||||
// differentiation for now relies on the title label shown on tap.
|
||||
// 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 {
|
||||
position = ownGeoPoint
|
||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
|
||||
icon = ContextCompat.getDrawable(context, R.drawable.ic_map_own)
|
||||
title = context.getString(R.string.v2x_map_own_label)
|
||||
}
|
||||
)
|
||||
@@ -128,23 +132,78 @@ 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 {
|
||||
position = GeoPoint(cam.latitude, cam.longitude)
|
||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
||||
icon = pin
|
||||
title = label
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// DENM hazard pins, added last so they draw on top of vehicle markers - a hazard
|
||||
// hidden behind a CAM pin defeats the point of showing it.
|
||||
denms.forEach { denm ->
|
||||
mv.overlays.add(
|
||||
Marker(mv).apply {
|
||||
position = GeoPoint(denm.latitude, denm.longitude)
|
||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
||||
icon = ContextCompat.getDrawable(context, R.drawable.ic_denm_warning)
|
||||
title = denm.causeCode?.let {
|
||||
context.getString(
|
||||
R.string.v2x_map_denm_labeled,
|
||||
it,
|
||||
denm.subCauseCode ?: 0,
|
||||
denm.stationId,
|
||||
)
|
||||
} ?: context.getString(R.string.v2x_map_denm_plain, denm.stationId)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
mv.controller.animateTo(ownGeoPoint)
|
||||
mv.invalidate()
|
||||
},
|
||||
// osmdroid's onDetach() permanently tears the MapView down: afterwards its
|
||||
// MapViewRepository holds a null MapView, so constructing a Marker against it throws
|
||||
// NullPointerException from deep inside InfoWindow's constructor.
|
||||
//
|
||||
// This used to run in the DisposableEffect's onDispose, which is NOT safe: that effect
|
||||
// is keyed on the lifecycle owner and disposes independently of this AndroidView, so
|
||||
// the update block above could still run against an already-detached MapView and
|
||||
// rebuild its markers. It crashed the app on 2026-08-17 once DENMs started arriving,
|
||||
// because every incoming message recomposes this view and there are far more updates
|
||||
// to land in that window than there used to be.
|
||||
//
|
||||
// onRelease is the callback that actually means "this View is gone": Compose
|
||||
// guarantees no further update after it.
|
||||
onRelease = { it.onDetach() },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marker tint per severity, matching [UseCaseAlertPanel]'s colours so the same station reads the
|
||||
* same way in both places. Stations with no active alert stay neutral grey-blue rather than green:
|
||||
* green would imply "checked and safe", when it only means "nothing detected yet".
|
||||
*/
|
||||
private fun AlertLevel?.toMarkerColor(): Int = when (this) {
|
||||
AlertLevel.WARNING -> 0xFFE53935.toInt()
|
||||
AlertLevel.AWARENESS -> 0xFFFFC107.toInt()
|
||||
AlertLevel.INFO -> 0xFF64B5F6.toInt()
|
||||
null -> 0xFF78909C.toInt()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NoFixPlaceholder(modifier: Modifier) {
|
||||
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
|
||||
@@ -10,18 +10,25 @@ import com.hawhamburg.micr0bu.data.mqtt.MqttPrefs
|
||||
import com.hawhamburg.micr0bu.data.mqtt.MqttRepository
|
||||
import com.hawhamburg.micr0bu.data.mqtt.ObuHardwarePreferences
|
||||
import com.hawhamburg.micr0bu.data.transport.EspLinkStatus
|
||||
import com.hawhamburg.micr0bu.data.transport.EspRxMode
|
||||
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.domain.denm.DenmEvent
|
||||
import com.hawhamburg.micr0bu.domain.denm.DenmParser
|
||||
import com.hawhamburg.micr0bu.domain.spat.SpatIntersection
|
||||
import com.hawhamburg.micr0bu.domain.denm.DenmUseCase
|
||||
import com.hawhamburg.micr0bu.domain.usecase.UseCaseAlert
|
||||
import com.hawhamburg.micr0bu.domain.usecase.UseCaseType
|
||||
import com.hawhamburg.micr0bu.service.CamPinger
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.runningFold
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -65,19 +72,6 @@ class MqttViewModel @Inject constructor(
|
||||
viewModelScope.launch { obuHardwarePrefs.setObuHardware(hardware) }
|
||||
}
|
||||
|
||||
/**
|
||||
* ESP32-C5-only: whether received CAM traffic is processed or discarded — see [EspRxMode]'s
|
||||
* KDoc for the important caveat that this doesn't actually disable the ESP32's receiver
|
||||
* (it can't, without also breaking TX).
|
||||
*/
|
||||
val espRxMode: StateFlow<EspRxMode> = obuHardwarePrefs.espRxModeFlow.stateIn(
|
||||
viewModelScope, SharingStarted.Eagerly, EspRxMode.SEND_AND_RECEIVE,
|
||||
)
|
||||
|
||||
fun setEspRxMode(mode: EspRxMode) {
|
||||
viewModelScope.launch { obuHardwarePrefs.setEspRxMode(mode) }
|
||||
}
|
||||
|
||||
/** True when a 192.168.42.x USB-C tethering network is detected. */
|
||||
val usbConnected: StateFlow<Boolean> = usbDetector.usbNetwork
|
||||
.map { it != null }
|
||||
@@ -104,7 +98,27 @@ class MqttViewModel @Inject constructor(
|
||||
val camPingerActive: StateFlow<Boolean> = camPinger.isActive
|
||||
val camPingerSentCount: StateFlow<Int> = camPinger.sentCount
|
||||
|
||||
fun startCamPinger() = camPinger.start()
|
||||
/** False while the pinger runs without a GNSS fix — it has no position to build a CAM from. */
|
||||
val camPingerHasFix: StateFlow<Boolean> = camPinger.hasFix
|
||||
|
||||
/**
|
||||
* 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 ─────────────────────────────────────────────────────────────────
|
||||
@@ -130,13 +144,134 @@ 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) ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* 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 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.
|
||||
*/
|
||||
val denmEvents: StateFlow<List<DenmEvent>> = combine(
|
||||
repo.topicMessages.map { byTopic ->
|
||||
(byTopic[DENM_RX_TOPIC] ?: emptyList())
|
||||
.mapNotNull { DenmParser.parse(it.payload, it.timestamp) }
|
||||
},
|
||||
// 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.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
|
||||
// when a DENM arrives, so a sender that simply stops transmitting - drives away, loses
|
||||
// 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),
|
||||
) { fromUseCaseApp, fromDecoder, _ ->
|
||||
val now = System.currentTimeMillis()
|
||||
(fromUseCaseApp + fromDecoder)
|
||||
.filterNot { it.isTermination } // the hazard is over - stop drawing it
|
||||
.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".
|
||||
.filter { now - it.timestamp <= DENM_TTL_MS }
|
||||
.sortedByDescending { it.timestamp }
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
/**
|
||||
* Live signal state per intersection, newest first, keyed by [IntersectionSignalState.key].
|
||||
*
|
||||
* 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.decodedSpat
|
||||
.runningFold(emptyMap<String, SpatIntersection>()) { acc, spat ->
|
||||
acc + spat.intersections.associate { i ->
|
||||
i.key to SpatIntersection(i, spat.stationId, spat.rssiDbm, spat.timestamp)
|
||||
}
|
||||
},
|
||||
tickerFlow(SPAT_EXPIRY_TICK_MS),
|
||||
) { byKey, _ ->
|
||||
val now = System.currentTimeMillis()
|
||||
byKey.values
|
||||
.filter { now - it.timestamp <= SPAT_TTL_MS }
|
||||
.sortedByDescending { it.timestamp }
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
/** Emits immediately, then every [periodMs], purely to re-trigger a time-dependent combine. */
|
||||
private fun tickerFlow(periodMs: Long): Flow<Long> = flow {
|
||||
while (true) {
|
||||
emit(System.currentTimeMillis())
|
||||
delay(periodMs)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** Use Case API topic carrying received DENMs (CiT One path only). */
|
||||
const val DENM_RX_TOPIC = "v2x-uca/output/json/denm"
|
||||
|
||||
/**
|
||||
* How long a hazard stays listed after its last repetition. A DENM has no "still here"
|
||||
* guarantee beyond the sender repeating it, and its own validityDuration is not decoded
|
||||
* yet, so silence is the only expiry signal available.
|
||||
*/
|
||||
const val DENM_TTL_MS = 60_000L
|
||||
|
||||
/** How often the list is re-evaluated for expiry. Sets the worst-case lateness of a fade. */
|
||||
const val DENM_EXPIRY_TICK_MS = 5_000L
|
||||
|
||||
/**
|
||||
* SPATEM repeats at ~2 Hz, so 15 s of silence is ~30 missed repetitions: the RSU is out of
|
||||
* range. Much shorter than the DENM window because a stale traffic light is more
|
||||
* misleading than a stale hazard - a light that stopped updating is not "still green".
|
||||
*/
|
||||
const val SPAT_TTL_MS = 15_000L
|
||||
const val SPAT_EXPIRY_TICK_MS = 2_000L
|
||||
|
||||
/** RSU CAMs arrive at ~2 Hz, same as any other station, so the same window applies. */
|
||||
const val RSU_TTL_MS = 15_000L
|
||||
const val RSU_EXPIRY_TICK_MS = 2_000L
|
||||
}
|
||||
|
||||
// ── DENM transmission ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -168,6 +303,24 @@ class MqttViewModel @Inject constructor(
|
||||
/** Latest known CAM per tracked remote road user, for the live map view (Section 13). */
|
||||
val remoteCamPositions: StateFlow<Map<Long, com.hawhamburg.micr0bu.domain.cam.Cam>> = camUseCaseRepository.remotePositions
|
||||
|
||||
/**
|
||||
* Every station to draw: road users from the detection engine, plus roadside units, which are
|
||||
* tracked outside it (see [com.hawhamburg.micr0bu.data.cam.CamUseCaseRepository.rsuStations]).
|
||||
*
|
||||
* The engine prunes its own stale entries; nothing prunes the RSU map, so the staleness window
|
||||
* is applied here. As with hazards and signals, expiry has to be clock-driven - an RSU that
|
||||
* goes out of range simply stops transmitting, and no further emission would arrive to
|
||||
* recompute the list.
|
||||
*/
|
||||
val stationsInRange: StateFlow<Map<Long, com.hawhamburg.micr0bu.domain.cam.Cam>> = combine(
|
||||
camUseCaseRepository.remotePositions,
|
||||
camUseCaseRepository.rsuStations,
|
||||
tickerFlow(RSU_EXPIRY_TICK_MS),
|
||||
) { roadUsers, rsus, _ ->
|
||||
val now = System.currentTimeMillis()
|
||||
roadUsers + rsus.filterValues { now - it.timestamp <= RSU_TTL_MS }
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, emptyMap())
|
||||
|
||||
/** True if [stationId] is the ego OBU's own — used for OWN/REMOTE badges in the raw message list. */
|
||||
fun isOwnStationId(stationId: Long): Boolean = camUseCaseRepository.isOwnStationId(stationId)
|
||||
|
||||
|
||||
@@ -93,6 +93,13 @@ class SensorViewModel(application: Application) : AndroidViewModel(application)
|
||||
|
||||
private var recordingTimerJob: Job? = null
|
||||
private var recordingSessionId: String = ""
|
||||
|
||||
/**
|
||||
* ID of the CSV session currently being written, or null when not recording. Read by
|
||||
* MainActivity so the trip started at the same moment can store it - see
|
||||
* [com.hawhamburg.micr0bu.data.db.RecordedTripEntity.sessionId] for why that link matters.
|
||||
*/
|
||||
val activeSessionId: String? get() = recordingSessionId.takeIf { _state.value.isRecording }
|
||||
private var csvWriter: BufferedWriter? = null
|
||||
|
||||
private val isoFmt = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US)
|
||||
@@ -187,7 +194,7 @@ class SensorViewModel(application: Application) : AndroidViewModel(application)
|
||||
csvWriter = BufferedWriter(FileWriter(File(sessionsDir, "$recordingSessionId.csv")))
|
||||
csvWriter?.apply {
|
||||
appendLine("# MicrOBU Session Export")
|
||||
appendLine("# Generated by MicrOBU v0.2.0 — HAW Hamburg / Project MicrOBU")
|
||||
appendLine("# Generated by MicrOBU v0.2.0 - HAW Hamburg / Project MicrOBU")
|
||||
appendLine("# Session ID,$recordingSessionId")
|
||||
appendLine("# Start,${isoFmt.format(Date(startTime))}")
|
||||
appendLine()
|
||||
|
||||
@@ -7,8 +7,8 @@ import android.os.PowerManager
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
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
|
||||
@@ -27,7 +27,7 @@ import kotlinx.coroutines.launch
|
||||
*/
|
||||
class TripRecordingViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
private val repository = TripRepository(AppDatabase.getInstance(application))
|
||||
private val repository = TripRepository(AppDatabase.getInstance(application), application)
|
||||
|
||||
// ── Battery optimisation prompt ───────────────────────────────────────────
|
||||
// Shown once on the Recording screen to ask the user to exempt the app so
|
||||
@@ -65,33 +65,27 @@ 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 ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Starts the foreground recording service if not already running,
|
||||
* or stops it if a trip is already active.
|
||||
*/
|
||||
fun toggleRecording() {
|
||||
if (serviceState.value.isRecording) stopRecording() else startRecording()
|
||||
fun toggleRecording(sessionId: String? = null) {
|
||||
if (serviceState.value.isRecording) stopRecording() else startRecording(sessionId)
|
||||
}
|
||||
|
||||
fun startRecording() {
|
||||
/**
|
||||
* @param sessionId ID of the CSV session recorded alongside this trip, so the trip row can
|
||||
* reference it and deleting the trip can delete the CSV too. Null if CSV recording isn't
|
||||
* running - the trip is still recorded, it just has no CSV to clean up later.
|
||||
*/
|
||||
fun startRecording(sessionId: String? = null) {
|
||||
val intent = Intent(getApplication(), TripRecordingService::class.java)
|
||||
.apply { action = TripRecordingService.ACTION_START }
|
||||
.apply {
|
||||
action = TripRecordingService.ACTION_START
|
||||
putExtra(TripRecordingService.EXTRA_SESSION_ID, sessionId)
|
||||
}
|
||||
getApplication<Application>().startForegroundService(intent)
|
||||
}
|
||||
|
||||
@@ -106,4 +100,25 @@ class TripRecordingViewModel(application: Application) : AndroidViewModel(applic
|
||||
fun deleteTrip(tripId: Long) {
|
||||
viewModelScope.launch { repository.deleteTrip(tripId) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds and shares the combined CSV for [tripId] - GPS track, detected events, V2X messages
|
||||
* and the raw sensor samples recorded alongside it. See [buildTripCsv] for the format and why
|
||||
* it's one file rather than several.
|
||||
*
|
||||
* [context] must be the Activity context (`LocalContext.current` from the composable), not
|
||||
* this ViewModel's Application context: the share sheet is launched with `startActivity`, and
|
||||
* doing that from a non-Activity context throws unless FLAG_ACTIVITY_NEW_TASK is set. Same
|
||||
* pattern as SessionLogScreen's share.
|
||||
*/
|
||||
fun exportTrip(context: Context, tripId: Long) {
|
||||
viewModelScope.launch {
|
||||
val trip = repository.getTrip(tripId) ?: return@launch
|
||||
shareTripCsv(
|
||||
context = context,
|
||||
trip = trip,
|
||||
v2xMessages = repository.getV2xMessagesForTripOnce(tripId),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<!--
|
||||
DENM map pin: the standard hazard warning triangle (! in a triangle).
|
||||
|
||||
Drawn rather than reused from Material's Icons.Filled.Warning because osmdroid Markers take a
|
||||
Drawable, not a Compose ImageVector, and a filled triangle with an opaque outline reads far
|
||||
better against arbitrary map tiles than a single-colour glyph does.
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="36dp"
|
||||
android:height="36dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
|
||||
<!-- White outline first, so the pin stays legible over dark map features. -->
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M12,1.2L0.6,21.4h22.8L12,1.2z" />
|
||||
|
||||
<!-- Amber triangle body. -->
|
||||
<path
|
||||
android:fillColor="#FFFFC107"
|
||||
android:pathData="M12,3.6L2.9,20.0h18.2L12,3.6z" />
|
||||
|
||||
<!-- Exclamation mark. -->
|
||||
<path
|
||||
android:fillColor="#FF1A1A1A"
|
||||
android:pathData="M11.1,8.4h1.8v5.4h-1.8z" />
|
||||
<path
|
||||
android:fillColor="#FF1A1A1A"
|
||||
android:pathData="M11.1,15.2h1.8v1.8h-1.8z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,24 @@
|
||||
<!--
|
||||
Live-map marker for the ego bike's own position.
|
||||
|
||||
Deliberately a "you are here" dot rather than a pin: own position is a fact about the viewer,
|
||||
not one of the objects being tracked, and giving it the same pin shape as remote stations made
|
||||
the two indistinguishable at a glance on a crowded map. White ring keeps it readable over dark
|
||||
map features.
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M12,12m-10,0a10,10 0 1,0 20,0a10,10 0 1,0 -20,0" />
|
||||
<path
|
||||
android:fillColor="#FF1E88E5"
|
||||
android:pathData="M12,12m-7.5,0a7.5,7.5 0 1,0 15,0a7.5,7.5 0 1,0 -15,0" />
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M12,12m-3,0a3,3 0 1,0 6,0a3,3 0 1,0 -6,0" />
|
||||
</vector>
|
||||
@@ -0,0 +1,30 @@
|
||||
<!--
|
||||
Live-map marker for a remote station heard over CAM.
|
||||
|
||||
A teardrop pin, anchored at its tip, so it clearly points at a location rather than covering it
|
||||
the way the ego dot does. The body is drawn in plain white and tinted at runtime by the
|
||||
station's most severe active alert level (see V2xLiveMapView) - which is why the fill here is
|
||||
white rather than a fixed colour: setTint on a coloured fill would muddy it.
|
||||
The dark outline is drawn first so the tinted body always sits inside a readable border.
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="30dp"
|
||||
android:height="30dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
|
||||
<!-- Outline -->
|
||||
<path
|
||||
android:fillColor="#FF1A1A1A"
|
||||
android:pathData="M12,0.8C7.4,0.8 3.7,4.5 3.7,9.1c0,6.2 8.3,14.1 8.3,14.1s8.3,-7.9 8.3,-14.1C20.3,4.5 16.6,0.8 12,0.8z" />
|
||||
|
||||
<!-- Body: tinted at runtime -->
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M12,2.4C8.3,2.4 5.3,5.4 5.3,9.1c0,5.2 6.7,11.7 6.7,11.7s6.7,-6.5 6.7,-11.7C18.7,5.4 15.7,2.4 12,2.4z" />
|
||||
|
||||
<!-- Inner dot, so the pin still reads as a marker once tinted -->
|
||||
<path
|
||||
android:fillColor="#FF1A1A1A"
|
||||
android:pathData="M12,9.1m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
|
||||
</vector>
|
||||
@@ -1,11 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Language names — intentionally NOT translated so they always read in their own language -->
|
||||
<!-- Language names - intentionally NOT translated so they always read in their own language -->
|
||||
<string name="lang_english">English</string>
|
||||
<string name="lang_german">Deutsch</string>
|
||||
|
||||
<!-- App -->
|
||||
<string name="app_subtitle">V2X Begleiter — Phase 02</string>
|
||||
<string name="app_subtitle">V2X Begleiter - Phase 02</string>
|
||||
|
||||
<!-- Navigation -->
|
||||
<string name="nav_dashboard">Dashboard</string>
|
||||
@@ -26,11 +26,24 @@
|
||||
<string name="dash_connected">Verbunden</string>
|
||||
<string name="dash_mqtt_connected">MQTT verbunden</string>
|
||||
<string name="dash_mqtt_connecting">Verbindung zum Broker…</string>
|
||||
<string name="dash_mqtt_error">Broker nicht erreichbar — V2X-Einstellungen prüfen</string>
|
||||
<string name="dash_mqtt_error">Broker nicht erreichbar - V2X-Einstellungen prüfen</string>
|
||||
<string name="dash_recording">Aufnahme</string>
|
||||
<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>
|
||||
@@ -63,12 +76,12 @@
|
||||
<string name="conn_bluetooth_phase3">Bluetooth</string>
|
||||
<string name="conn_bluetooth_phase3_desc">Bluetooth-Verbindung ist für Phase 03 geplant und noch nicht implementiert.</string>
|
||||
<string name="conn_esp32_title">ESP32-C5 (USB Seriell)</string>
|
||||
<string name="conn_esp32_phase3_desc">Die USB-Seriell-Verbindung zum ESP32-C5 ist für Phase 03 vorgesehen, aber noch nicht funktionsfähig — dafür muss zuerst das ESP32-Firmware-Protokoll nach Kotlin übersetzt werden.</string>
|
||||
<string name="conn_esp32_state_disconnected">Nicht verbunden — ESP32-C5 an den nativen USB-C-Port anschließen und Verbinden antippen.</string>
|
||||
<string name="conn_esp32_phase3_desc">Die USB-Seriell-Verbindung zum ESP32-C5 ist für Phase 03 vorgesehen, aber noch nicht funktionsfähig - dafür muss zuerst das ESP32-Firmware-Protokoll nach Kotlin übersetzt werden.</string>
|
||||
<string name="conn_esp32_state_disconnected">Nicht verbunden - ESP32-C5 an den nativen USB-C-Port anschließen und Verbinden antippen.</string>
|
||||
<string name="conn_esp32_state_device_attached">Gerät erkannt, wird geöffnet…</string>
|
||||
<string name="conn_esp32_state_permission_requested">Warte auf USB-Berechtigung…</string>
|
||||
<string name="conn_esp32_state_connected">Verbunden</string>
|
||||
<string name="conn_esp32_state_error">Verbindungsfehler — Kabel und nativen USB-C-Port prüfen und erneut versuchen.</string>
|
||||
<string name="conn_esp32_state_error">Verbindungsfehler - Kabel und nativen USB-C-Port prüfen und erneut versuchen.</string>
|
||||
<string name="conn_esp32_connect">Mit ESP32-C5 verbinden</string>
|
||||
<string name="conn_scan">Geräte suchen</string>
|
||||
<string name="conn_scanning">Suche läuft…</string>
|
||||
@@ -110,6 +123,7 @@
|
||||
<string name="log_session_number">Sitzung #%1$d</string>
|
||||
<string name="log_share_cd">CSV teilen</string>
|
||||
<string name="log_save_cd">Auf Gerät speichern</string>
|
||||
<string name="trip_export_cd">Fahrt als CSV exportieren</string>
|
||||
<string name="log_delete_cd">Sitzung löschen</string>
|
||||
<string name="log_empty_title">Noch keine Sitzungen</string>
|
||||
<string name="log_empty_desc">Gehe zu Aufnahme und starte eine Sitzung</string>
|
||||
@@ -125,7 +139,7 @@
|
||||
<string name="gnss_open_maps_sub">Öffnet Ihre bevorzugte Karten-App</string>
|
||||
<string name="gnss_view_inapp">In App anzeigen</string>
|
||||
<string name="gnss_view_inapp_sub">Aktuellen Standort auf einer In-App-Karte anzeigen</string>
|
||||
<string name="gnss_no_fix">Noch kein GPS-Signal — gehen Sie ins Freie</string>
|
||||
<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>
|
||||
@@ -159,21 +173,17 @@
|
||||
<string name="settings_developer">Entwickler</string>
|
||||
<string name="settings_dev_mode">Entwicklermodus</string>
|
||||
<string name="settings_wifi">WLAN-OBU-Verbindung</string>
|
||||
<string name="settings_wifi_val">Nur Entwicklermodus — noch nicht implementiert</string>
|
||||
<string name="settings_wifi_val">Nur Entwicklermodus - noch nicht implementiert</string>
|
||||
<string name="settings_about">Über</string>
|
||||
<string name="settings_app_version">App-Version</string>
|
||||
<string name="settings_app_version_val">0.4.0 (Phase 02 — USB-C + DENM TX)</string>
|
||||
<string name="settings_app_version_val">0.5.0 (Phase 03 - ESP32-C5-Seriellverbindung + CAM vom Smartphone)</string>
|
||||
<string name="settings_connection">Verbindung</string>
|
||||
<string name="settings_usb_auto_detect">OBU per USB-C automatisch erkennen</string>
|
||||
<string name="settings_usb_manual_ip">OBU-IP (manuell)</string>
|
||||
<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_esp32_rx_mode">CAM-Empfang</string>
|
||||
<string name="settings_esp32_rx_mode_send_only">Nur senden</string>
|
||||
<string name="settings_esp32_rx_mode_send_and_receive">Senden & Empfangen</string>
|
||||
<string name="settings_esp32_rx_mode_desc">„Nur senden" ignoriert von nahen Stationen empfangene CAM (der ESP32 empfängt sie physisch weiterhin — der Empfänger kann nicht abgeschaltet werden, ohne auch das Senden zu unterbrechen — sie werden nur nicht von der App verarbeitet). Nützlich, um den Sendepfad isoliert zu testen.</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_usb_transport">Aktiver Transport</string>
|
||||
<string name="settings_transport_usbc">USB-C</string>
|
||||
<string name="settings_transport_wifi">WLAN</string>
|
||||
@@ -186,30 +196,59 @@
|
||||
<string name="mqtt_auto_scroll">Automatisch scrollen</string>
|
||||
<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>
|
||||
|
||||
<!-- DENM TX — nur manueller Antennen-/RSU-Reichweitentest, nicht Use-Case-gesteuert -->
|
||||
<!-- DENM TX - nur manueller Antennen-/RSU-Reichweitentest, nicht Use-Case-gesteuert -->
|
||||
<string name="mqtt_last_tx">Zuletzt gesendete Nutzlast:</string>
|
||||
<string name="mqtt_denm_tx_title">DENM-Übertragung (manueller Test)</string>
|
||||
<string name="mqtt_send_denm">Test-DENM senden</string>
|
||||
<string name="mqtt_stop_denm">DENM stoppen</string>
|
||||
<string name="mqtt_denm_use_case_desc">Liegengebliebenes Fahrzeug (causeCode 94)</string>
|
||||
<string name="mqtt_denm_not_connected">Mit OBU verbinden, um DENM-Auslösung zu aktivieren</string>
|
||||
<string name="mqtt_denm_active">DENM aktiv — OBU sendet über ITS-G5</string>
|
||||
<string name="mqtt_denm_hint">Manueller Antennen-/RSU-Reichweitentest — sendet uca-denmctrl (retained) an v2x-uca/input/denmtrg. Wird nicht durch erkannte Ereignisse oder Use-Case-Alarme ausgelöst.</string>
|
||||
<string name="mqtt_denm_active">DENM aktiv - OBU sendet über ITS-G5</string>
|
||||
<string name="mqtt_denm_hint">Manueller Antennen-/RSU-Reichweitentest - sendet uca-denmctrl (retained) an v2x-uca/input/denmtrg. Wird nicht durch erkannte Ereignisse oder Use-Case-Alarme ausgelöst.</string>
|
||||
<string name="mqtt_denm_show">Letztes TX anzeigen</string>
|
||||
<string name="mqtt_denm_hide">Ausblenden</string>
|
||||
|
||||
<!-- CAM-Pinger — nur ESP32-C5, manueller Bank-Test, Gegenstück zur DENM-TX-Karte oben -->
|
||||
<!-- CAM-Pinger - nur ESP32-C5, manueller Bank-Test, Gegenstück zur DENM-TX-Karte oben -->
|
||||
<string name="mqtt_cam_pinger_title">CAM-Pinger (manueller Test)</string>
|
||||
<string name="mqtt_cam_pinger_desc">Fester Standort, 1-Hz-CAM-Ping — prüft die serielle Verbindung und den ESP32-Funkpfad ohne GNSS-Bewegung oder Fahrtaufzeichnung.</string>
|
||||
<string name="mqtt_cam_pinger_desc">1-Hz-CAM-Ping aus Live-GNSS- und IMU-Daten - prüft die serielle Verbindung und den ESP32-Funkpfad ohne Fahrtaufzeichnung.</string>
|
||||
<string name="mqtt_cam_pinger_no_fix">Warte auf GNSS-Fix - noch nichts gesendet</string>
|
||||
|
||||
<!-- Empfangene CAMs (ESP32-C5-Pfad) -->
|
||||
<string name="v2x_cam_rx_count">%1$d Station(en) in Reichweite - jeweils neueste CAM</string>
|
||||
<string name="v2x_cam_rx_none">Keine CAMs empfangen</string>
|
||||
<string name="v2x_cam_rx_none_hint">Dekodierte CAMs benachbarter Stationen erscheinen hier, sobald sie eintreffen.</string>
|
||||
<string name="v2x_cam_rx_station">Station %1$d · %2$s</string>
|
||||
<string name="v2x_cam_rx_kinematics">%1$.1f km/h · Kurs %2$.0f°</string>
|
||||
<string name="v2x_cam_rx_distance">%1$.0f m</string>
|
||||
<string name="v2x_cam_rx_distance_unknown">- m</string>
|
||||
<string name="v2x_cam_rx_rssi">%1$d dBm</string>
|
||||
<string name="v2x_cam_rx_none_stations">Keine CAMs empfangen</string>
|
||||
<string name="v2x_cam_rx_no_kinematics">Straßenseiteneinheit - keine Kinematik</string>
|
||||
|
||||
<!-- DENM-Kartenmarker -->
|
||||
<string name="v2x_map_denm_labeled">Gefahr: Ursache %1$d/%2$d (Station %3$d)</string>
|
||||
<string name="v2x_map_denm_plain">Gefahr von Station %1$d</string>
|
||||
|
||||
<!-- ETSI-Stationstypen -->
|
||||
<string name="station_type_pedestrian">Fußgänger</string>
|
||||
<string name="station_type_cyclist">Radfahrer</string>
|
||||
<string name="station_type_car">Pkw</string>
|
||||
<string name="station_type_bus">Bus</string>
|
||||
<string name="station_type_truck">Lkw</string>
|
||||
<string name="station_type_rsu">Straßenseiteneinheit</string>
|
||||
<string name="station_type_other">Typ %1$d</string>
|
||||
<string name="mqtt_cam_pinger_not_connected">ESP32-C5 verbinden, um den CAM-Pinger zu aktivieren</string>
|
||||
<string name="mqtt_cam_pinger_active">Sendet — 1 CAM/s über die serielle Verbindung</string>
|
||||
<string name="mqtt_cam_pinger_active">Sendet - 1 CAM/s über die serielle Verbindung</string>
|
||||
<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_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>
|
||||
|
||||
@@ -220,15 +259,15 @@
|
||||
<string name="mqtt_usecase_detail">Station %1$d · %2$.0f m · Annäherung %3$.1f m/s · TTC %4$.1f s · %5$s</string>
|
||||
|
||||
<!-- Verständliche Use-Case-Beschreibungen -->
|
||||
<string name="usecase_narrative_ima_b">Kreuzendes Fahrzeug — %1$.0f s bis Konflikt</string>
|
||||
<string name="usecase_narrative_ima_s">Stehendes Fahrzeug könnte losfahren — %1$.0f s</string>
|
||||
<string name="usecase_narrative_rtw_b">Auto biegt rechts auf dich zu — %1$.0f s</string>
|
||||
<string name="usecase_narrative_ltw_b">Auto biegt links auf dich zu — %1$.0f s</string>
|
||||
<string name="usecase_narrative_smva_bcw_b">Schnell nahendes Fahrzeug — %1$.0f s</string>
|
||||
<string name="usecase_narrative_ima_b">Kreuzendes Fahrzeug - %1$.0f s bis Konflikt</string>
|
||||
<string name="usecase_narrative_ima_s">Stehendes Fahrzeug könnte losfahren - %1$.0f s</string>
|
||||
<string name="usecase_narrative_rtw_b">Auto biegt rechts auf dich zu - %1$.0f s</string>
|
||||
<string name="usecase_narrative_ltw_b">Auto biegt links auf dich zu - %1$.0f s</string>
|
||||
<string name="usecase_narrative_smva_bcw_b">Schnell nahendes Fahrzeug - %1$.0f s</string>
|
||||
|
||||
<!-- Einstellungen > Use Case Alerts -->
|
||||
<string name="settings_usecase_alerts">Use Case Alerts</string>
|
||||
<string name="settings_usecase_alerts_desc">Ein-/Ausschalten pro Use Case für das CAM-basierte Use-Case-Alarm-Panel im V2X-Monitor. Alle Use Cases sind CAM-only — keiner löst ein DENM aus.</string>
|
||||
<string name="settings_usecase_alerts_desc">Ein-/Ausschalten pro Use Case für das CAM-basierte Use-Case-Alarm-Panel im V2X-Monitor. Alle Use Cases sind CAM-only - keiner löst ein DENM aus.</string>
|
||||
<string name="settings_usecase_alert_levels_title">Alarmstufen-Schwellenwerte (nur lesend)</string>
|
||||
<string name="settings_usecase_level_warning">Warning</string>
|
||||
<string name="settings_usecase_level_awareness">Awareness</string>
|
||||
@@ -242,21 +281,17 @@
|
||||
<string name="dash_transport_bt">BT</string>
|
||||
|
||||
<!-- OBU stationType warning -->
|
||||
<string name="dash_station_type_warning">⚠ OBU-stationType ≠ 2 (Radfahrer) — VRU-Erkennung an ausgerüsteten Kreuzungen ggf. beeinträchtigt</string>
|
||||
<string name="dash_station_type_warning">⚠ OBU-stationType ≠ 2 (Radfahrer) - VRU-Erkennung an ausgerüsteten Kreuzungen ggf. beeinträchtigt</string>
|
||||
|
||||
<string name="settings_platform">Plattform</string>
|
||||
<string name="settings_platform_val">Android / Kotlin / Jetpack Compose</string>
|
||||
<string name="settings_project">Projekt</string>
|
||||
<string name="settings_project_val">MicrOBU — HAW Hamburg</string>
|
||||
<string name="settings_project_val">MicrOBU - HAW Hamburg & consider it GmbH</string>
|
||||
|
||||
<!-- Phase A: Trips (bottom nav) -->
|
||||
<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>
|
||||
|
||||
@@ -272,4 +307,10 @@
|
||||
|
||||
<!-- Phase A: Trip Review screen -->
|
||||
<string name="trip_review_title">Fahrtanalyse</string>
|
||||
|
||||
<!-- Empfangene SPATEM-Liste (ESP32-C5) -->
|
||||
<string name="v2x_spat_rx_count">%1$d signalisierte Kreuzung(en) - Live-SPAT</string>
|
||||
<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>
|
||||
</resources>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<resources>
|
||||
<!-- App -->
|
||||
<string name="app_name">MicrOBU</string>
|
||||
<string name="app_subtitle">V2X Companion — Phase 02</string>
|
||||
<string name="app_subtitle">V2X Companion - Phase 02</string>
|
||||
|
||||
<!-- Language names (always shown in their own language) -->
|
||||
<string name="lang_english">English</string>
|
||||
@@ -27,11 +27,24 @@
|
||||
<string name="dash_connected">Connected</string>
|
||||
<string name="dash_mqtt_connected">MQTT connected</string>
|
||||
<string name="dash_mqtt_connecting">Connecting to broker…</string>
|
||||
<string name="dash_mqtt_error">Broker unreachable — check V2X settings</string>
|
||||
<string name="dash_mqtt_error">Broker unreachable - check V2X settings</string>
|
||||
<string name="dash_recording">Recording</string>
|
||||
<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>
|
||||
@@ -64,12 +77,12 @@
|
||||
<string name="conn_bluetooth_phase3">Bluetooth</string>
|
||||
<string name="conn_bluetooth_phase3_desc">Bluetooth connection is planned for Phase 03 and is not yet implemented.</string>
|
||||
<string name="conn_esp32_title">ESP32-C5 (USB Serial)</string>
|
||||
<string name="conn_esp32_phase3_desc">USB-serial connection to the ESP32-C5 is scaffolded for Phase 03 but not yet functional — it needs the ESP32 firmware protocol translated to Kotlin first.</string>
|
||||
<string name="conn_esp32_state_disconnected">Not connected — plug the ESP32-C5 into the native USB-C port and tap Connect.</string>
|
||||
<string name="conn_esp32_phase3_desc">USB-serial connection to the ESP32-C5 is scaffolded for Phase 03 but not yet functional - it needs the ESP32 firmware protocol translated to Kotlin first.</string>
|
||||
<string name="conn_esp32_state_disconnected">Not connected - plug the ESP32-C5 into the native USB-C port and tap Connect.</string>
|
||||
<string name="conn_esp32_state_device_attached">Device detected, opening…</string>
|
||||
<string name="conn_esp32_state_permission_requested">Waiting for USB permission…</string>
|
||||
<string name="conn_esp32_state_connected">Connected</string>
|
||||
<string name="conn_esp32_state_error">Connection error — check the cable and native USB-C port, then try again.</string>
|
||||
<string name="conn_esp32_state_error">Connection error - check the cable and native USB-C port, then try again.</string>
|
||||
<string name="conn_esp32_connect">Connect to ESP32-C5</string>
|
||||
<string name="conn_scan">Scan for Devices</string>
|
||||
<string name="conn_scanning">Scanning…</string>
|
||||
@@ -111,6 +124,7 @@
|
||||
<string name="log_session_number">Session #%1$d</string>
|
||||
<string name="log_share_cd">Share CSV</string>
|
||||
<string name="log_save_cd">Save to device</string>
|
||||
<string name="trip_export_cd">Export trip CSV</string>
|
||||
<string name="log_delete_cd">Delete session</string>
|
||||
<string name="log_empty_title">No sessions yet</string>
|
||||
<string name="log_empty_desc">Go to Record and start a session</string>
|
||||
@@ -126,7 +140,7 @@
|
||||
<string name="gnss_open_maps_sub">Opens in your preferred maps application</string>
|
||||
<string name="gnss_view_inapp">View in App</string>
|
||||
<string name="gnss_view_inapp_sub">Show current location on an in-app map</string>
|
||||
<string name="gnss_no_fix">No GPS fix yet — move to an open area</string>
|
||||
<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>
|
||||
@@ -160,21 +174,17 @@
|
||||
<string name="settings_developer">Developer</string>
|
||||
<string name="settings_dev_mode">Developer mode</string>
|
||||
<string name="settings_wifi">Wi-Fi OBU connection</string>
|
||||
<string name="settings_wifi_val">Dev mode only — not implemented</string>
|
||||
<string name="settings_wifi_val">Dev mode only - not implemented</string>
|
||||
<string name="settings_about">About</string>
|
||||
<string name="settings_app_version">App version</string>
|
||||
<string name="settings_app_version_val">0.4.0 (Phase 02 — USB-C + DENM TX)</string>
|
||||
<string name="settings_app_version_val">0.5.0 (Phase 03 - ESP32-C5 serial link + phone-built CAM)</string>
|
||||
<string name="settings_connection">Connection</string>
|
||||
<string name="settings_usb_auto_detect">Auto-detect OBU via USB-C</string>
|
||||
<string name="settings_usb_manual_ip">Manual OBU IP</string>
|
||||
<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_esp32_rx_mode">CAM Reception</string>
|
||||
<string name="settings_esp32_rx_mode_send_only">Send Only</string>
|
||||
<string name="settings_esp32_rx_mode_send_and_receive">Send & Receive</string>
|
||||
<string name="settings_esp32_rx_mode_desc">"Send Only" ignores CAM received from nearby stations (still physically received by the ESP32 — its receiver can\'t be turned off without also breaking transmit — just not processed by the app). Useful for isolating TX-path testing.</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_usb_transport">Active transport</string>
|
||||
<string name="settings_transport_usbc">USB-C</string>
|
||||
<string name="settings_transport_wifi">Wi-Fi</string>
|
||||
@@ -187,30 +197,71 @@
|
||||
<string name="mqtt_auto_scroll">Auto-scroll to latest</string>
|
||||
<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>
|
||||
|
||||
<!-- DENM TX — manual antenna/RSU-range test tool only, not use-case-driven -->
|
||||
<!-- DENM TX - manual antenna/RSU-range test tool only, not use-case-driven -->
|
||||
<string name="mqtt_last_tx">Last transmitted payload:</string>
|
||||
<string name="mqtt_denm_tx_title">DENM Transmission (Manual Test)</string>
|
||||
<string name="mqtt_send_denm">Send Test DENM</string>
|
||||
<string name="mqtt_stop_denm">Stop DENM</string>
|
||||
<string name="mqtt_denm_use_case_desc">Aftermarket Stationary Vehicle (causeCode 94)</string>
|
||||
<string name="mqtt_denm_not_connected">Connect to the OBU to enable DENM triggering</string>
|
||||
<string name="mqtt_denm_active">DENM active — OBU broadcasting via ITS-G5</string>
|
||||
<string name="mqtt_denm_hint">Manual antenna/RSU range test — publishes uca-denmctrl (retained) to v2x-uca/input/denmtrg. Not triggered by detected events or use case alerts.</string>
|
||||
<string name="mqtt_denm_active">DENM active - OBU broadcasting via ITS-G5</string>
|
||||
<string name="mqtt_denm_hint">Manual antenna/RSU range test - publishes uca-denmctrl (retained) to v2x-uca/input/denmtrg. Not triggered by detected events or use case alerts.</string>
|
||||
<string name="mqtt_denm_show">Show last TX</string>
|
||||
<string name="mqtt_denm_hide">Hide</string>
|
||||
|
||||
<!-- CAM Pinger — ESP32-C5-only manual bench test, equivalent to the DENM TX card above -->
|
||||
<!-- CAM Pinger - ESP32-C5-only manual bench test, equivalent to the DENM TX card above -->
|
||||
<string name="mqtt_cam_pinger_title">CAM Pinger (Manual Test)</string>
|
||||
<string name="mqtt_cam_pinger_desc">Fixed-location 1 Hz CAM ping — verifies the serial link and ESP32 radio path without needing GNSS movement or a trip recording.</string>
|
||||
<string name="mqtt_cam_pinger_desc">1 Hz CAM ping built from live GNSS and IMU data - verifies the serial link and ESP32 radio path without needing a trip recording.</string>
|
||||
<string name="mqtt_cam_pinger_no_fix">Waiting for GNSS fix - nothing transmitted yet</string>
|
||||
|
||||
<!-- Received-CAM list (ESP32-C5 path) -->
|
||||
<string name="v2x_cam_rx_count">%1$d station(s) in range - latest CAM per station</string>
|
||||
<string name="v2x_cam_rx_none">No CAMs received</string>
|
||||
<string name="v2x_cam_rx_none_hint">Decoded CAMs from nearby stations appear here as they arrive.</string>
|
||||
<string name="v2x_cam_rx_station">Station %1$d · %2$s</string>
|
||||
<string name="v2x_cam_rx_kinematics">%1$.1f km/h · heading %2$.0f°</string>
|
||||
<string name="v2x_cam_rx_distance">%1$.0f m</string>
|
||||
<string name="v2x_cam_rx_distance_unknown">- m</string>
|
||||
<string name="v2x_cam_rx_rssi">%1$d dBm</string>
|
||||
<string name="v2x_cam_rx_none_stations">No CAMs received</string>
|
||||
<string name="v2x_cam_rx_no_kinematics">roadside unit - no kinematics reported</string>
|
||||
|
||||
<!-- Received-DENM list (ESP32-C5 path) -->
|
||||
<string name="v2x_denm_rx_count">%1$d active hazard(s) - latest DENM per event</string>
|
||||
<string name="v2x_denm_rx_hazard">%1$s · station %2$d</string>
|
||||
<string name="v2x_denm_rx_cause_code">cause %1$d/%2$d</string>
|
||||
<string name="v2x_denm_rx_radius">%1$d m radius</string>
|
||||
|
||||
<!-- Received-SPATEM list (ESP32-C5 path) -->
|
||||
<string name="v2x_spat_rx_count">%1$d signalised intersection(s) - live SPAT</string>
|
||||
<string name="v2x_spat_rx_title">Intersection %1$s · station %2$d</string>
|
||||
<string name="v2x_spat_group">SG%1$d</string>
|
||||
<string name="v2x_spat_countdown">%1$.0f s</string>
|
||||
|
||||
<!-- DENM map pins -->
|
||||
<string name="v2x_map_denm_labeled">Hazard: cause %1$d/%2$d (station %3$d)</string>
|
||||
<string name="v2x_map_denm_plain">Hazard from station %1$d</string>
|
||||
|
||||
<!-- ETSI station types -->
|
||||
<string name="station_type_pedestrian">Pedestrian</string>
|
||||
<string name="station_type_cyclist">Cyclist</string>
|
||||
<string name="station_type_car">Car</string>
|
||||
<string name="station_type_bus">Bus</string>
|
||||
<string name="station_type_truck">Truck</string>
|
||||
<string name="station_type_rsu">Roadside unit</string>
|
||||
<string name="station_type_other">Type %1$d</string>
|
||||
<string name="mqtt_cam_pinger_not_connected">Connect the ESP32-C5 to enable the CAM pinger</string>
|
||||
<string name="mqtt_cam_pinger_active">Pinging — 1 CAM/s over the serial link</string>
|
||||
<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_send_failures">Write failures: %1$d consecutive - CAMs are not reaching the ESP32</string>
|
||||
<string name="mqtt_cam_pinger_fw_counters">ESP32: tx fail %1$d · oversize %2$d · crc err %3$d</string>
|
||||
<string name="mqtt_cam_pinger_loopback">Own TX heard back: %1$d frames · %2$d dBm</string>
|
||||
<string name="mqtt_cam_pinger_loopback_no_rssi">Own TX heard back: %1$d frames</string>
|
||||
<string name="mqtt_start_pinger">Start Pinger</string>
|
||||
<string name="mqtt_stop_pinger">Stop Pinger</string>
|
||||
|
||||
@@ -220,16 +271,16 @@
|
||||
<string name="mqtt_usecase_none_active">No active use case alerts</string>
|
||||
<string name="mqtt_usecase_detail">Station %1$d · %2$.0f m · closing %3$.1f m/s · TTC %4$.1f s · %5$s</string>
|
||||
|
||||
<!-- Human-readable use-case narratives (Section 10.4 — not just raw JSON) -->
|
||||
<string name="usecase_narrative_ima_b">Crossing vehicle ahead — %1$.0f s to conflict</string>
|
||||
<string name="usecase_narrative_ima_s">Stopped vehicle may pull out — %1$.0f s</string>
|
||||
<string name="usecase_narrative_rtw_b">Car turning right toward you — %1$.0f s</string>
|
||||
<string name="usecase_narrative_ltw_b">Car turning left toward you — %1$.0f s</string>
|
||||
<string name="usecase_narrative_smva_bcw_b">Fast-closing vehicle nearby — %1$.0f s</string>
|
||||
<!-- Human-readable use-case narratives (Section 10.4 - not just raw JSON) -->
|
||||
<string name="usecase_narrative_ima_b">Crossing vehicle ahead - %1$.0f s to conflict</string>
|
||||
<string name="usecase_narrative_ima_s">Stopped vehicle may pull out - %1$.0f s</string>
|
||||
<string name="usecase_narrative_rtw_b">Car turning right toward you - %1$.0f s</string>
|
||||
<string name="usecase_narrative_ltw_b">Car turning left toward you - %1$.0f s</string>
|
||||
<string name="usecase_narrative_smva_bcw_b">Fast-closing vehicle nearby - %1$.0f s</string>
|
||||
|
||||
<!-- Settings > Use Case Alerts -->
|
||||
<string name="settings_usecase_alerts">Use Case Alerts</string>
|
||||
<string name="settings_usecase_alerts_desc">Per-use-case enable/disable for the CAM-based Use Case Alert panel on the V2X Monitor screen. All use cases are CAM-only — none of them trigger a DENM.</string>
|
||||
<string name="settings_usecase_alerts_desc">Per-use-case enable/disable for the CAM-based Use Case Alert panel on the V2X Monitor screen. All use cases are CAM-only - none of them trigger a DENM.</string>
|
||||
<string name="settings_usecase_alert_levels_title">Alert level thresholds (read-only)</string>
|
||||
<string name="settings_usecase_level_warning">Warning</string>
|
||||
<string name="settings_usecase_level_awareness">Awareness</string>
|
||||
@@ -243,21 +294,17 @@
|
||||
<string name="dash_transport_bt">BT</string>
|
||||
|
||||
<!-- OBU stationType warning -->
|
||||
<string name="dash_station_type_warning">⚠ OBU stationType ≠ 2 (cyclist) — VRU detection may be impaired at equipped intersections</string>
|
||||
<string name="dash_station_type_warning">⚠ OBU stationType ≠ 2 (cyclist) - VRU detection may be impaired at equipped intersections</string>
|
||||
|
||||
<string name="settings_platform">Platform</string>
|
||||
<string name="settings_platform_val">Android / Kotlin / Jetpack Compose</string>
|
||||
<string name="settings_project">Project</string>
|
||||
<string name="settings_project_val">MicrOBU — HAW Hamburg</string>
|
||||
<string name="settings_project_val">MicrOBU - HAW Hamburg & consider it GmbH</string>
|
||||
|
||||
<!-- Phase A: Trip Recording (bottom nav) -->
|
||||
<string name="nav_trips">Trips</string>
|
||||
|
||||
<!-- Phase A: Recording screen event counters -->
|
||||
<string name="rec_events_detected">Detected Events</string>
|
||||
<string name="rec_event_braking">Braking</string>
|
||||
<string name="rec_event_turning">Turning</string>
|
||||
<string name="rec_event_stopping">Stopping</string>
|
||||
<string name="rec_stream_event_detection">Event Detection</string>
|
||||
<string name="rec_open_session_log">CSV Session Log</string>
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.hawhamburg.micr0bu
|
||||
|
||||
import com.hawhamburg.micr0bu.domain.asn1.CamUperCodec
|
||||
import com.hawhamburg.micr0bu.domain.cam.Cam
|
||||
import com.hawhamburg.micr0bu.domain.cam.StationType
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Golden-byte test for the CAM this app transmits.
|
||||
*
|
||||
* ## Why a byte-for-byte fixture
|
||||
* This project has now shipped the same class of bug three times: a field encoded with the wrong
|
||||
* number of bits, which both ends of this codebase then read back with the *same* wrong number.
|
||||
* Phone and ESP32 agree perfectly with each other and with nothing else, so every internal test
|
||||
* passes while the frames on air are malformed. It cost a hardware session each time
|
||||
* (`CurvatureCalculationMode`, the GeoNetworking reserved bytes, and `yawRateConfidence`).
|
||||
*
|
||||
* A round-trip test through this codebase's own decoder cannot catch that - it shares the
|
||||
* mistake. Only an independent implementation can. So the expected bytes below were produced by
|
||||
* `asn1tools` compiled from the real ETSI modules in `asn1/`: it decoded this
|
||||
* encoder's output and re-encoded it, and the result was byte-identical to what is asserted here.
|
||||
* That is stronger than "it parses" - it means this encoder emits exactly what the reference
|
||||
* encoder emits.
|
||||
*
|
||||
* If a field width is ever "tidied up", this test fails. Do not regenerate the expected value from
|
||||
* this encoder's own output - regenerate it through asn1tools, or the test is worthless.
|
||||
*/
|
||||
class CamEncodeGoldenTest {
|
||||
|
||||
/**
|
||||
* asn1tools-verified encoding of [referenceCam]. The trap this pins down: `YawRateConfidence`
|
||||
* has nine enumerands (0..8), so it needs 4 bits and `unavailable` is 8 - not 3 bits and 7.
|
||||
*/
|
||||
private val expectedHex =
|
||||
"0202000f423f3700402ab215af6e286477dffffffc23b7743e0027ffc0d0fe0118329337feebfff6000000"
|
||||
|
||||
private val referenceCam = Cam(
|
||||
stationId = 999_999L,
|
||||
stationType = StationType.CYCLIST,
|
||||
latitude = 53.5544955,
|
||||
longitude = 10.0225470,
|
||||
speedMps = 4.17,
|
||||
headingDeg = 63.9,
|
||||
yawRateDps = null,
|
||||
driveDirection = 0,
|
||||
vehicleLengthM = 1.8,
|
||||
vehicleWidthM = 0.7,
|
||||
accelerationMps2 = 0.4,
|
||||
timestamp = 1_787_100_000_000L,
|
||||
isOwn = true,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `encodes a CAM exactly as the ETSI reference encoder does`() {
|
||||
val encoded = CamUperCodec.encode(referenceCam)
|
||||
.joinToString("") { "%02x".format(it) }
|
||||
assertEquals(expectedHex, encoded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `own decoder agrees with the encoder on every field it reads`() {
|
||||
// Self-consistency is necessary but NOT sufficient - see the class KDoc. This guards the
|
||||
// decoder against drifting away from the encoder, while the golden bytes above are what
|
||||
// guards both of them against drifting away from the standard.
|
||||
val round = CamUperCodec.decode(CamUperCodec.encode(referenceCam), referenceCam.timestamp)
|
||||
requireNotNull(round)
|
||||
assertEquals(referenceCam.stationId, round.stationId)
|
||||
assertEquals(referenceCam.stationType, round.stationType)
|
||||
assertEquals(referenceCam.latitude, round.latitude, 1e-7)
|
||||
assertEquals(referenceCam.longitude, round.longitude, 1e-7)
|
||||
assertEquals(referenceCam.speedMps, round.speedMps, 1e-9)
|
||||
assertEquals(referenceCam.headingDeg, round.headingDeg, 1e-9)
|
||||
assertEquals(referenceCam.vehicleLengthM!!, round.vehicleLengthM!!, 1e-9)
|
||||
assertEquals(referenceCam.vehicleWidthM!!, round.vehicleWidthM!!, 1e-9)
|
||||
assertEquals(referenceCam.accelerationMps2!!, round.accelerationMps2!!, 1e-9)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package com.hawhamburg.micr0bu
|
||||
|
||||
import com.hawhamburg.micr0bu.data.transport.EspLinkStatus
|
||||
import com.hawhamburg.micr0bu.data.transport.GnPositionVector
|
||||
import com.hawhamburg.micr0bu.domain.asn1.ItsTime
|
||||
import com.hawhamburg.micr0bu.domain.cam.Cam
|
||||
import com.hawhamburg.micr0bu.domain.cam.StationType
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pins the phone side of SERIAL_MSG_CAM_TX_PV: the 24-byte prefix the ESP32 turns into the
|
||||
* GeoNetworking Source Position Vector, and the heartbeat capability bit that decides whether the
|
||||
* phone may send that message at all.
|
||||
*
|
||||
* ## Where the expected bytes come from
|
||||
* Not from this code. They were produced with Python's `struct.pack("<IiihH", ...)` from the
|
||||
* layout documented at SERIAL_MSG_CAM_TX_PV in `serial_link.h`, independently of this encoder, so
|
||||
* an agreement here is not an encoder agreeing with itself.
|
||||
*
|
||||
* That same `struct.pack` call is what the bench harness used on 2026-09-10 to drive an
|
||||
* ESP32-C5 over its native USB port with this message. The CiT One OBU, an independent
|
||||
* GeoNetworking stack, decoded every Source Position Vector field of the resulting
|
||||
* transmissions (station type, PAI, latitude, longitude, speed, heading and timestamp) back to
|
||||
* the values sent. These are bytes a third-party receiver has accepted on air, not only bytes
|
||||
* this app agrees with.
|
||||
*/
|
||||
class CamTxPvSerialTest {
|
||||
|
||||
private fun String.hexToBytes(): ByteArray =
|
||||
chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
|
||||
private fun ByteArray.u32le(at: Int): Long =
|
||||
(0 until 4).fold(0L) { acc, i -> acc or ((this[at + i].toLong() and 0xFF) shl (8 * i)) }
|
||||
|
||||
// ---- the wire layout -------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `encodes the prefix byte for byte`() {
|
||||
val pv = GnPositionVector(
|
||||
mac = "024d49435230".hexToBytes(),
|
||||
stationType = 2,
|
||||
pai = true,
|
||||
tstMs = 0x12345678L,
|
||||
latTenMicroDeg = 535_543_026,
|
||||
lonTenMicroDeg = 100_226_476,
|
||||
speedCms = 543,
|
||||
headingDeciDeg = 1234,
|
||||
)
|
||||
// 024d49435230 | 02 | 01 | 78563412 | f2bceb1f | ac55f905 | 1f02 | d204
|
||||
assertEquals("024d49435230020178563412f2bceb1fac55f9051f02d204", pv.toSerialPrefix().toHex())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `encodes negative, extreme and flag-clear values`() {
|
||||
// Southern and western hemisphere, full reverse speed, heading at its maximum, PAI clear:
|
||||
// the sign handling that a northern-hemisphere bench test never exercises.
|
||||
val pv = GnPositionVector(
|
||||
mac = "020000000001".hexToBytes(),
|
||||
stationType = 2,
|
||||
pai = false,
|
||||
tstMs = 0xFFFF_FFFFL,
|
||||
latTenMicroDeg = -335_543_026,
|
||||
lonTenMicroDeg = -100_226_476,
|
||||
speedCms = -16384,
|
||||
headingDeciDeg = 3599,
|
||||
)
|
||||
assertEquals("0200000000010200ffffffff0e0500ec54aa06fa00c00f0e", pv.toSerialPrefix().toHex())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the timestamp is reduced modulo 2^32 on the wire`() {
|
||||
// TimestampIts passed 2^32 ms about 49.7 days after its 2004 epoch, so every real value
|
||||
// today is wider than 32 bits and the reduction is the normal case, not an edge case.
|
||||
val pv = vectorAt(tstMs = 716_121_572_779L)
|
||||
assertEquals(3_157_001_643L, pv.toSerialPrefix().u32le(8))
|
||||
}
|
||||
|
||||
// ---- building it from a CAM ------------------------------------------------------------
|
||||
|
||||
private val cam = Cam(
|
||||
stationId = 1_234_567_890L,
|
||||
stationType = StationType.CYCLIST,
|
||||
latitude = 53.5543026,
|
||||
longitude = 10.0226476,
|
||||
speedMps = 5.43,
|
||||
headingDeg = 123.4,
|
||||
yawRateDps = null,
|
||||
accelerationMps2 = null,
|
||||
timestamp = 1_789_036_772_779L,
|
||||
isOwn = true,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `fromCam takes the same values the CAM payload carries`() {
|
||||
val pv = GnPositionVector.fromCam(cam, accuracyM = 5f, mac = "024d49435230".hexToBytes())
|
||||
assertEquals(2, pv.stationType)
|
||||
assertEquals(535_543_026, pv.latTenMicroDeg)
|
||||
assertEquals(100_226_476, pv.lonTenMicroDeg)
|
||||
assertEquals(543, pv.speedCms)
|
||||
assertEquals(1234, pv.headingDeciDeg)
|
||||
assertTrue(pv.pai)
|
||||
// The GN TST and the CAM's generationDeltaTime must follow one time rule.
|
||||
assertEquals(ItsTime.timestampIts(cam.timestamp), pv.tstMs)
|
||||
assertEquals(716_121_572_779L, pv.tstMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `speed is clamped to the 15-bit field, never wrapped`() {
|
||||
// A wrapped 15-bit speed flips its sign bit and reads as reversing at speed.
|
||||
assertEquals(16383, GnPositionVector.fromCam(cam.copy(speedMps = 400.0), 5f, mac).speedCms)
|
||||
assertEquals(-16384, GnPositionVector.fromCam(cam.copy(speedMps = -400.0), 5f, mac).speedCms)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `heading wraps into 0 to 3599`() {
|
||||
assertEquals(0, GnPositionVector.fromCam(cam.copy(headingDeg = 360.0), 5f, mac).headingDeciDeg)
|
||||
assertEquals(50, GnPositionVector.fromCam(cam.copy(headingDeg = 725.0), 5f, mac).headingDeciDeg)
|
||||
assertEquals(3590, GnPositionVector.fromCam(cam.copy(headingDeg = -1.0), 5f, mac).headingDeciDeg)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-finite speed or heading does not throw`() {
|
||||
val pv = GnPositionVector.fromCam(
|
||||
cam.copy(speedMps = Double.NaN, headingDeg = Double.POSITIVE_INFINITY), 5f, mac,
|
||||
)
|
||||
assertEquals(0, pv.speedCms)
|
||||
assertEquals(0, pv.headingDeciDeg)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PAI follows the horizontal accuracy`() {
|
||||
assertTrue(GnPositionVector.fromCam(cam, GnPositionVector.PAI_MAX_ACCURACY_M, mac).pai)
|
||||
assertFalse(GnPositionVector.fromCam(cam, 25f, mac).pai)
|
||||
// Android reports 0 when it has no accuracy estimate: unknown is not accurate.
|
||||
assertFalse(GnPositionVector.fromCam(cam, 0f, mac).pai)
|
||||
assertFalse(GnPositionVector.fromCam(cam, null, mac).pai)
|
||||
}
|
||||
|
||||
@Test(expected = IllegalArgumentException::class)
|
||||
fun `an address that is not six bytes is rejected`() {
|
||||
GnPositionVector.fromCam(cam, 5f, ByteArray(5))
|
||||
}
|
||||
|
||||
// ---- capability negotiation ------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `firmware that predates the capability byte advertises nothing`() {
|
||||
// Old firmware sends a 7-byte heartbeat. Reading that as "no CAM_TX_PV" is what keeps a
|
||||
// new app on the legacy message, which that firmware still understands.
|
||||
val status = EspLinkStatus.parse("00000000000000".hexToBytes())!!
|
||||
assertEquals(0, status.capabilities)
|
||||
assertFalse(status.supportsCamTxPv)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `firmware that advertises CAM_TX_PV is recognised`() {
|
||||
val status = EspLinkStatus.parse("0000000000000001".hexToBytes())!!
|
||||
assertTrue(status.supportsCamTxPv)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a capability byte without the CAM_TX_PV bit does not enable it`() {
|
||||
assertFalse(EspLinkStatus.parse("0000000000000002".hexToBytes())!!.supportsCamTxPv)
|
||||
}
|
||||
|
||||
private val mac = "024d49435230".hexToBytes()
|
||||
|
||||
private fun vectorAt(tstMs: Long) = GnPositionVector(
|
||||
mac = mac, stationType = 2, pai = false, tstMs = tstMs,
|
||||
latTenMicroDeg = 0, lonTenMicroDeg = 0, speedCms = 0, headingDeciDeg = 0,
|
||||
)
|
||||
|
||||
private fun ByteArray.toHex() = joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package com.hawhamburg.micr0bu
|
||||
|
||||
import com.hawhamburg.micr0bu.data.transport.BtpPort
|
||||
import com.hawhamburg.micr0bu.data.transport.V2xRxFrame
|
||||
import com.hawhamburg.micr0bu.domain.asn1.DenmUperCodec
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Regression tests for the ESP32-C5 over-the-air receive path: the `V2X_RX` serial payload layout
|
||||
* and [DenmUperCodec].
|
||||
*
|
||||
* ## Where the fixtures come from
|
||||
* These are **real frames**, not hand-built ones. They were taken from
|
||||
* `its-g5-receiver-firmware/recordings/capture_20260817_171055.pcap` — a live capture of a CiT One
|
||||
* OBU running the HLN-SV use case — by replaying the capture through a port of the firmware's
|
||||
* `gn_unwrap_its()` and `serial_link_send_v2x_rx()`, so each fixture is byte-for-byte what the
|
||||
* ESP32-C5 hands the phone over USB. The capture's 4-byte 802.11 FCS is trimmed, because the WiFi
|
||||
* driver strips it before the promiscuous callback ever sees the frame.
|
||||
*
|
||||
* ## Why the expected values can be trusted
|
||||
* Every asserted field was cross-checked against `asn1tools` decoding the same bytes with the real
|
||||
* ETSI modules in `asn1/` (`denm_1_3_1.asn` + `cdd_1_3_1_1.asn`) — an
|
||||
* independent implementation, not this codebase's own arithmetic. Across the full capture set that
|
||||
* cross-check agreed on all 1885 decodable DENMs, on every field below including `detectionTime`.
|
||||
*
|
||||
* That matters because this project has twice shipped a UPER bug that was invisible until measured
|
||||
* against real traffic (a one-bit `CurvatureCalculationMode` in CAM, and a 16-vs-17-bit
|
||||
* `ValidityDuration` here that made a real frame read causeCode 47 instead of 94). Hand-built
|
||||
* fixtures would have happily reproduced both. If a field width is ever "cleaned up", these tests
|
||||
* are what should fail.
|
||||
*/
|
||||
class DenmAirReceiveTest {
|
||||
|
||||
// ---- real captured V2X_RX serial payloads ----------------------------------------------
|
||||
|
||||
/**
|
||||
* An active DENM: GeoBroadcast, BTP port 2002, stationaryVehicle (94/0), 1000 m relevance
|
||||
* radius. 406-byte UPER message — most of it the AlacarteContainer this decoder deliberately
|
||||
* stops before reading.
|
||||
*/
|
||||
private val ACTIVE_V2X_RX =
|
||||
"d207c10102bfeb1f7c53f905e8030201fa012bd0e77d0095e8000314c8317dba65320c5f6ff5590a8027143257c1dd1d" +
|
||||
"d0001970898000781432f0030008b9f1be8a2fe943f9e6d390895181e3603696f542543bf04d0052201c02d9df83d7f6" +
|
||||
"e159a88c4f016c402b2d548063f814fa02d66d24044bc0f2d018fb8cb0275e07d480681e550595eff16bfc5cc4e0040f" +
|
||||
"80989ffe970e40d0bbffceffdcb1e20045dffe5802e184200bdefd313f9f4b3b008977e571fb0c58c00d1fc0ed301a7b" +
|
||||
"5840121e04a380d518ce008beffa8c0044c2dc018f7f80a00775bf401dfbf39500fda4ea038de000d800a18970036f02" +
|
||||
"7fc01fee0b006b781ee6007a7e2c026bbfc0eff7e2f6c012bdf86b7f4953a1018af014b4004cd890031f8088200fb67c" +
|
||||
"0018fc00e4ffe4328100efe024e7ff41afe0090f00bc3fff8d1880207802e1ffcd666a00c7c082aff763c1e017bdffe1" +
|
||||
"7fee59230167efd073fb70abe005ef7f31dffaf5a3c05b3bff6effecb16a0063e0036804dd8380077efe833ff62b7c00" +
|
||||
"4d77ee4200a249c010dfc0f84fecfc3f808d3dfbb47fa795fe007cc0e1178000f9010000"
|
||||
|
||||
/**
|
||||
* The termination of that same event, sent once when the hazard ended. Note it carries **no
|
||||
* SituationContainer** — a terminating DENM says "event over", not what the event was — so
|
||||
* `causeCode` is legitimately null here and code must not treat that as a decode failure.
|
||||
*/
|
||||
private val TERMINATION_V2X_RX =
|
||||
"d207c101b6c6eb1f2158f905e8030201fa012bd00f7d0095e8000314c8329f58e5320ca7d792ac857db38a1951098498" +
|
||||
"48000ccd7d40003c0a00000019"
|
||||
|
||||
/** A real CAM from the same capture, for checking the port routing rejects non-DENM cleanly. */
|
||||
private val CAM_V2X_RX =
|
||||
"d107c100000000000000000000000202fa012bd0f8e8605ab214f9ae2864b2415015000032c950487c1fa0010ebfe9ea" +
|
||||
"7b33ff01fffa0028331400fbfab8fe6eb5a222ebe078d80da5bd50950efc134014880980b677e0f5fdb856542313c05b" +
|
||||
"100acb552018fe053f80b59b490112f03cb4063ee33009d781f5201a079541657bfc5aff1731380103e02627ffa5c390" +
|
||||
"342efff3bff72c7b001177ff9600b8610802f7bf4c4fe7d2ce20225df95c7ec316300347f03b4c069ed6100486900000" +
|
||||
"0801"
|
||||
|
||||
/** Later than every fixture's detectionTime, so the decoder's sanity window accepts them. */
|
||||
private val receivedAt = 1_787_100_000_000L
|
||||
|
||||
private fun String.hexToBytes(): ByteArray =
|
||||
chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
|
||||
// ---- the V2X_RX prefix contract --------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `active DENM frame parses its metadata prefix`() {
|
||||
val frame = V2xRxFrame.parse(ACTIVE_V2X_RX.hexToBytes())
|
||||
assertNotNull(frame)
|
||||
frame!!
|
||||
|
||||
assertEquals(BtpPort.DENM, frame.btpPort)
|
||||
assertEquals(-63, frame.rssiDbm) // int8: must survive as negative, not 193
|
||||
|
||||
// GeoBroadcast destination area, converted from GeoNetworking's big-endian 1/10 microdegree
|
||||
// to the little-endian prefix and back out again.
|
||||
val area = frame.geoArea
|
||||
assertNotNull(area)
|
||||
assertEquals(53.5543554, area!!.latitude, 1e-7)
|
||||
assertEquals(10.0225916, area.longitude, 1e-7)
|
||||
assertEquals(1000, area.radiusMeters)
|
||||
|
||||
assertEquals(406, frame.uper.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `frame no larger than the firmware's serial payload cap`() {
|
||||
// SERIAL_LINK_MAX_PAYLOAD is 512 on both sides; the firmware counts an oversize drop rather
|
||||
// than truncating. A real GeoBroadcast DENM is the largest thing this path carries today.
|
||||
assertTrue(
|
||||
"real DENM V2X_RX payload must fit SERIAL_LINK_MAX_PAYLOAD",
|
||||
ACTIVE_V2X_RX.hexToBytes().size <= 512,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse rejects a payload with no room for a message`() {
|
||||
assertNull(V2xRxFrame.parse(ByteArray(V2xRxFrame.PREFIX_SIZE)))
|
||||
assertNull(V2xRxFrame.parse(ByteArray(3)))
|
||||
}
|
||||
|
||||
// ---- DENM decode ----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `decodes a real stationaryVehicle DENM`() {
|
||||
val frame = V2xRxFrame.parse(ACTIVE_V2X_RX.hexToBytes())!!
|
||||
val denm = DenmUperCodec.decode(
|
||||
bytes = frame.uper,
|
||||
receivedAtEpochMs = receivedAt,
|
||||
rssiDbm = frame.rssiDbm,
|
||||
relevanceRadiusM = frame.geoArea?.radiusMeters,
|
||||
)
|
||||
assertNotNull("real captured DENM must decode", denm)
|
||||
denm!!
|
||||
|
||||
// actionID - the ETSI event identity, cross-checked against asn1tools.
|
||||
assertEquals(4_194_380_752L, denm.stationId)
|
||||
assertEquals(6, denm.sequenceNumber)
|
||||
|
||||
assertEquals(53.5543554, denm.latitude, 1e-7)
|
||||
assertEquals(10.0225916, denm.longitude, 1e-7)
|
||||
|
||||
assertEquals(94, denm.causeCode) // stationaryVehicle
|
||||
assertEquals(0, denm.subCauseCode)
|
||||
assertEquals(5, denm.stationType) // passengerCar
|
||||
|
||||
assertFalse(denm.isTermination)
|
||||
|
||||
// detectionTime is a 42-bit TimestampIts counted from the 2004 ITS epoch. Getting either
|
||||
// the width or the epoch wrong lands the hazard decades away, so the absolute value is
|
||||
// asserted rather than a range.
|
||||
assertEquals(1_786_979_460_563L, denm.detectionTimeMs)
|
||||
|
||||
// Carried through from the GeoNetworking header and the serial prefix, not the payload.
|
||||
assertEquals(1000, denm.relevanceRadiusM)
|
||||
assertEquals(-63, denm.rssiDbm)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decodes a termination DENM and keeps the same event identity`() {
|
||||
val active = V2xRxFrame.parse(ACTIVE_V2X_RX.hexToBytes())!!
|
||||
val term = V2xRxFrame.parse(TERMINATION_V2X_RX.hexToBytes())!!
|
||||
|
||||
val activeDenm = DenmUperCodec.decode(active.uper, receivedAt)!!
|
||||
val termDenm = DenmUperCodec.decode(term.uper, receivedAt)!!
|
||||
|
||||
assertTrue(termDenm.isTermination)
|
||||
assertNull("a terminating DENM carries no SituationContainer", termDenm.causeCode)
|
||||
assertEquals(4_194_380_752L, termDenm.stationId)
|
||||
assertEquals(6, termDenm.sequenceNumber)
|
||||
assertEquals(1_786_980_053_703L, termDenm.detectionTimeMs)
|
||||
|
||||
// The whole point of keying dedup on actionID: the termination must land on the same key as
|
||||
// the event it ends, so filtering terminations actually removes that hazard from the map
|
||||
// instead of leaving the active pin behind next to a hidden one.
|
||||
assertEquals(activeDenm.dedupKey, termDenm.dedupKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `does not decode a CAM as a DENM`() {
|
||||
val cam = V2xRxFrame.parse(CAM_V2X_RX.hexToBytes())!!
|
||||
assertEquals(BtpPort.CAM, cam.btpPort)
|
||||
assertNull("CAM must not decode as DENM - messageID guards this", DenmUperCodec.decode(cam.uper, receivedAt))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns null for a truncated DENM rather than a misplaced hazard`() {
|
||||
val frame = V2xRxFrame.parse(ACTIVE_V2X_RX.hexToBytes())!!
|
||||
// Cut inside the ManagementContainer: the BitReader runs out mid-field.
|
||||
assertNull(DenmUperCodec.decode(frame.uper.copyOfRange(0, 12), receivedAt))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `future detection time beyond the sanity window is dropped, not surfaced`() {
|
||||
val frame = V2xRxFrame.parse(ACTIVE_V2X_RX.hexToBytes())!!
|
||||
// A phone whose clock is more than a day behind the sender: the event still decodes, but
|
||||
// the implausible timestamp is reported as unknown instead of being shown.
|
||||
val denm = DenmUperCodec.decode(frame.uper, receivedAtEpochMs = 1_700_000_000_000L)
|
||||
assertNotNull(denm)
|
||||
assertEquals(94, denm!!.causeCode)
|
||||
assertNull(denm.detectionTimeMs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package com.hawhamburg.micr0bu
|
||||
|
||||
import com.hawhamburg.micr0bu.domain.denm.DenmParser
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Tests [DenmParser] against the CiT One Use Case API's documented DENM schema.
|
||||
*
|
||||
* The payload below is the worked example from `CI-CiT-MQTT_API_Documentation-v6-20250221.pdf`,
|
||||
* listing 2.6 (section 2.2.4, "Processed DENM"), reproduced field-for-field. That document is the
|
||||
* contract for this topic, so it is the right thing to pin against - the previous parser was
|
||||
* written before the schema was checked and silently dropped every real DENM for two independent
|
||||
* reasons: the station id is `originatingStationId` (not `stationId`), and `eventPosition` is
|
||||
* itself a GeoJSON Point rather than an object containing one.
|
||||
*/
|
||||
class DenmParserMqttTest {
|
||||
|
||||
/** Listing 2.6 from the API documentation, with the doc's inline comments removed. */
|
||||
private val documentedDenm = """
|
||||
{
|
||||
"type": "v2x-denm",
|
||||
"originatingStationId": 1345267,
|
||||
"sequenceNumber": 1,
|
||||
"detectionTime": "2021-05-11T12:01:02+00:00",
|
||||
"referenceTime": "2021-05-11T12:01:02+00:00",
|
||||
"eventPosition": { "type": "Point", "coordinates": [9.9800230, 53.5560783, 15] },
|
||||
"relevanceTrafficDirection": "upstreamTraffic",
|
||||
"stationType": "roadSideUnit",
|
||||
"causeCode": "trafficCondition",
|
||||
"subCauseCode": 0
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
@Test
|
||||
fun `parses the documented DENM payload`() {
|
||||
val denm = DenmParser.parse(documentedDenm, timestamp = 1_787_000_000_000L)
|
||||
assertNotNull("the API's own documented payload must parse", denm)
|
||||
denm!!
|
||||
|
||||
assertEquals(1_345_267L, denm.stationId)
|
||||
assertEquals(1, denm.sequenceNumber)
|
||||
|
||||
// GeoJSON is [longitude, latitude, altitude] - getting this order wrong puts a Hamburg
|
||||
// hazard in Somalia, and both values are plausible-looking numbers either way.
|
||||
assertEquals(53.5560783, denm.latitude, 1e-7)
|
||||
assertEquals(9.9800230, denm.longitude, 1e-7)
|
||||
|
||||
assertEquals(1, denm.causeCode) // trafficCondition
|
||||
assertEquals(0, denm.subCauseCode)
|
||||
assertEquals(15, denm.stationType) // roadSideUnit is 15, not 12 - the enum has a gap
|
||||
assertFalse(denm.isTermination)
|
||||
|
||||
// 2021-05-11T12:01:02Z
|
||||
assertEquals(1_620_734_462_000L, denm.detectionTimeMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `termination is signalled by the key being present`() {
|
||||
val terminated = documentedDenm.replace(
|
||||
"\"sequenceNumber\": 1,",
|
||||
"\"sequenceNumber\": 1,\n \"termination\": true,",
|
||||
)
|
||||
val denm = DenmParser.parse(terminated)
|
||||
assertNotNull(denm)
|
||||
assertTrue(denm!!.isTermination)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a termination shares the dedup key of the event it ends`() {
|
||||
val active = DenmParser.parse(documentedDenm)!!
|
||||
val terminated = DenmParser.parse(
|
||||
documentedDenm.replace(
|
||||
"\"sequenceNumber\": 1,",
|
||||
"\"sequenceNumber\": 1,\n \"termination\": true,",
|
||||
)
|
||||
)!!
|
||||
// Without this, a cancelled hazard would be filtered out while the active pin it was
|
||||
// meant to cancel stayed on the map forever.
|
||||
assertEquals(active.dedupKey, terminated.dedupKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cause code names follow the ETSI spelling the API uses`() {
|
||||
// ETSI's CauseCodeType really does spell it with three n's, and the API follows.
|
||||
val aqua = documentedDenm.replace("\"trafficCondition\"", "\"aquaplannning\"")
|
||||
assertEquals(7, DenmParser.parse(aqua)!!.causeCode)
|
||||
|
||||
val stationary = documentedDenm.replace("\"trafficCondition\"", "\"stationaryVehicle\"")
|
||||
assertEquals(94, DenmParser.parse(stationary)!!.causeCode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a payload with no usable position is rejected rather than placed at null island`() {
|
||||
val noPosition = documentedDenm.replace(
|
||||
"\"eventPosition\": { \"type\": \"Point\", \"coordinates\": [9.9800230, 53.5560783, 15] },",
|
||||
"",
|
||||
)
|
||||
assertNull(DenmParser.parse(noPosition))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `integer causeCode and stationId spellings still parse`() {
|
||||
// The air path and any future firmware-side JSON produce integers; those must keep working.
|
||||
val numeric = """
|
||||
{"stationId": 42, "causeCode": 94, "subCauseCode": 1,
|
||||
"eventPosition": {"type": "Point", "coordinates": [10.0, 53.5]}}
|
||||
""".trimIndent()
|
||||
val denm = DenmParser.parse(numeric)
|
||||
assertNotNull(denm)
|
||||
assertEquals(42L, denm!!.stationId)
|
||||
assertEquals(94, denm.causeCode)
|
||||
assertEquals(1, denm.subCauseCode)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
@@ -116,12 +116,17 @@ class EventDetectorTest {
|
||||
// ─── Hard brake ───────────────────────────────────────────────────────────
|
||||
|
||||
@Test fun `hard brake triggers BRAKING event`() = runCollecting { events ->
|
||||
// Phase 1: fill window at 10 m/s with constant accel (no std dev → no braking)
|
||||
repeat(config.windowSize) { i ->
|
||||
detector.processSample(1.0, 0.05, 10.0, 0.0, 53.5, 10.0, i * 20L)
|
||||
}
|
||||
// Phase 2: GPS drops to 4 m/s (drop = 6 m/s > 0.5 threshold).
|
||||
// Alternate hi/lo accel to exceed the std-dev threshold.
|
||||
// Phase 1: cruising at 10 m/s with the accelerometer variability a moving bike actually
|
||||
// has. This matters: [EventDetector] requires the speed drop and the accel std dev to be
|
||||
// true on the SAME frame, and the std dev is a rolling window. Filling phase 1 with a
|
||||
// constant accel drives that window to zero, so on the one frame where the speed drop
|
||||
// exists the std dev is still ~0.75 and braking can never start - by the time the window
|
||||
// has recovered, prevSpeedMps has caught up and the drop is gone.
|
||||
//
|
||||
// Constant accel right up to the instant of a brake is also not physical. The IMU is
|
||||
// sampled continuously while GPS speed lags, so the shaking precedes the reported drop.
|
||||
alternatingAccelFrames(n = config.windowSize, speedMps = 10.0, timeOffset = 0)
|
||||
// Phase 2: GPS reports 4 m/s (drop = 6 m/s > 0.5 threshold).
|
||||
alternatingAccelFrames(
|
||||
n = config.brakingSustainedFrames + 5,
|
||||
speedMps = 4.0,
|
||||
@@ -132,15 +137,14 @@ class EventDetectorTest {
|
||||
}
|
||||
|
||||
@Test fun `hard brake with large speed drop has HIGH confidence`() = runCollecting { events ->
|
||||
repeat(config.windowSize) { i ->
|
||||
detector.processSample(1.0, 0.05, 10.0, 0.0, 53.5, 10.0, i * 20L)
|
||||
}
|
||||
// Drop of 8 m/s > brakingHighConfidenceRate (1.5)
|
||||
// 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 > 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 }
|
||||
@@ -153,15 +157,18 @@ class EventDetectorTest {
|
||||
}
|
||||
|
||||
@Test fun `moderate speed drop has MEDIUM confidence`() = runCollecting { events ->
|
||||
repeat(config.windowSize) { i ->
|
||||
detector.processSample(1.0, 0.05, 3.0, 0.0, 53.5, 10.0, i * 20L)
|
||||
}
|
||||
// Drop of 0.8 m/s — above speed-drop threshold (0.5) but below high-conf rate (1.5)
|
||||
// Variability established before the drop - see `hard brake triggers BRAKING event`.
|
||||
alternatingAccelFrames(n = config.windowSize, speedMps = 3.0, timeOffset = 0)
|
||||
// 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 }
|
||||
@@ -176,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,
|
||||
@@ -190,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())
|
||||
@@ -202,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,
|
||||
@@ -250,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,
|
||||
@@ -262,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())
|
||||
}
|
||||
@@ -294,8 +301,14 @@ class EventDetectorTest {
|
||||
repeat(5) {
|
||||
detector.processSample(0.5, 0.1, 5.0, 2.0, 53.5, 10.0, t++ * 20L)
|
||||
}
|
||||
// Second stop episode
|
||||
repeat(stopFrames) {
|
||||
// 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. 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,48 @@
|
||||
package com.hawhamburg.micr0bu
|
||||
|
||||
import com.hawhamburg.micr0bu.domain.asn1.CamUperCodec
|
||||
import com.hawhamburg.micr0bu.domain.cam.StationType
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* A roadside unit's CAM must decode, not be dropped.
|
||||
*
|
||||
* ETSI's `HighFrequencyContainer` is a CHOICE, and an RSU picks `rsuContainerHighFrequency`
|
||||
* instead of `basicVehicleContainerHighFrequency`. That container holds no kinematics at all -
|
||||
* only an optional protected-zone list - so an earlier version of the decoder bailed on it and
|
||||
* every RSU CAM was silently discarded. The bench RSU sends CAM and SPATEM from the same station
|
||||
* id, so dropping its CAM meant the one station a rider most wants to see never appeared.
|
||||
*
|
||||
* The fixture is a real 26-byte RSU CAM taken live from the OBU's `v2x/rx/cam` topic; the
|
||||
* expected values are asn1tools' decoding of those same bytes using the ETSI modules in the
|
||||
* `asn1/`.
|
||||
*/
|
||||
class RsuCamDecodeTest {
|
||||
|
||||
private val rsuCam = "020239b9de898b8b00fab215af6e286477c0c20c200033fa4e80"
|
||||
|
||||
private fun String.hexToBytes(): ByteArray =
|
||||
chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
|
||||
@Test
|
||||
fun `decodes a roadside unit CAM for position and station type`() {
|
||||
val cam = CamUperCodec.decode(rsuCam.hexToBytes(), receivedAtEpochMs = 1_787_100_000_000L)
|
||||
assertNotNull("an RSU CAM must not be dropped", cam)
|
||||
cam!!
|
||||
|
||||
assertEquals(968_482_441L, cam.stationId)
|
||||
assertEquals(StationType.ROAD_SIDE_UNIT, cam.stationType)
|
||||
assertEquals(15, cam.stationType) // the enumeration jumps 11 -> 15; 12 would be wrong
|
||||
|
||||
assertEquals(53.5544955, cam.latitude, 1e-7)
|
||||
assertEquals(10.0225470, cam.longitude, 1e-7)
|
||||
|
||||
// An RSU has no kinematics to report. Zero is a placeholder, which is only safe because
|
||||
// CamUseCaseRepository keeps RSU CAMs out of UseCaseDetectionEngine - otherwise this
|
||||
// would read as a permanently stopped vehicle and raise a standing false alert.
|
||||
assertEquals(0.0, cam.speedMps, 0.0)
|
||||
assertEquals(0.0, cam.headingDeg, 0.0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package com.hawhamburg.micr0bu
|
||||
|
||||
import com.hawhamburg.micr0bu.domain.asn1.SpatemUperCodec
|
||||
import com.hawhamburg.micr0bu.domain.spat.SignalPhase
|
||||
import com.hawhamburg.micr0bu.domain.spat.SignalPhaseEvent
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Regression tests for [SpatemUperCodec], using real SPATEMs as fixtures.
|
||||
*
|
||||
* ## Where the fixtures come from
|
||||
* Both are genuine over-the-air messages, taken from the protobuf-wrapped UPER the OBU publishes
|
||||
* (field 3 of the blob) - one from the live bench RSU trigger, one from the 2026-03-18 drive
|
||||
* recorded in `its-g5-receiver-firmware/recordings/its_messages_*.db`.
|
||||
*
|
||||
* ## Why the expected values can be trusted
|
||||
* The bit layout was validated by replaying **79,042 real SPATEMs** - the whole drive across 7+
|
||||
* RSUs plus the live trigger - through a port of this decoder and comparing every field against
|
||||
* `asn1tools` decoding the same bytes with the ETSI modules in `asn1/`.
|
||||
* All 79,042 matched exactly, and none hit an unsupported branch. The values asserted below are
|
||||
* that independent decoder's output, not this codebase's own arithmetic.
|
||||
*
|
||||
* The two fixtures are deliberately different shapes: the live one is minimal (no region, no
|
||||
* maneuverAssistList), the recorded one exercises `region`, a 7-entry event list, and the
|
||||
* variable-length `maneuverAssistList` that has to be walked to find the next field.
|
||||
*/
|
||||
class SpatemUperCodecTest {
|
||||
|
||||
/**
|
||||
* Live bench RSU, 58 bytes: one intersection (id 23, no region), two signal groups.
|
||||
* This is the smallest shape seen in practice.
|
||||
*/
|
||||
private val liveSpatem =
|
||||
"020439b9de89451672018000b81040051672adb401001143707ff07ff07ff7a23840484048404bc00851dc1fd41fd41f" +
|
||||
"d5e86e112a112a112af0"
|
||||
|
||||
/**
|
||||
* Real RSU from the drive, 251 bytes: region 3 / intersection 121, four signal groups, up to
|
||||
* seven predicted phases each, and a maneuverAssistList.
|
||||
*/
|
||||
private val recordedSpatem =
|
||||
"0204001233b441ae520188001803c8402001ae527d6b032016467032f0424039d2a43819f021981d6150dc0ed812ac10" +
|
||||
"91088e077609600852846703f705dc04fb3a43820302f582851d0dc120c19a0161d020008004404c8ae065e0848073a5" +
|
||||
"477033e043303ac2a1b81db025582122111c0eec12c010a508ae07ee0bb809f67477040605eb050a3a1b8241833402c3" +
|
||||
"a04003001480d919c0cbc10900e74a90e067c08660758543703b604ab0424422381dd82580214a119c0fdc177013ece9" +
|
||||
"0e080c0bd60a1474370483066805874080080031021a1b81a6821201d89919c0dfc11580f8ce86e08200b0409f694670" +
|
||||
"44205b9052d48801800680"
|
||||
|
||||
private val receivedAt = 1_787_100_000_000L
|
||||
|
||||
private fun String.hexToBytes(): ByteArray =
|
||||
chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
|
||||
@Test
|
||||
fun `decodes the live bench SPATEM`() {
|
||||
val spat = SpatemUperCodec.decode(liveSpatem.hexToBytes(), receivedAt, rssiDbm = -55)
|
||||
assertNotNull("real captured SPATEM must decode", spat)
|
||||
spat!!
|
||||
|
||||
assertEquals(968_482_441L, spat.stationId)
|
||||
assertEquals(333_426, spat.minuteOfYear)
|
||||
assertEquals(-55, spat.rssiDbm)
|
||||
assertEquals(1, spat.intersections.size)
|
||||
|
||||
val i = spat.intersections[0]
|
||||
assertNull("this RSU sends no RoadRegulatorID", i.region)
|
||||
assertEquals(23, i.id)
|
||||
assertEquals(1, i.revision)
|
||||
assertEquals(333_426, i.moy)
|
||||
assertEquals(44_468, i.timeStampMs)
|
||||
assertEquals("-1/23", i.key)
|
||||
|
||||
assertEquals(2, i.movements.size)
|
||||
val sg1 = i.movements[0]
|
||||
assertEquals(1, sg1.signalGroup)
|
||||
assertEquals(2, sg1.events.size)
|
||||
assertEquals(SignalPhase.STOP_AND_REMAIN, sg1.current!!.phase)
|
||||
assertEquals(4094, sg1.current!!.minEndTimeDs)
|
||||
|
||||
val sg2 = i.movements[1]
|
||||
assertEquals(2, sg2.signalGroup)
|
||||
assertEquals(SignalPhase.PERMISSIVE_CLEARANCE, sg2.current!!.phase)
|
||||
assertEquals(4074, sg2.current!!.minEndTimeDs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decodes a real RSU SPATEM with region and maneuverAssistList`() {
|
||||
val spat = SpatemUperCodec.decode(recordedSpatem.hexToBytes(), receivedAt)
|
||||
assertNotNull(spat)
|
||||
spat!!
|
||||
|
||||
assertEquals(1_192_884L, spat.stationId)
|
||||
assertEquals(1, spat.intersections.size)
|
||||
|
||||
val i = spat.intersections[0]
|
||||
// IntersectionID is only unique within a RoadRegulatorID, so the region must survive
|
||||
// decoding - the drive contains the same intersection id under different regions.
|
||||
assertEquals(3, i.region)
|
||||
assertEquals(121, i.id)
|
||||
assertEquals(4, i.revision)
|
||||
assertEquals(110_162, i.moy)
|
||||
assertEquals(32_107, i.timeStampMs)
|
||||
assertEquals("3/121", i.key)
|
||||
|
||||
assertEquals(4, i.movements.size)
|
||||
assertEquals(listOf(1, 2, 3, 4), i.movements.map { it.signalGroup })
|
||||
|
||||
// A 7-entry prediction list: the current phase plus the upcoming sequence.
|
||||
val sg1 = i.movements[0]
|
||||
assertEquals(7, sg1.events.size)
|
||||
assertEquals(SignalPhase.PROTECTED_MOVEMENT_ALLOWED, sg1.current!!.phase)
|
||||
assertEquals(1630, sg1.current!!.minEndTimeDs)
|
||||
assertEquals(2120, sg1.current!!.maxEndTimeDs)
|
||||
assertEquals(1850, sg1.current!!.likelyTimeDs)
|
||||
|
||||
assertEquals(SignalPhase.PERMISSIVE_MOVEMENT_ALLOWED, i.movements[1].current!!.phase)
|
||||
assertEquals(SignalPhase.STOP_AND_REMAIN, i.movements[3].current!!.phase)
|
||||
assertEquals(4, i.movements[3].events.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `phase helpers classify the states a driver cares about`() {
|
||||
assertTrue(SignalPhase.PROTECTED_MOVEMENT_ALLOWED.isGo)
|
||||
assertTrue(SignalPhase.PERMISSIVE_MOVEMENT_ALLOWED.isGo)
|
||||
assertTrue(SignalPhase.STOP_AND_REMAIN.isStop)
|
||||
assertTrue(SignalPhase.PRE_MOVEMENT.isTransition)
|
||||
assertTrue(SignalPhase.PROTECTED_CLEARANCE.isTransition)
|
||||
// dark and unavailable are none of the three - they must not read as "go".
|
||||
assertTrue(!SignalPhase.DARK.isGo && !SignalPhase.DARK.isStop)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `countdown handles TimeMark wrapping at the hour boundary`() {
|
||||
val topOfHour = receivedAt - (receivedAt % 3_600_000L)
|
||||
|
||||
// 400.0 s into the hour, light changes at 409.4 s -> 9.4 s away.
|
||||
val soon = SignalPhaseEvent(SignalPhase.STOP_AND_REMAIN, 4094, null, null)
|
||||
assertEquals(9.4, soon.secondsUntil(topOfHour + 400_000L)!!, 1e-6)
|
||||
|
||||
// 3590 s into the hour, mark is 10.0 s - that is the NEXT hour, i.e. 20 s away, not
|
||||
// 3580 s in the past. Without the wrap correction a countdown goes hugely negative once
|
||||
// per hour, which is exactly when a driver is watching it.
|
||||
val wrapped = SignalPhaseEvent(SignalPhase.STOP_AND_REMAIN, 100, null, null)
|
||||
assertEquals(20.0, wrapped.secondsUntil(topOfHour + 3_590_000L)!!, 1e-6)
|
||||
|
||||
// 36001 is the spec's "unknown" marker and must not be shown as a real countdown.
|
||||
val unknown = SignalPhaseEvent(SignalPhase.DARK, 36001, null, null)
|
||||
assertNull(unknown.secondsUntil(topOfHour))
|
||||
assertNull(SignalPhaseEvent(SignalPhase.DARK, null, null, null).secondsUntil(topOfHour))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `does not decode another message type as SPATEM`() {
|
||||
// protocolVersion 2, messageId 2 (CAM) - the header guard must reject it outright.
|
||||
val cam = byteArrayOf(2, 2, 0, 0, 0, 1, 0, 0, 0, 0)
|
||||
assertNull(SpatemUperCodec.decode(cam, receivedAt))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns null for a truncated SPATEM rather than a wrong light`() {
|
||||
val full = liveSpatem.hexToBytes()
|
||||
assertNull(SpatemUperCodec.decode(full.copyOfRange(0, 12), receivedAt))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License (MIT)
|
||||
|
||||
Copyright (c) 2026 consider it GmbH
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
@@ -0,0 +1,70 @@
|
||||
# ETSI ASN.1 modules
|
||||
|
||||
The ASN.1 schemas this project's UPER codecs are validated against. **Schemas only** — no parser
|
||||
code. Nothing in the app or the firmware reads these files at build or run time; they exist so the
|
||||
hand-written codecs in `app/src/main/java/.../domain/asn1/` can be checked against an independent
|
||||
implementation.
|
||||
|
||||
## Why this is here rather than just documented
|
||||
|
||||
Three of this project's tests assert exact bytes:
|
||||
|
||||
- `CamEncodeGoldenTest` — the CAM this app transmits, byte for byte
|
||||
- `DenmAirReceiveTest`, `SpatemUperCodecTest` — real captured frames with field values
|
||||
|
||||
Those expected values were produced by decoding with `asn1tools` compiled from these modules. Without
|
||||
them the fixtures cannot be regenerated or re-verified, and a golden-byte test you cannot regenerate
|
||||
is a test nobody can safely touch.
|
||||
|
||||
This matters because the project has shipped the same class of bug three times: a field encoded with
|
||||
the wrong number of bits, which this codebase then read back with the *same* wrong number. Phone and
|
||||
ESP32 agree perfectly with each other and with nothing else, so every internal round-trip test passes
|
||||
while the frames on air are malformed (`CurvatureCalculationMode`, the GeoNetworking reserved bytes,
|
||||
`yawRateConfidence`). Only a second, independent implementation catches that — which is what these
|
||||
modules provide.
|
||||
|
||||
## Provenance
|
||||
|
||||
Taken from consider it GmbH's C-ITS-Parser:
|
||||
|
||||
- <https://github.com/consider-it/C-ITS-Parser>
|
||||
- commit `f457426efc2486fac49a02fc9a1c8c7762d160e9`
|
||||
- MIT licensed — see `LICENSE`, retained here as the licence requires
|
||||
|
||||
Only the seven `.asn` files below are copied, out of a 4.2 MB checkout. The upstream Rust parser is
|
||||
not used by this project in any way. The schemas themselves are ETSI's standard definitions; the
|
||||
upstream repo's contribution is assembling them into a compilable set.
|
||||
|
||||
| file | used for |
|
||||
|---|---|
|
||||
| `cam_1_4_1.asn` + `cdd_1_3_1_1.asn` | CAM encode/decode |
|
||||
| `denm_1_3_1.asn` + `cdd_1_3_1_1.asn` | DENM decode |
|
||||
| `spatem_2_2_1.asn`, `mapem_2_2_1.asn`, `dsrc_2_2_1.asn`, `cdd_2_2_1.asn` | SPATEM/MAPEM decode |
|
||||
|
||||
Note CAM/DENM use the release-1 common dictionary (`cdd_1_3_1_1`) while SPATEM/MAPEM use release 2
|
||||
(`cdd_2_2_1`). Both are needed; they are not interchangeable.
|
||||
|
||||
These seven were verified sufficient on their own: copied into an empty directory, all three specs
|
||||
compile and reproduce the committed golden bytes.
|
||||
|
||||
## Regenerating a fixture
|
||||
|
||||
Requires Python with `asn1tools` (verified with 0.167.0):
|
||||
|
||||
```python
|
||||
import asn1tools
|
||||
spec = asn1tools.compile_files(["asn1/cam_1_4_1.asn", "asn1/cdd_1_3_1_1.asn"], "uper")
|
||||
spec.decode("CAM", raw_uper_bytes)
|
||||
```
|
||||
|
||||
For SPATEM, compile `spatem_2_2_1.asn`, `mapem_2_2_1.asn`, `dsrc_2_2_1.asn`, `cdd_2_2_1.asn`
|
||||
together and decode `"SPATEM"`.
|
||||
|
||||
**Never regenerate a golden fixture from this project's own encoder output** — that is precisely the
|
||||
mistake these files exist to catch. Regenerate through `asn1tools`, or the test is worthless.
|
||||
|
||||
## Updating
|
||||
|
||||
Re-pin deliberately, not casually. If a newer ETSI release is adopted, copy the new modules, then
|
||||
re-run the golden-byte tests and confirm any change in expected bytes is explained by the spec
|
||||
change rather than by a decoder regression.
|
||||
@@ -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,511 @@
|
||||
ITS-Container {
|
||||
itu-t (0) identified-organization (4) etsi (0) itsDomain (5) wg1 (1) ts (102894) cdd (2) version (2)
|
||||
}
|
||||
|
||||
DEFINITIONS AUTOMATIC TAGS ::=
|
||||
|
||||
BEGIN
|
||||
|
||||
ItsPduHeader ::= SEQUENCE {
|
||||
protocolVersion INTEGER (0..255),
|
||||
messageID INTEGER{ denm(1), cam(2), poi(3), spatem(4), mapem(5), ivim(6), ev-rsr(7), tistpgtransaction(8), srem(9), ssem(10), evcsn(11), saem(12), rtcmem(13) } (0..255), -- Mantis #7209, #7005
|
||||
stationID StationID
|
||||
}
|
||||
|
||||
StationID ::= INTEGER(0..4294967295)
|
||||
|
||||
ReferencePosition ::= SEQUENCE {
|
||||
latitude Latitude,
|
||||
longitude Longitude,
|
||||
positionConfidenceEllipse PosConfidenceEllipse ,
|
||||
altitude Altitude
|
||||
}
|
||||
|
||||
DeltaReferencePosition ::= SEQUENCE {
|
||||
deltaLatitude DeltaLatitude,
|
||||
deltaLongitude DeltaLongitude,
|
||||
deltaAltitude DeltaAltitude
|
||||
}
|
||||
|
||||
Longitude ::= INTEGER {oneMicrodegreeEast (10), oneMicrodegreeWest (-10), unavailable(1800000001)} (-1800000000..1800000001)
|
||||
|
||||
Latitude ::= INTEGER {oneMicrodegreeNorth (10), oneMicrodegreeSouth (-10), unavailable(900000001)} (-900000000..900000001)
|
||||
|
||||
Altitude ::= SEQUENCE {
|
||||
altitudeValue AltitudeValue,
|
||||
altitudeConfidence AltitudeConfidence
|
||||
}
|
||||
|
||||
AltitudeValue ::= INTEGER {referenceEllipsoidSurface(0), oneCentimeter(1), unavailable(800001)} (-100000..800001)
|
||||
|
||||
AltitudeConfidence ::= ENUMERATED {
|
||||
alt-000-01 (0),
|
||||
alt-000-02 (1),
|
||||
alt-000-05 (2),
|
||||
alt-000-10 (3),
|
||||
alt-000-20 (4),
|
||||
alt-000-50 (5),
|
||||
alt-001-00 (6),
|
||||
alt-002-00 (7),
|
||||
alt-005-00 (8),
|
||||
alt-010-00 (9),
|
||||
alt-020-00 (10),
|
||||
alt-050-00 (11),
|
||||
alt-100-00 (12),
|
||||
alt-200-00 (13),
|
||||
outOfRange (14),
|
||||
unavailable (15)
|
||||
}
|
||||
|
||||
DeltaLongitude ::= INTEGER {oneMicrodegreeEast (10), oneMicrodegreeWest (-10), unavailable(131072)} (-131071..131072)
|
||||
|
||||
DeltaLatitude ::= INTEGER {oneMicrodegreeNorth (10), oneMicrodegreeSouth (-10) , unavailable(131072)} (-131071..131072)
|
||||
|
||||
DeltaAltitude ::= INTEGER {oneCentimeterUp (1), oneCentimeterDown (-1), unavailable(12800)} (-12700..12800)
|
||||
|
||||
PosConfidenceEllipse ::= SEQUENCE {
|
||||
semiMajorConfidence SemiAxisLength,
|
||||
semiMinorConfidence SemiAxisLength,
|
||||
semiMajorOrientation HeadingValue
|
||||
}
|
||||
|
||||
PathPoint ::= SEQUENCE {
|
||||
pathPosition DeltaReferencePosition,
|
||||
pathDeltaTime PathDeltaTime OPTIONAL
|
||||
}
|
||||
|
||||
PathDeltaTime ::= INTEGER {tenMilliSecondsInPast(1)} (1..65535, ...)
|
||||
|
||||
PtActivation ::= SEQUENCE {
|
||||
ptActivationType PtActivationType,
|
||||
ptActivationData PtActivationData
|
||||
}
|
||||
|
||||
PtActivationType ::= INTEGER {undefinedCodingType(0), r09-16CodingType(1), vdv-50149CodingType(2)} (0..255)
|
||||
|
||||
PtActivationData ::= OCTET STRING (SIZE(1..20))
|
||||
|
||||
AccelerationControl ::= BIT STRING {
|
||||
brakePedalEngaged (0),
|
||||
gasPedalEngaged (1),
|
||||
emergencyBrakeEngaged (2),
|
||||
collisionWarningEngaged (3),
|
||||
accEngaged (4),
|
||||
cruiseControlEngaged (5),
|
||||
speedLimiterEngaged (6)
|
||||
} (SIZE(7))
|
||||
|
||||
|
||||
SemiAxisLength ::= INTEGER{oneCentimeter(1), outOfRange(4094), unavailable(4095)} (0..4095)
|
||||
|
||||
CauseCode ::= SEQUENCE {
|
||||
causeCode CauseCodeType,
|
||||
subCauseCode SubCauseCodeType,
|
||||
...
|
||||
}
|
||||
|
||||
CauseCodeType ::= INTEGER {
|
||||
reserved (0),
|
||||
trafficCondition (1),
|
||||
accident (2),
|
||||
roadworks (3),
|
||||
impassability (5),
|
||||
adverseWeatherCondition-Adhesion (6),
|
||||
aquaplannning (7),
|
||||
hazardousLocation-SurfaceCondition (9),
|
||||
hazardousLocation-ObstacleOnTheRoad (10),
|
||||
hazardousLocation-AnimalOnTheRoad (11),
|
||||
humanPresenceOnTheRoad (12),
|
||||
wrongWayDriving (14),
|
||||
rescueAndRecoveryWorkInProgress (15),
|
||||
adverseWeatherCondition-ExtremeWeatherCondition (17),
|
||||
adverseWeatherCondition-Visibility (18),
|
||||
adverseWeatherCondition-Precipitation (19),
|
||||
slowVehicle (26),
|
||||
dangerousEndOfQueue (27),
|
||||
vehicleBreakdown (91),
|
||||
postCrash (92),
|
||||
humanProblem (93),
|
||||
stationaryVehicle (94),
|
||||
emergencyVehicleApproaching (95),
|
||||
hazardousLocation-DangerousCurve (96),
|
||||
collisionRisk (97),
|
||||
signalViolation (98),
|
||||
dangerousSituation (99)
|
||||
} (0..255)
|
||||
|
||||
SubCauseCodeType ::= INTEGER (0..255)
|
||||
|
||||
TrafficConditionSubCauseCode ::= INTEGER {unavailable(0), increasedVolumeOfTraffic(1), trafficJamSlowlyIncreasing(2), trafficJamIncreasing(3), trafficJamStronglyIncreasing(4), trafficStationary(5), trafficJamSlightlyDecreasing(6), trafficJamDecreasing(7), trafficJamStronglyDecreasing(8)} (0..255)
|
||||
|
||||
AccidentSubCauseCode ::= INTEGER {unavailable(0), multiVehicleAccident(1), heavyAccident(2), accidentInvolvingLorry(3), accidentInvolvingBus(4), accidentInvolvingHazardousMaterials(5), accidentOnOppositeLane(6), unsecuredAccident(7), assistanceRequested(8)} (0..255)
|
||||
|
||||
RoadworksSubCauseCode ::= INTEGER {unavailable(0), majorRoadworks(1), roadMarkingWork(2), slowMovingRoadMaintenance(3), shortTermStationaryRoadworks(4), streetCleaning(5), winterService(6)} (0..255)
|
||||
|
||||
HumanPresenceOnTheRoadSubCauseCode ::= INTEGER {unavailable(0), childrenOnRoadway(1), cyclistOnRoadway(2), motorcyclistOnRoadway(3)} (0..255)
|
||||
|
||||
WrongWayDrivingSubCauseCode ::= INTEGER {unavailable(0), wrongLane(1), wrongDirection(2)} (0..255)
|
||||
|
||||
AdverseWeatherCondition-ExtremeWeatherConditionSubCauseCode ::= INTEGER {unavailable(0), strongWinds(1), damagingHail(2), hurricane(3), thunderstorm(4), tornado(5), blizzard(6)} (0..255)
|
||||
|
||||
AdverseWeatherCondition-AdhesionSubCauseCode ::= INTEGER {unavailable(0), heavyFrostOnRoad(1), fuelOnRoad(2), mudOnRoad(3), snowOnRoad(4), iceOnRoad(5), blackIceOnRoad(6), oilOnRoad(7), looseChippings(8), instantBlackIce(9), roadsSalted(10)} (0..255)
|
||||
|
||||
AdverseWeatherCondition-VisibilitySubCauseCode ::= INTEGER {unavailable(0), fog(1), smoke(2), heavySnowfall(3), heavyRain(4), heavyHail(5), lowSunGlare(6), sandstorms(7), swarmsOfInsects(8)} (0..255)
|
||||
|
||||
AdverseWeatherCondition-PrecipitationSubCauseCode ::= INTEGER {unavailable(0), heavyRain(1), heavySnowfall(2), softHail(3)} (0..255)
|
||||
|
||||
SlowVehicleSubCauseCode ::= INTEGER {unavailable(0), maintenanceVehicle(1), vehiclesSlowingToLookAtAccident(2), abnormalLoad(3), abnormalWideLoad(4), convoy(5), snowplough(6), deicing(7), saltingVehicles(8)} (0..255)
|
||||
|
||||
StationaryVehicleSubCauseCode ::= INTEGER {unavailable(0), humanProblem(1), vehicleBreakdown(2), postCrash(3), publicTransportStop(4), carryingDangerousGoods(5)} (0..255)
|
||||
|
||||
HumanProblemSubCauseCode ::= INTEGER {unavailable(0), glycemiaProblem(1), heartProblem(2)} (0..255)
|
||||
|
||||
EmergencyVehicleApproachingSubCauseCode ::= INTEGER {unavailable(0), emergencyVehicleApproaching(1), prioritizedVehicleApproaching(2)} (0..255)
|
||||
|
||||
HazardousLocation-DangerousCurveSubCauseCode ::= INTEGER {unavailable(0), dangerousLeftTurnCurve(1), dangerousRightTurnCurve(2), multipleCurvesStartingWithUnknownTurningDirection(3), multipleCurvesStartingWithLeftTurn(4), multipleCurvesStartingWithRightTurn(5)} (0..255)
|
||||
|
||||
HazardousLocation-SurfaceConditionSubCauseCode ::= INTEGER {unavailable(0), rockfalls(1), earthquakeDamage(2), sewerCollapse(3), subsidence(4), snowDrifts(5), stormDamage(6), burstPipe(7), volcanoEruption(8), fallingIce(9)} (0..255)
|
||||
|
||||
HazardousLocation-ObstacleOnTheRoadSubCauseCode ::= INTEGER {unavailable(0), shedLoad(1), partsOfVehicles(2), partsOfTyres(3), bigObjects(4), fallenTrees(5), hubCaps(6), waitingVehicles(7)} (0..255)
|
||||
|
||||
HazardousLocation-AnimalOnTheRoadSubCauseCode ::= INTEGER {unavailable(0), wildAnimals(1), herdOfAnimals(2), smallAnimals(3), largeAnimals(4)} (0..255)
|
||||
|
||||
CollisionRiskSubCauseCode ::= INTEGER {unavailable(0), longitudinalCollisionRisk(1), crossingCollisionRisk(2), lateralCollisionRisk(3), vulnerableRoadUser(4)} (0..255)
|
||||
|
||||
SignalViolationSubCauseCode ::= INTEGER {unavailable(0), stopSignViolation(1), trafficLightViolation(2), turningRegulationViolation(3)} (0..255)
|
||||
|
||||
RescueAndRecoveryWorkInProgressSubCauseCode ::= INTEGER {unavailable(0), emergencyVehicles(1), rescueHelicopterLanding(2), policeActivityOngoing(3), medicalEmergencyOngoing(4), childAbductionInProgress(5)} (0..255)
|
||||
|
||||
DangerousEndOfQueueSubCauseCode ::= INTEGER {unavailable(0), suddenEndOfQueue(1), queueOverHill(2), queueAroundBend(3), queueInTunnel(4)} (0..255)
|
||||
|
||||
DangerousSituationSubCauseCode ::= INTEGER {unavailable(0), emergencyElectronicBrakeEngaged(1), preCrashSystemEngaged(2), espEngaged(3), absEngaged(4), aebEngaged(5), brakeWarningEngaged(6), collisionRiskWarningEngaged(7)} (0..255)
|
||||
|
||||
VehicleBreakdownSubCauseCode ::= INTEGER {unavailable(0), lackOfFuel (1), lackOfBatteryPower (2), engineProblem(3), transmissionProblem(4), engineCoolingProblem(5), brakingSystemProblem(6), steeringProblem(7), tyrePuncture(8), tyrePressureProblem(9)} (0..255)
|
||||
|
||||
PostCrashSubCauseCode ::= INTEGER {unavailable(0), accidentWithoutECallTriggered (1), accidentWithECallManuallyTriggered (2), accidentWithECallAutomaticallyTriggered (3), accidentWithECallTriggeredWithoutAccessToCellularNetwork(4)} (0..255)
|
||||
|
||||
Curvature ::= SEQUENCE {
|
||||
curvatureValue CurvatureValue,
|
||||
curvatureConfidence CurvatureConfidence
|
||||
}
|
||||
|
||||
CurvatureValue ::= INTEGER {straight(0), unavailable(1023)} (-1023..1023)
|
||||
|
||||
CurvatureConfidence ::= ENUMERATED {
|
||||
onePerMeter-0-00002 (0),
|
||||
onePerMeter-0-0001 (1),
|
||||
onePerMeter-0-0005 (2),
|
||||
onePerMeter-0-002 (3),
|
||||
onePerMeter-0-01 (4),
|
||||
onePerMeter-0-1 (5),
|
||||
outOfRange (6),
|
||||
unavailable (7)
|
||||
}
|
||||
|
||||
CurvatureCalculationMode ::= ENUMERATED {yawRateUsed(0), yawRateNotUsed(1), unavailable(2), ...}
|
||||
|
||||
Heading ::= SEQUENCE {
|
||||
headingValue HeadingValue,
|
||||
headingConfidence HeadingConfidence
|
||||
}
|
||||
|
||||
HeadingValue ::= INTEGER {wgs84North(0), wgs84East(900), wgs84South(1800), wgs84West(2700), unavailable(3601)} (0..3601)
|
||||
|
||||
HeadingConfidence ::= INTEGER {equalOrWithinZeroPointOneDegree (1), equalOrWithinOneDegree (10), outOfRange(126), unavailable(127)} (1..127)
|
||||
|
||||
LanePosition ::= INTEGER {offTheRoad(-1), innerHardShoulder(0),
|
||||
innermostDrivingLane(1), secondLaneFromInside(2), outerHardShoulder(14) } (-1..14)
|
||||
|
||||
ClosedLanes ::= SEQUENCE {
|
||||
innerhardShoulderStatus HardShoulderStatus OPTIONAL,
|
||||
outerhardShoulderStatus HardShoulderStatus OPTIONAL,
|
||||
drivingLaneStatus DrivingLaneStatus OPTIONAL,
|
||||
...
|
||||
}
|
||||
|
||||
HardShoulderStatus ::= ENUMERATED {availableForStopping(0), closed(1), availableForDriving(2)}
|
||||
|
||||
DrivingLaneStatus ::= BIT STRING (SIZE (1..13))
|
||||
|
||||
|
||||
PerformanceClass ::= INTEGER {unavailable(0), performanceClassA(1), performanceClassB(2)} (0..7)
|
||||
|
||||
SpeedValue ::= INTEGER {standstill(0), oneCentimeterPerSec(1), unavailable(16383)} (0..16383)
|
||||
|
||||
SpeedConfidence ::= INTEGER {equalOrWithinOneCentimeterPerSec(1), equalOrWithinOneMeterPerSec(100), outOfRange(126), unavailable(127)} (1..127)
|
||||
|
||||
VehicleMass ::= INTEGER {hundredKg(1), unavailable(1024)} (1..1024)
|
||||
|
||||
Speed ::= SEQUENCE {
|
||||
speedValue SpeedValue,
|
||||
speedConfidence SpeedConfidence
|
||||
}
|
||||
|
||||
DriveDirection ::= ENUMERATED {forward (0), backward (1), unavailable (2)}
|
||||
|
||||
EmbarkationStatus ::= BOOLEAN
|
||||
|
||||
LongitudinalAcceleration ::= SEQUENCE {
|
||||
longitudinalAccelerationValue LongitudinalAccelerationValue,
|
||||
longitudinalAccelerationConfidence AccelerationConfidence
|
||||
}
|
||||
|
||||
LongitudinalAccelerationValue ::= INTEGER {pointOneMeterPerSecSquaredForward(1), pointOneMeterPerSecSquaredBackward(-1), unavailable(161)} (-160 .. 161)
|
||||
|
||||
AccelerationConfidence ::= INTEGER {pointOneMeterPerSecSquared(1), outOfRange(101), unavailable(102)} (0 .. 102)
|
||||
|
||||
LateralAcceleration ::= SEQUENCE {
|
||||
lateralAccelerationValue LateralAccelerationValue,
|
||||
lateralAccelerationConfidence AccelerationConfidence
|
||||
}
|
||||
|
||||
LateralAccelerationValue ::= INTEGER {pointOneMeterPerSecSquaredToRight(-1), pointOneMeterPerSecSquaredToLeft(1), unavailable(161)} (-160 .. 161)
|
||||
|
||||
VerticalAcceleration ::= SEQUENCE {
|
||||
verticalAccelerationValue VerticalAccelerationValue,
|
||||
verticalAccelerationConfidence AccelerationConfidence
|
||||
}
|
||||
|
||||
VerticalAccelerationValue ::= INTEGER {pointOneMeterPerSecSquaredUp(1), pointOneMeterPerSecSquaredDown(-1), unavailable(161)} (-160 .. 161)
|
||||
|
||||
StationType ::= INTEGER {unknown(0), pedestrian(1), cyclist(2), moped(3), motorcycle(4), passengerCar(5), bus(6),
|
||||
lightTruck(7), heavyTruck(8), trailer(9), specialVehicles(10), tram(11), roadSideUnit(15)} (0..255)
|
||||
|
||||
ExteriorLights ::= BIT STRING {
|
||||
lowBeamHeadlightsOn (0),
|
||||
highBeamHeadlightsOn (1),
|
||||
leftTurnSignalOn (2),
|
||||
rightTurnSignalOn (3),
|
||||
daytimeRunningLightsOn (4),
|
||||
reverseLightOn (5),
|
||||
fogLightOn (6),
|
||||
parkingLightsOn (7)
|
||||
} (SIZE(8))
|
||||
|
||||
DangerousGoodsBasic::= ENUMERATED {
|
||||
explosives1(0),
|
||||
explosives2(1),
|
||||
explosives3(2),
|
||||
explosives4(3),
|
||||
explosives5(4),
|
||||
explosives6(5),
|
||||
flammableGases(6),
|
||||
nonFlammableGases(7),
|
||||
toxicGases(8),
|
||||
flammableLiquids(9),
|
||||
flammableSolids(10),
|
||||
substancesLiableToSpontaneousCombustion(11),
|
||||
substancesEmittingFlammableGasesUponContactWithWater(12),
|
||||
oxidizingSubstances(13),
|
||||
organicPeroxides(14),
|
||||
toxicSubstances(15),
|
||||
infectiousSubstances(16),
|
||||
radioactiveMaterial(17),
|
||||
corrosiveSubstances(18),
|
||||
miscellaneousDangerousSubstances(19)
|
||||
}
|
||||
|
||||
DangerousGoodsExtended ::= SEQUENCE {
|
||||
dangerousGoodsType DangerousGoodsBasic,
|
||||
unNumber INTEGER (0..9999),
|
||||
elevatedTemperature BOOLEAN,
|
||||
tunnelsRestricted BOOLEAN,
|
||||
limitedQuantity BOOLEAN,
|
||||
emergencyActionCode IA5String (SIZE (1..24)) OPTIONAL,
|
||||
phoneNumber PhoneNumber OPTIONAL,
|
||||
companyName UTF8String (SIZE (1..24)) OPTIONAL,
|
||||
...
|
||||
}
|
||||
|
||||
SpecialTransportType ::= BIT STRING {heavyLoad(0), excessWidth(1), excessLength(2), excessHeight(3)} (SIZE(4))
|
||||
|
||||
LightBarSirenInUse ::= BIT STRING {
|
||||
lightBarActivated (0),
|
||||
sirenActivated (1)
|
||||
} (SIZE(2))
|
||||
|
||||
HeightLonCarr ::= INTEGER {oneCentimeter(1), unavailable(100)} (1..100)
|
||||
|
||||
PosLonCarr ::= INTEGER {oneCentimeter(1), unavailable(127)} (1..127)
|
||||
|
||||
PosPillar ::= INTEGER {tenCentimeters(1), unavailable(30)} (1..30)
|
||||
|
||||
PosCentMass ::= INTEGER {tenCentimeters(1), unavailable(63)} (1..63)
|
||||
|
||||
RequestResponseIndication ::= ENUMERATED {request(0), response(1)}
|
||||
|
||||
SpeedLimit ::= INTEGER {oneKmPerHour(1)} (1..255)
|
||||
|
||||
StationarySince ::= ENUMERATED {lessThan1Minute(0), lessThan2Minutes(1), lessThan15Minutes(2), equalOrGreater15Minutes(3)}
|
||||
|
||||
Temperature ::= INTEGER {equalOrSmallerThanMinus60Deg (-60), oneDegreeCelsius(1), equalOrGreaterThan67Deg(67)} (-60..67)
|
||||
|
||||
TrafficRule ::= ENUMERATED {noPassing(0), noPassingForTrucks(1), passToRight(2), passToLeft(3), ...
|
||||
}
|
||||
|
||||
WheelBaseVehicle ::= INTEGER {tenCentimeters(1), unavailable(127)} (1..127)
|
||||
|
||||
TurningRadius ::= INTEGER {point4Meters(1), unavailable(255)} (1..255)
|
||||
|
||||
PosFrontAx ::= INTEGER {tenCentimeters(1), unavailable(20)} (1..20)
|
||||
|
||||
PositionOfOccupants ::= BIT STRING {
|
||||
row1LeftOccupied (0),
|
||||
row1RightOccupied (1),
|
||||
row1MidOccupied (2),
|
||||
row1NotDetectable (3),
|
||||
row1NotPresent (4),
|
||||
row2LeftOccupied (5),
|
||||
row2RightOccupied (6),
|
||||
row2MidOccupied (7),
|
||||
row2NotDetectable (8),
|
||||
row2NotPresent (9),
|
||||
row3LeftOccupied (10),
|
||||
row3RightOccupied (11),
|
||||
row3MidOccupied (12),
|
||||
row3NotDetectable (13),
|
||||
row3NotPresent (14),
|
||||
row4LeftOccupied (15),
|
||||
row4RightOccupied (16),
|
||||
row4MidOccupied (17),
|
||||
row4NotDetectable (18),
|
||||
row4NotPresent (19)} (SIZE(20))
|
||||
|
||||
PositioningSolutionType ::= ENUMERATED {noPositioningSolution(0), sGNSS(1), dGNSS(2), sGNSSplusDR(3), dGNSSplusDR(4), dR(5), ...}
|
||||
|
||||
VehicleIdentification ::= SEQUENCE {
|
||||
wMInumber WMInumber OPTIONAL,
|
||||
vDS VDS OPTIONAL,
|
||||
...
|
||||
}
|
||||
|
||||
WMInumber ::= IA5String (SIZE(1..3))
|
||||
|
||||
VDS ::= IA5String (SIZE(6))
|
||||
|
||||
EnergyStorageType ::= BIT STRING {hydrogenStorage(0), electricEnergyStorage(1), liquidPropaneGas(2), compressedNaturalGas(3), diesel(4), gasoline(5), ammonia(6)} (SIZE(7))
|
||||
|
||||
VehicleLength ::= SEQUENCE {
|
||||
vehicleLengthValue VehicleLengthValue,
|
||||
vehicleLengthConfidenceIndication VehicleLengthConfidenceIndication
|
||||
}
|
||||
|
||||
VehicleLengthValue ::= INTEGER {tenCentimeters(1), outOfRange(1022), unavailable(1023)} (1..1023)
|
||||
|
||||
VehicleLengthConfidenceIndication ::= ENUMERATED {noTrailerPresent(0), trailerPresentWithKnownLength(1), trailerPresentWithUnknownLength(2), trailerPresenceIsUnknown(3), unavailable(4)}
|
||||
|
||||
VehicleWidth ::= INTEGER {tenCentimeters(1), outOfRange(61), unavailable(62)} (1..62)
|
||||
|
||||
PathHistory::= SEQUENCE (SIZE(0..40)) OF PathPoint
|
||||
|
||||
EmergencyPriority ::= BIT STRING {requestForRightOfWay(0), requestForFreeCrossingAtATrafficLight(1)} (SIZE(2))
|
||||
|
||||
InformationQuality ::= INTEGER {unavailable(0), lowest(1), highest(7)} (0..7)
|
||||
|
||||
RoadType ::= ENUMERATED {
|
||||
urban-NoStructuralSeparationToOppositeLanes(0),
|
||||
urban-WithStructuralSeparationToOppositeLanes(1),
|
||||
nonUrban-NoStructuralSeparationToOppositeLanes(2),
|
||||
nonUrban-WithStructuralSeparationToOppositeLanes(3)}
|
||||
|
||||
SteeringWheelAngle ::= SEQUENCE {
|
||||
steeringWheelAngleValue SteeringWheelAngleValue,
|
||||
steeringWheelAngleConfidence SteeringWheelAngleConfidence
|
||||
}
|
||||
|
||||
SteeringWheelAngleValue ::= INTEGER {straight(0), onePointFiveDegreesToRight(-1), onePointFiveDegreesToLeft(1), unavailable(512)} (-511..512)
|
||||
|
||||
SteeringWheelAngleConfidence ::= INTEGER {equalOrWithinOnePointFiveDegree (1), outOfRange(126), unavailable(127)} (1..127)
|
||||
|
||||
TimestampIts ::= INTEGER {utcStartOf2004(0), oneMillisecAfterUTCStartOf2004(1)} (0..4398046511103)
|
||||
|
||||
VehicleRole ::= ENUMERATED {default(0), publicTransport(1), specialTransport(2), dangerousGoods(3), roadWork(4), rescue(5), emergency(6), safetyCar(7), agriculture(8), commercial(9), military(10), roadOperator(11), taxi(12), reserved1(13), reserved2(14), reserved3(15)}
|
||||
|
||||
YawRate::= SEQUENCE {
|
||||
yawRateValue YawRateValue,
|
||||
yawRateConfidence YawRateConfidence
|
||||
}
|
||||
|
||||
YawRateValue ::= INTEGER {straight(0), degSec-000-01ToRight(-1), degSec-000-01ToLeft(1), unavailable(32767)} (-32766..32767)
|
||||
|
||||
YawRateConfidence ::= ENUMERATED {
|
||||
degSec-000-01 (0),
|
||||
degSec-000-05 (1),
|
||||
degSec-000-10 (2),
|
||||
degSec-001-00 (3),
|
||||
degSec-005-00 (4),
|
||||
degSec-010-00 (5),
|
||||
degSec-100-00 (6),
|
||||
outOfRange (7),
|
||||
unavailable (8)
|
||||
}
|
||||
|
||||
ProtectedZoneType::= ENUMERATED { permanentCenDsrcTolling (0), ..., temporaryCenDsrcTolling (1) }
|
||||
|
||||
RelevanceDistance ::= ENUMERATED {lessThan50m(0), lessThan100m(1), lessThan200m(2), lessThan500m(3), lessThan1000m(4), lessThan5km(5), lessThan10km(6), over10km(7)}
|
||||
|
||||
RelevanceTrafficDirection ::= ENUMERATED {allTrafficDirections(0), upstreamTraffic(1), downstreamTraffic(2), oppositeTraffic(3)}
|
||||
|
||||
TransmissionInterval ::= INTEGER {oneMilliSecond(1), tenSeconds(10000)} (1..10000)
|
||||
|
||||
ValidityDuration ::= INTEGER {timeOfDetection(0), oneSecondAfterDetection(1)} (0..86400)
|
||||
|
||||
ActionID ::= SEQUENCE {
|
||||
originatingStationID StationID,
|
||||
sequenceNumber SequenceNumber
|
||||
}
|
||||
|
||||
ItineraryPath ::= SEQUENCE SIZE(1..40) OF ReferencePosition
|
||||
|
||||
ProtectedCommunicationZone ::= SEQUENCE {
|
||||
protectedZoneType ProtectedZoneType,
|
||||
expiryTime TimestampIts OPTIONAL,
|
||||
protectedZoneLatitude Latitude,
|
||||
protectedZoneLongitude Longitude,
|
||||
protectedZoneRadius ProtectedZoneRadius OPTIONAL,
|
||||
protectedZoneID ProtectedZoneID OPTIONAL,
|
||||
...
|
||||
}
|
||||
|
||||
Traces ::= SEQUENCE SIZE(1..7) OF PathHistory
|
||||
|
||||
NumberOfOccupants ::= INTEGER {oneOccupant (1), unavailable(127)} (0 .. 127)
|
||||
|
||||
SequenceNumber ::= INTEGER (0..65535)
|
||||
|
||||
PositionOfPillars ::= SEQUENCE (SIZE(1..3, ...)) OF PosPillar
|
||||
|
||||
RestrictedTypes ::= SEQUENCE (SIZE(1..3, ...)) OF StationType
|
||||
|
||||
EventHistory::= SEQUENCE (SIZE(1..23)) OF EventPoint
|
||||
|
||||
EventPoint ::= SEQUENCE {
|
||||
eventPosition DeltaReferencePosition,
|
||||
eventDeltaTime PathDeltaTime OPTIONAL,
|
||||
informationQuality InformationQuality
|
||||
}
|
||||
|
||||
ProtectedCommunicationZonesRSU ::= SEQUENCE (SIZE(1..16)) OF ProtectedCommunicationZone
|
||||
|
||||
|
||||
|
||||
CenDsrcTollingZone ::= SEQUENCE {
|
||||
protectedZoneLatitude Latitude,
|
||||
protectedZoneLongitude Longitude,
|
||||
cenDsrcTollingZoneID CenDsrcTollingZoneID OPTIONAL,
|
||||
...
|
||||
}
|
||||
|
||||
ProtectedZoneRadius ::= INTEGER {oneMeter(1)} (1..255,...)
|
||||
|
||||
ProtectedZoneID ::= INTEGER (0.. 134217727)
|
||||
|
||||
CenDsrcTollingZoneID ::= ProtectedZoneID
|
||||
|
||||
DigitalMap ::= SEQUENCE (SIZE(1..256)) OF ReferencePosition
|
||||
|
||||
OpeningDaysHours ::= UTF8String
|
||||
|
||||
PhoneNumber ::= NumericString (SIZE(1..16))
|
||||
|
||||
END
|
||||
+7260
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,108 @@
|
||||
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
|
||||
+4468
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,54 @@
|
||||
/** draft 001 of the MAPEM-PDU-Descriptions module for TS 103 831 V2.2.1 integrating:
|
||||
* initial revision based on ASN.1 files of [ISO TS 19091] and [SAE J2735]
|
||||
*/
|
||||
-- Note: the above information will be deleted before publication
|
||||
|
||||
--! @options: no-fields-header
|
||||
|
||||
MAPEM-PDU-Descriptions {
|
||||
itu-t (0) identified-organization (4) etsi (0) itsDomain (5) wg1 (1) ts103301 (103301) mapem (1) version2 (2)
|
||||
}
|
||||
|
||||
DEFINITIONS AUTOMATIC TAGS ::=
|
||||
|
||||
BEGIN
|
||||
|
||||
IMPORTS
|
||||
|
||||
/**
|
||||
* Includes from ETSI-ITS-DSRC
|
||||
*/
|
||||
MapData
|
||||
FROM ETSI-ITS-DSRC {
|
||||
itu-t (0) identified-organization (4) etsi (0) itsDomain (5) wg1 (1) ts103301 (103301) dsrc (6) major-version-2 (2) minor-version-1 (1)
|
||||
}
|
||||
WITH SUCCESSORS
|
||||
|
||||
/**
|
||||
* Include ETSI TS 102 894-2 (ETSI-ITS-CDD)
|
||||
*/
|
||||
ItsPduHeader
|
||||
FROM ETSI-ITS-CDD {
|
||||
itu-t (0) identified-organization (4) etsi (0) itsDomain (5) wg1 (1) 102894 cdd (2) major-version-4 (4) minor-version-1 (1)
|
||||
}
|
||||
WITH SUCCESSORS;
|
||||
|
||||
/**
|
||||
* Map (lane topology) extended Message
|
||||
* This DF includes DEs for the MAPEM: protocolVersion, the MAPEM message type identifier `messageID`,
|
||||
* the station identifier `stationID` of the originating ITS-S and the Map data from ETSI-ITS-DSRC.
|
||||
*
|
||||
* @field header: The DE `protocolVersion` is used to select the appropriate protocol decoder at the receiving ITS-S.
|
||||
* It shall be set to 2.
|
||||
* The DE `messageID` shall be mapem(5).
|
||||
* @field map: contains the MAP data as defined in ETSI-ITS-DSRC.
|
||||
*
|
||||
* @category: Basic Information
|
||||
* @revision: V1.3.1
|
||||
*/
|
||||
MAPEM ::= SEQUENCE {
|
||||
header ItsPduHeader,
|
||||
map MapData
|
||||
}
|
||||
|
||||
END
|
||||
@@ -0,0 +1,56 @@
|
||||
/** draft 001 of the SPATEM-PDU-Descriptions module for TS 103 301 V2.2.1 integrating:
|
||||
* initial revision based on ASN.1 files of [ISO TS 19091] and [SAE J2735]
|
||||
*/
|
||||
-- Note: the above information will be deleted before publication
|
||||
|
||||
--! @options: no-fields-header
|
||||
|
||||
SPATEM-PDU-Descriptions {
|
||||
itu-t (0) identified-organization (4) etsi (0) itsDomain (5) wg1 (1) ts103301 (103301) spatem (0) major-version-2 (2) minor-version-1 (1)
|
||||
}
|
||||
|
||||
DEFINITIONS AUTOMATIC TAGS ::=
|
||||
|
||||
BEGIN
|
||||
|
||||
IMPORTS
|
||||
|
||||
/**
|
||||
* Includes from ETSI-ITS-DSRC
|
||||
*/
|
||||
SPAT
|
||||
FROM ETSI-ITS-DSRC {
|
||||
itu-t (0) identified-organization (4) etsi (0) itsDomain (5) wg1 (1) ts103301 (103301) dsrc (6) major-version-2 (2) minor-version-1 (1)
|
||||
}
|
||||
WITH SUCCESSORS
|
||||
|
||||
/**
|
||||
* Include ETSI TS 102 894-2 (ETSI-ITS-CDD)
|
||||
*/
|
||||
ItsPduHeader
|
||||
FROM ETSI-ITS-CDD {
|
||||
itu-t (0) identified-organization (4) etsi (0) itsDomain (5) wg1 (1) 102894 cdd (2) major-version-4 (4) minor-version-1 (1)
|
||||
}
|
||||
WITH SUCCESSORS;
|
||||
|
||||
/**
|
||||
* Signal phase and timing extended Message
|
||||
*
|
||||
* Signal phase and timing extended Message Root
|
||||
* This DF includes DEs for the SPATEM: protocolVersion, the SPATEM message type identifier `messageID`,
|
||||
* the station identifier `stationID` of the originating ITS-S and the SPaT data from ETSI-ITS-DSRC module.
|
||||
*
|
||||
* @field header: The DE `protocolVersion` used to select the appropriate protocol decoder at the receiving ITS-S.
|
||||
* It shall be set to 2.
|
||||
* The DE `messageID` shall be spatem(4).
|
||||
* @field spat: contains the SPaT data as defined in ETSI-ITS-DSRC.
|
||||
*
|
||||
* @category: Basic Information
|
||||
* @revision: V1.3.1
|
||||
*/
|
||||
SPATEM ::= SEQUENCE {
|
||||
header ItsPduHeader,
|
||||
spat SPAT
|
||||
}
|
||||
|
||||
END
|
||||
@@ -0,0 +1,102 @@
|
||||
# Sniffer board and capture tooling
|
||||
|
||||
How to put an ESP32-C5 on the ITS-G5 channel as a passive sniffer, pull its captures onto this
|
||||
PC, and check what is on air. The sniffer firmware itself is the third-party
|
||||
`its-g5-receiver-firmware` checkout beside this repo; only the tooling and these notes are ours.
|
||||
|
||||
| File | What it does |
|
||||
|---|---|
|
||||
| `live_capture.py` | Streams the device's captures into a growing `.pcap` while it runs. The usual choice. |
|
||||
| `dump_pcap.py` | Pulls one capture out of the device's in-memory buffer after the fact. |
|
||||
| `../obu-firmware/test/pcap_gn_tally.py` | Tallies GeoNetworking headers per station over a `.pcap`. |
|
||||
|
||||
One-time: `pip install pyserial` (present in Python 3.11 on the bench PC, so `py -3.11` works).
|
||||
|
||||
## Which port
|
||||
|
||||
The sniffer firmware's console, and with it the pcap stream, goes out **UART0** - the board's
|
||||
USB-bridge port (a CH343, its own COM number), not the native USB-C port. A board with only one
|
||||
USB-C port cannot be used as a sniffer for this reason. On the bench this has been COM5 and, after
|
||||
a re-enumeration, COM8.
|
||||
|
||||
## Live capture (preferred)
|
||||
|
||||
```powershell
|
||||
cd capture
|
||||
py -3.11 live_capture.py COM8
|
||||
```
|
||||
|
||||
It writes `recordings/capture_<timestamp>.pcap` next to itself, flushing after every packet, so
|
||||
the file can be read while it grows. Stop it with Ctrl+C. Use `-o <dir>` to write elsewhere;
|
||||
`recordings/` is gitignored, since captures are large and are data rather than source. Captures
|
||||
taken before 2026-09-14 are still in `its-g5-receiver-firmware/recordings/`; the host tests read
|
||||
both directories.
|
||||
|
||||
### The CR insertion, and why captures used to be corrupt
|
||||
|
||||
ESP-IDF's newlib console converts LF to CRLF on its way out, and that applies to every `0x0a` byte
|
||||
of the **binary** pcap stream, not only to log text. Each inserted CR shifts everything after it,
|
||||
so pcap record headers and captured frames alike come out corrupt, and the file stops being
|
||||
parseable at the first occurrence.
|
||||
|
||||
Measured on 2026-09-14: a 787 KB capture parsed cleanly for only 82 of about 2000 records, and
|
||||
DENMs appeared on nonsense BTP ports because their payloads contain `0x0a` often. `undo_crlf()` in
|
||||
`live_capture.py` reverses it on the raw stream before any framing, which is exact; afterwards a
|
||||
capture parsed to EOF and DENMs read as port 2002 again.
|
||||
|
||||
**Captures taken before 2026-09-14 are truncated at their first corrupted record.** Anything
|
||||
measured from them is worth re-checking. `dump_pcap.py` reads the same console and has not been
|
||||
given the same treatment yet.
|
||||
|
||||
## Checking a capture
|
||||
|
||||
```powershell
|
||||
py -3.11 ..\obu-firmware\test\pcap_gn_tally.py recordings\capture_<timestamp>.pcap
|
||||
```
|
||||
|
||||
One row per station, packet type, BTP port and GN lifetime. For the messages themselves, decode
|
||||
the payloads with `asn1tools` against the modules in `../asn1/` and re-encode them: identical bytes
|
||||
mean the message was read exactly, wrong bytes mean it was not. `obu-firmware/test/check_replay.py`
|
||||
does this over a whole capture.
|
||||
|
||||
## Flashing the sniffer firmware
|
||||
|
||||
From the receiver checkout, with its **pinned** ESP-IDF (not the global 5.5.4 install):
|
||||
|
||||
```powershell
|
||||
cd its-g5-receiver-firmware
|
||||
git submodule update --init --recursive
|
||||
.\esp-idf\install.bat
|
||||
Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass
|
||||
.\esp-idf\export.ps1
|
||||
idf.py set-target esp32c5
|
||||
idf.py -p COM8 -b 921600 flash
|
||||
```
|
||||
|
||||
Its `sdkconfig` for a bare board (nothing wired) needs SPI Ethernet off, and the pcap destination
|
||||
set to Memory, both under `idf.py menuconfig`. Wired variants have ready-made configs in that
|
||||
checkout: `sdkconfig.proto-w5500`, `sdkconfig.proto-enc28j60`, `sdkconfig.proto-spi-eppp`.
|
||||
|
||||
To reflash a board that already has a built image, without a toolchain terminal:
|
||||
|
||||
```powershell
|
||||
cd its-g5-receiver-firmware\build
|
||||
C:\Espressif\python_env\idf5.5_py3.11_env\Scripts\python.exe -m esptool --chip esp32c5 -p COM8 -b 921600 write_flash --flash_mode dio --flash_freq 80m --flash_size 16MB 0x2000 bootloader/bootloader.bin 0x8000 partition_table/partition-table.bin 0x1e000 ota_data_initial.bin 0x20000 its-g5-receiver-firmware.bin
|
||||
```
|
||||
|
||||
## Pulling a capture after the fact
|
||||
|
||||
Only for the Memory destination, and the buffer is small (`SNIFFER_PCAP_MEMORY_SIZE`, 4096 bytes
|
||||
by default) - a smoke test, not a session. In the device console (`idf.py -p COM8 monitor`, exit
|
||||
with Ctrl+T then Ctrl+X):
|
||||
|
||||
```
|
||||
sniffer -P
|
||||
sniffer --stop
|
||||
```
|
||||
|
||||
Then, with the port free:
|
||||
|
||||
```powershell
|
||||
py -3.11 dump_pcap.py COM8
|
||||
```
|
||||
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Pulls a capture off the ITS-G5 receiver's in-memory pcap buffer over the existing USB serial
|
||||
connection and saves it as a real .pcap file on this machine.
|
||||
|
||||
Requires the firmware to be built with:
|
||||
Example Configuration -> Select destination to store pcap file -> Memory
|
||||
|
||||
Usage (typical):
|
||||
1. Close idf.py monitor (only one program can hold the COM port at a time).
|
||||
2. Run a capture on the device: `sniffer -P` ... let it run ... `sniffer --stop`
|
||||
3. python dump_pcap.py COM5
|
||||
|
||||
The device has no access to this computer's filesystem, so it can't write here directly. Instead,
|
||||
`pcap --dump` streams the raw pcap bytes back over the same serial link, wrapped in plain-text
|
||||
markers ("===PCAP-DUMP-START:<len>===" ... raw bytes ... "===PCAP-DUMP-END==="). This script finds
|
||||
those markers and writes just the raw bytes out as a .pcap file.
|
||||
|
||||
Install dependency once: pip install pyserial
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import re
|
||||
import sys
|
||||
|
||||
try:
|
||||
import serial
|
||||
except ImportError:
|
||||
print("Missing dependency. Install it with: pip install pyserial", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
START_RE = re.compile(rb"===PCAP-DUMP-START:(\d+)===\n")
|
||||
END_MARKER = b"\n===PCAP-DUMP-END===\n"
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("port", help="Serial port the device is on, e.g. COM5")
|
||||
parser.add_argument("-b", "--baud", type=int, default=115200, help="Baud rate (default: 115200)")
|
||||
parser.add_argument("-o", "--outdir", default="recordings", help="Output directory (default: ./recordings)")
|
||||
parser.add_argument("-t", "--timeout", type=float, default=15.0, help="Seconds to wait for the dump to start")
|
||||
args = parser.parse_args()
|
||||
|
||||
import os
|
||||
os.makedirs(args.outdir, exist_ok=True)
|
||||
|
||||
print(f"Opening {args.port} @ {args.baud}...")
|
||||
with serial.Serial(args.port, args.baud, timeout=1) as ser:
|
||||
# Nudge the console in case there's stale input, then request the dump.
|
||||
ser.reset_input_buffer()
|
||||
ser.write(b"\r\n")
|
||||
ser.write(b"pcap -f dump --dump\r\n")
|
||||
|
||||
print("Waiting for dump to start...")
|
||||
buf = b""
|
||||
match = None
|
||||
deadline = datetime.datetime.now() + datetime.timedelta(seconds=args.timeout)
|
||||
while datetime.datetime.now() < deadline:
|
||||
chunk = ser.read(256)
|
||||
if chunk:
|
||||
buf += chunk
|
||||
match = START_RE.search(buf)
|
||||
if match:
|
||||
break
|
||||
if not match:
|
||||
print("Timed out waiting for '===PCAP-DUMP-START:...===' marker.\n"
|
||||
"Check that: the firmware is built with the Memory pcap destination, a capture was\n"
|
||||
"actually taken ('sniffer -P' then 'sniffer --stop'), and no other program (like\n"
|
||||
"idf.py monitor) is holding the serial port open.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
length = int(match.group(1))
|
||||
print(f"Dump starting, {length} bytes expected.")
|
||||
|
||||
# Anything after the marker in our buffer is already part of the payload.
|
||||
payload = buf[match.end():]
|
||||
remaining = length - len(payload)
|
||||
while remaining > 0:
|
||||
chunk = ser.read(min(remaining, 4096))
|
||||
if not chunk:
|
||||
print(f"Serial read timed out with {remaining} bytes still missing.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
payload += chunk
|
||||
remaining -= len(chunk)
|
||||
|
||||
# Drain (and sanity-check) the trailing end marker, but don't fail hard if it's not exact.
|
||||
tail = ser.read(len(END_MARKER))
|
||||
if tail != END_MARKER:
|
||||
print("Warning: end marker didn't match exactly - payload may still be fine.", file=sys.stderr)
|
||||
|
||||
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
outpath = os.path.join(args.outdir, f"capture_{timestamp}.pcap")
|
||||
with open(outpath, "wb") as f:
|
||||
f.write(payload)
|
||||
|
||||
print(f"Saved {len(payload)} bytes to {outpath}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,226 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Continuously listens on the ITS-G5 receiver's serial console and writes every captured packet into a
|
||||
live-growing .pcap file, with no console commands needed on the device side.
|
||||
|
||||
The firmware streams every packet it captures out over the same serial connection the console runs on,
|
||||
automatically, as soon as the sniffer is running (which happens on boot by default). Each packet is framed
|
||||
with plain-text markers so this script can pull the binary pcap bytes out of the stream even though
|
||||
regular log lines are interleaved with it:
|
||||
|
||||
===PCAP-LIVE-HEADER:<len>===\\n<24 raw bytes>\\n (sent once, the pcap global header)
|
||||
===PCAP-LIVE-PKT:<len>===\\n<raw bytes>\\n (sent once per captured packet)
|
||||
|
||||
Usage:
|
||||
python live_capture.py COM5
|
||||
|
||||
Runs until you press Ctrl+C. Writes to recordings/capture_<timestamp>.pcap, flushing after every packet
|
||||
so you can open the file in Wireshark while it's still being written (use "File > Open" again, or
|
||||
Wireshark's own "Follow" won't auto-refresh but re-opening will show the latest packets).
|
||||
|
||||
Install dependency once: pip install pyserial
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
|
||||
try:
|
||||
import serial
|
||||
except ImportError:
|
||||
print("Missing dependency. Install it with: pip install pyserial", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# \r? because the ESP console emits CRLF: on Windows the markers arrive as
|
||||
# "===PCAP-LIVE-PKT:310===\r\n", which never matched a bare \n and left the capture silently
|
||||
# empty while the device was streaming perfectly well.
|
||||
HEADER_RE = re.compile(rb"===PCAP-LIVE-HEADER:(\d+)===\r?\n")
|
||||
PKT_RE = re.compile(rb"===PCAP-LIVE-PKT:(\d+)===\r?\n")
|
||||
|
||||
LINKTYPE_ETHERNET = 1
|
||||
LINKTYPE_IEEE802_11_RADIOTAP = 127
|
||||
|
||||
|
||||
def mac_str(b):
|
||||
return ":".join(f"{x:02x}" for x in b)
|
||||
|
||||
|
||||
def build_default_pcap_header(link_type):
|
||||
"""Synthesizes the same 24-byte global pcap header the firmware would have sent, for when we
|
||||
connect after the device's one-time header already went out (see the race note in main())."""
|
||||
header = bytearray(24)
|
||||
header[0:4] = bytes([0xD4, 0xC3, 0xB2, 0xA1]) # magic (LE bytes of 0xA1B2C3D4)
|
||||
header[4:6] = (2).to_bytes(2, "little") # major version
|
||||
header[6:8] = (4).to_bytes(2, "little") # minor version
|
||||
header[16:20] = (0x40000).to_bytes(4, "little") # snaplen
|
||||
header[20:24] = link_type.to_bytes(4, "little")
|
||||
return bytes(header)
|
||||
|
||||
|
||||
def summarize_packet(link_type, record_bytes, index):
|
||||
"""Best-effort human-readable one-line summary of a captured packet, for live feedback.
|
||||
record_bytes is the raw 16-byte pcap record header followed by the captured frame."""
|
||||
seconds = int.from_bytes(record_bytes[0:4], "little")
|
||||
microseconds = int.from_bytes(record_bytes[4:8], "little")
|
||||
cap_len = int.from_bytes(record_bytes[8:12], "little")
|
||||
frame = record_bytes[16:]
|
||||
ts = f"{seconds}.{microseconds:06d}"
|
||||
|
||||
if link_type == LINKTYPE_IEEE802_11_RADIOTAP and len(frame) >= 24:
|
||||
radiotap_len = int.from_bytes(frame[2:4], "little")
|
||||
rssi = frame[8] - 256 if frame[8] >= 128 else frame[8]
|
||||
station_id = int.from_bytes(frame[16:24], "little")
|
||||
mac_frame = frame[radiotap_len:]
|
||||
if len(mac_frame) >= 16:
|
||||
dst = mac_str(mac_frame[4:10])
|
||||
src = mac_str(mac_frame[10:16])
|
||||
else:
|
||||
dst = src = "?"
|
||||
station = f"{station_id:012x}" if station_id else "unknown"
|
||||
return (f"#{index:<5} [{ts}] len={cap_len:<5} rssi={rssi:>4}dBm "
|
||||
f"station={station} {src} -> {dst}")
|
||||
|
||||
if link_type == LINKTYPE_ETHERNET and len(frame) >= 14:
|
||||
dst = mac_str(frame[0:6])
|
||||
src = mac_str(frame[6:12])
|
||||
ethertype = int.from_bytes(frame[12:14], "big")
|
||||
return f"#{index:<5} [{ts}] len={cap_len:<5} eth {src} -> {dst} type=0x{ethertype:04x}"
|
||||
|
||||
return f"#{index:<5} [{ts}] len={cap_len:<5} (unrecognized frame format)"
|
||||
|
||||
|
||||
def undo_crlf(chunk, state):
|
||||
"""Undo the CR the device console inserts before every LF.
|
||||
|
||||
ESP-IDF's newlib console converts LF to CRLF on its way out, and that happens to every 0x0A
|
||||
byte of the binary pcap stream too, not only to log text. Each inserted CR shifts everything
|
||||
after it, so pcap record headers and captured frames alike come out corrupt. This is the
|
||||
"byte inserted mid-frame" seen in older recordings; with DENM traffic on air it wrecks most
|
||||
of a capture (measured 2026-09-14: a 787 KB file parsed cleanly for only 82 records).
|
||||
|
||||
Dropping one CR immediately before each LF undoes it exactly, provided it is done on the raw
|
||||
stream before any framing and a trailing CR is carried across read boundaries. CR and LF are
|
||||
written as byte values here so the transformation cannot be confused with an escape.
|
||||
"""
|
||||
CR, LF = bytes([13]), bytes([10])
|
||||
if state["pending_cr"]:
|
||||
chunk = CR + chunk
|
||||
state["pending_cr"] = False
|
||||
if chunk.endswith(CR):
|
||||
chunk = chunk[:-1]
|
||||
state["pending_cr"] = True
|
||||
return chunk.replace(CR + LF, LF)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("port", help="Serial port the device is on, e.g. COM5")
|
||||
parser.add_argument("-b", "--baud", type=int, default=115200, help="Baud rate (default: 115200)")
|
||||
parser.add_argument("-o", "--outdir", default="recordings", help="Output directory (default: ./recordings)")
|
||||
args = parser.parse_args()
|
||||
|
||||
os.makedirs(args.outdir, exist_ok=True)
|
||||
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
outpath = os.path.join(args.outdir, f"capture_{timestamp}.pcap")
|
||||
|
||||
print(f"Opening {args.port} @ {args.baud}...")
|
||||
print(f"Writing live capture to {outpath}")
|
||||
print("Press Ctrl+C to stop.")
|
||||
|
||||
header_written = False
|
||||
link_type = None
|
||||
packet_count = 0
|
||||
buf = b""
|
||||
total_bytes = 0
|
||||
last_status = time.monotonic()
|
||||
printed_raw_preview = False
|
||||
|
||||
crlf_state = {"pending_cr": False}
|
||||
|
||||
with serial.Serial(args.port, args.baud, timeout=1) as ser, open(outpath, "wb") as outfile:
|
||||
def read_bytes(n):
|
||||
return undo_crlf(ser.read(n), crlf_state)
|
||||
|
||||
try:
|
||||
while True:
|
||||
chunk = read_bytes(256)
|
||||
if chunk:
|
||||
buf += chunk
|
||||
total_bytes += len(chunk)
|
||||
|
||||
now = time.monotonic()
|
||||
if now - last_status >= 2:
|
||||
last_status = now
|
||||
print(f"[diagnostic] {total_bytes} raw bytes received so far, "
|
||||
f"{packet_count} packets recognized, header_written={header_written}")
|
||||
if total_bytes > 0 and not printed_raw_preview and not header_written and not PKT_RE.search(buf):
|
||||
# We're getting bytes but none of them look like our markers - show a preview
|
||||
# so we can tell whether this is plain log text (markers just haven't shown up
|
||||
# yet), garbage (baud/port mismatch), or something else entirely.
|
||||
preview = buf[:200]
|
||||
print(f"[diagnostic] no markers matched yet - raw preview: {preview!r}")
|
||||
printed_raw_preview = True
|
||||
elif total_bytes == 0:
|
||||
print("[diagnostic] zero bytes received from the port at all - this points at "
|
||||
"the wrong COM port, another program holding the port, or a port that "
|
||||
"isn't actually wired to the console/sniffer output.")
|
||||
|
||||
# The device only sends the global header once, right when the sniffer first starts
|
||||
# (typically within a second or two of boot). If this script connects even slightly
|
||||
# late - very likely right after a fresh flash, since esptool itself resets the board -
|
||||
# that header is already gone before we ever see it. Rather than blocking forever
|
||||
# waiting for a header that's never coming, look for whichever marker shows up first.
|
||||
header_match = None if header_written else HEADER_RE.search(buf)
|
||||
pkt_match = PKT_RE.search(buf)
|
||||
|
||||
if header_match and (not pkt_match or header_match.start() < pkt_match.start()):
|
||||
length = int(header_match.group(1))
|
||||
buf = buf[header_match.end():]
|
||||
while len(buf) < length:
|
||||
buf += read_bytes(length - len(buf))
|
||||
header_bytes = buf[:length]
|
||||
outfile.write(header_bytes)
|
||||
outfile.flush()
|
||||
buf = buf[length:]
|
||||
header_written = True
|
||||
if length >= 24:
|
||||
link_type = int.from_bytes(header_bytes[20:24], "little")
|
||||
print(f"Got pcap global header (link type {link_type}) - device is streaming.\n")
|
||||
continue
|
||||
|
||||
if not header_written and pkt_match:
|
||||
link_type = LINKTYPE_IEEE802_11_RADIOTAP
|
||||
outfile.write(build_default_pcap_header(link_type))
|
||||
outfile.flush()
|
||||
header_written = True
|
||||
print("Note: missed the device's one-time pcap header (it was likely sent before "
|
||||
"this script connected, e.g. right after a flash/reset) - assuming WLAN "
|
||||
"radiotap capture and writing a default header instead.\n")
|
||||
# fall through and process pkt_match below, don't discard this packet
|
||||
|
||||
if not pkt_match:
|
||||
# Keep the buffer from growing unbounded while waiting for a marker, but don't
|
||||
# discard anything - a marker could be split across reads.
|
||||
if len(buf) > 65536:
|
||||
buf = buf[-4096:]
|
||||
continue
|
||||
|
||||
length = int(pkt_match.group(1))
|
||||
buf = buf[pkt_match.end():]
|
||||
while len(buf) < length:
|
||||
buf += read_bytes(length - len(buf))
|
||||
record_bytes = buf[:length]
|
||||
outfile.write(record_bytes)
|
||||
outfile.flush()
|
||||
buf = buf[length:]
|
||||
packet_count += 1
|
||||
print(summarize_packet(link_type, record_bytes, packet_count))
|
||||
except KeyboardInterrupt:
|
||||
print(f"\nStopped. {packet_count} packets saved to {outpath}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,249 @@
|
||||
# Requirements traceability matrix
|
||||
|
||||
Maps every section of `V2X_MicrOBU_Android_App_Requirements_v15.pdf` (37 pages, chapters 0–13) to
|
||||
its implementation and to the evidence that it works.
|
||||
|
||||
**Status as of 2026-08-25**, commit `cc35994`. Test counts refer to `app/src/test/` (41 tests,
|
||||
0 failures); bench measurements refer to `05-obu-bench-test-2026-08-25.md`.
|
||||
|
||||
## How to read this
|
||||
|
||||
The project began against the **consider it CiT One OBU** and later added the **ESP32-C5** as a
|
||||
second hardware path (requirements chapter 13). That transition is the single biggest source of
|
||||
divergence in this table, and it is deliberate rather than drift: chapter 13 was written to describe
|
||||
it. Where a requirement was authored assuming the CiT One, the ESP32-C5 path may satisfy it by a
|
||||
different mechanism, satisfy it only partly, or make it inapplicable.
|
||||
|
||||
Status values:
|
||||
|
||||
| status | meaning |
|
||||
|---|---|
|
||||
| **Done** | implemented and exercised on both hardware paths |
|
||||
| **Done (CiT One)** | implemented; applies only to the CiT One path by design |
|
||||
| **Done (ESP32-C5)** | implemented; applies only to the ESP32-C5 path by design |
|
||||
| **Partial** | implemented in part, with the remainder identified |
|
||||
| **Backlog** | explicitly deferred in the requirements themselves |
|
||||
| **Superseded** | the ESP32-C5 transition changed the answer; see the note |
|
||||
| **Not started** | no implementation |
|
||||
|
||||
"Cited" means a source file names the section in a KDoc comment — 23 files do. Absence of a citation
|
||||
is **not** absence of implementation; several well-covered areas were written before that convention
|
||||
and are mapped here by inspection.
|
||||
|
||||
---
|
||||
|
||||
## 0. Project Context & Main Goal
|
||||
|
||||
| § | Title | Status | Implementation | Evidence |
|
||||
|---|---|---|---|---|
|
||||
| 0.1 | Technological Innovation Context | n/a | context, not a requirement | — |
|
||||
| 0.2 | Bicycle Safety Use Case Foundation (C2C-CC) | **Done** | `DenmUseCase.kt` *(cited)*, `UseCaseType.kt` | use cases 1.1/1.2 below |
|
||||
|
||||
**Note.** 0.2 is the origin of the project's central architectural rule: the CAM-based use cases
|
||||
never generate DENM. `DenmUseCase.kt` records this, and it survived the ESP32 transition unchanged.
|
||||
|
||||
## 1. Phase 01 — Data Collection
|
||||
|
||||
| § | Title | Status | Implementation | Evidence |
|
||||
|---|---|---|---|---|
|
||||
| 1.1 | Test Intersections & Use Cases | **Done** | `UseCaseDetectionEngine.kt`, `UseCaseType.kt` *(both cited)* | IMA-S observed firing on hardware, 2026-08-25 |
|
||||
| 1.2 | C2C-CC Use Case Mapping & Roadmap | **Done** | `UseCaseType.kt`, `UseCaseDetectionConfig.kt` *(both cited)* | 5 use cases enumerated: IMA-B, IMA-S, RTW-B, LTW-B, SMVA/BCW-B |
|
||||
|
||||
## 2. Functional Requirements
|
||||
|
||||
| § | Title | Status | Implementation | Evidence |
|
||||
|---|---|---|---|---|
|
||||
| 2.1 | Screens & Features | **Done** | `ui/screens/` — Dashboard, Record, Trips, V2X, Settings | bench screenshots |
|
||||
| 2.2 | User Interactions | **Done** | `UseCaseAlertPreferences.kt` *(cited)*, Settings screen | per-use-case toggles |
|
||||
| 2.3 | Backend, Database & Login | **Partial** | `data/db/` (Room: sessions, trips, events, V2X messages) | no login/backend; local-only by design |
|
||||
|
||||
**Gap.** 2.3's backend and login have no implementation. Everything is on-device. Worth stating
|
||||
explicitly in the thesis as a scope boundary rather than leaving it to be discovered.
|
||||
|
||||
## 3. Non-Functional Requirements
|
||||
|
||||
| § | Title | Status | Implementation | Evidence |
|
||||
|---|---|---|---|---|
|
||||
| 3.1 | Android Version & Target Devices | **Done** | `minSdk 29`, `targetSdk 36` | runs on Pixel 9 Pro |
|
||||
| 3.2 | Performance & Offline | **Done** | offline by construction on the ESP32-C5 path | 210 MB PSS, 43 threads, no GC pressure attributable to the app |
|
||||
|
||||
**ESP32-C5 note.** 3.2's offline requirement is *more* satisfied after the transition: the ESP32-C5
|
||||
path needs no MQTT broker and no network at all.
|
||||
|
||||
## 4. Technical Requirements
|
||||
|
||||
| § | Title | Status | Implementation | Evidence |
|
||||
|---|---|---|---|---|
|
||||
| 4.1 | Development Environment | **Done** | Gradle 8.10.2, AGP, Android Studio JBR | builds from CLI |
|
||||
| 4.2 | Architecture | **Done** | `UseCaseDetectionEngine.kt` *(cited)*; MVVM + Hilt + repositories | pure-domain classes unit-tested without Android |
|
||||
| 4.3 | Key Libraries | **Done** | Compose, Room, Hilt, osmdroid, usb-serial-for-android, Paho MQTT | — |
|
||||
| 4.4 | Security Requirements (future) | **Backlog** | — | see note |
|
||||
|
||||
**Security note (4.4).** ETSI TS 103 097 message signing is not implemented and is out of scope. The
|
||||
firmware rejects secured packets (GN `NextHeader=2`) rather than mis-parsing them; 75 such frames
|
||||
were observed on the bench. The OBU under test runs `ItsGnSecurity = 0`, so this has not blocked
|
||||
anything. **This is the most likely reviewer question and the answer should be pre-written.**
|
||||
|
||||
## 5. Design Requirements
|
||||
|
||||
| § | Title | Status | Implementation | Evidence |
|
||||
|---|---|---|---|---|
|
||||
| 5.1–5.4 | Visual language, system bars, UX, wireframes | **Done** | Compose Material 3, edge-to-edge | screenshots |
|
||||
| 5.5 | Alert Level Model (C2C-CC three-tier) | **Done** | `AlertLevel.kt`, `UseCaseDetectionConfig.kt`, `UseCaseDetectionEngine.kt` *(all cited)* | Info / Awareness / Warning; observed WARNING on hardware |
|
||||
|
||||
## 6. Sensor Data Streams (Phase 01)
|
||||
|
||||
| § | Title | Status | Implementation | Evidence |
|
||||
|---|---|---|---|---|
|
||||
| 6 | Sensor Data Streams | **Done** | `SensorRepository.kt`, `CamUseCaseRepository.kt` *(cited)* | GNSS, accel, gyro, magnetometer, barometer |
|
||||
|
||||
## 7. Recording Session & Data Export
|
||||
|
||||
| § | Title | Status | Implementation | Evidence |
|
||||
|---|---|---|---|---|
|
||||
| 7.1 | Recording Mode | **Done** | `TripRecordingService.kt`, `RecordingScreen.kt` | — |
|
||||
| 7.2 | CSV Export Format | **Done** | `CsvExporter.kt`, `TripExporter.kt` | export includes V2X messages and RSSI |
|
||||
|
||||
## 8. Connectivity
|
||||
|
||||
| § | Title | Status | Implementation | Evidence |
|
||||
|---|---|---|---|---|
|
||||
| 8.1 | Connection Phase Roadmap | **Superseded** | `ObuHardware.kt`, `TransportType.kt` | chapter 13 replaced the roadmap |
|
||||
| 8.2 | Transport Methods Detail | **Partial** | `UsbSerialTransport.kt`, `UsbNetworkDetector.kt`, `MqttRepository.kt` | USB-C both paths; **Bluetooth not implemented** (13.9 keeps it open) |
|
||||
|
||||
## 9. Phase 01 Key Design Principles
|
||||
|
||||
| § | Title | Status | Implementation | Evidence |
|
||||
|---|---|---|---|---|
|
||||
| 9 | Key Design Principles | **Done** | pure-domain `domain/` packages, no Android imports | `EventDetectorTest`, `UseCaseDetectionEngine` unit-testable |
|
||||
|
||||
## 10. Phase 02 — OBU Communication (CAM-based use cases)
|
||||
|
||||
| § | Title | Status | Implementation | Evidence |
|
||||
|---|---|---|---|---|
|
||||
| 10.1 | USB-C Wired Transport | **Done (CiT One)** | `UsbNetworkDetector.kt` (IP over USB tethering) | superseded on the ESP32-C5 path by USB serial |
|
||||
| 10.2 | CAM Reception & Use Case Detection | **Done** | `Cam.kt`, `CamUseCaseRepository.kt`, `MqttViewModel.kt`, `MqttTopicViewerScreen.kt` *(all cited)* | 1244 CAMs decoded in 305 s, 0 failures |
|
||||
| 10.2.1 | Detection Algorithm Detail | **Done** | `UseCaseDetectionEngine.kt`, `UseCaseDetectionConfig.kt` | IMA-S fired with TTC 4.8 s on hardware |
|
||||
| 10.3 | Test and Verification Procedure | **Done** | `CamParser.kt`, `UseCaseDetectionConfig.kt`, `UseCaseDetectionEngine.kt` *(all cited)* | extended well beyond the original procedure — see chapter 14 below |
|
||||
| 10.4 | New/Updated UI Elements | **Done** | `MqttTopicViewerScreen.kt` *(cited)* | alert panel, list/topics/map |
|
||||
| 10.5 | Phase 02 Key Technical Decisions | **Done** | `DenmUseCase.kt` | DENM decoupled from sensor triggers |
|
||||
|
||||
**Transition note (10.1).** Phase 02 assumed IP-over-USB tethering to the CiT One. The ESP32-C5 path
|
||||
uses a custom framed serial protocol over USB CDC instead. Both are "USB-C wired transport", but they
|
||||
share no code. The requirement is satisfied twice, by different means.
|
||||
|
||||
## 11. Phase A — Trip Recording & Cyclist Event Detection
|
||||
|
||||
| § | Title | Status | Implementation | Evidence |
|
||||
|---|---|---|---|---|
|
||||
| 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 | **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.
|
||||
Investigation (2026-08-25) established the **detector was correct and the tests were wrong**: they
|
||||
described stimuli the detector cannot physically see, because they ignored the settling time of the
|
||||
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 |
|
||||
|---|---|---|---|
|
||||
| 12.1 | Multi-Vehicle Handling & Notification Limits | **Partial** | engine tracks per-station history; no notification cap |
|
||||
| 12.2 | Intersection Ambiguity: Traffic-Light State | **Partial — advanced** | **SPATEM now decoded** (`SpatemUperCodec.kt`), see below |
|
||||
| 12.3 | Shared-Road / Bike Lane Awareness Dataset | **Not started** | would need MAPEM lane geometry |
|
||||
| 12.4 | Geo-Server for Crowdsourced Trajectory Data | **Not started** | related to the 2.3 backend gap |
|
||||
| 12.5 | Geofencing Around High-Risk Intersections | **Not started** | — |
|
||||
| 12.6 | Sensor Fusion Roadmap (Kalman Filter) | **Not started** | — |
|
||||
| 12.7 | EventDetector Evolution | **Not started** | — |
|
||||
|
||||
**12.2 is the notable movement.** The requirements listed traffic-light state as future/backlog. It
|
||||
is now partly delivered: SPATEM is received over the air and decoded to per-signal-group phase and
|
||||
timing, validated against 79,042 real messages. What remains is the *association* problem — knowing
|
||||
which signal group applies to the rider's lane — which needs MAPEM geometry (12.3). Worth presenting
|
||||
as a backlog item advanced ahead of schedule, with the remaining half named precisely.
|
||||
|
||||
## 13. Phase 03 — ESP32-C5 Dual-OBU & Phone-Generated CAM
|
||||
|
||||
The transition chapter. Cited by **16 source files**, more than any other.
|
||||
|
||||
| § | Title | Status | Implementation | Evidence |
|
||||
|---|---|---|---|---|
|
||||
| 13.1 | ESP32-C5 as a Second OBU Option | **Done** | `obu-firmware/`, `ObuHardware.kt` | bench campaign T1–T9 |
|
||||
| 13.2 | Transport: USB-C to ESP32 | **Done** | `SerialFrame.kt`, `serial_link.c` | 2868 frames / 305 s, 0 errors |
|
||||
| 13.3 | Settings: OBU Hardware Selection | **Done** | `ObuHardwarePreferences.kt`, Settings | both paths selectable |
|
||||
| 13.4 | DENM Trigger Retained for CiT One Only | **Done (CiT One)** | `MqttTopicViewerScreen.kt` gating | trigger hidden on the ESP32-C5 path |
|
||||
| 13.5 | Phone-Generated CAM | **Done (ESP32-C5)** | `PhoneCamBuilder.kt`, `CamUperCodec.kt`, `CamTransmitLoop.kt` | **golden-byte test**; 26 own CAMs verified off-air by an independent decoder |
|
||||
| 13.6 | Adaptive CAM Transmission Rate | **Partial** | `CamTransmitConfig.kt` | fixed 1 Hz pinger; adaptive rate not implemented |
|
||||
| 13.7 | UI Updates: Map View & Dashboard | **Done** | `V2xLiveMapView.kt`, `DashboardScreen.kt` | screenshots |
|
||||
| 13.8 | Detection Engine: Unchanged for Reception | **Done** | `UseCaseDetectionEngine.kt` | engine is transport-agnostic; confirmed by inspection — zero SPATEM/MAPEM references |
|
||||
| 13.9 | Bluetooth Transport (Open Discussion) | **Not started** | — | remains open, as the requirement says |
|
||||
|
||||
**13.8 is the load-bearing claim of the transition** and it holds: the same detection engine serves
|
||||
both hardware paths, fed by `CamUseCaseRepository` from either MQTT or serial. That is what makes the
|
||||
ESP32-C5 a drop-in second OBU rather than a fork of the application.
|
||||
|
||||
---
|
||||
|
||||
## Beyond the requirements
|
||||
|
||||
Work delivered that chapter 13 does not cover, because it postdates v15 of the document. For a
|
||||
publication these are contributions rather than scope creep, and they need their own section.
|
||||
|
||||
| area | what | evidence |
|
||||
|---|---|---|
|
||||
| DENM over-the-air receive | GeoBroadcast unwrapping, `DenmUperCodec` | 1885 DENMs cross-checked, 0 mismatches |
|
||||
| SPATEM receive | `SpatemUperCodec`, live signal phase UI | 79,042 messages cross-checked, 0 mismatches |
|
||||
| RSU CAM support | `rsuContainerHighFrequency` decoding | 611 RSU CAMs decoded on the bench |
|
||||
| Verification methodology | independent ETSI oracle, golden bytes, real captures | three encoding bugs found that self-consistent tests structurally cannot catch |
|
||||
|
||||
## Open items
|
||||
|
||||
Ranked by what a reviewer is most likely to probe.
|
||||
|
||||
1. **11.6 Phase A success criteria** — needs a real ride. The bench proves reception; it cannot
|
||||
prove the use case behaves correctly with two genuinely moving stations. This is the main
|
||||
remaining evidence gap for the central claim.
|
||||
2. **4.4 Security** — no message signing. Pre-write the answer.
|
||||
3. **2.3 Backend & login** — not implemented; state as a scope boundary.
|
||||
4. **13.6 Adaptive CAM rate** — fixed 1 Hz.
|
||||
5. **8.2 / 13.9 Bluetooth** — not implemented; the requirements leave it open.
|
||||
6. **12.2 remainder** — signal-group-to-lane association needs MAPEM.
|
||||
|
||||
## Known limitations carried deliberately
|
||||
|
||||
Documented decisions, not oversights. Each has its reasoning recorded in the commit history and in
|
||||
`05-obu-bench-test-2026-08-25.md`.
|
||||
|
||||
- **512-byte serial payload cap.** ~70% of *road* RSU SPATEMs would be dropped. Accepted: the
|
||||
intersection use case is CAM-driven and needs none of it, and raising the cap would put a
|
||||
measured, zero-failure chain at risk for an add-on. CAM 26–211 B and DENM 402 B both fit.
|
||||
- **No MAPEM decoder.** Nothing on air transmits it; it exists only in recorded drive data.
|
||||
- **Secured messages rejected**, not mis-parsed.
|
||||
- **No auto-reconnect** after USB re-enumeration; requires a manual Connect.
|
||||
- **Station IDs rotate** (observed twice within one session), so they cannot identify a physical
|
||||
unit over time.
|
||||
@@ -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.
@@ -0,0 +1,151 @@
|
||||
% MicrOBU standards and source references.
|
||||
% Collected for the technical documentation and any later publication.
|
||||
% Keep entry keys stable once cited.
|
||||
|
||||
@techreport{etsi_en_302_637_2,
|
||||
author = {{ETSI}},
|
||||
title = {Intelligent Transport Systems (ITS); Vehicular Communications; Basic Set of Applications; Part 2: Specification of Cooperative Awareness Basic Service},
|
||||
institution = {European Telecommunications Standards Institute},
|
||||
type = {{EN}},
|
||||
number = {302 637-2},
|
||||
note = {CAM encode and decode},
|
||||
}
|
||||
|
||||
@techreport{etsi_en_302_637_3,
|
||||
author = {{ETSI}},
|
||||
title = {Intelligent Transport Systems (ITS); Vehicular Communications; Basic Set of Applications; Part 3: Specifications of Decentralized Environmental Notification Basic Service},
|
||||
institution = {European Telecommunications Standards Institute},
|
||||
type = {{EN}},
|
||||
number = {302 637-3},
|
||||
note = {DENM decode and the HLN-SV transmit profile},
|
||||
}
|
||||
|
||||
@techreport{etsi_ts_103_301,
|
||||
author = {{ETSI}},
|
||||
title = {Intelligent Transport Systems (ITS); Vehicular Communications; Basic Set of Applications; Facilities layer protocols and communication requirements for infrastructure services},
|
||||
institution = {European Telecommunications Standards Institute},
|
||||
type = {{TS}},
|
||||
number = {103 301},
|
||||
note = {SPATEM and MAPEM},
|
||||
}
|
||||
|
||||
@techreport{sae_j2735,
|
||||
author = {{SAE International}},
|
||||
title = {V2X Communications Message Set Dictionary},
|
||||
institution = {SAE International},
|
||||
type = {Standard},
|
||||
number = {J2735},
|
||||
note = {SPATEM and MAPEM message content},
|
||||
}
|
||||
|
||||
@techreport{etsi_ts_102_894_2,
|
||||
author = {{ETSI}},
|
||||
title = {Intelligent Transport Systems (ITS); Users and applications requirements; Part 2: Applications and facilities layer common data dictionary},
|
||||
institution = {European Telecommunications Standards Institute},
|
||||
type = {{TS}},
|
||||
number = {102 894-2},
|
||||
note = {Common data dictionary for all messages},
|
||||
}
|
||||
|
||||
@techreport{etsi_en_302_636_4_1,
|
||||
author = {{ETSI}},
|
||||
title = {Intelligent Transport Systems (ITS); Vehicular Communications; GeoNetworking; Part 4: Geographical addressing and forwarding for point-to-point and point-to-multipoint communications; Sub-part 1: Media-Independent Functionality},
|
||||
institution = {European Telecommunications Standards Institute},
|
||||
type = {{EN}},
|
||||
number = {302 636-4-1},
|
||||
note = {GeoNetworking Basic, Common and SHB headers; verified field by field},
|
||||
}
|
||||
|
||||
@techreport{etsi_en_302_636_5_1,
|
||||
author = {{ETSI}},
|
||||
title = {Intelligent Transport Systems (ITS); Vehicular Communications; GeoNetworking; Part 5: Transport Protocols; Sub-part 1: Basic Transport Protocol},
|
||||
institution = {European Telecommunications Standards Institute},
|
||||
type = {{EN}},
|
||||
number = {302 636-5-1},
|
||||
note = {BTP-B header},
|
||||
}
|
||||
|
||||
@techreport{etsi_ts_103_248,
|
||||
author = {{ETSI}},
|
||||
title = {Intelligent Transport Systems (ITS); GeoNetworking; Port Numbers for the Basic Transport Protocol (BTP)},
|
||||
institution = {European Telecommunications Standards Institute},
|
||||
type = {{TS}},
|
||||
number = {103 248},
|
||||
note = {BTP destination ports 2001, 2002, 2003, 2004},
|
||||
}
|
||||
|
||||
@techreport{etsi_ts_103_097,
|
||||
author = {{ETSI}},
|
||||
title = {Intelligent Transport Systems (ITS); Security; Security header and certificate formats},
|
||||
institution = {European Telecommunications Standards Institute},
|
||||
type = {{TS}},
|
||||
number = {103 097},
|
||||
note = {Message signing; not implemented in this project},
|
||||
}
|
||||
|
||||
@techreport{ieee_802_11_ocb,
|
||||
author = {{IEEE}},
|
||||
title = {IEEE Standard for Information Technology; Telecommunications and Information Exchange between Systems; Local and Metropolitan Area Networks; Specific Requirements; Part 11: Wireless LAN Medium Access Control (MAC) and Physical Layer (PHY) Specifications},
|
||||
institution = {Institute of Electrical and Electronics Engineers},
|
||||
type = {Standard},
|
||||
number = {802.11},
|
||||
note = {Operation outside the context of a BSS (OCB), the frame layer used by ITS-G5},
|
||||
}
|
||||
|
||||
@techreport{c2ccc_wp_2324,
|
||||
author = {{Car 2 Car Communication Consortium}},
|
||||
title = {Bicycle Safety Use Cases},
|
||||
institution = {Car 2 Car Communication Consortium},
|
||||
type = {White Paper},
|
||||
number = {C2CCC\_WP\_2324},
|
||||
version = {1.0},
|
||||
year = {2026},
|
||||
month = {5},
|
||||
note = {Source of the five in-scope use cases: IMA-B, IMA-S, RTW-B, LTW-B, SMVA/BCW-B},
|
||||
}
|
||||
|
||||
@techreport{microbu_requirements_v15,
|
||||
author = {{HAW Hamburg}},
|
||||
title = {V2X MicrOBU Android App Requirements},
|
||||
institution = {HAW Hamburg, Urban Mobility Lab},
|
||||
version = {15},
|
||||
note = {The requirements baseline, chapters 0 to 13, 37 pages},
|
||||
}
|
||||
|
||||
@manual{considerit_mqtt_api_v6,
|
||||
author = {{consider it GmbH}},
|
||||
title = {CiT MQTT API Documentation},
|
||||
version = {6},
|
||||
year = {2025},
|
||||
month = {2},
|
||||
note = {The MQTT API used on the CiT One hardware path},
|
||||
}
|
||||
|
||||
@manual{considerit_tx_use_cases_v4,
|
||||
author = {{consider it GmbH}},
|
||||
title = {CiT Transmit Use Cases},
|
||||
version = {4},
|
||||
year = {2025},
|
||||
month = {2},
|
||||
note = {Transmit use case definitions including HLN-SV, causeCode 94},
|
||||
}
|
||||
|
||||
@misc{considerit_cits_parser,
|
||||
author = {{consider it GmbH}},
|
||||
title = {{C-ITS-Parser}},
|
||||
howpublished = {\url{https://github.com/consider-it/C-ITS-Parser}},
|
||||
note = {Commit f457426efc2486fac49a02fc9a1c8c7762d160e9, MIT licensed. Source of the seven vendored ASN.1 modules in \texttt{asn1/}},
|
||||
}
|
||||
|
||||
@misc{asn1tools,
|
||||
title = {{asn1tools}},
|
||||
howpublished = {Python package, version 0.167.0},
|
||||
note = {The independent ASN.1 implementation used as the verification oracle},
|
||||
}
|
||||
|
||||
@misc{its_g5_receiver_firmware_txenabled,
|
||||
author = {{opentrafficmap}},
|
||||
title = {{its-g5-receiver-firmware\_txenabled}},
|
||||
howpublished = {\url{https://codeberg.org/opentrafficmap/its-g5-receiver-firmware_txenabled}},
|
||||
note = {Transmit-enabled fork of the receiver firmware; source of the raw transmit bypass in \texttt{tx\_custom.c}},
|
||||
}
|
||||
@@ -4,6 +4,7 @@ kotlin = "2.1.0"
|
||||
ksp = "2.1.0-1.0.29"
|
||||
hilt = "2.56.2"
|
||||
junit = "4.13.2"
|
||||
json = "20240303"
|
||||
hiltNavigationCompose = "1.2.0"
|
||||
datastore = "1.1.1"
|
||||
pahoMqtt = "1.2.5"
|
||||
@@ -49,6 +50,7 @@ osmdroid = { group = "org.osmdroid", name = "osmdroid-android", version.ref = "o
|
||||
androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "splashscreen" }
|
||||
usb-serial-android = { group = "com.github.mik3y", name = "usb-serial-for-android", version.ref = "usbSerial" }
|
||||
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||
json = { group = "org.json", name = "json", version.ref = "json" }
|
||||
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" }
|
||||
|
||||
[plugins]
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
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)
|
||||
@@ -0,0 +1,99 @@
|
||||
# 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.
|
||||
|
||||
## Toolchain: use a dedicated terminal (ESP-IDF 5.5.4)
|
||||
|
||||
This project builds against the **global** ESP-IDF 5.5.4, NOT the 6.1 checkout
|
||||
that `obu-firmware` uses. Keep one terminal per toolchain and never export both
|
||||
in the same window - the second export inherits the first's
|
||||
`IDF_PYTHON_ENV_PATH` and then fails every dependency check (`click`,
|
||||
`esptool`, `cryptography`, ... "not met"). That is env-var bleed, not a broken
|
||||
install: do **not** run `install.bat` to "fix" it, that damages one of the two
|
||||
environments.
|
||||
|
||||
| Terminal | Export | Project |
|
||||
|---|---|---|
|
||||
| Transmitter | `C:\Espressif\frameworks\esp-idf-v5.5.4\export.ps1` | this one |
|
||||
| OBU | `...\micrOBU_workspace\its-g5-receiver-firmware\esp-idf\export.ps1` | `obu-firmware` |
|
||||
|
||||
If a terminal has already been used for the other IDF, clear the state first:
|
||||
|
||||
```powershell
|
||||
$env:IDF_PYTHON_ENV_PATH = $null; $env:IDF_PATH = $null
|
||||
```
|
||||
|
||||
Also note `build/` here was regenerated from scratch (its CMake cache still
|
||||
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.
|
||||
|
||||
## CAM encoding
|
||||
|
||||
`main/cam.c` IS compiled here (unlike `obu-firmware`'s copy, which is a
|
||||
reference only). It must stay bit-identical to `obu-firmware/main/cam.c` and
|
||||
the app's `CamUperCodec.kt` - all three encode the same wire format, and a
|
||||
one-bit divergence in any of them is invisible on the bench but wrong against
|
||||
real equipment. See the `CurvatureCalculationMode` comment in that file.
|
||||
|
||||
Implements one profile so far: **HLN-SV** (aftermarket stationary recovery
|
||||
vehicle), causeCode 94 (stationaryVehicle), subCauseCode 0, active while the
|
||||
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/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
|
||||
2004-01-01), fixed (non-rotating) pseudonym MAC, SHB instead of GeoBroadcast
|
||||
(no multi-hop forwarding), unsecured (no IEEE 1609.2 signing).
|
||||
|
||||
## Running it as a bench beacon
|
||||
|
||||
This firmware needs no phone: it beacons a CAM every second by itself
|
||||
(`TX_INTERVAL_MS`) from station `0x0BADC0DE` (195936478), stationType 5
|
||||
(passengerCar), at the hardcoded bench position, under the fixed MAC
|
||||
`02:00:00:00:00:01`, on 5900 MHz. That makes it the quickest way to put known,
|
||||
repeatable traffic on air, and it is how the 4-bit `yawRateConfidence` encoding
|
||||
was confirmed over the air on 2026-09-14.
|
||||
|
||||
A board with only one USB-C port is fine. This firmware's console is on UART0,
|
||||
so such a board shows no log output, but nothing here needs the console.
|
||||
|
||||
Flash it from the toolchain terminal (ESP-IDF 5.5.4, see the table above):
|
||||
|
||||
```powershell
|
||||
cd C:\Users\Ashin\AndroidStudioProjects\MicrOBU\obu-cam-transmistter
|
||||
idf.py -p COM10 -b 921600 flash
|
||||
```
|
||||
|
||||
Or flash the existing build without any toolchain terminal:
|
||||
|
||||
```powershell
|
||||
cd obu-cam-transmistter\build
|
||||
C:\Espressif\python_env\idf5.5_py3.11_env\Scripts\python.exe -m esptool --chip esp32c5 -p COM10 -b 921600 write_flash --flash_mode dio --flash_freq 80m --flash_size 2MB 0x2000 bootloader/bootloader.bin 0x8000 partition_table/partition-table.bin 0x10000 obu_firmware.bin
|
||||
```
|
||||
|
||||
It starts beaconing as soon as it boots, so there is nothing to start by hand,
|
||||
and unplugging it is how you stop it.
|
||||
|
||||
**It transmits under the same MAC as the phone's CAM pinger**, so on air the two
|
||||
are told apart by station ID (195936478 here, 999999 for the pinger), never by
|
||||
source address.
|
||||
|
||||
To see what it is sending, capture on the sniffer board and decode:
|
||||
|
||||
```powershell
|
||||
cd capture
|
||||
py -3.11 live_capture.py COM8
|
||||
py -3.11 ..\obu-firmware\test\pcap_gn_tally.py recordings\capture_<timestamp>.pcap
|
||||
```
|
||||
|
||||
The tally lists it as SHB / port 2001 / lifetime `0x05`. For the message itself,
|
||||
decode the payload with `asn1tools` against `asn1/cam_1_4_1.asn` +
|
||||
`asn1/cdd_1_3_1_1.asn`; re-encoding must return the identical bytes. On
|
||||
2026-09-14, 72 of 72 frames did.
|
||||
@@ -0,0 +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"
|
||||
INCLUDE_DIRS "."
|
||||
REQUIRES esp_event esp_netif nvs_flash driver esp_phy
|
||||
PRIV_REQUIRES esp_wifi
|
||||
)
|
||||
@@ -0,0 +1,141 @@
|
||||
#include "cam.h"
|
||||
#include <string.h>
|
||||
|
||||
// MSB-first bit packer - identical approach to denm.c (ASN.1 UPER is a
|
||||
// bitstream, not a byte stream).
|
||||
typedef struct {
|
||||
uint8_t *buf;
|
||||
size_t buf_len;
|
||||
size_t bit_pos;
|
||||
} bitwriter_t;
|
||||
|
||||
static void bw_init(bitwriter_t *bw, uint8_t *buf, size_t len)
|
||||
{
|
||||
bw->buf = buf;
|
||||
bw->buf_len = len;
|
||||
bw->bit_pos = 0;
|
||||
memset(buf, 0, len);
|
||||
}
|
||||
|
||||
static void bw_put_bits(bitwriter_t *bw, uint64_t value, int nbits)
|
||||
{
|
||||
for (int i = nbits - 1; i >= 0; i--) {
|
||||
size_t byte_idx = bw->bit_pos / 8;
|
||||
int bit_idx = 7 - (int)(bw->bit_pos % 8);
|
||||
if (byte_idx >= bw->buf_len) {
|
||||
return; // overflow guard - check return value of cam_encode
|
||||
}
|
||||
uint8_t bit = (value >> i) & 1;
|
||||
bw->buf[byte_idx] = (uint8_t)(bw->buf[byte_idx] | (bit << bit_idx));
|
||||
bw->bit_pos++;
|
||||
}
|
||||
}
|
||||
|
||||
static size_t bw_byte_len(const bitwriter_t *bw)
|
||||
{
|
||||
return (bw->bit_pos + 7) / 8;
|
||||
}
|
||||
|
||||
int cam_encode(const cam_fields_t *f, uint8_t *buf, size_t buf_len)
|
||||
{
|
||||
bitwriter_t bw;
|
||||
bw_init(&bw, buf, buf_len);
|
||||
|
||||
// ---- ItsPduHeader ---- (SEQUENCE, no OPTIONALs, no "..." -> no preamble)
|
||||
bw_put_bits(&bw, 2, 8); // protocolVersion INTEGER(0..255) = 2
|
||||
bw_put_bits(&bw, 2, 8); // messageID INTEGER(0..255) = cam(2)
|
||||
bw_put_bits(&bw, f->station_id, 32); // stationID StationID INTEGER(0..4294967295)
|
||||
|
||||
// ---- CoopAwareness ---- (SEQUENCE, no OPTIONALs, no "...")
|
||||
// generationDeltaTime GenerationDeltaTime INTEGER(0..65535) -> 16 bits
|
||||
bw_put_bits(&bw, f->generation_delta_time, 16);
|
||||
|
||||
// ---- CamParameters ---- (SEQUENCE, EXTENSIBLE "...", 2 OPTIONALs:
|
||||
// lowFrequencyContainer, specialVehicleContainer)
|
||||
bw_put_bits(&bw, 0, 1); // extension bit: no extension additions
|
||||
bw_put_bits(&bw, 1, 1); // lowFrequencyContainer present
|
||||
bw_put_bits(&bw, 0, 1); // specialVehicleContainer absent
|
||||
|
||||
// ---- BasicContainer ---- (SEQUENCE, EXTENSIBLE "...", no OPTIONALs)
|
||||
bw_put_bits(&bw, 0, 1); // extension bit: none
|
||||
bw_put_bits(&bw, f->station_type, 8); // stationType StationType INTEGER(0..255)
|
||||
|
||||
// ReferencePosition (SEQUENCE, no OPTIONALs/"..."), identical widths to
|
||||
// DENM eventPosition (see denm.c for the constraint derivations):
|
||||
// Latitude INTEGER(-900000000..900000001) -> 31 bits, offset from -900000000
|
||||
uint32_t lat_offset = (uint32_t)((int64_t)f->latitude_tenmicrodeg - (-900000000));
|
||||
bw_put_bits(&bw, lat_offset, 31);
|
||||
// Longitude INTEGER(-1800000000..1800000001) -> 32 bits, offset from -1800000000
|
||||
uint32_t lon_offset = (uint32_t)((int64_t)f->longitude_tenmicrodeg - (-1800000000));
|
||||
bw_put_bits(&bw, lon_offset, 32);
|
||||
// PosConfidenceEllipse: SemiAxisLength(0..4095)->12, HeadingValue(0..3601)->12
|
||||
bw_put_bits(&bw, 4095, 12); // semiMajorConfidence: unavailable
|
||||
bw_put_bits(&bw, 4095, 12); // semiMinorConfidence: unavailable
|
||||
bw_put_bits(&bw, 3601, 12); // semiMajorOrientation: unavailable
|
||||
// Altitude: AltitudeValue(-100000..800001)->20 (offset from -100000),
|
||||
// AltitudeConfidence ENUM 16 values -> 4 bits
|
||||
bw_put_bits(&bw, 900001, 20); // 800001 ("unavailable") - (-100000) = 900001
|
||||
bw_put_bits(&bw, 15, 4); // altitudeConfidence: unavailable(15)
|
||||
|
||||
// ---- HighFrequencyContainer ---- CHOICE { basicVehicleContainerHighFrequency,
|
||||
// rsuContainerHighFrequency, ... } - EXTENSIBLE, 2 root alternatives.
|
||||
bw_put_bits(&bw, 0, 1); // CHOICE extension bit: value is in root
|
||||
bw_put_bits(&bw, 0, 1); // index: 0 = basicVehicleContainerHighFrequency (1 bit for 2 alts)
|
||||
|
||||
// BasicVehicleContainerHighFrequency (SEQUENCE, NOT extensible, 7 OPTIONALs
|
||||
// accelerationControl..cenDsrcTollingZone - all absent).
|
||||
bw_put_bits(&bw, 0, 7); // 7 optional-presence bits, all absent
|
||||
|
||||
// Heading: HeadingValue(0..3601)->12, HeadingConfidence(1..127)->7 (offset from 1)
|
||||
bw_put_bits(&bw, f->heading_ddeg, 12);
|
||||
bw_put_bits(&bw, 127 - 1, 7); // headingConfidence: unavailable(127)
|
||||
// Speed: SpeedValue(0..16383)->14, SpeedConfidence(1..127)->7 (offset from 1)
|
||||
bw_put_bits(&bw, f->speed_cm_s, 14);
|
||||
bw_put_bits(&bw, 127 - 1, 7); // speedConfidence: unavailable(127)
|
||||
// DriveDirection ENUM {forward,backward,unavailable} -> 2 bits
|
||||
bw_put_bits(&bw, 2, 2); // unavailable
|
||||
// VehicleLength: VehicleLengthValue(1..1023)->10 (offset from 1),
|
||||
// VehicleLengthConfidenceIndication ENUM 5 values -> 3 bits
|
||||
bw_put_bits(&bw, (uint32_t)f->vehicle_length_dm - 1, 10);
|
||||
bw_put_bits(&bw, 4, 3); // vehicleLengthConfidenceIndication: unavailable(4)
|
||||
// VehicleWidth INTEGER(1..62) -> 6 bits (offset from 1)
|
||||
bw_put_bits(&bw, (uint32_t)f->vehicle_width_dm - 1, 6);
|
||||
// LongitudinalAcceleration: value(-160..161)->9 (offset from -160),
|
||||
// AccelerationConfidence(0..102)->7
|
||||
bw_put_bits(&bw, 161 - (uint32_t)(-160), 9); // longitudinalAccelerationValue: unavailable(161)
|
||||
bw_put_bits(&bw, 102, 7); // confidence: unavailable(102)
|
||||
// Curvature: CurvatureValue(-1023..1023)->11 (offset from -1023),
|
||||
// CurvatureConfidence ENUM 8 values -> 3 bits
|
||||
bw_put_bits(&bw, 1023 - (uint32_t)(-1023), 11); // curvatureValue: unavailable(1023)
|
||||
bw_put_bits(&bw, 7, 3); // curvatureConfidence: unavailable(7)
|
||||
// CurvatureCalculationMode ENUM {yawRateUsed,yawRateNotUsed,unavailable, ...} - note the
|
||||
// extension marker: UPER encodes an extensible ENUMERATED as an extension bit followed by
|
||||
// the root-list index, so this is 1 + 2 = 3 bits, NOT 2. Writing only the 2-bit index shifted
|
||||
// yawRate and the entire low-frequency container one bit early for any standards-compliant
|
||||
// receiver - including, after its matching fix, the phone app's own decoder.
|
||||
// Keep in lockstep with obu-firmware/main/cam.c and the app's CamUperCodec.kt.
|
||||
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 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, 8, 4); // yawRateConfidence: unavailable(8)
|
||||
|
||||
// ---- LowFrequencyContainer ---- CHOICE { basicVehicleContainerLowFrequency,
|
||||
// ... } - EXTENSIBLE, 1 root alternative (index needs 0 bits).
|
||||
bw_put_bits(&bw, 0, 1); // CHOICE extension bit: value is in root
|
||||
|
||||
// BasicVehicleContainerLowFrequency (SEQUENCE, no OPTIONALs/"...")
|
||||
// vehicleRole VehicleRole ENUM 16 values -> 4 bits
|
||||
bw_put_bits(&bw, 0, 4); // default(0)
|
||||
// exteriorLights ExteriorLights BIT STRING(SIZE(8)) -> 8 bits, all off
|
||||
bw_put_bits(&bw, 0, 8);
|
||||
// pathHistory PathHistory ::= SEQUENCE(SIZE(0..40)) OF PathPoint -> count 0..40 = 6 bits
|
||||
bw_put_bits(&bw, 0, 6); // empty path history
|
||||
|
||||
return (int)bw_byte_len(&bw);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
#ifndef CAM_H
|
||||
#define CAM_H
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
|
||||
// Minimal CAM (Cooperative Awareness Message) per ETSI EN 302 637-2 v1.4.1
|
||||
// (CAM-PDU-Descriptions) + TS 102 894-2 v1.3.1 (CDD / ITS-Container), matching
|
||||
// the field set the working Rust reference (esp32-c_its-companion, feat/tx-cam,
|
||||
// src/applogic/cam_tx.rs) transmits:
|
||||
// - ItsPduHeader (protocolVersion 2, messageID 2 = cam)
|
||||
// - CoopAwareness { generationDeltaTime, camParameters }
|
||||
// - CamParameters {
|
||||
// basicContainer { stationType, referencePosition },
|
||||
// highFrequencyContainer = basicVehicleContainerHighFrequency { ... },
|
||||
// lowFrequencyContainer = basicVehicleContainerLowFrequency { ... }
|
||||
// }
|
||||
// All vehicle-dynamics fields we don't measure are encoded as their ASN.1
|
||||
// "unavailable" value. Speed is a real 0 (correct for a stationary station).
|
||||
|
||||
typedef struct {
|
||||
uint32_t station_id;
|
||||
uint8_t station_type; // StationType(0..255): 5 = passengerCar
|
||||
uint16_t generation_delta_time; // TimestampIts mod 65536 (ms); 0 until a real clock is wired
|
||||
int32_t latitude_tenmicrodeg; // Latitude, 1/10 microdegree
|
||||
int32_t longitude_tenmicrodeg; // Longitude, 1/10 microdegree
|
||||
uint16_t speed_cm_s; // SpeedValue, 0.01 m/s units (0 = stationary)
|
||||
uint16_t heading_ddeg; // HeadingValue, 0.1 deg units (0..3600), 3601 = unavailable
|
||||
uint16_t vehicle_length_dm; // VehicleLengthValue(1..1023), 10cm steps
|
||||
uint8_t vehicle_width_dm; // VehicleWidth(1..62), 10cm steps
|
||||
} cam_fields_t;
|
||||
|
||||
// Encodes the CAM as ASN.1 UPER. Returns bytes written, or -1 if buf too small.
|
||||
int cam_encode(const cam_fields_t *f, uint8_t *buf, size_t buf_len);
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,153 @@
|
||||
#include "denm.h"
|
||||
#include <string.h>
|
||||
|
||||
// Minimal MSB-first bit packer - ASN.1 UPER is a bitstream, not a byte
|
||||
// stream, so we can't just memcpy structs.
|
||||
typedef struct {
|
||||
uint8_t *buf;
|
||||
size_t buf_len;
|
||||
size_t bit_pos;
|
||||
} bitwriter_t;
|
||||
|
||||
static void bw_init(bitwriter_t *bw, uint8_t *buf, size_t len)
|
||||
{
|
||||
bw->buf = buf;
|
||||
bw->buf_len = len;
|
||||
bw->bit_pos = 0;
|
||||
memset(buf, 0, len);
|
||||
}
|
||||
|
||||
static void bw_put_bits(bitwriter_t *bw, uint64_t value, int nbits)
|
||||
{
|
||||
for (int i = nbits - 1; i >= 0; i--) {
|
||||
size_t byte_idx = bw->bit_pos / 8;
|
||||
int bit_idx = 7 - (int)(bw->bit_pos % 8);
|
||||
if (byte_idx >= bw->buf_len) {
|
||||
return; // overflow guard - silently truncates, check return value of denm_encode
|
||||
}
|
||||
uint8_t bit = (value >> i) & 1;
|
||||
bw->buf[byte_idx] = (uint8_t)(bw->buf[byte_idx] | (bit << bit_idx));
|
||||
bw->bit_pos++;
|
||||
}
|
||||
}
|
||||
|
||||
static size_t bw_byte_len(const bitwriter_t *bw)
|
||||
{
|
||||
return (bw->bit_pos + 7) / 8;
|
||||
}
|
||||
|
||||
int denm_encode(const denm_fields_t *f, uint8_t *buf, size_t buf_len)
|
||||
{
|
||||
bitwriter_t bw;
|
||||
bw_init(&bw, buf, buf_len);
|
||||
|
||||
// ---- ItsPduHeader ---- (SEQUENCE, no OPTIONALs, no "..." -> no preamble at all)
|
||||
bw_put_bits(&bw, 2, 8); // protocolVersion INTEGER(0..255) = 2
|
||||
bw_put_bits(&bw, 1, 8); // messageID INTEGER(0..255) = denm(1)
|
||||
bw_put_bits(&bw, f->station_id, 32); // stationID = StationID INTEGER(0..4294967295) = 32 bits
|
||||
|
||||
// ---- DenmPayload (DecentralizedEnvironmentalNotificationMessage) ----
|
||||
// No "..." on this SEQUENCE -> no extension bit, just the 3-bit
|
||||
// optional-component preamble in declared order: situation, location,
|
||||
// alacarte. "No additional parameters" means location/alacarte stay
|
||||
// absent.
|
||||
bw_put_bits(&bw, 1, 1); // situation present
|
||||
bw_put_bits(&bw, 0, 1); // location absent
|
||||
bw_put_bits(&bw, 0, 1); // alacarte absent
|
||||
|
||||
// ---- ManagementContainer ----
|
||||
// This SEQUENCE ends in "..." in the real ASN.1 module -> extensible,
|
||||
// so it needs a leading 1-bit extension flag (0 = no extension
|
||||
// additions used) BEFORE the 5-bit optional/default preamble
|
||||
// (termination, relevanceDistance, relevanceTrafficDirection,
|
||||
// validityDuration, transmissionInterval, in that declared order). An
|
||||
// earlier version of this code omitted the extension bit entirely,
|
||||
// which would shift every single bit after it and corrupt the whole
|
||||
// rest of the message for any spec-compliant decoder.
|
||||
bw_put_bits(&bw, 0, 1); // ManagementContainer extension bit: none used
|
||||
bw_put_bits(&bw, f->terminate ? 1 : 0, 1); // termination present only when cancelling
|
||||
bw_put_bits(&bw, 0, 1); // relevanceDistance absent
|
||||
bw_put_bits(&bw, 0, 1); // relevanceTrafficDirection absent
|
||||
bw_put_bits(&bw, 0, 1); // validityDuration absent -> default 600s applies
|
||||
bw_put_bits(&bw, 0, 1); // transmissionInterval absent
|
||||
|
||||
// actionID = ActionID{ originatingStationID StationID(32), sequenceNumber
|
||||
// SequenceNumber(0..65535, 16 bits) } - no OPTIONALs/"..." -> no preamble.
|
||||
// Keep sequenceNumber constant across repeats of the SAME event - it's
|
||||
// the caller's job (see main.c) to only bump it on a genuinely new event
|
||||
// and reuse it for that event's eventual termination message.
|
||||
bw_put_bits(&bw, f->station_id, 32);
|
||||
bw_put_bits(&bw, f->sequence_number, 16);
|
||||
|
||||
// detectionTime / referenceTime: TimestampIts INTEGER(0..4398046511103)
|
||||
// = exactly 42 bits (2^42), ms since 2004-01-01T00:00:00Z. NOT WIRED UP
|
||||
// YET - there's no RTC/NTP sync in this skeleton, so this is 0 (decodes
|
||||
// as 2004-01-01). Wire in SNTP or a GNSS UTC fix before this is real.
|
||||
bw_put_bits(&bw, 0, 42);
|
||||
bw_put_bits(&bw, 0, 42);
|
||||
|
||||
// termination VALUE - only emitted when present (per the preamble bit
|
||||
// above - UPER never encodes a value for an absent optional component).
|
||||
// Termination ::= ENUMERATED{isCancellation(0), isNegation(1)}, no
|
||||
// "...", 2 values -> 1 bit.
|
||||
if (f->terminate) {
|
||||
bw_put_bits(&bw, 0, 1); // isCancellation
|
||||
}
|
||||
|
||||
// eventPosition (ReferencePosition ::= SEQUENCE{latitude, longitude,
|
||||
// positionConfidenceEllipse, altitude} - no OPTIONALs/"..." -> no
|
||||
// preamble, straight concatenation). Widths below are each field's
|
||||
// exact constrained-INTEGER range size from ITS-Container.asn, encoded
|
||||
// as an unsigned offset from the type's declared minimum - NOT assumed
|
||||
// to match neighboring fields (latitude and longitude are different
|
||||
// widths, which is easy to miss).
|
||||
// Latitude ::= INTEGER(-900000000..900000001) -> range 1800000002 -> 31 bits
|
||||
uint32_t lat_offset = (uint32_t)(f->latitude_tenmicrodeg - (-900000000));
|
||||
bw_put_bits(&bw, lat_offset, 31);
|
||||
// Longitude ::= INTEGER(-1800000000..1800000001) -> range 3600000002 -> 32 bits
|
||||
uint32_t lon_offset = (uint32_t)(f->longitude_tenmicrodeg - (-1800000000));
|
||||
bw_put_bits(&bw, lon_offset, 32);
|
||||
// PosConfidenceEllipse ::= SEQUENCE{semiMajorConfidence, semiMinorConfidence,
|
||||
// semiMajorOrientation} - no preamble.
|
||||
// SemiAxisLength ::= INTEGER(0..4095) -> 12 bits (not 16 - this was wrong before)
|
||||
bw_put_bits(&bw, 4095, 12); // semiMajorConfidence: unavailable
|
||||
bw_put_bits(&bw, 4095, 12); // semiMinorConfidence: unavailable
|
||||
// HeadingValue ::= INTEGER(0..3601) -> 12 bits (not 16 - this was wrong before)
|
||||
bw_put_bits(&bw, 3601, 12); // semiMajorOrientation: unavailable
|
||||
// Altitude ::= SEQUENCE{altitudeValue, altitudeConfidence} - no preamble.
|
||||
// AltitudeValue ::= INTEGER(-100000..800001) -> range 900002 -> 20 bits
|
||||
// (not 24 - this was wrong before), offset-encoded from -100000.
|
||||
bw_put_bits(&bw, 900001, 20); // 800001 ("unavailable") - (-100000) = 900001
|
||||
// AltitudeConfidence ::= ENUMERATED, 16 named values, no "..." -> 4 bits
|
||||
bw_put_bits(&bw, 15, 4); // unavailable
|
||||
|
||||
// stationType: StationType INTEGER(0..255) -> 8 bits fixed regardless of
|
||||
// how sparse the named values are.
|
||||
bw_put_bits(&bw, f->station_type, 8);
|
||||
|
||||
// ---- SituationContainer ----
|
||||
// This SEQUENCE also ends in "..." -> its own 1-bit extension flag,
|
||||
// THEN the 2-bit preamble (linkedCause, eventHistory), THEN the
|
||||
// mandatory field values. An earlier version of this code put the
|
||||
// linkedCause/eventHistory bits at the END instead of the start, and
|
||||
// had no extension bit at all - both are structural bugs that would
|
||||
// desync any spec-compliant decoder from this point on.
|
||||
bw_put_bits(&bw, 0, 1); // SituationContainer extension bit: none used
|
||||
bw_put_bits(&bw, 0, 1); // linkedCause absent
|
||||
bw_put_bits(&bw, 0, 1); // eventHistory absent
|
||||
|
||||
// informationQuality: InformationQuality INTEGER(0..7) -> 3 bits
|
||||
bw_put_bits(&bw, 1, 3); // low quality - no real sensor input, just the hazard-light GPIO
|
||||
|
||||
// eventType: CauseCode ::= SEQUENCE{causeCode, subCauseCode, ...} - this
|
||||
// inner SEQUENCE is ALSO extensible ("..."), so it gets its own leading
|
||||
// extension bit before its two mandatory fields.
|
||||
bw_put_bits(&bw, 0, 1); // CauseCode extension bit: none used
|
||||
bw_put_bits(&bw, f->cause_code, 8); // CauseCodeType INTEGER(0..255) -> 8 bits
|
||||
bw_put_bits(&bw, f->sub_cause_code, 8); // SubCauseCodeType INTEGER(0..255) -> 8 bits
|
||||
|
||||
// linkedCause / eventHistory: both absent, already signalled in the
|
||||
// preamble above - UPER writes no value bits for them.
|
||||
|
||||
return (int)bw_byte_len(&bw);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
#ifndef DENM_H
|
||||
#define DENM_H
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
#include <stdbool.h>
|
||||
|
||||
// Full CauseCodeType enumeration, straight from the authoritative source:
|
||||
// ETSI TS 102 894-2 (CDD) ITS-Container.asn, CauseCodeType definition.
|
||||
// (Values 1/2/3/14/26/27/91/94/95/97 were already cross-checked earlier
|
||||
// against a real captured DENM; the rest are now confirmed the same way,
|
||||
// from the actual ASN.1 module rather than guessed.)
|
||||
#define DENM_CAUSE_RESERVED 0
|
||||
#define DENM_CAUSE_TRAFFIC_CONDITION 1
|
||||
#define DENM_CAUSE_ACCIDENT 2
|
||||
#define DENM_CAUSE_ROADWORKS 3
|
||||
#define DENM_CAUSE_IMPASSABILITY 5
|
||||
#define DENM_CAUSE_ADVERSE_WEATHER_ADHESION 6
|
||||
#define DENM_CAUSE_AQUAPLANNING 7
|
||||
#define DENM_CAUSE_HAZARDOUS_LOCATION_SURFACE_CONDITION 9
|
||||
#define DENM_CAUSE_HAZARDOUS_LOCATION_OBSTACLE_ON_ROAD 10
|
||||
#define DENM_CAUSE_HAZARDOUS_LOCATION_ANIMAL_ON_ROAD 11
|
||||
#define DENM_CAUSE_HUMAN_PRESENCE_ON_ROAD 12
|
||||
#define DENM_CAUSE_WRONG_WAY_DRIVING 14
|
||||
#define DENM_CAUSE_RESCUE_AND_RECOVERY_WORK_IN_PROGRESS 15
|
||||
#define DENM_CAUSE_ADVERSE_WEATHER_EXTREME 17
|
||||
#define DENM_CAUSE_ADVERSE_WEATHER_VISIBILITY 18
|
||||
#define DENM_CAUSE_ADVERSE_WEATHER_PRECIPITATION 19
|
||||
#define DENM_CAUSE_SLOW_VEHICLE 26
|
||||
#define DENM_CAUSE_DANGEROUS_END_OF_QUEUE 27
|
||||
#define DENM_CAUSE_VEHICLE_BREAKDOWN 91
|
||||
#define DENM_CAUSE_POST_CRASH 92
|
||||
#define DENM_CAUSE_HUMAN_PROBLEM 93
|
||||
#define DENM_CAUSE_STATIONARY_VEHICLE 94
|
||||
#define DENM_CAUSE_EMERGENCY_VEHICLE_APPROACHING 95
|
||||
#define DENM_CAUSE_HAZARDOUS_LOCATION_DANGEROUS_CURVE 96
|
||||
#define DENM_CAUSE_COLLISION_RISK 97
|
||||
#define DENM_CAUSE_SIGNAL_VIOLATION 98
|
||||
#define DENM_CAUSE_DANGEROUS_SITUATION 99
|
||||
|
||||
typedef struct {
|
||||
uint32_t station_id;
|
||||
uint16_t sequence_number; // keep constant across repeats of the SAME event; only bump on a genuinely new event
|
||||
uint8_t cause_code; // e.g. 94 = stationaryVehicle
|
||||
uint8_t sub_cause_code; // 0 = unspecified
|
||||
uint8_t station_type; // StationType, e.g. 5 = passengerCar - match geonet_wrap_shb's station_type param
|
||||
int32_t latitude_tenmicrodeg; // 1/10 microdegree; 0 = placeholder/unavailable
|
||||
int32_t longitude_tenmicrodeg; // 1/10 microdegree; 0 = placeholder/unavailable
|
||||
bool terminate; // true = encode this as a Termination(isCancellation) message instead of a normal update
|
||||
} denm_fields_t;
|
||||
|
||||
// Encodes a minimal DENM (ItsPduHeader + ManagementContainer +
|
||||
// SituationContainer only - no location/alacarte containers) as ASN.1 UPER,
|
||||
// per the actual ETSI EN 302 637-3 / TS 102 894-2 ASN.1 modules (fetched
|
||||
// from forge.etsi.org, not reconstructed from memory). Returns bytes
|
||||
// written, or -1 if buf too small.
|
||||
//
|
||||
// Two things worth knowing if you're reading this against the modules
|
||||
// yourself: ManagementContainer, SituationContainer, and CauseCode are all
|
||||
// declared with a trailing "..." (extensible), which means each needs its
|
||||
// own leading extension bit in the UPER encoding - easy to miss, and this
|
||||
// code got it wrong in an earlier version. Field bit-widths below (e.g.
|
||||
// latitude=31 bits, longitude=32 bits, position-confidence fields=12 bits,
|
||||
// altitudeValue=20 bits) are derived directly from each type's declared
|
||||
// INTEGER constraint range, not assumed to match neighboring fields.
|
||||
int denm_encode(const denm_fields_t *f, uint8_t *buf, size_t buf_len);
|
||||
|
||||
#endif
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user