Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73d4477f1e | ||
|
|
277c6b20f4 | ||
|
|
0f06cdb339 | ||
|
|
75d6d3b85c | ||
|
|
1baae2c5f6 | ||
|
|
8871708a98 | ||
|
|
d7043bb04d | ||
|
|
e908f7fae1 | ||
|
|
8f397eea20 | ||
|
|
cc395771ea | ||
|
|
83153a0971 | ||
|
|
3eeccfb268 | ||
|
|
5ec3619cbe | ||
|
|
1ad123a6f8 | ||
|
|
83ccf335bb |
@@ -27,6 +27,9 @@ secrets.properties
|
|||||||
/.idea/appInsightsSettings.xml
|
/.idea/appInsightsSettings.xml
|
||||||
/.idea/studiobot.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.
|
# 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.defaults is the real, intentional configuration; these two are output.
|
||||||
sdkconfig.old
|
sdkconfig.old
|
||||||
@@ -38,7 +41,11 @@ sdkconfig.old
|
|||||||
# Third-party working copies kept beside the project, not part of it. The ASN.1
|
# 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.
|
# modules this project actually needs are vendored under asn1/ instead.
|
||||||
/C-ITS-Parser/
|
/C-ITS-Parser/
|
||||||
|
/vanetza/
|
||||||
|
|
||||||
# Office lock files. Word/Excel create these beside a document while it is open
|
# 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.
|
# 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/
|
||||||
|
|||||||
@@ -38,9 +38,9 @@ Both paths converge at `CamUseCaseRepository`, which normalises whatever arrived
|
|||||||
|
|
||||||
**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.
|
**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 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.
|
**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.
|
||||||
|
|
||||||
**Trip review**; past trips displayed on an OpenStreetMap layer with detected events overlaid as coloured pins. Tap any pin for event details.
|
**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.
|
**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.
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -248,10 +248,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
val trip = trips.firstOrNull { it.id == tripId }
|
val trip = trips.firstOrNull { it.id == tripId }
|
||||||
|
|
||||||
if (trip != null) {
|
if (trip != null) {
|
||||||
TripReviewScreen(
|
TripReviewScreen(trip = trip)
|
||||||
trip = trip,
|
|
||||||
viewModel = tripViewModel,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package com.hawhamburg.micr0bu.data
|
||||||
|
|
||||||
|
import android.os.SystemClock
|
||||||
|
import android.util.Log
|
||||||
|
import com.hawhamburg.micr0bu.domain.asn1.ItsTime
|
||||||
|
import java.time.DateTimeException
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Puts the timestamps this phone transmits on GNSS time instead of its own wall clock.
|
||||||
|
*
|
||||||
|
* ## Why
|
||||||
|
* Every CAM carries a generationDeltaTime and every GeoNetworking header a TST, and receivers use
|
||||||
|
* them to judge how fresh a message is and in what order messages came. Both used to come straight
|
||||||
|
* from `System.currentTimeMillis()`, so they were only as good as the phone's clock setting. On
|
||||||
|
* 2026-09-10 the bench phone was 24 minutes fast: automatic time had no source (no SIM, and the
|
||||||
|
* lab Wi-Fi has no internet time), so it had not set the clock once in 69 hours, and every CAM
|
||||||
|
* went out stamped 24 minutes in the future. A bike-mounted phone on the road is in exactly that
|
||||||
|
* position. GNSS time depends on none of it.
|
||||||
|
*
|
||||||
|
* ## How
|
||||||
|
* [SystemClock.currentGnssTimeClock] (API 29, this app's minSdk) is a UTC clock the platform keeps
|
||||||
|
* synchronised from GNSS fixes. One reading of it taken alongside the wall clock gives the wall
|
||||||
|
* clock's error, which is then applied to the fix's own timestamp. When GNSS time is unavailable,
|
||||||
|
* typically indoors before any satellite fix since boot, the wall clock is used unchanged.
|
||||||
|
*
|
||||||
|
* Which clock is in use is logged whenever it changes, with the measured error, so a capture shows
|
||||||
|
* where a given run's timestamps came from.
|
||||||
|
*
|
||||||
|
* Only the transmit path uses this. Everything else in the app stays on the wall clock, because
|
||||||
|
* received messages, sensor samples and trip records are all stamped with it and must stay
|
||||||
|
* comparable with one another.
|
||||||
|
*/
|
||||||
|
object GnssTimeSource {
|
||||||
|
private const val TAG = "GnssTimeSource"
|
||||||
|
|
||||||
|
/** Whether the last correction used GNSS time; null before the first. For change-only logging. */
|
||||||
|
@Volatile private var lastUsedGnss: Boolean? = null
|
||||||
|
|
||||||
|
/** [systemMs], a wall-clock reading, moved onto GNSS time where GNSS time is available. */
|
||||||
|
fun correct(systemMs: Long): Long {
|
||||||
|
val systemNow = System.currentTimeMillis()
|
||||||
|
val gnssNow = try {
|
||||||
|
SystemClock.currentGnssTimeClock().millis()
|
||||||
|
} catch (e: DateTimeException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
noteSource(gnssNow, systemNow)
|
||||||
|
return ItsTime.onGnssTime(systemMs, gnssNow, systemNow)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun noteSource(gnssNow: Long?, systemNow: Long) {
|
||||||
|
val usingGnss = gnssNow != null
|
||||||
|
if (lastUsedGnss == usingGnss) return
|
||||||
|
lastUsedGnss = usingGnss
|
||||||
|
if (gnssNow != null) {
|
||||||
|
Log.i(TAG, "transmit timestamps now on GNSS time; phone clock is " +
|
||||||
|
"${"%+.1f".format((systemNow - gnssNow) / 1000.0)} s off")
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "GNSS time unavailable, transmit timestamps fall back to the phone clock, " +
|
||||||
|
"which has no automatic time source without a SIM or internet")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@ package com.hawhamburg.micr0bu.data
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
import com.hawhamburg.micr0bu.data.db.DetectedEventEntity
|
|
||||||
import com.hawhamburg.micr0bu.data.db.RecordedTripEntity
|
import com.hawhamburg.micr0bu.data.db.RecordedTripEntity
|
||||||
import com.hawhamburg.micr0bu.data.db.V2xMessageEntity
|
import com.hawhamburg.micr0bu.data.db.V2xMessageEntity
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -23,12 +22,15 @@ fun tripFileName(trip: RecordedTripEntity): String =
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds a single combined CSV for one trip: the raw sensor samples recorded alongside it, the
|
* Builds a single combined CSV for one trip: the raw sensor samples recorded alongside it, the
|
||||||
* events the detector fired, the GPS track, and every V2X message seen during the ride — all in
|
* GPS track, and every V2X message seen during the ride — all in one file, ordered by time.
|
||||||
* 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
|
* **Why one file rather than a zip of tables.** The point of the export is correlation: what was
|
||||||
* the bike doing when that CAM arrived, what did the detector make of it. Splitting those into
|
* the bike doing when that CAM arrived. Splitting those into separate files pushes the join
|
||||||
* separate files pushes the join onto whoever opens it. A leading `type` column keeps the rows
|
* 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
|
* distinguishable, which is the same shape the existing session CSV already uses, so the two
|
||||||
* remain readable by the same tooling.
|
* remain readable by the same tooling.
|
||||||
*
|
*
|
||||||
@@ -44,7 +46,6 @@ fun tripFileName(trip: RecordedTripEntity): String =
|
|||||||
suspend fun buildTripCsv(
|
suspend fun buildTripCsv(
|
||||||
context: Context,
|
context: Context,
|
||||||
trip: RecordedTripEntity,
|
trip: RecordedTripEntity,
|
||||||
events: List<DetectedEventEntity>,
|
|
||||||
v2xMessages: List<V2xMessageEntity>,
|
v2xMessages: List<V2xMessageEntity>,
|
||||||
): String = withContext(Dispatchers.IO) {
|
): String = withContext(Dispatchers.IO) {
|
||||||
buildString {
|
buildString {
|
||||||
@@ -59,7 +60,6 @@ suspend fun buildTripCsv(
|
|||||||
appendLine()
|
appendLine()
|
||||||
appendLine(
|
appendLine(
|
||||||
"type,timestamp_ms,timestamp_iso,lat,lon,speed_ms,heading_deg," +
|
"type,timestamp_ms,timestamp_iso,lat,lon,speed_ms,heading_deg," +
|
||||||
"event_type,confidence,peak_accel,peak_gyro,duration_ms," +
|
|
||||||
"station_id,station_type,is_own,yaw_rate_dps,rssi_dbm"
|
"station_id,station_type,is_own,yaw_rate_dps,rssi_dbm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -68,16 +68,6 @@ suspend fun buildTripCsv(
|
|||||||
appendLine(
|
appendLine(
|
||||||
"gps,${point.timestamp},${isoUtc.format(Date(point.timestamp))}," +
|
"gps,${point.timestamp},${isoUtc.format(Date(point.timestamp))}," +
|
||||||
"${point.lat},${point.lon},,," +
|
"${point.lat},${point.lon},,," +
|
||||||
",,,,," +
|
|
||||||
",,,"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
for (e in events) {
|
|
||||||
appendLine(
|
|
||||||
"event,${e.timestamp},${isoUtc.format(Date(e.timestamp))}," +
|
|
||||||
"${e.latitude},${e.longitude},${e.speedMps},," +
|
|
||||||
"${e.type},${e.confidence},${e.peakAccelMagnitude},${e.peakGyroMagnitude},${e.durationMs}," +
|
|
||||||
",,,,"
|
",,,,"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -86,14 +76,13 @@ suspend fun buildTripCsv(
|
|||||||
appendLine(
|
appendLine(
|
||||||
"v2x,${m.timestamp},${isoUtc.format(Date(m.timestamp))}," +
|
"v2x,${m.timestamp},${isoUtc.format(Date(m.timestamp))}," +
|
||||||
"${m.latitude},${m.longitude},${m.speedMps},${m.headingDeg}," +
|
"${m.latitude},${m.longitude},${m.speedMps},${m.headingDeg}," +
|
||||||
",,,,," +
|
|
||||||
"${m.stationId},${m.stationType},${m.isOwn},${m.yawRateDps ?: ""},${m.rssiDbm ?: ""}"
|
"${m.stationId},${m.stationType},${m.isOwn},${m.yawRateDps ?: ""},${m.rssiDbm ?: ""}"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Raw sensor samples, copied verbatim from the session CSV. Appended last rather than
|
// 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
|
// merge-sorted in: a long ride is hundreds of thousands of rows, and sorting them against
|
||||||
// the (comparatively tiny) event/V2X sets in memory would defeat the streaming that
|
// the (comparatively tiny) V2X set in memory would defeat the streaming that
|
||||||
// CsvExporter deliberately does. Each row carries its own timestamp, so sort on load.
|
// 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") }
|
val sessionCsv = trip.sessionId?.let { File(File(context.filesDir, "sessions"), "$it.csv") }
|
||||||
if (sessionCsv != null && sessionCsv.exists()) {
|
if (sessionCsv != null && sessionCsv.exists()) {
|
||||||
@@ -110,12 +99,11 @@ suspend fun buildTripCsv(
|
|||||||
suspend fun shareTripCsv(
|
suspend fun shareTripCsv(
|
||||||
context: Context,
|
context: Context,
|
||||||
trip: RecordedTripEntity,
|
trip: RecordedTripEntity,
|
||||||
events: List<DetectedEventEntity>,
|
|
||||||
v2xMessages: List<V2xMessageEntity>,
|
v2xMessages: List<V2xMessageEntity>,
|
||||||
) {
|
) {
|
||||||
val fileName = tripFileName(trip)
|
val fileName = tripFileName(trip)
|
||||||
val cacheFile = File(context.cacheDir, fileName)
|
val cacheFile = File(context.cacheDir, fileName)
|
||||||
val csv = buildTripCsv(context, trip, events, v2xMessages)
|
val csv = buildTripCsv(context, trip, v2xMessages)
|
||||||
|
|
||||||
withContext(Dispatchers.IO) { cacheFile.writeText(csv) }
|
withContext(Dispatchers.IO) { cacheFile.writeText(csv) }
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,9 @@ package com.hawhamburg.micr0bu.data
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.hawhamburg.micr0bu.data.db.AppDatabase
|
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.RecordedTripEntity
|
||||||
import com.hawhamburg.micr0bu.data.db.V2xMessageEntity
|
import com.hawhamburg.micr0bu.data.db.V2xMessageEntity
|
||||||
import com.hawhamburg.micr0bu.domain.cam.Cam
|
import com.hawhamburg.micr0bu.domain.cam.Cam
|
||||||
import com.hawhamburg.micr0bu.domain.detection.DetectedEvent
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@@ -15,7 +13,7 @@ import java.io.File
|
|||||||
private const val TAG = "TripRepository"
|
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
|
* 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.
|
* dispatcher; Room executes the actual SQL on its own I/O thread pool.
|
||||||
@@ -81,14 +79,11 @@ class TripRepository(db: AppDatabase, private val context: Context) {
|
|||||||
* One-shot snapshots for export. The Flow-returning variants above stay observable for the UI;
|
* 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.
|
* an export wants a value it can write out, not a stream it has to unsubscribe from.
|
||||||
*/
|
*/
|
||||||
suspend fun getEventsForTripOnce(tripId: Long): List<DetectedEventEntity> =
|
|
||||||
dao.getEventsForTrip(tripId).first()
|
|
||||||
|
|
||||||
suspend fun getV2xMessagesForTripOnce(tripId: Long): List<V2xMessageEntity> =
|
suspend fun getV2xMessagesForTripOnce(tripId: Long): List<V2xMessageEntity> =
|
||||||
dao.getV2xMessagesForTrip(tripId).first()
|
dao.getV2xMessagesForTrip(tripId).first()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deletes a trip and everything belonging to it: detected events and V2X messages go via the
|
* Deletes a trip and everything belonging to it: V2X messages go via the
|
||||||
* schema's CASCADE foreign keys, and the CSV recorded alongside it is removed here.
|
* 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
|
* The CSV is a plain file outside the database, so nothing deletes it implicitly - before
|
||||||
@@ -109,32 +104,6 @@ class TripRepository(db: AppDatabase, private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Events ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Persists a domain [DetectedEvent] for the given [tripId].
|
|
||||||
* Converts the domain model to the Room entity.
|
|
||||||
*/
|
|
||||||
suspend fun insertEvent(tripId: Long, event: DetectedEvent) =
|
|
||||||
dao.insertEvent(
|
|
||||||
DetectedEventEntity(
|
|
||||||
tripId = tripId,
|
|
||||||
timestamp = event.timestamp,
|
|
||||||
type = event.type.name,
|
|
||||||
confidence = event.confidence.name,
|
|
||||||
latitude = event.latitude,
|
|
||||||
longitude = event.longitude,
|
|
||||||
speedMps = event.speedMps.toFloat(),
|
|
||||||
peakAccelMagnitude = event.peakAccelMagnitude.toFloat(),
|
|
||||||
peakGyroMagnitude = event.peakGyroMagnitude.toFloat(),
|
|
||||||
durationMs = event.durationMs,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Emits events for [tripId] ordered by timestamp, updating whenever the DB changes. */
|
|
||||||
fun getEventsForTrip(tripId: Long): Flow<List<DetectedEventEntity>> =
|
|
||||||
dao.getEventsForTrip(tripId)
|
|
||||||
|
|
||||||
// ── V2X messages (Phase 03) ──────────────────────────────────────────────────
|
// ── V2X messages (Phase 03) ──────────────────────────────────────────────────
|
||||||
// Retention policy: only ever called while a trip is actively recording — see
|
// Retention policy: only ever called while a trip is actively recording — see
|
||||||
// V2xMessageEntity's KDoc and CamUseCaseRepository.processedCam's collector in
|
// V2xMessageEntity's KDoc and CamUseCaseRepository.processedCam's collector in
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import com.hawhamburg.micr0bu.domain.spat.SpatEvent
|
|||||||
import com.hawhamburg.micr0bu.domain.usecase.UseCaseAlert
|
import com.hawhamburg.micr0bu.domain.usecase.UseCaseAlert
|
||||||
import com.hawhamburg.micr0bu.domain.usecase.UseCaseDetectionEngine
|
import com.hawhamburg.micr0bu.domain.usecase.UseCaseDetectionEngine
|
||||||
import com.hawhamburg.micr0bu.domain.usecase.UseCaseType
|
import com.hawhamburg.micr0bu.domain.usecase.UseCaseType
|
||||||
|
import com.hawhamburg.micr0bu.service.CamPinger
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -104,6 +105,8 @@ class CamUseCaseRepository @Inject constructor(
|
|||||||
private val usbSerialTransport: UsbSerialTransport,
|
private val usbSerialTransport: UsbSerialTransport,
|
||||||
private val camCodec: RealAsn1UperCodec,
|
private val camCodec: RealAsn1UperCodec,
|
||||||
private val obuHardwarePrefs: ObuHardwarePreferences,
|
private val obuHardwarePrefs: ObuHardwarePreferences,
|
||||||
|
private val pseudonymManager: PseudonymManager,
|
||||||
|
private val camPinger: CamPinger,
|
||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
) {
|
) {
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
@@ -312,8 +315,8 @@ class CamUseCaseRepository @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Our own station ID. On the CiT One path it's learned from v2x/rx/obu_gnss; the ESP32-C5
|
// Our own station ID. On the CiT One path it's learned from v2x/rx/obu_gnss; the ESP32-C5
|
||||||
// path has no such topic, so it comes from the same persisted value CamTransmitLoop puts
|
// path has no such topic, so it follows the current transmit pseudonym, the same one
|
||||||
// in outgoing CAMs.
|
// 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
|
// 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
|
// [handleCamFromSerial] never fired - so the phone's own CAMs, which the ESP32 hears back
|
||||||
@@ -321,10 +324,13 @@ class CamUseCaseRepository @Inject constructor(
|
|||||||
// sitting exactly on top of the ego position, fed into the detection engine as a
|
// sitting exactly on top of the ego position, fed into the detection engine as a
|
||||||
// collision partner for itself.
|
// collision partner for itself.
|
||||||
scope.launch {
|
scope.launch {
|
||||||
obuHardwarePrefs.obuHardwareFlow.collect { hardware ->
|
combine(obuHardwarePrefs.obuHardwareFlow, pseudonymManager.currentFlow) { hardware, pseudonym ->
|
||||||
|
hardware to pseudonym
|
||||||
|
}.collect { (hardware, pseudonym) ->
|
||||||
currentHardware = hardware
|
currentHardware = hardware
|
||||||
if (hardware == ObuHardware.ESP32_C5) {
|
if (hardware == ObuHardware.ESP32_C5) {
|
||||||
_ownStationId.value = obuHardwarePrefs.getOrCreateOwnStationId()
|
// currentFlow re-emits on every rotation, so this tracks the live identity.
|
||||||
|
_ownStationId.value = (pseudonym ?: pseudonymManager.current()).stationId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -339,11 +345,17 @@ class CamUseCaseRepository @Inject constructor(
|
|||||||
* recognised as our own rather than tracked as another road user. Also drives the OWN/REMOTE
|
* recognised as our own rather than tracked as another road user. Also drives the OWN/REMOTE
|
||||||
* badges in the raw message list.
|
* badges in the raw message list.
|
||||||
*
|
*
|
||||||
* The rule lives in [OwnStationIds], which explains why there are two such ids and what goes
|
* The rule lives in [OwnStationIds], which explains which ids count and what goes wrong when
|
||||||
* wrong when only one of them is checked.
|
* 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 =
|
fun isOwnStationId(stationId: Long): Boolean =
|
||||||
OwnStationIds.isOwn(stationId, _ownStationId.value)
|
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
|
* Primary ego state source: `v2x/rx/obu_gnss`, ~4 Hz, carries position/speed/heading/yaw
|
||||||
@@ -392,9 +404,11 @@ class CamUseCaseRepository @Inject constructor(
|
|||||||
|
|
||||||
private fun handleCam(payload: String, timestamp: Long) {
|
private fun handleCam(payload: String, timestamp: Long) {
|
||||||
val cam = CamParser.parse(payload, _ownStationId.value, timestamp) ?: return
|
val cam = CamParser.parse(payload, _ownStationId.value, timestamp) ?: return
|
||||||
// A bench ping the CiT One's radio picked up and relayed here. Not a road user, and not
|
// This phone's own bench ping, relayed back by the CiT One's radio: not a road user, and not
|
||||||
// ego state either: the ping is built from the same phone GNSS the engine already has.
|
// ego state either, since it is built from the same phone GNSS the engine already has.
|
||||||
if (cam.stationId == OwnStationIds.BENCH_PING) return
|
// 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) {
|
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
|
// update is freshest, so this only actually wins when both obu_gnss and phone GNSS
|
||||||
|
|||||||
@@ -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 = [
|
entities = [
|
||||||
SessionEntity::class,
|
SessionEntity::class,
|
||||||
RecordedTripEntity::class,
|
RecordedTripEntity::class,
|
||||||
DetectedEventEntity::class,
|
|
||||||
V2xMessageEntity::class,
|
V2xMessageEntity::class,
|
||||||
],
|
],
|
||||||
version = 4,
|
version = 5,
|
||||||
exportSchema = false,
|
exportSchema = false,
|
||||||
)
|
)
|
||||||
abstract class AppDatabase : RoomDatabase() {
|
abstract class AppDatabase : RoomDatabase() {
|
||||||
@@ -34,13 +33,29 @@ abstract class AppDatabase : RoomDatabase() {
|
|||||||
AppDatabase::class.java,
|
AppDatabase::class.java,
|
||||||
"micr0bu.db",
|
"micr0bu.db",
|
||||||
)
|
)
|
||||||
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4)
|
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5)
|
||||||
.build()
|
.build()
|
||||||
.also { INSTANCE = it }
|
.also { INSTANCE = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Migrations ────────────────────────────────────────────────────────
|
// ── 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:
|
* Two additions:
|
||||||
* - `trips.sessionId` links a trip to the CSV recording session captured alongside it, so
|
* - `trips.sessionId` links a trip to the CSV recording session captured alongside it, so
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
package com.hawhamburg.micr0bu.data.db
|
|
||||||
|
|
||||||
import androidx.room.ColumnInfo
|
|
||||||
import androidx.room.Entity
|
|
||||||
import androidx.room.ForeignKey
|
|
||||||
import androidx.room.PrimaryKey
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One detected cyclist event (braking / turning / stopping) linked to a
|
|
||||||
* [RecordedTripEntity] via the [tripId] foreign key.
|
|
||||||
*
|
|
||||||
* [type] and [confidence] are stored as the enum name strings so that the
|
|
||||||
* database remains human-readable.
|
|
||||||
*/
|
|
||||||
@Entity(
|
|
||||||
tableName = "detected_events",
|
|
||||||
foreignKeys = [
|
|
||||||
ForeignKey(
|
|
||||||
entity = RecordedTripEntity::class,
|
|
||||||
parentColumns = ["id"],
|
|
||||||
childColumns = ["tripId"],
|
|
||||||
onDelete = ForeignKey.CASCADE,
|
|
||||||
)
|
|
||||||
],
|
|
||||||
)
|
|
||||||
data class DetectedEventEntity(
|
|
||||||
@PrimaryKey(autoGenerate = true)
|
|
||||||
val id: Long = 0,
|
|
||||||
|
|
||||||
@ColumnInfo(index = true)
|
|
||||||
val tripId: Long,
|
|
||||||
|
|
||||||
/** Wall-clock epoch ms of the first qualifying sensor frame. */
|
|
||||||
val timestamp: Long,
|
|
||||||
|
|
||||||
/** EventType.name — one of BRAKING, TURNING, STOPPING. */
|
|
||||||
val type: String,
|
|
||||||
|
|
||||||
/** Confidence.name — one of HIGH, MEDIUM, LOW. */
|
|
||||||
val confidence: String,
|
|
||||||
|
|
||||||
val latitude: Double,
|
|
||||||
val longitude: Double,
|
|
||||||
val speedMps: Float,
|
|
||||||
val peakAccelMagnitude: Float,
|
|
||||||
val peakGyroMagnitude: Float,
|
|
||||||
|
|
||||||
/** Duration from first qualifying frame to emission (ms). */
|
|
||||||
val durationMs: Long,
|
|
||||||
)
|
|
||||||
@@ -27,17 +27,6 @@ interface TripDao {
|
|||||||
@Query("DELETE FROM trips WHERE id = :id")
|
@Query("DELETE FROM trips WHERE id = :id")
|
||||||
suspend fun deleteTripById(id: Long)
|
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) ──────────────────────────────────────────────────
|
// ── V2X messages (Phase 03) ──────────────────────────────────────────────────
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
|||||||
@@ -6,12 +6,13 @@ import androidx.datastore.preferences.core.longPreferencesKey
|
|||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import androidx.datastore.preferences.preferencesDataStore
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
import com.hawhamburg.micr0bu.data.transport.ObuHardware
|
import com.hawhamburg.micr0bu.data.transport.ObuHardware
|
||||||
|
import com.hawhamburg.micr0bu.domain.cam.Pseudonym
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
import kotlin.random.Random
|
|
||||||
|
|
||||||
private val Context.obuHardwareDataStore by preferencesDataStore(name = "obu_hardware_prefs")
|
private val Context.obuHardwareDataStore by preferencesDataStore(name = "obu_hardware_prefs")
|
||||||
|
|
||||||
@@ -26,7 +27,11 @@ class ObuHardwarePreferences @Inject constructor(
|
|||||||
) {
|
) {
|
||||||
private object Keys {
|
private object Keys {
|
||||||
val OBU_HARDWARE = stringPreferencesKey("obu_hardware")
|
val OBU_HARDWARE = stringPreferencesKey("obu_hardware")
|
||||||
|
// The current transmit pseudonym. Three keys, but only ever read or written together;
|
||||||
|
// see loadPseudonym.
|
||||||
val OWN_STATION_ID = longPreferencesKey("own_station_id")
|
val OWN_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 ->
|
val obuHardwareFlow: Flow<ObuHardware> = context.obuHardwareDataStore.data.map { prefs ->
|
||||||
@@ -37,31 +42,36 @@ class ObuHardwarePreferences @Inject constructor(
|
|||||||
context.obuHardwareDataStore.edit { prefs -> prefs[Keys.OBU_HARDWARE] = hardware.id }
|
context.obuHardwareDataStore.edit { prefs -> prefs[Keys.OBU_HARDWARE] = hardware.id }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** This device's own CAM StationID, or null if one hasn't been assigned yet. */
|
/**
|
||||||
val ownStationIdFlow: Flow<Long?> = context.obuHardwareDataStore.data.map { prefs ->
|
* The transmit pseudonym last saved by [savePseudonym], or null if there is none.
|
||||||
prefs[Keys.OWN_STATION_ID]
|
*
|
||||||
|
* All three parts must be present. An install from before pseudonym rotation has a station ID
|
||||||
|
* but no MAC or creation time, and loads as null so that a complete new pseudonym is
|
||||||
|
* generated. Keeping the old ID alongside a fresh MAC would be exactly the partial rotation
|
||||||
|
* [Pseudonym] exists to rule out.
|
||||||
|
*
|
||||||
|
* Only [com.hawhamburg.micr0bu.data.cam.PseudonymManager] should call this: it is the one
|
||||||
|
* owner of the phone's transmit identity.
|
||||||
|
*/
|
||||||
|
suspend fun loadPseudonym(): Pseudonym? {
|
||||||
|
val prefs = context.obuHardwareDataStore.data.first()
|
||||||
|
val stationId = prefs[Keys.OWN_STATION_ID] ?: return null
|
||||||
|
val mac = prefs[Keys.OWN_MAC]?.let(::macFromHex) ?: return null
|
||||||
|
val createdAtMs = prefs[Keys.OWN_PSEUDONYM_CREATED_MS] ?: return null
|
||||||
|
return Pseudonym(stationId, mac, createdAtMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Persists [pseudonym] in a single edit, so a crash can never leave half an identity stored. */
|
||||||
* Returns this device's own CAM StationID, generating and persisting a random one on first
|
suspend fun savePseudonym(pseudonym: Pseudonym) {
|
||||||
* call.
|
context.obuHardwareDataStore.edit { p ->
|
||||||
*
|
p[Keys.OWN_STATION_ID] = pseudonym.stationId
|
||||||
* Replaces the previous hardcoded 0: receivers key on StationID to track a station across
|
p[Keys.OWN_MAC] = pseudonym.mac.joinToString("") { "%02x".format(it) }
|
||||||
* successive CAMs, so every MicrOBU broadcasting 0 makes two units in the same area
|
p[Keys.OWN_PSEUDONYM_CREATED_MS] = pseudonym.createdAtMs
|
||||||
* indistinguishable to any receiver — including this app's own detection engine, which
|
|
||||||
* dedupes remote stations by ID. Random rather than derived from a hardware identifier both
|
|
||||||
* because ETSI expects station IDs to be pseudonymous and because Android hardware IDs aren't
|
|
||||||
* readable without privileged permissions on modern versions.
|
|
||||||
*
|
|
||||||
* Range is 1..2^32-2: StationID is INTEGER(0..4294967295), and 0 is avoided so leftover
|
|
||||||
* placeholder traffic stays distinguishable from a real assignment.
|
|
||||||
*/
|
|
||||||
suspend fun getOrCreateOwnStationId(): Long {
|
|
||||||
val prefs = context.obuHardwareDataStore.edit { p ->
|
|
||||||
if (p[Keys.OWN_STATION_ID] == null) {
|
|
||||||
p[Keys.OWN_STATION_ID] = Random.nextLong(1L, 0xFFFF_FFFEL)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return prefs[Keys.OWN_STATION_ID]!!
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun macFromHex(hex: String): ByteArray? =
|
||||||
|
if (hex.length != 12) null
|
||||||
|
else runCatching { ByteArray(6) { i -> hex.substring(2 * i, 2 * i + 2).toInt(16).toByte() } }
|
||||||
|
.getOrNull()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
package com.hawhamburg.micr0bu.data.transport
|
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
|
* 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
|
* firmware's `obu-firmware/main/serial_link.c`/`.h` — frame shape and CRC algorithm MUST stay
|
||||||
@@ -23,6 +28,15 @@ object SerialFrameType {
|
|||||||
/** ESP32 -> phone: periodic heartbeat + drop counters, independent of CAM traffic.
|
/** ESP32 -> phone: periodic heartbeat + drop counters, independent of CAM traffic.
|
||||||
* Payload layout is [EspLinkStatus] — see its KDoc. */
|
* Payload layout is [EspLinkStatus] — see its KDoc. */
|
||||||
const val STATUS: Int = 0x03
|
const val STATUS: Int = 0x03
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phone -> ESP32: a CAM together with the GeoNetworking Source Position Vector to transmit it
|
||||||
|
* under. Payload is the [GnPositionVector.PREFIX_SIZE]-byte [GnPositionVector] prefix, then
|
||||||
|
* the CAM UPER. Sent only to firmware whose heartbeat advertises
|
||||||
|
* [EspLinkStatus.supportsCamTxPv]; `serial_link.h` explains why this is a new type rather
|
||||||
|
* than a changed [CAM_TX].
|
||||||
|
*/
|
||||||
|
const val CAM_TX_PV: Int = 0x05
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -59,10 +73,22 @@ data class EspLinkStatus(
|
|||||||
val txFailures: Int,
|
val txFailures: Int,
|
||||||
/** Frames from the phone the firmware dropped on CRC mismatch. */
|
/** Frames from the phone the firmware dropped on CRC mismatch. */
|
||||||
val rxCrcErrors: Int,
|
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 {
|
companion object {
|
||||||
const val PAYLOAD_SIZE = 7
|
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). */
|
/** Returns null if [payload] isn't a well-formed status payload (e.g. older firmware). */
|
||||||
fun parse(payload: ByteArray): EspLinkStatus? {
|
fun parse(payload: ByteArray): EspLinkStatus? {
|
||||||
if (payload.size < PAYLOAD_SIZE) return null
|
if (payload.size < PAYLOAD_SIZE) return null
|
||||||
@@ -72,6 +98,7 @@ data class EspLinkStatus(
|
|||||||
oversizeDrops = u16(1),
|
oversizeDrops = u16(1),
|
||||||
txFailures = u16(3),
|
txFailures = u16(3),
|
||||||
rxCrcErrors = u16(5),
|
rxCrcErrors = u16(5),
|
||||||
|
capabilities = if (payload.size > PAYLOAD_SIZE) payload[7].toInt() and 0xFF else 0,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -161,6 +188,116 @@ data class V2xRxFrame(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The GeoNetworking Source Position Vector content sent with each CAM: the 24-byte little-endian
|
||||||
|
* prefix of a [SerialFrameType.CAM_TX_PV] payload. Must stay in lockstep with the layout at
|
||||||
|
* `SERIAL_MSG_CAM_TX_PV` in `serial_link.h`, which the firmware decodes into `gn_lpv_t`.
|
||||||
|
*
|
||||||
|
* Every field is something the ESP32-C5 cannot know by itself, since it has no GNSS and no clock
|
||||||
|
* on the OCB channel. That is why its GN header used to carry fixed bench placeholders instead,
|
||||||
|
* describing a stationary car at the bench while the CAM inside described the moving rider.
|
||||||
|
*/
|
||||||
|
data class GnPositionVector(
|
||||||
|
/** Pseudonym, 6 bytes: both the 802.11 source address and the GN_ADDR MID. */
|
||||||
|
val mac: ByteArray,
|
||||||
|
/** TS 102 894-2 StationType. */
|
||||||
|
val stationType: Int,
|
||||||
|
/** Position Accuracy Indicator. */
|
||||||
|
val pai: Boolean,
|
||||||
|
/** TimestampIts at which the position was acquired; reduced modulo 2^32 on the wire. */
|
||||||
|
val tstMs: Long,
|
||||||
|
/** 1/10 microdegree. */
|
||||||
|
val latTenMicroDeg: Int,
|
||||||
|
/** 1/10 microdegree. */
|
||||||
|
val lonTenMicroDeg: Int,
|
||||||
|
/** 0.01 m/s, within the GN field's 15-bit signed range. */
|
||||||
|
val speedCms: Int,
|
||||||
|
/** 0.1 degree from north, clockwise, 0..3599. */
|
||||||
|
val headingDeciDeg: Int,
|
||||||
|
) {
|
||||||
|
init {
|
||||||
|
require(mac.size == 6) { "a MAC is 6 bytes, got ${mac.size}" }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The 24-byte prefix, little-endian like the rest of this framing. */
|
||||||
|
fun toSerialPrefix(): ByteArray {
|
||||||
|
val out = ByteArray(PREFIX_SIZE)
|
||||||
|
mac.copyInto(out, destinationOffset = 0)
|
||||||
|
out[6] = stationType.toByte()
|
||||||
|
out[7] = (if (pai) 0x01 else 0x00).toByte()
|
||||||
|
putLe(out, 8, tstMs, 4)
|
||||||
|
putLe(out, 12, latTenMicroDeg.toLong(), 4)
|
||||||
|
putLe(out, 16, lonTenMicroDeg.toLong(), 4)
|
||||||
|
putLe(out, 20, speedCms.toLong(), 2)
|
||||||
|
putLe(out, 22, headingDeciDeg.toLong(), 2)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generated equals/hashCode would compare the MAC array by identity.
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (other !is GnPositionVector) return false
|
||||||
|
return mac.contentEquals(other.mac) && stationType == other.stationType &&
|
||||||
|
pai == other.pai && tstMs == other.tstMs && latTenMicroDeg == other.latTenMicroDeg &&
|
||||||
|
lonTenMicroDeg == other.lonTenMicroDeg && speedCms == other.speedCms &&
|
||||||
|
headingDeciDeg == other.headingDeciDeg
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
var h = mac.contentHashCode()
|
||||||
|
for (v in listOf(stationType, pai.hashCode(), tstMs.hashCode(), latTenMicroDeg,
|
||||||
|
lonTenMicroDeg, speedCms, headingDeciDeg)) h = 31 * h + v
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val PREFIX_SIZE = 24
|
||||||
|
|
||||||
|
/** The GN speed field is 15-bit signed, in 0.01 m/s. */
|
||||||
|
const val SPEED_MIN_CMS = -16384
|
||||||
|
const val SPEED_MAX_CMS = 16383
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Largest Android horizontal accuracy, in metres, that still sets the Position Accuracy
|
||||||
|
* Indicator.
|
||||||
|
*
|
||||||
|
* EN 302 636-4-1 sets PAI when the 95% semi-major confidence is below itsGnPaiInterval / 2,
|
||||||
|
* and itsGnPaiInterval defaults to 80 m, so the bound is 40 m at 95%. Android reports a 68%
|
||||||
|
* radius instead, and for a circular 2-D error the 95% radius is about 1.62 times the 68%
|
||||||
|
* one, so 40 m becomes about 24.7 m on Android's scale.
|
||||||
|
*/
|
||||||
|
const val PAI_MAX_ACCURACY_M = 24.7f
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The position vector for [cam], built from the same values the CAM payload carries, so
|
||||||
|
* the two layers of one frame describe the same station at the same moment. [accuracyM] is
|
||||||
|
* Android's horizontal accuracy; null or 0 means unknown and leaves PAI clear.
|
||||||
|
*/
|
||||||
|
fun fromCam(cam: Cam, accuracyM: Float?, mac: ByteArray): GnPositionVector =
|
||||||
|
GnPositionVector(
|
||||||
|
mac = mac,
|
||||||
|
stationType = cam.stationType,
|
||||||
|
pai = accuracyM != null && accuracyM > 0f && accuracyM <= PAI_MAX_ACCURACY_M,
|
||||||
|
tstMs = ItsTime.timestampIts(cam.timestamp),
|
||||||
|
// Same rounding as CamUperCodec's referencePosition, so the GN position and the
|
||||||
|
// CAM's own position agree to the last digit.
|
||||||
|
latTenMicroDeg = (cam.latitude * 1e7).roundToLong().toInt(),
|
||||||
|
lonTenMicroDeg = (cam.longitude * 1e7).roundToLong().toInt(),
|
||||||
|
// Clamped, never wrapped: a wrapped 15-bit speed flips sign and reads as reversing.
|
||||||
|
speedCms = if (cam.speedMps.isFinite()) {
|
||||||
|
(cam.speedMps * 100).roundToInt().coerceIn(SPEED_MIN_CMS, SPEED_MAX_CMS)
|
||||||
|
} else 0,
|
||||||
|
headingDeciDeg = if (cam.headingDeg.isFinite()) {
|
||||||
|
Math.floorMod((cam.headingDeg * 10).roundToInt(), 3600)
|
||||||
|
} else 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun putLe(out: ByteArray, offset: Int, value: Long, bytes: Int) {
|
||||||
|
for (i in 0 until bytes) out[offset + i] = ((value ushr (8 * i)) and 0xFF).toByte()
|
||||||
|
}
|
||||||
|
|
||||||
data class DecodedFrame(val type: Int, val payload: ByteArray)
|
data class DecodedFrame(val type: Int, val payload: ByteArray)
|
||||||
|
|
||||||
object SerialFrameEncoder {
|
object SerialFrameEncoder {
|
||||||
|
|||||||
@@ -329,12 +329,14 @@ class UsbSerialTransport @Inject constructor(
|
|||||||
prev.oversizeDrops != status.oversizeDrops ||
|
prev.oversizeDrops != status.oversizeDrops ||
|
||||||
prev.txFailures != status.txFailures ||
|
prev.txFailures != status.txFailures ||
|
||||||
prev.rxCrcErrors != status.rxCrcErrors ||
|
prev.rxCrcErrors != status.rxCrcErrors ||
|
||||||
prev.status != status.status
|
prev.status != status.status ||
|
||||||
|
prev.capabilities != status.capabilities
|
||||||
) {
|
) {
|
||||||
Log.i(TAG, "ESP32 counters: status=${status.status} " +
|
Log.i(TAG, "ESP32 counters: status=${status.status} " +
|
||||||
"oversizeDrops=${status.oversizeDrops} " +
|
"oversizeDrops=${status.oversizeDrops} " +
|
||||||
"txFailures=${status.txFailures} " +
|
"txFailures=${status.txFailures} " +
|
||||||
"rxCrcErrors=${status.rxCrcErrors}")
|
"rxCrcErrors=${status.rxCrcErrors} " +
|
||||||
|
"capabilities=${status.capabilities}")
|
||||||
}
|
}
|
||||||
_linkStatus.value = status
|
_linkStatus.value = status
|
||||||
}
|
}
|
||||||
@@ -386,6 +388,25 @@ class UsbSerialTransport @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Which frame type the last CAM went out as, so a change of path is logged once, not per CAM. */
|
||||||
|
@Volatile private var lastTxWithPositionVector: Boolean? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs whenever CAMs switch between [SerialFrameType.CAM_TX_PV] and legacy
|
||||||
|
* [SerialFrameType.CAM_TX]. Without it, "the GN header still says bench" has no visible cause
|
||||||
|
* in a logcat capture: it looks identical whether the firmware is old or the phone is.
|
||||||
|
*/
|
||||||
|
private fun noteTxPath(withPositionVector: Boolean, requested: Boolean) {
|
||||||
|
if (lastTxWithPositionVector == withPositionVector) return
|
||||||
|
lastTxWithPositionVector = withPositionVector
|
||||||
|
Log.i(TAG, when {
|
||||||
|
withPositionVector -> "CAM TX path: CAM_TX_PV, GN position vector supplied by the phone"
|
||||||
|
requested -> "CAM TX path: legacy CAM_TX, firmware has not advertised CAM_TX_PV yet; " +
|
||||||
|
"GN position vector is the firmware's bench placeholder"
|
||||||
|
else -> "CAM TX path: legacy CAM_TX, no position vector supplied"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encodes [camUperBytes] as a [SerialFrameType.CAM_TX] frame and writes it to the port.
|
* 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
|
* No-op (returns false) if not currently connected — callers (the CAM transmit loop) should
|
||||||
@@ -394,15 +415,28 @@ class UsbSerialTransport @Inject constructor(
|
|||||||
* [consecutiveWriteFailures] so they can't stay invisible.
|
* [consecutiveWriteFailures] so they can't stay invisible.
|
||||||
*
|
*
|
||||||
* Blocking: writes with a 200 ms timeout, so call from a background dispatcher.
|
* 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
|
val p = port
|
||||||
if (p == null) {
|
if (p == null) {
|
||||||
_consecutiveWriteFailures.update { it + 1 }
|
_consecutiveWriteFailures.update { it + 1 }
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return try {
|
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)
|
p.write(frame, /* timeout ms */ 200)
|
||||||
_consecutiveWriteFailures.value = 0
|
_consecutiveWriteFailures.value = 0
|
||||||
true
|
true
|
||||||
|
|||||||
@@ -34,9 +34,6 @@ object CamUperCodec {
|
|||||||
/** Encode buffer size — matches `cam.c`'s `cam_payload[96]`, the known-sufficient size. */
|
/** Encode buffer size — matches `cam.c`'s `cam_payload[96]`, the known-sufficient size. */
|
||||||
private const val ENCODE_BUFFER_BYTES = 96
|
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
|
// ASN.1 "unavailable" sentinel values, straight from the CAM/ITS-Container modules (also
|
||||||
// documented inline in cam.c against each field).
|
// documented inline in cam.c against each field).
|
||||||
private const val HEADING_UNAVAILABLE = 3601
|
private const val HEADING_UNAVAILABLE = 3601
|
||||||
@@ -47,9 +44,13 @@ object CamUperCodec {
|
|||||||
private const val ACCEL_UNAVAILABLE = 161
|
private const val ACCEL_UNAVAILABLE = 161
|
||||||
private const val YAW_RATE_UNAVAILABLE = 32767
|
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 {
|
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
|
// 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).
|
// happen with a real clock, but avoids a negative/UB result if it ever does).
|
||||||
return Math.floorMod(itsMs, 65536L).toInt()
|
return Math.floorMod(itsMs, 65536L).toInt()
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.hawhamburg.micr0bu.domain.asn1
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ITS time, as used by every timestamp this app puts on the air.
|
||||||
|
*
|
||||||
|
* TimestampIts (ETSI TS 102 894-2) counts milliseconds from 2004-01-01T00:00:00Z. Two fields in a
|
||||||
|
* single transmitted frame come from it: the CAM's generationDeltaTime (modulo 65536) and the
|
||||||
|
* GeoNetworking Source Position Vector's TST (modulo 2^32). A receiver can compare the two, so
|
||||||
|
* they must follow one rule. Both go through here so they cannot drift apart.
|
||||||
|
*
|
||||||
|
* **Which clock.** The input should be GNSS time, not the phone's wall clock. A phone with no SIM
|
||||||
|
* and no internet time has no automatic time source at all, and the bench phone was found 24
|
||||||
|
* minutes fast that way. `GnssTimeSource` moves a timestamp onto GNSS time, using [onGnssTime],
|
||||||
|
* before it gets here.
|
||||||
|
*
|
||||||
|
* **Open question: leap seconds.** This is Unix time minus the 2004 epoch, with no leap-second
|
||||||
|
* term. If TimestampIts is read as TAI-based, the correct value is currently 5 s higher, for the
|
||||||
|
* five leap seconds inserted since 2004. Whichever reading turns out right, it is changed here and
|
||||||
|
* nowhere else. Settling it needs a frame from a third-party stack with a trusted clock, such as
|
||||||
|
* the RSU's CAM compared against GNSS time, and no such traffic was on air when this was written.
|
||||||
|
*/
|
||||||
|
object ItsTime {
|
||||||
|
/** 2004-01-01T00:00:00Z in Unix epoch milliseconds. */
|
||||||
|
const val EPOCH_MS = 1_072_915_200_000L
|
||||||
|
|
||||||
|
/** TimestampIts for wall-clock [epochMs], before any modulo is applied. */
|
||||||
|
fun timestampIts(epochMs: Long): Long = epochMs - EPOCH_MS
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves [systemMs], a reading of this phone's wall clock, onto GNSS time, using one pair of
|
||||||
|
* simultaneous readings of both clocks: [gnssNowMs] and [systemNowMs]. Their difference is the
|
||||||
|
* wall clock's error, whatever caused it, and the age of [systemMs] is preserved. Returns
|
||||||
|
* [systemMs] unchanged when there is no GNSS reading.
|
||||||
|
*
|
||||||
|
* Pure so the arithmetic can be tested apart from the Android clock API, which is where the
|
||||||
|
* readings come from (see `GnssTimeSource`).
|
||||||
|
*/
|
||||||
|
fun onGnssTime(systemMs: Long, gnssNowMs: Long?, systemNowMs: Long): Long =
|
||||||
|
if (gnssNowMs == null) systemMs else systemMs + (gnssNowMs - systemNowMs)
|
||||||
|
}
|
||||||
@@ -5,26 +5,27 @@ package com.hawhamburg.micr0bu.domain.cam
|
|||||||
* user when a frame comes back off the air.
|
* user when a frame comes back off the air.
|
||||||
*
|
*
|
||||||
* ## Why this exists
|
* ## Why this exists
|
||||||
* On the ESP32-C5 path the radio receives promiscuously, so it hears the phone's own
|
* A receiver that fails to recognise its own transmissions tracks itself: a station sitting exactly
|
||||||
* transmissions. Anything that decodes received CAMs has to recognise them, or the phone tracks
|
* on top of the ego position, moving at the ego's own speed and heading, handed to
|
||||||
* itself: a station sitting exactly on top of the ego position, moving at the ego's own speed and
|
* [com.hawhamburg.micr0bu.domain.usecase.UseCaseDetectionEngine] as a collision partner for itself.
|
||||||
* heading, handed to [com.hawhamburg.micr0bu.domain.usecase.UseCaseDetectionEngine] as a
|
* The phone's own frames can come back to it off the air, for example relayed by the CiT One's
|
||||||
* collision partner for itself.
|
* radio when a phone is connected to both OBUs at once.
|
||||||
*
|
*
|
||||||
* ## Why two IDs
|
* ## Which IDs count
|
||||||
* The phone transmits under two different station IDs by design:
|
* - 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].
|
||||||
*
|
*
|
||||||
* - [com.hawhamburg.micr0bu.service.CamTransmitLoop] uses the persisted per-install ID from
|
* ## Why the bench ID is conditional
|
||||||
* `ObuHardwarePreferences.getOrCreateOwnStationId()`, which is the real identity this station
|
* It used to count as ours unconditionally, on every phone, and that hid other phones' pings. On
|
||||||
* presents to the world.
|
* the 2026-09-10 bench one phone pinged through an ESP32 while a second phone watched through the
|
||||||
* - [com.hawhamburg.micr0bu.service.CamPinger] uses [BENCH_PING], a fixed and recognisable value,
|
* CiT One, and the watcher silently discarded every ping as its own frame heard back, although it
|
||||||
* so manual bench pings stay identifiable in captures and cannot be confused with the
|
* had sent none. A fixed ID shared by every MicrOBU is only ours on the phone actually using it.
|
||||||
* recording-driven stream when both run at once.
|
* The one case this cannot resolve is two phones pinging at the same time: each hides the other.
|
||||||
*
|
|
||||||
* That second ID is the whole reason this object exists. A filter that knew only the persisted ID
|
|
||||||
* let every bench ping return as a ghost road user, which is the bug this centralises the fix
|
|
||||||
* for. Keeping the rule in one place, in a layer with no Android dependencies, is what makes it
|
|
||||||
* testable and what stops the next transmit path from reintroducing the same gap.
|
|
||||||
*/
|
*/
|
||||||
object OwnStationIds {
|
object OwnStationIds {
|
||||||
|
|
||||||
@@ -34,21 +35,47 @@ object OwnStationIds {
|
|||||||
*/
|
*/
|
||||||
const val BENCH_PING = 999_999L
|
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.
|
* True when [stationId] is one this phone transmits under.
|
||||||
*
|
*
|
||||||
* [persistedOwnId] is the per-install station ID, or null before it has been loaded. Station
|
* [ownIds] is every non-bench ID currently counted as ours: the current and recently retired
|
||||||
* ID 0 is never ours: it is the "not known yet" placeholder used while the ego identity is
|
* transmit pseudonyms, plus the CiT One's own ID on that path. [benchPingIsOurs] says whether
|
||||||
* still being resolved, and matching on it would swallow real traffic.
|
* [BENCH_PING] is ours right now; see the function of the same name.
|
||||||
*
|
*
|
||||||
* [BENCH_PING] counts as ours unconditionally, not merely while the pinger is running. A
|
* Station ID 0 is never ours: it is the "not known yet" placeholder used while the ego
|
||||||
* time-windowed check would still let a frame transmitted moments before Stop arrive
|
* identity is still being resolved, and matching on it would swallow real traffic.
|
||||||
* afterwards and be tracked as a stranger. The cost is that a genuine remote station using
|
|
||||||
* this ID would be ignored, which is not a real risk at a lab site and is the bargain that
|
|
||||||
* reserving a fixed ID already implies.
|
|
||||||
*/
|
*/
|
||||||
fun isOwn(stationId: Long, persistedOwnId: Long?): Boolean {
|
fun isOwn(stationId: Long, ownIds: Set<Long>, benchPingIsOurs: Boolean): Boolean {
|
||||||
if (stationId == 0L) return false
|
if (stationId == 0L) return false
|
||||||
return stationId == persistedOwnId || stationId == BENCH_PING
|
if (stationId == BENCH_PING) return benchPingIsOurs
|
||||||
|
return stationId in ownIds
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,10 +24,10 @@ object PhoneCamBuilder {
|
|||||||
* @param gyroZRadPerSec latest gyroscope z-axis reading, rad/s (device frame). Positive per
|
* @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
|
* 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.
|
* yaw rate convention already used by [Cam.yawRateDps] to match OBU/remote CAM data.
|
||||||
* @param stationId this device's own station ID, from
|
* @param stationId the station ID to transmit under: the current pseudonym from
|
||||||
* [com.hawhamburg.micr0bu.data.mqtt.ObuHardwarePreferences.getOrCreateOwnStationId] — a
|
* [com.hawhamburg.micr0bu.data.cam.PseudonymManager], or the bench pinger's fixed ID.
|
||||||
* persisted random value, not a placeholder. Receivers use it to track this station across
|
* Receivers track a station across successive CAMs by this ID, which is why it only ever
|
||||||
* successive CAMs, so it must be stable for the life of the install and distinct per device.
|
* changes in a coordinated rotation together with the link-layer address.
|
||||||
* @param longitudinalAccelMps2 along-track acceleration, signed (positive = accelerating).
|
* @param longitudinalAccelMps2 along-track acceleration, signed (positive = accelerating).
|
||||||
* Derived from successive GNSS speed samples by [com.hawhamburg.micr0bu.service.CamTransmitLoop]
|
* 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,
|
* rather than from the accelerometer: CAM wants acceleration along the direction of travel,
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package com.hawhamburg.micr0bu.domain.cam
|
||||||
|
|
||||||
|
import kotlin.random.Random
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The identity this phone transmits under on the ESP32-C5 path: the CAM stationID, and the
|
||||||
|
* link-layer address the firmware writes into both the GeoNetworking GN_ADDR and the 802.11
|
||||||
|
* source address.
|
||||||
|
*
|
||||||
|
* ## Why the two change together
|
||||||
|
* A pseudonym only makes a station harder to follow if every identifier on the frame changes at
|
||||||
|
* the same moment. Rotating the address while keeping the stationID, or the reverse, leaves the
|
||||||
|
* unchanged one as a stable handle, so a receiver loses nothing and the rotation buys nothing.
|
||||||
|
* Holding both in one value that is only ever replaced whole makes a partial rotation impossible
|
||||||
|
* to express.
|
||||||
|
*
|
||||||
|
* ## Why every [ROTATION_INTERVAL_MS]
|
||||||
|
* Real ITS stacks change pseudonym every few minutes, 5 to 15 being typical, and the CiT One was
|
||||||
|
* seen rotating its station ID twice within one bench session. Ten minutes sits in that range.
|
||||||
|
*
|
||||||
|
* ## A limit worth stating
|
||||||
|
* Nothing this app transmits is signed (there is no ETSI TS 103 097 security), so rotation gives
|
||||||
|
* nominal unlinkability at best: an unsigned frame's content can still be correlated across a
|
||||||
|
* change. This is the correct behaviour to build on, not a privacy guarantee.
|
||||||
|
*/
|
||||||
|
data class Pseudonym(
|
||||||
|
val stationId: Long,
|
||||||
|
/** Six bytes, locally administered and unicast. See [generate]. */
|
||||||
|
val mac: ByteArray,
|
||||||
|
/** Wall-clock ms this pseudonym was created, for [isExpired]. */
|
||||||
|
val createdAtMs: Long,
|
||||||
|
) {
|
||||||
|
init {
|
||||||
|
require(mac.size == 6) { "a MAC is 6 bytes, got ${mac.size}" }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True once this pseudonym has been in use for [intervalMs], or if the clock has moved back
|
||||||
|
* past its creation time. The second case rotates rather than trusting a creation time that
|
||||||
|
* now lies in the future, which would otherwise pin one identity until the clock caught up.
|
||||||
|
*/
|
||||||
|
fun isExpired(nowMs: Long, intervalMs: Long = ROTATION_INTERVAL_MS): Boolean =
|
||||||
|
nowMs < createdAtMs || nowMs - createdAtMs >= intervalMs
|
||||||
|
|
||||||
|
// Generated equals/hashCode would compare the MAC array by identity, so two pseudonyms with
|
||||||
|
// the same bytes would compare unequal.
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (other !is Pseudonym) return false
|
||||||
|
return stationId == other.stationId && createdAtMs == other.createdAtMs &&
|
||||||
|
mac.contentEquals(other.mac)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int =
|
||||||
|
31 * (31 * stationId.hashCode() + mac.contentHashCode()) + createdAtMs.hashCode()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val ROTATION_INTERVAL_MS = 10 * 60_000L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A fresh identity. StationID is INTEGER(0..4294967295); 0 is avoided because it is the
|
||||||
|
* "not yet known" placeholder elsewhere in this app, and [OwnStationIds.BENCH_PING] is
|
||||||
|
* avoided so a rider can never be mistaken for the bench pinger.
|
||||||
|
*
|
||||||
|
* The MAC is random with the locally-administered bit set and the group bit clear. A
|
||||||
|
* source address must never be a group address, and a random one must not claim a real
|
||||||
|
* vendor's OUI. [OwnStationIds.BENCH_PING_MAC] is excluded for the same reason as the ID.
|
||||||
|
*/
|
||||||
|
fun generate(nowMs: Long, random: Random = Random.Default): Pseudonym {
|
||||||
|
var stationId: Long
|
||||||
|
do {
|
||||||
|
stationId = random.nextLong(1L, 0xFFFF_FFFEL)
|
||||||
|
} while (stationId == OwnStationIds.BENCH_PING)
|
||||||
|
|
||||||
|
var mac: ByteArray
|
||||||
|
do {
|
||||||
|
mac = random.nextBytes(6)
|
||||||
|
mac[0] = ((mac[0].toInt() and 0xFC) or 0x02).toByte()
|
||||||
|
} while (mac.contentEquals(OwnStationIds.BENCH_PING_MAC))
|
||||||
|
|
||||||
|
return Pseudonym(stationId, mac, nowMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,26 @@ package com.hawhamburg.micr0bu.domain.detection
|
|||||||
* All detection thresholds in one place.
|
* All detection thresholds in one place.
|
||||||
*
|
*
|
||||||
* Pass a custom instance to [EventDetector] to tune behaviour without
|
* 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(
|
data class DetectionConfig(
|
||||||
|
|
||||||
@@ -17,42 +36,51 @@ data class DetectionConfig(
|
|||||||
* Minimum speed drop (m/s) from the reference speed at braking onset for
|
* Minimum speed drop (m/s) from the reference speed at braking onset for
|
||||||
* a frame to qualify as a braking frame.
|
* 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. */
|
/** 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. */
|
/** 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
|
* Peak *cumulative* speed drop (m/s) from the onset reference speed above
|
||||||
* confidence is upgraded from MEDIUM to HIGH.
|
* 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 ───────────────────────────────────────────────────────────────
|
// ── TURNING ───────────────────────────────────────────────────────────────
|
||||||
/** Minimum gyro mean (rad/s) required for a frame to qualify as 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
|
/** Bearing-change rate (°/s) that must be exceeded when speed is above the
|
||||||
* minimum threshold for a HIGH-confidence turning confirmation. */
|
* 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. */
|
/** GPS speed (m/s) above which the bearing-change criterion is enforced. */
|
||||||
val turningMinSpeedThreshold: Double = 2.0,
|
val turningMinSpeedThreshold: Double = 2.0,
|
||||||
|
|
||||||
/** Consecutive turning frames required before an event is emitted. */
|
/** Consecutive turning frames required before an event is emitted. */
|
||||||
val turningSustainedFrames: Int = 20,
|
val turningSustainedFrames: Int = 30,
|
||||||
|
|
||||||
// ── STOPPING ─────────────────────────────────────────────────────────────
|
// ── STOPPING ─────────────────────────────────────────────────────────────
|
||||||
/** GPS speed (m/s) below which a frame is considered a potential stop. */
|
/** 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.
|
/** Consecutive stop frames required (> this value) before an event is emitted.
|
||||||
* At 50 Hz, 100 frames ≈ 2 s. */
|
* At 50 Hz, 150 frames ≈ 3 s. */
|
||||||
val stoppingFrames: Int = 100,
|
val stoppingFrames: Int = 150,
|
||||||
|
|
||||||
/** Maximum accel std-dev (m/s²) allowed for a frame to count as stationary. */
|
/** 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
|
* to [events] (a hot [SharedFlow]). Debounce is implemented with
|
||||||
* consecutive-frame counters, not timers.
|
* 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
|
* GPS updates at 1 Hz whilst sensors fire at ~50 Hz. [speedMps] and
|
||||||
* [bearingChangeDegPerSec] should be the values from the last known GPS fix;
|
* [bearingChangeDegPerSec] should be the values from the last known GPS fix;
|
||||||
* the detector compares speed against a *reference speed at braking onset*
|
* 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 brakingFrames = 0
|
||||||
private var brakingOnsetSpeed = 0.0 // reference speed when braking started
|
private var brakingOnsetSpeed = 0.0 // reference speed when braking started
|
||||||
private var brakingStartTime = 0L
|
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
|
private var peakAccelBraking = 0.0
|
||||||
|
|
||||||
// ── Turning state ─────────────────────────────────────────────────────────
|
// ── Turning state ─────────────────────────────────────────────────────────
|
||||||
@@ -124,7 +134,7 @@ class EventDetector(private val config: DetectionConfig = DetectionConfig()) {
|
|||||||
|
|
||||||
if (brakingFrames == config.brakingSustainedFrames) {
|
if (brakingFrames == config.brakingSustainedFrames) {
|
||||||
val confidence =
|
val confidence =
|
||||||
if (peakBrakingDrop > config.brakingHighConfidenceRate) Confidence.HIGH
|
if (peakBrakingDrop > config.brakingHighConfidencePeakDrop) Confidence.HIGH
|
||||||
else Confidence.MEDIUM
|
else Confidence.MEDIUM
|
||||||
|
|
||||||
_events.tryEmit(
|
_events.tryEmit(
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
package com.hawhamburg.micr0bu.service
|
package com.hawhamburg.micr0bu.service
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.os.SystemClock
|
||||||
import com.hawhamburg.micr0bu.data.GnssReading
|
import com.hawhamburg.micr0bu.data.GnssReading
|
||||||
|
import com.hawhamburg.micr0bu.data.GnssTimeSource
|
||||||
import com.hawhamburg.micr0bu.data.SensorRepository
|
import com.hawhamburg.micr0bu.data.SensorRepository
|
||||||
|
import com.hawhamburg.micr0bu.data.transport.GnPositionVector
|
||||||
import com.hawhamburg.micr0bu.data.transport.UsbSerialTransport
|
import com.hawhamburg.micr0bu.data.transport.UsbSerialTransport
|
||||||
import com.hawhamburg.micr0bu.domain.asn1.RealAsn1UperCodec
|
import com.hawhamburg.micr0bu.domain.asn1.RealAsn1UperCodec
|
||||||
import com.hawhamburg.micr0bu.domain.cam.OwnStationIds
|
import com.hawhamburg.micr0bu.domain.cam.OwnStationIds
|
||||||
@@ -68,6 +71,20 @@ class CamPinger @Inject constructor(
|
|||||||
/** False while the pinger is running but has no GNSS fix yet — nothing is being transmitted. */
|
/** False while the pinger is running but has no GNSS fix yet — nothing is being transmitted. */
|
||||||
val hasFix: StateFlow<Boolean> = _hasFix.asStateFlow()
|
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() {
|
fun start() {
|
||||||
if (job?.isActive == true) return
|
if (job?.isActive == true) return
|
||||||
_sentCount.value = 0
|
_sentCount.value = 0
|
||||||
@@ -87,13 +104,17 @@ class CamPinger @Inject constructor(
|
|||||||
_hasFix.value = gnss != null
|
_hasFix.value = gnss != null
|
||||||
if (gnss != null) {
|
if (gnss != null) {
|
||||||
val cam = PhoneCamBuilder.build(
|
val cam = PhoneCamBuilder.build(
|
||||||
gnss = gnss,
|
// Stamped on GNSS time rather than the phone clock; see GnssTimeSource.
|
||||||
|
gnss = gnss.copy(timestamp = GnssTimeSource.correct(gnss.timestamp)),
|
||||||
gyroZRadPerSec = latestGyroZ,
|
gyroZRadPerSec = latestGyroZ,
|
||||||
stationId = OwnStationIds.BENCH_PING,
|
stationId = OwnStationIds.BENCH_PING,
|
||||||
longitudinalAccelMps2 = longitudinalAccel(gnss),
|
longitudinalAccelMps2 = longitudinalAccel(gnss),
|
||||||
)
|
)
|
||||||
val bytes = codec.encodeCam(cam)
|
val bytes = codec.encodeCam(cam)
|
||||||
if (usbSerialTransport.sendCamTx(bytes)) {
|
// Fixed bench identity on every layer, the link-layer address included, so a ping
|
||||||
|
// stays recognisable in a capture and never rotates.
|
||||||
|
val pv = GnPositionVector.fromCam(cam, gnss.accuracyM, OwnStationIds.BENCH_PING_MAC)
|
||||||
|
if (usbSerialTransport.sendCamTx(bytes, pv)) {
|
||||||
_sentCount.update { it + 1 }
|
_sentCount.update { it + 1 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -120,6 +141,9 @@ class CamPinger @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun stop() {
|
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?.cancel()
|
||||||
job = null
|
job = null
|
||||||
_isActive.value = false
|
_isActive.value = false
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ package com.hawhamburg.micr0bu.service
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import com.hawhamburg.micr0bu.data.GnssReading
|
import com.hawhamburg.micr0bu.data.GnssReading
|
||||||
|
import com.hawhamburg.micr0bu.data.GnssTimeSource
|
||||||
import com.hawhamburg.micr0bu.data.SensorRepository
|
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.mqtt.ObuHardwarePreferences
|
||||||
|
import com.hawhamburg.micr0bu.data.transport.GnPositionVector
|
||||||
import com.hawhamburg.micr0bu.data.transport.ObuHardware
|
import com.hawhamburg.micr0bu.data.transport.ObuHardware
|
||||||
import com.hawhamburg.micr0bu.data.transport.UsbSerialTransport
|
import com.hawhamburg.micr0bu.data.transport.UsbSerialTransport
|
||||||
import com.hawhamburg.micr0bu.domain.asn1.RealAsn1UperCodec
|
import com.hawhamburg.micr0bu.domain.asn1.RealAsn1UperCodec
|
||||||
@@ -49,6 +52,7 @@ class CamTransmitLoop @Inject constructor(
|
|||||||
private val obuHardwarePrefs: ObuHardwarePreferences,
|
private val obuHardwarePrefs: ObuHardwarePreferences,
|
||||||
private val usbSerialTransport: UsbSerialTransport,
|
private val usbSerialTransport: UsbSerialTransport,
|
||||||
private val codec: RealAsn1UperCodec,
|
private val codec: RealAsn1UperCodec,
|
||||||
|
private val pseudonymManager: PseudonymManager,
|
||||||
) {
|
) {
|
||||||
private val config = CamTransmitConfig()
|
private val config = CamTransmitConfig()
|
||||||
private val sensorRepository = SensorRepository(context)
|
private val sensorRepository = SensorRepository(context)
|
||||||
@@ -63,14 +67,6 @@ class CamTransmitLoop @Inject constructor(
|
|||||||
/** Previous GNSS fix, kept only to derive along-track acceleration — see [longitudinalAccel]. */
|
/** Previous GNSS fix, kept only to derive along-track acceleration — see [longitudinalAccel]. */
|
||||||
@Volatile private var previousGnss: GnssReading? = null
|
@Volatile private var previousGnss: GnssReading? = null
|
||||||
|
|
||||||
/**
|
|
||||||
* Own station id for the ESP32-C5 path, loaded once per [start] from
|
|
||||||
* [ObuHardwarePreferences.getOrCreateOwnStationId]. 0 means "not loaded yet" — the loop waits
|
|
||||||
* for the real value rather than beaconing as station 0, which would be indistinguishable
|
|
||||||
* from every other MicrOBU to any receiver.
|
|
||||||
*/
|
|
||||||
@Volatile var stationId: Long = 0L
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Call when a braking/turning/stopping event fires during an active trip — bumps the CAM
|
* Call when a braking/turning/stopping event fires during an active trip — bumps the CAM
|
||||||
* rate to [CamTransmitConfig.elevatedRateHz] for [ELEVATED_HOLD_MS] so nearby stations get
|
* rate to [CamTransmitConfig.elevatedRateHz] for [ELEVATED_HOLD_MS] so nearby stations get
|
||||||
@@ -90,7 +86,6 @@ class CamTransmitLoop @Inject constructor(
|
|||||||
elevatedUntilMs = 0L
|
elevatedUntilMs = 0L
|
||||||
previousGnss = null
|
previousGnss = null
|
||||||
job = scope.launch {
|
job = scope.launch {
|
||||||
stationId = obuHardwarePrefs.getOrCreateOwnStationId()
|
|
||||||
obuHardwarePrefs.obuHardwareFlow.collectLatest { hardware ->
|
obuHardwarePrefs.obuHardwareFlow.collectLatest { hardware ->
|
||||||
if (hardware != ObuHardware.ESP32_C5) return@collectLatest
|
if (hardware != ObuHardware.ESP32_C5) return@collectLatest
|
||||||
runTransmitLoop()
|
runTransmitLoop()
|
||||||
@@ -111,9 +106,15 @@ class CamTransmitLoop @Inject constructor(
|
|||||||
while (true) {
|
while (true) {
|
||||||
val gnss = latestGnss
|
val gnss = latestGnss
|
||||||
if (gnss != null) {
|
if (gnss != null) {
|
||||||
val cam = PhoneCamBuilder.build(gnss, latestGyroZ, stationId, longitudinalAccel(gnss))
|
// Asked for per CAM rather than once per trip: that is what lets a pseudonym
|
||||||
|
// rotation fall cleanly between two frames instead of inside one.
|
||||||
|
val pseudonym = pseudonymManager.current()
|
||||||
|
// Stamped on GNSS time rather than the phone clock; see GnssTimeSource. Only the
|
||||||
|
// outgoing CAM is: acceleration below still differences wall-clock samples.
|
||||||
|
val fix = gnss.copy(timestamp = GnssTimeSource.correct(gnss.timestamp))
|
||||||
|
val cam = PhoneCamBuilder.build(fix, latestGyroZ, pseudonym.stationId, longitudinalAccel(gnss))
|
||||||
val bytes = codec.encodeCam(cam)
|
val bytes = codec.encodeCam(cam)
|
||||||
usbSerialTransport.sendCamTx(bytes)
|
usbSerialTransport.sendCamTx(bytes, GnPositionVector.fromCam(cam, gnss.accuracyM, pseudonym.mac))
|
||||||
}
|
}
|
||||||
delay((1000.0 / currentRateHz(gnss)).toLong())
|
delay((1000.0 / currentRateHz(gnss)).toLong())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,9 +26,7 @@ import com.hawhamburg.micr0bu.R
|
|||||||
import com.hawhamburg.micr0bu.data.TripRepository
|
import com.hawhamburg.micr0bu.data.TripRepository
|
||||||
import com.hawhamburg.micr0bu.data.cam.CamUseCaseRepository
|
import com.hawhamburg.micr0bu.data.cam.CamUseCaseRepository
|
||||||
import com.hawhamburg.micr0bu.data.db.AppDatabase
|
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.EventDetector
|
||||||
import com.hawhamburg.micr0bu.domain.detection.EventType
|
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -108,19 +106,10 @@ class TripRecordingService : Service() {
|
|||||||
// V2xMessageEntity's KDoc for why nothing is retained outside of one.
|
// V2xMessageEntity's KDoc for why nothing is retained outside of one.
|
||||||
@Inject lateinit var camUseCaseRepository: CamUseCaseRepository
|
@Inject lateinit var camUseCaseRepository: CamUseCaseRepository
|
||||||
private var v2xLoggingJob: Job? = null
|
private var v2xLoggingJob: Job? = null
|
||||||
private val detector = EventDetector(
|
// These nine thresholds used to be overridden here; they are now the DetectionConfig
|
||||||
DetectionConfig(
|
// defaults, so there is one configuration and the unit tests exercise it. Behaviour is
|
||||||
brakingSpeedDropThreshold = 1.0,
|
// unchanged - see DetectionConfig's KDoc.
|
||||||
brakingAccelStdDevThreshold = 1.8,
|
private val detector = EventDetector()
|
||||||
brakingSustainedFrames = 25,
|
|
||||||
turningGyroMeanThreshold = 0.6,
|
|
||||||
turningBearingChangeThreshold = 15.0,
|
|
||||||
turningSustainedFrames = 30,
|
|
||||||
stoppingSpeedThreshold = 0.3,
|
|
||||||
stoppingFrames = 150,
|
|
||||||
stoppingAccelStdDevThreshold = 0.10,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── Sensor fusion state ───────────────────────────────────────────────────
|
// ── Sensor fusion state ───────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -153,10 +142,11 @@ class TripRecordingService : Service() {
|
|||||||
private val gpsTrackBuilder = StringBuilder("[")
|
private val gpsTrackBuilder = StringBuilder("[")
|
||||||
private var gpsPointCount = 0
|
private var gpsPointCount = 0
|
||||||
|
|
||||||
// Event counts
|
// Number of manoeuvres the detector fired during this trip. The only thing kept about
|
||||||
private var brakingCount = 0
|
// them: it fills the trips.eventCount column, which predates this change and cannot be
|
||||||
private var turningCount = 0
|
// dropped without rebuilding the trips table. See EventDetector's KDoc for why the
|
||||||
private var stoppingCount = 0
|
// detector still runs at all.
|
||||||
|
private var detectedEventCount = 0
|
||||||
|
|
||||||
// ── SensorEventListener ───────────────────────────────────────────────────
|
// ── SensorEventListener ───────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -253,9 +243,7 @@ class TripRecordingService : Service() {
|
|||||||
).also { it.acquire() }
|
).also { it.acquire() }
|
||||||
|
|
||||||
detector.reset()
|
detector.reset()
|
||||||
brakingCount = 0
|
detectedEventCount = 0
|
||||||
turningCount = 0
|
|
||||||
stoppingCount = 0
|
|
||||||
distanceMetres = 0f
|
distanceMetres = 0f
|
||||||
prevLat = Double.NaN
|
prevLat = Double.NaN
|
||||||
prevLon = Double.NaN
|
prevLon = Double.NaN
|
||||||
@@ -273,35 +261,20 @@ class TripRecordingService : Service() {
|
|||||||
isRecording = true,
|
isRecording = true,
|
||||||
currentTripId = currentTripId,
|
currentTripId = currentTripId,
|
||||||
elapsedSeconds = 0L,
|
elapsedSeconds = 0L,
|
||||||
brakingCount = 0,
|
|
||||||
turningCount = 0,
|
|
||||||
stoppingCount = 0,
|
|
||||||
currentSpeedMs = 0f,
|
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 {
|
serviceScope.launch {
|
||||||
detector.events.collect { event ->
|
detector.events.collect { _ ->
|
||||||
if (currentTripId < 0) return@collect
|
if (currentTripId < 0) return@collect
|
||||||
repository.insertEvent(currentTripId, event)
|
|
||||||
// Bump the CAM transmit rate through the maneuver, not just at detection instant.
|
// Bump the CAM transmit rate through the maneuver, not just at detection instant.
|
||||||
// No-op on the CiT One path (see CamTransmitLoop's KDoc).
|
// No-op on the CiT One path (see CamTransmitLoop's KDoc).
|
||||||
camTransmitLoop.onDetectedEvent()
|
camTransmitLoop.onDetectedEvent()
|
||||||
when (event.type) {
|
detectedEventCount++
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,7 +329,7 @@ class TripRecordingService : Service() {
|
|||||||
v2xLoggingJob = null
|
v2xLoggingJob = null
|
||||||
|
|
||||||
val endTime = System.currentTimeMillis()
|
val endTime = System.currentTimeMillis()
|
||||||
val totalEvents = brakingCount + turningCount + stoppingCount
|
val totalEvents = detectedEventCount
|
||||||
|
|
||||||
// Close GPS track JSON
|
// Close GPS track JSON
|
||||||
gpsTrackBuilder.append("]")
|
gpsTrackBuilder.append("]")
|
||||||
@@ -454,10 +427,7 @@ class TripRecordingService : Service() {
|
|||||||
private fun buildNotification(elapsedSeconds: Long) =
|
private fun buildNotification(elapsedSeconds: Long) =
|
||||||
NotificationCompat.Builder(this, CHANNEL_ID)
|
NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
.setContentTitle("Recording trip")
|
.setContentTitle("Recording trip")
|
||||||
.setContentText(
|
.setContentText("⏱ ${formatElapsed(elapsedSeconds)}")
|
||||||
"⏱ ${formatElapsed(elapsedSeconds)} · " +
|
|
||||||
"🚨 $brakingCount 🔄 $turningCount 🛑 $stoppingCount"
|
|
||||||
)
|
|
||||||
.setSmallIcon(R.mipmap.ic_launcher_foreground)
|
.setSmallIcon(R.mipmap.ic_launcher_foreground)
|
||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
.setOnlyAlertOnce(true)
|
.setOnlyAlertOnce(true)
|
||||||
|
|||||||
@@ -17,9 +17,6 @@ object TripServiceBus {
|
|||||||
val isRecording: Boolean = false,
|
val isRecording: Boolean = false,
|
||||||
val currentTripId: Long = -1L,
|
val currentTripId: Long = -1L,
|
||||||
val elapsedSeconds: Long = 0L,
|
val elapsedSeconds: Long = 0L,
|
||||||
val brakingCount: Int = 0,
|
|
||||||
val turningCount: Int = 0,
|
|
||||||
val stoppingCount: Int = 0,
|
|
||||||
val currentSpeedMs: Float = 0f,
|
val currentSpeedMs: Float = 0f,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -141,46 +141,6 @@ fun RecordingScreen(
|
|||||||
|
|
||||||
Spacer(Modifier.height(8.dp))
|
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 ─────────────────────────────────────────
|
// ── CSV Session Log shortcut ─────────────────────────────────────────
|
||||||
if (!state.isRecording) {
|
if (!state.isRecording) {
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
@@ -228,25 +188,6 @@ fun RecordingScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun EventCountBadge(label: String, count: Int, color: Color) {
|
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
|
||||||
Text(
|
|
||||||
text = count.toString(),
|
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
fontFamily = FontFamily.Monospace,
|
|
||||||
color = color,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(2.dp))
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun StreamRow(label: String, active: Boolean) {
|
private fun StreamRow(label: String, active: Boolean) {
|
||||||
Row(
|
Row(
|
||||||
|
|||||||
@@ -145,7 +145,6 @@ private fun TripCard(
|
|||||||
val durationSec = ((trip.endTime - trip.startTime) / 1000).coerceAtLeast(0)
|
val durationSec = ((trip.endTime - trip.startTime) / 1000).coerceAtLeast(0)
|
||||||
TripStatChip("⏱ ${formatDuration(durationSec)}")
|
TripStatChip("⏱ ${formatDuration(durationSec)}")
|
||||||
TripStatChip("📍 ${formatDistance(trip.distanceMetres)}")
|
TripStatChip("📍 ${formatDistance(trip.distanceMetres)}")
|
||||||
TripStatChip("🚨 ${trip.eventCount} events")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
IconButton(onClick = onOpen) {
|
IconButton(onClick = onOpen) {
|
||||||
|
|||||||
@@ -42,9 +42,7 @@ import androidx.compose.ui.viewinterop.AndroidView
|
|||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
import com.hawhamburg.micr0bu.data.db.DetectedEventEntity
|
|
||||||
import com.hawhamburg.micr0bu.data.db.RecordedTripEntity
|
import com.hawhamburg.micr0bu.data.db.RecordedTripEntity
|
||||||
import com.hawhamburg.micr0bu.viewmodel.TripRecordingViewModel
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.osmdroid.config.Configuration
|
import org.osmdroid.config.Configuration
|
||||||
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
|
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
|
||||||
@@ -61,7 +59,6 @@ import java.util.Locale
|
|||||||
@Composable
|
@Composable
|
||||||
fun TripReviewScreen(
|
fun TripReviewScreen(
|
||||||
trip: RecordedTripEntity,
|
trip: RecordedTripEntity,
|
||||||
viewModel: TripRecordingViewModel,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -70,15 +67,8 @@ fun TripReviewScreen(
|
|||||||
// provider is ready before MapView is constructed in the factory block.
|
// provider is ready before MapView is constructed in the factory block.
|
||||||
initOsmReview(context)
|
initOsmReview(context)
|
||||||
|
|
||||||
LaunchedEffect(trip.id) { viewModel.loadTripEvents(trip.id) }
|
|
||||||
val events by viewModel.selectedTripEvents.collectAsState()
|
|
||||||
|
|
||||||
val gpsPoints = remember(trip.gpsTrackJson) { parseGpsTrack(trip.gpsTrackJson) }
|
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 mapViewRef = remember { mutableStateOf<MapView?>(null) }
|
||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
|
|
||||||
@@ -108,13 +98,6 @@ fun TripReviewScreen(
|
|||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
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 ───────────────────────────────────────────────────────────────
|
// ── Map ───────────────────────────────────────────────────────────────
|
||||||
@@ -141,33 +124,6 @@ fun TripReviewScreen(
|
|||||||
mv.overlays.add(polyline)
|
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
|
// Auto-fit the camera to the track — deferred via post() so the
|
||||||
// MapView has been measured before zoomToBoundingBox is called.
|
// MapView has been measured before zoomToBoundingBox is called.
|
||||||
// Calling it with width/height == 0 (before first layout) crashes osmdroid.
|
// 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 ─────────────────────────────────────────────────────────
|
// ── GPS track parsing ─────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import com.hawhamburg.micr0bu.data.TripRepository
|
import com.hawhamburg.micr0bu.data.TripRepository
|
||||||
import com.hawhamburg.micr0bu.data.shareTripCsv
|
import com.hawhamburg.micr0bu.data.shareTripCsv
|
||||||
import com.hawhamburg.micr0bu.data.db.AppDatabase
|
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.RecordedTripEntity
|
||||||
import com.hawhamburg.micr0bu.service.TripRecordingService
|
import com.hawhamburg.micr0bu.service.TripRecordingService
|
||||||
import com.hawhamburg.micr0bu.service.TripServiceBus
|
import com.hawhamburg.micr0bu.service.TripServiceBus
|
||||||
@@ -66,20 +65,6 @@ class TripRecordingViewModel(application: Application) : AndroidViewModel(applic
|
|||||||
/** All recorded trips, newest first. */
|
/** All recorded trips, newest first. */
|
||||||
val trips: Flow<List<RecordedTripEntity>> = repository.getAllTrips()
|
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 ─────────────────────────────────────────────────────
|
// ── Recording control ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -132,7 +117,6 @@ class TripRecordingViewModel(application: Application) : AndroidViewModel(applic
|
|||||||
shareTripCsv(
|
shareTripCsv(
|
||||||
context = context,
|
context = context,
|
||||||
trip = trip,
|
trip = trip,
|
||||||
events = repository.getEventsForTripOnce(tripId),
|
|
||||||
v2xMessages = repository.getV2xMessagesForTripOnce(tripId),
|
v2xMessages = repository.getV2xMessagesForTripOnce(tripId),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -292,10 +292,6 @@
|
|||||||
<string name="nav_trips">Fahrten</string>
|
<string name="nav_trips">Fahrten</string>
|
||||||
|
|
||||||
<!-- Phase A: Recording screen event counters -->
|
<!-- 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_stream_event_detection">Ereigniserkennung</string>
|
||||||
<string name="rec_open_session_log">CSV-Sitzungsprotokoll</string>
|
<string name="rec_open_session_log">CSV-Sitzungsprotokoll</string>
|
||||||
|
|
||||||
|
|||||||
@@ -305,10 +305,6 @@
|
|||||||
<string name="nav_trips">Trips</string>
|
<string name="nav_trips">Trips</string>
|
||||||
|
|
||||||
<!-- Phase A: Recording screen event counters -->
|
<!-- 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_stream_event_detection">Event Detection</string>
|
||||||
<string name="rec_open_session_log">CSV Session Log</string>
|
<string name="rec_open_session_log">CSV Session Log</string>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
package com.hawhamburg.micr0bu
|
||||||
|
|
||||||
|
import com.hawhamburg.micr0bu.data.transport.EspLinkStatus
|
||||||
|
import com.hawhamburg.micr0bu.data.transport.GnPositionVector
|
||||||
|
import com.hawhamburg.micr0bu.domain.asn1.ItsTime
|
||||||
|
import com.hawhamburg.micr0bu.domain.cam.Cam
|
||||||
|
import com.hawhamburg.micr0bu.domain.cam.StationType
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pins the phone side of SERIAL_MSG_CAM_TX_PV: the 24-byte prefix the ESP32 turns into the
|
||||||
|
* GeoNetworking Source Position Vector, and the heartbeat capability bit that decides whether the
|
||||||
|
* phone may send that message at all.
|
||||||
|
*
|
||||||
|
* ## Where the expected bytes come from
|
||||||
|
* Not from this code. They were produced with Python's `struct.pack("<IiihH", ...)` from the
|
||||||
|
* layout documented at SERIAL_MSG_CAM_TX_PV in `serial_link.h`, independently of this encoder, so
|
||||||
|
* an agreement here is not an encoder agreeing with itself.
|
||||||
|
*
|
||||||
|
* That same `struct.pack` call is what the bench harness used on 2026-09-10 to drive an
|
||||||
|
* ESP32-C5 over its native USB port with this message. The CiT One OBU, an independent
|
||||||
|
* GeoNetworking stack, decoded every Source Position Vector field of the resulting
|
||||||
|
* transmissions (station type, PAI, latitude, longitude, speed, heading and timestamp) back to
|
||||||
|
* the values sent. These are bytes a third-party receiver has accepted on air, not only bytes
|
||||||
|
* this app agrees with.
|
||||||
|
*/
|
||||||
|
class CamTxPvSerialTest {
|
||||||
|
|
||||||
|
private fun String.hexToBytes(): ByteArray =
|
||||||
|
chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||||
|
|
||||||
|
private fun ByteArray.u32le(at: Int): Long =
|
||||||
|
(0 until 4).fold(0L) { acc, i -> acc or ((this[at + i].toLong() and 0xFF) shl (8 * i)) }
|
||||||
|
|
||||||
|
// ---- the wire layout -------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `encodes the prefix byte for byte`() {
|
||||||
|
val pv = GnPositionVector(
|
||||||
|
mac = "024d49435230".hexToBytes(),
|
||||||
|
stationType = 2,
|
||||||
|
pai = true,
|
||||||
|
tstMs = 0x12345678L,
|
||||||
|
latTenMicroDeg = 535_543_026,
|
||||||
|
lonTenMicroDeg = 100_226_476,
|
||||||
|
speedCms = 543,
|
||||||
|
headingDeciDeg = 1234,
|
||||||
|
)
|
||||||
|
// 024d49435230 | 02 | 01 | 78563412 | f2bceb1f | ac55f905 | 1f02 | d204
|
||||||
|
assertEquals("024d49435230020178563412f2bceb1fac55f9051f02d204", pv.toSerialPrefix().toHex())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `encodes negative, extreme and flag-clear values`() {
|
||||||
|
// Southern and western hemisphere, full reverse speed, heading at its maximum, PAI clear:
|
||||||
|
// the sign handling that a northern-hemisphere bench test never exercises.
|
||||||
|
val pv = GnPositionVector(
|
||||||
|
mac = "020000000001".hexToBytes(),
|
||||||
|
stationType = 2,
|
||||||
|
pai = false,
|
||||||
|
tstMs = 0xFFFF_FFFFL,
|
||||||
|
latTenMicroDeg = -335_543_026,
|
||||||
|
lonTenMicroDeg = -100_226_476,
|
||||||
|
speedCms = -16384,
|
||||||
|
headingDeciDeg = 3599,
|
||||||
|
)
|
||||||
|
assertEquals("0200000000010200ffffffff0e0500ec54aa06fa00c00f0e", pv.toSerialPrefix().toHex())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the timestamp is reduced modulo 2^32 on the wire`() {
|
||||||
|
// TimestampIts passed 2^32 ms about 49.7 days after its 2004 epoch, so every real value
|
||||||
|
// today is wider than 32 bits and the reduction is the normal case, not an edge case.
|
||||||
|
val pv = vectorAt(tstMs = 716_121_572_779L)
|
||||||
|
assertEquals(3_157_001_643L, pv.toSerialPrefix().u32le(8))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- building it from a CAM ------------------------------------------------------------
|
||||||
|
|
||||||
|
private val cam = Cam(
|
||||||
|
stationId = 1_234_567_890L,
|
||||||
|
stationType = StationType.CYCLIST,
|
||||||
|
latitude = 53.5543026,
|
||||||
|
longitude = 10.0226476,
|
||||||
|
speedMps = 5.43,
|
||||||
|
headingDeg = 123.4,
|
||||||
|
yawRateDps = null,
|
||||||
|
accelerationMps2 = null,
|
||||||
|
timestamp = 1_789_036_772_779L,
|
||||||
|
isOwn = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `fromCam takes the same values the CAM payload carries`() {
|
||||||
|
val pv = GnPositionVector.fromCam(cam, accuracyM = 5f, mac = "024d49435230".hexToBytes())
|
||||||
|
assertEquals(2, pv.stationType)
|
||||||
|
assertEquals(535_543_026, pv.latTenMicroDeg)
|
||||||
|
assertEquals(100_226_476, pv.lonTenMicroDeg)
|
||||||
|
assertEquals(543, pv.speedCms)
|
||||||
|
assertEquals(1234, pv.headingDeciDeg)
|
||||||
|
assertTrue(pv.pai)
|
||||||
|
// The GN TST and the CAM's generationDeltaTime must follow one time rule.
|
||||||
|
assertEquals(ItsTime.timestampIts(cam.timestamp), pv.tstMs)
|
||||||
|
assertEquals(716_121_572_779L, pv.tstMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `speed is clamped to the 15-bit field, never wrapped`() {
|
||||||
|
// A wrapped 15-bit speed flips its sign bit and reads as reversing at speed.
|
||||||
|
assertEquals(16383, GnPositionVector.fromCam(cam.copy(speedMps = 400.0), 5f, mac).speedCms)
|
||||||
|
assertEquals(-16384, GnPositionVector.fromCam(cam.copy(speedMps = -400.0), 5f, mac).speedCms)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `heading wraps into 0 to 3599`() {
|
||||||
|
assertEquals(0, GnPositionVector.fromCam(cam.copy(headingDeg = 360.0), 5f, mac).headingDeciDeg)
|
||||||
|
assertEquals(50, GnPositionVector.fromCam(cam.copy(headingDeg = 725.0), 5f, mac).headingDeciDeg)
|
||||||
|
assertEquals(3590, GnPositionVector.fromCam(cam.copy(headingDeg = -1.0), 5f, mac).headingDeciDeg)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `non-finite speed or heading does not throw`() {
|
||||||
|
val pv = GnPositionVector.fromCam(
|
||||||
|
cam.copy(speedMps = Double.NaN, headingDeg = Double.POSITIVE_INFINITY), 5f, mac,
|
||||||
|
)
|
||||||
|
assertEquals(0, pv.speedCms)
|
||||||
|
assertEquals(0, pv.headingDeciDeg)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `PAI follows the horizontal accuracy`() {
|
||||||
|
assertTrue(GnPositionVector.fromCam(cam, GnPositionVector.PAI_MAX_ACCURACY_M, mac).pai)
|
||||||
|
assertFalse(GnPositionVector.fromCam(cam, 25f, mac).pai)
|
||||||
|
// Android reports 0 when it has no accuracy estimate: unknown is not accurate.
|
||||||
|
assertFalse(GnPositionVector.fromCam(cam, 0f, mac).pai)
|
||||||
|
assertFalse(GnPositionVector.fromCam(cam, null, mac).pai)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(expected = IllegalArgumentException::class)
|
||||||
|
fun `an address that is not six bytes is rejected`() {
|
||||||
|
GnPositionVector.fromCam(cam, 5f, ByteArray(5))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- capability negotiation ------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `firmware that predates the capability byte advertises nothing`() {
|
||||||
|
// Old firmware sends a 7-byte heartbeat. Reading that as "no CAM_TX_PV" is what keeps a
|
||||||
|
// new app on the legacy message, which that firmware still understands.
|
||||||
|
val status = EspLinkStatus.parse("00000000000000".hexToBytes())!!
|
||||||
|
assertEquals(0, status.capabilities)
|
||||||
|
assertFalse(status.supportsCamTxPv)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `firmware that advertises CAM_TX_PV is recognised`() {
|
||||||
|
val status = EspLinkStatus.parse("0000000000000001".hexToBytes())!!
|
||||||
|
assertTrue(status.supportsCamTxPv)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a capability byte without the CAM_TX_PV bit does not enable it`() {
|
||||||
|
assertFalse(EspLinkStatus.parse("0000000000000002".hexToBytes())!!.supportsCamTxPv)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val mac = "024d49435230".hexToBytes()
|
||||||
|
|
||||||
|
private fun vectorAt(tstMs: Long) = GnPositionVector(
|
||||||
|
mac = mac, stationType = 2, pai = false, tstMs = tstMs,
|
||||||
|
latTenMicroDeg = 0, lonTenMicroDeg = 0, speedCms = 0, headingDeciDeg = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun ByteArray.toHex() = joinToString("") { "%02x".format(it) }
|
||||||
|
}
|
||||||
@@ -21,34 +21,34 @@ import kotlin.math.sqrt
|
|||||||
*
|
*
|
||||||
* No Android emulator required — all production classes have zero Android imports.
|
* No Android emulator required — all production classes have zero Android imports.
|
||||||
*
|
*
|
||||||
* The test [config] uses a smaller window and fewer sustained frames than the
|
* The test [config] shortens only the window and the sustained-frame counts, so
|
||||||
* production defaults so tests run in milliseconds without generating thousands
|
* tests run in milliseconds instead of generating thousands of synthetic
|
||||||
* of synthetic samples.
|
* 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
|
* Accel-std-dev notes
|
||||||
* -------------------
|
* -------------------
|
||||||
* A production threshold of 1.2 m/s² requires genuine variability in the window.
|
* The braking accel-std-dev threshold of 1.8 m/s² requires genuine variability
|
||||||
* In the "hard brake" tests we alternate between high and low accel values
|
* in the window. In the "hard brake" tests we alternate between high and low
|
||||||
* (e.g. 3.5 / 0.5), which yields std dev ≈ 1.5 with a 10-sample window.
|
* 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)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
class EventDetectorTest {
|
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(
|
private val config = DetectionConfig(
|
||||||
windowSize = 10,
|
windowSize = 10,
|
||||||
brakingSustainedFrames = 5,
|
brakingSustainedFrames = 5,
|
||||||
turningSustainedFrames = 8,
|
turningSustainedFrames = 8,
|
||||||
stoppingFrames = 20,
|
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
|
private lateinit var detector: EventDetector
|
||||||
@@ -71,12 +71,12 @@ class EventDetectorTest {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Produces [n] frames with alternating accelMagnitude values of [hi] and [lo],
|
* Produces [n] frames with alternating accelMagnitude values of [hi] and [lo],
|
||||||
* giving a population std dev of |hi - lo| / 2, which exceeds the production
|
* giving a population std dev of |hi - lo| / 2, which exceeds the shipping
|
||||||
* threshold of 1.2 m/s² when hi=3.5 and lo=0.5 (std dev = 1.5).
|
* threshold of 1.8 m/s² when hi=4.5 and lo=0.5 (std dev = 2.0).
|
||||||
*/
|
*/
|
||||||
private fun alternatingAccelFrames(
|
private fun alternatingAccelFrames(
|
||||||
n: Int,
|
n: Int,
|
||||||
hi: Double = 3.5,
|
hi: Double = 4.5,
|
||||||
lo: Double = 0.5,
|
lo: Double = 0.5,
|
||||||
speedMps: Double = 10.0,
|
speedMps: Double = 10.0,
|
||||||
bearingChangeDps: Double = 0.0,
|
bearingChangeDps: Double = 0.0,
|
||||||
@@ -139,12 +139,12 @@ class EventDetectorTest {
|
|||||||
@Test fun `hard brake with large speed drop has HIGH confidence`() = runCollecting { events ->
|
@Test fun `hard brake with large speed drop has HIGH confidence`() = runCollecting { events ->
|
||||||
// Variability established before the drop - see the note in the test above.
|
// Variability established before the drop - see the note in the test above.
|
||||||
alternatingAccelFrames(n = config.windowSize, speedMps = 10.0, timeOffset = 0)
|
alternatingAccelFrames(n = config.windowSize, speedMps = 10.0, timeOffset = 0)
|
||||||
// Drop of 8 m/s > brakingHighConfidenceRate (1.5)
|
// Drop of 8 m/s > brakingHighConfidencePeakDrop (1.5)
|
||||||
alternatingAccelFrames(
|
alternatingAccelFrames(
|
||||||
n = config.brakingSustainedFrames + 5,
|
n = config.brakingSustainedFrames + 5,
|
||||||
hi = 3.5,
|
hi = 4.5,
|
||||||
lo = 0.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,
|
timeOffset = config.windowSize,
|
||||||
)
|
)
|
||||||
val braking = events.filter { it.type == EventType.BRAKING }
|
val braking = events.filter { it.type == EventType.BRAKING }
|
||||||
@@ -159,12 +159,16 @@ class EventDetectorTest {
|
|||||||
@Test fun `moderate speed drop has MEDIUM confidence`() = runCollecting { events ->
|
@Test fun `moderate speed drop has MEDIUM confidence`() = runCollecting { events ->
|
||||||
// Variability established before the drop - see `hard brake triggers BRAKING event`.
|
// Variability established before the drop - see `hard brake triggers BRAKING event`.
|
||||||
alternatingAccelFrames(n = config.windowSize, speedMps = 3.0, timeOffset = 0)
|
alternatingAccelFrames(n = config.windowSize, speedMps = 3.0, timeOffset = 0)
|
||||||
// Drop of 0.8 m/s — above speed-drop threshold (0.5) but below high-conf rate (1.5)
|
// Drop of 1.2 m/s — above the speed-drop threshold (1.0) but below the
|
||||||
|
// high-confidence peak drop (1.5), so this must land as MEDIUM. The window
|
||||||
|
// between those two values is narrow at the shipping thresholds, which is
|
||||||
|
// itself worth knowing: MEDIUM braking is only emitted for drops in
|
||||||
|
// (1.0, 1.5] m/s.
|
||||||
alternatingAccelFrames(
|
alternatingAccelFrames(
|
||||||
n = config.brakingSustainedFrames + 5,
|
n = config.brakingSustainedFrames + 5,
|
||||||
hi = 3.5,
|
hi = 4.5,
|
||||||
lo = 0.5,
|
lo = 0.5,
|
||||||
speedMps = 2.2, // drop = 0.8 m/s
|
speedMps = 1.8, // drop = 1.2 m/s
|
||||||
timeOffset = config.windowSize,
|
timeOffset = config.windowSize,
|
||||||
)
|
)
|
||||||
val braking = events.filter { it.type == EventType.BRAKING }
|
val braking = events.filter { it.type == EventType.BRAKING }
|
||||||
@@ -179,9 +183,9 @@ class EventDetectorTest {
|
|||||||
repeat(total) { i ->
|
repeat(total) { i ->
|
||||||
detector.processSample(
|
detector.processSample(
|
||||||
accelMagnitude = 0.3,
|
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
|
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,
|
latitude = 53.5,
|
||||||
longitude = 10.0,
|
longitude = 10.0,
|
||||||
timestamp = i * 20L,
|
timestamp = i * 20L,
|
||||||
@@ -193,7 +197,7 @@ class EventDetectorTest {
|
|||||||
@Test fun `turning with both signals agreeing gets HIGH confidence`() = runCollecting { events ->
|
@Test fun `turning with both signals agreeing gets HIGH confidence`() = runCollecting { events ->
|
||||||
val total = config.windowSize + config.turningSustainedFrames + 4
|
val total = config.windowSize + config.turningSustainedFrames + 4
|
||||||
repeat(total) { i ->
|
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 }
|
val turning = events.filter { it.type == EventType.TURNING }
|
||||||
assertTrue(turning.isNotEmpty())
|
assertTrue(turning.isNotEmpty())
|
||||||
@@ -205,9 +209,9 @@ class EventDetectorTest {
|
|||||||
repeat(total) { i ->
|
repeat(total) { i ->
|
||||||
detector.processSample(
|
detector.processSample(
|
||||||
accelMagnitude = 0.2,
|
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
|
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,
|
latitude = 53.5,
|
||||||
longitude = 10.0,
|
longitude = 10.0,
|
||||||
timestamp = i * 20L,
|
timestamp = i * 20L,
|
||||||
@@ -253,7 +257,7 @@ class EventDetectorTest {
|
|||||||
// Speed stays at zero; occasional accel/gyro spikes from bag jostle
|
// Speed stays at zero; occasional accel/gyro spikes from bag jostle
|
||||||
repeat(50) { i ->
|
repeat(50) { i ->
|
||||||
val accel = if (i % 5 == 0) 1.8 else 0.3 // jitter but mean is below std-dev threshold
|
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(
|
detector.processSample(
|
||||||
accelMagnitude = accel,
|
accelMagnitude = accel,
|
||||||
gyroMagnitude = gyro,
|
gyroMagnitude = gyro,
|
||||||
@@ -265,7 +269,7 @@ class EventDetectorTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
// speed = 0 → no speed drop possible → no BRAKING
|
// 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 }
|
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())
|
assertTrue("Bag movement must not trigger BRAKING or TURNING, got: $events", unwanted.isEmpty())
|
||||||
}
|
}
|
||||||
@@ -299,13 +303,12 @@ class EventDetectorTest {
|
|||||||
}
|
}
|
||||||
// Second stop episode. Deliberately longer than the first: stopping also requires the
|
// 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
|
// accel std dev to be BELOW a threshold, and the rolling window still holds the five
|
||||||
// moving samples above. It takes 8 further frames for those to drain out far enough for
|
// moving samples above. At the shipping threshold of 0.10 m/s² even a single 0.5 sample
|
||||||
// the std dev to fall under 0.15, and only then does the counter start. The first episode
|
// left in a 10-sample window gives a std dev of ~0.14, so ALL five have to be evicted
|
||||||
// needs no such allowance because the window begins empty.
|
// 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
|
||||||
// stoppingFrames + 5 was not enough - the second episode reached 17 of the 21 frames it
|
// episode needs no such allowance because the window begins empty.
|
||||||
// needs and silently emitted nothing, which is what made this test fail.
|
repeat(config.stoppingFrames + 20) {
|
||||||
repeat(config.stoppingFrames + 10) {
|
|
||||||
detector.processSample(0.02, 0.01, 0.1, 0.0, 53.5, 10.0, t++ * 20L)
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,66 +1,95 @@
|
|||||||
package com.hawhamburg.micr0bu
|
package com.hawhamburg.micr0bu
|
||||||
|
|
||||||
import com.hawhamburg.micr0bu.domain.cam.OwnStationIds
|
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.assertFalse
|
||||||
import org.junit.Assert.assertNotEquals
|
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pins the rule that decides whether a received CAM is one this phone sent.
|
* Pins the rule that decides whether a received CAM is one this phone sent.
|
||||||
*
|
*
|
||||||
* ## The bug this exists to prevent
|
* ## The bugs this exists to prevent
|
||||||
* The phone transmits under two station IDs: the persisted per-install one used by
|
* Getting it wrong fails in two opposite directions, and each has happened:
|
||||||
* `CamTransmitLoop`, and a fixed bench ID used by `CamPinger` so pings stay identifiable in
|
|
||||||
* captures. The ESP32-C5 receives promiscuously, so both come straight back off the air.
|
|
||||||
*
|
*
|
||||||
* The filter originally checked only the persisted ID. Every bench ping therefore returned as a
|
* - **Too narrow.** An own frame that is not recognised comes back as a remote road user sitting
|
||||||
* remote road user sitting exactly on top of the ego position, moving at the ego's own speed and
|
* exactly on the ego position, and is fed to the detection engine as a collision partner for
|
||||||
* heading, and was fed to the detection engine as a collision partner for itself. Nothing failed
|
* itself. That happened with the bench pinger's separate ID, and pseudonym rotation creates the
|
||||||
* loudly: the app simply raised use case alerts against itself for as long as the pinger ran.
|
* 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
|
||||||
* These tests are what should fail if a third transmit path is ever added without teaching this
|
* through the CiT One silently discarded another phone's pings as its own, although it had sent
|
||||||
* rule about it.
|
* none. Nothing appeared on its V2X screen while the broker was full of them.
|
||||||
*/
|
*/
|
||||||
class OwnStationIdsTest {
|
class OwnStationIdsTest {
|
||||||
|
|
||||||
private val persisted = 1_691_338_363L
|
private val current = 1_691_338_363L
|
||||||
|
private val retired = 2_222_222_222L
|
||||||
|
private val ours = setOf(current, retired)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `recognises the persisted transmit id`() {
|
fun `recognises the current transmit id`() {
|
||||||
assertTrue(OwnStationIds.isOwn(persisted, persisted))
|
assertTrue(OwnStationIds.isOwn(current, ours, benchPingIsOurs = false))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `recognises the bench ping id even though it is not the persisted one`() {
|
fun `recognises a recently retired id, so a frame sent just before a rotation is still ours`() {
|
||||||
// The regression. The pinger's id is deliberately different, which is exactly why a
|
assertTrue(OwnStationIds.isOwn(retired, ours, benchPingIsOurs = false))
|
||||||
// filter written around the persisted id alone let every ping through.
|
|
||||||
assertNotEquals(
|
|
||||||
"the bench id is meant to be distinct, or this test proves nothing",
|
|
||||||
persisted,
|
|
||||||
OwnStationIds.BENCH_PING,
|
|
||||||
)
|
|
||||||
assertTrue(OwnStationIds.isOwn(OwnStationIds.BENCH_PING, persisted))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `recognises the bench ping id before the persisted id has loaded`() {
|
fun `another phone's bench ping is shown, not swallowed as our own`() {
|
||||||
// The persisted id is read asynchronously, so it can still be null while the pinger is
|
// The 2026-09-10 regression: this phone is not pinging, so 999999 is someone else.
|
||||||
// already transmitting. The ping must be recognised as ours regardless.
|
assertFalse(OwnStationIds.isOwn(BENCH_PING, ours, benchPingIsOurs = false))
|
||||||
assertTrue(OwnStationIds.isOwn(OwnStationIds.BENCH_PING, null))
|
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
|
@Test
|
||||||
fun `treats a genuine remote station as remote`() {
|
fun `treats a genuine remote station as remote`() {
|
||||||
assertFalse(OwnStationIds.isOwn(2_741_041_966L, persisted))
|
assertFalse(OwnStationIds.isOwn(2_741_041_966L, ours, benchPingIsOurs = true))
|
||||||
assertFalse(OwnStationIds.isOwn(2_741_041_966L, null))
|
assertFalse(OwnStationIds.isOwn(2_741_041_966L, emptySet(), benchPingIsOurs = false))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `station id zero is never ours`() {
|
fun `station id zero is never ours`() {
|
||||||
// 0 is the "not resolved yet" placeholder for the ego identity. Matching on it would
|
// 0 is the "not resolved yet" placeholder for the ego identity. Matching on it would
|
||||||
// swallow real traffic from any station that reported 0.
|
// swallow real traffic from any station that reported 0.
|
||||||
assertFalse(OwnStationIds.isOwn(0L, null))
|
assertFalse(OwnStationIds.isOwn(0L, setOf(0L), benchPingIsOurs = true))
|
||||||
assertFalse(OwnStationIds.isOwn(0L, 0L))
|
}
|
||||||
|
|
||||||
|
// ---- 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,102 @@
|
|||||||
|
# Sniffer board and capture tooling
|
||||||
|
|
||||||
|
How to put an ESP32-C5 on the ITS-G5 channel as a passive sniffer, pull its captures onto this
|
||||||
|
PC, and check what is on air. The sniffer firmware itself is the third-party
|
||||||
|
`its-g5-receiver-firmware` checkout beside this repo; only the tooling and these notes are ours.
|
||||||
|
|
||||||
|
| File | What it does |
|
||||||
|
|---|---|
|
||||||
|
| `live_capture.py` | Streams the device's captures into a growing `.pcap` while it runs. The usual choice. |
|
||||||
|
| `dump_pcap.py` | Pulls one capture out of the device's in-memory buffer after the fact. |
|
||||||
|
| `../obu-firmware/test/pcap_gn_tally.py` | Tallies GeoNetworking headers per station over a `.pcap`. |
|
||||||
|
|
||||||
|
One-time: `pip install pyserial` (present in Python 3.11 on the bench PC, so `py -3.11` works).
|
||||||
|
|
||||||
|
## Which port
|
||||||
|
|
||||||
|
The sniffer firmware's console, and with it the pcap stream, goes out **UART0** - the board's
|
||||||
|
USB-bridge port (a CH343, its own COM number), not the native USB-C port. A board with only one
|
||||||
|
USB-C port cannot be used as a sniffer for this reason. On the bench this has been COM5 and, after
|
||||||
|
a re-enumeration, COM8.
|
||||||
|
|
||||||
|
## Live capture (preferred)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd capture
|
||||||
|
py -3.11 live_capture.py COM8
|
||||||
|
```
|
||||||
|
|
||||||
|
It writes `recordings/capture_<timestamp>.pcap` next to itself, flushing after every packet, so
|
||||||
|
the file can be read while it grows. Stop it with Ctrl+C. Use `-o <dir>` to write elsewhere;
|
||||||
|
`recordings/` is gitignored, since captures are large and are data rather than source. Captures
|
||||||
|
taken before 2026-09-14 are still in `its-g5-receiver-firmware/recordings/`; the host tests read
|
||||||
|
both directories.
|
||||||
|
|
||||||
|
### The CR insertion, and why captures used to be corrupt
|
||||||
|
|
||||||
|
ESP-IDF's newlib console converts LF to CRLF on its way out, and that applies to every `0x0a` byte
|
||||||
|
of the **binary** pcap stream, not only to log text. Each inserted CR shifts everything after it,
|
||||||
|
so pcap record headers and captured frames alike come out corrupt, and the file stops being
|
||||||
|
parseable at the first occurrence.
|
||||||
|
|
||||||
|
Measured on 2026-09-14: a 787 KB capture parsed cleanly for only 82 of about 2000 records, and
|
||||||
|
DENMs appeared on nonsense BTP ports because their payloads contain `0x0a` often. `undo_crlf()` in
|
||||||
|
`live_capture.py` reverses it on the raw stream before any framing, which is exact; afterwards a
|
||||||
|
capture parsed to EOF and DENMs read as port 2002 again.
|
||||||
|
|
||||||
|
**Captures taken before 2026-09-14 are truncated at their first corrupted record.** Anything
|
||||||
|
measured from them is worth re-checking. `dump_pcap.py` reads the same console and has not been
|
||||||
|
given the same treatment yet.
|
||||||
|
|
||||||
|
## Checking a capture
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
py -3.11 ..\obu-firmware\test\pcap_gn_tally.py recordings\capture_<timestamp>.pcap
|
||||||
|
```
|
||||||
|
|
||||||
|
One row per station, packet type, BTP port and GN lifetime. For the messages themselves, decode
|
||||||
|
the payloads with `asn1tools` against the modules in `../asn1/` and re-encode them: identical bytes
|
||||||
|
mean the message was read exactly, wrong bytes mean it was not. `obu-firmware/test/check_replay.py`
|
||||||
|
does this over a whole capture.
|
||||||
|
|
||||||
|
## Flashing the sniffer firmware
|
||||||
|
|
||||||
|
From the receiver checkout, with its **pinned** ESP-IDF (not the global 5.5.4 install):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd its-g5-receiver-firmware
|
||||||
|
git submodule update --init --recursive
|
||||||
|
.\esp-idf\install.bat
|
||||||
|
Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass
|
||||||
|
.\esp-idf\export.ps1
|
||||||
|
idf.py set-target esp32c5
|
||||||
|
idf.py -p COM8 -b 921600 flash
|
||||||
|
```
|
||||||
|
|
||||||
|
Its `sdkconfig` for a bare board (nothing wired) needs SPI Ethernet off, and the pcap destination
|
||||||
|
set to Memory, both under `idf.py menuconfig`. Wired variants have ready-made configs in that
|
||||||
|
checkout: `sdkconfig.proto-w5500`, `sdkconfig.proto-enc28j60`, `sdkconfig.proto-spi-eppp`.
|
||||||
|
|
||||||
|
To reflash a board that already has a built image, without a toolchain terminal:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd its-g5-receiver-firmware\build
|
||||||
|
C:\Espressif\python_env\idf5.5_py3.11_env\Scripts\python.exe -m esptool --chip esp32c5 -p COM8 -b 921600 write_flash --flash_mode dio --flash_freq 80m --flash_size 16MB 0x2000 bootloader/bootloader.bin 0x8000 partition_table/partition-table.bin 0x1e000 ota_data_initial.bin 0x20000 its-g5-receiver-firmware.bin
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pulling a capture after the fact
|
||||||
|
|
||||||
|
Only for the Memory destination, and the buffer is small (`SNIFFER_PCAP_MEMORY_SIZE`, 4096 bytes
|
||||||
|
by default) - a smoke test, not a session. In the device console (`idf.py -p COM8 monitor`, exit
|
||||||
|
with Ctrl+T then Ctrl+X):
|
||||||
|
|
||||||
|
```
|
||||||
|
sniffer -P
|
||||||
|
sniffer --stop
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, with the port free:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
py -3.11 dump_pcap.py COM8
|
||||||
|
```
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Pulls a capture off the ITS-G5 receiver's in-memory pcap buffer over the existing USB serial
|
||||||
|
connection and saves it as a real .pcap file on this machine.
|
||||||
|
|
||||||
|
Requires the firmware to be built with:
|
||||||
|
Example Configuration -> Select destination to store pcap file -> Memory
|
||||||
|
|
||||||
|
Usage (typical):
|
||||||
|
1. Close idf.py monitor (only one program can hold the COM port at a time).
|
||||||
|
2. Run a capture on the device: `sniffer -P` ... let it run ... `sniffer --stop`
|
||||||
|
3. python dump_pcap.py COM5
|
||||||
|
|
||||||
|
The device has no access to this computer's filesystem, so it can't write here directly. Instead,
|
||||||
|
`pcap --dump` streams the raw pcap bytes back over the same serial link, wrapped in plain-text
|
||||||
|
markers ("===PCAP-DUMP-START:<len>===" ... raw bytes ... "===PCAP-DUMP-END==="). This script finds
|
||||||
|
those markers and writes just the raw bytes out as a .pcap file.
|
||||||
|
|
||||||
|
Install dependency once: pip install pyserial
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import datetime
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
try:
|
||||||
|
import serial
|
||||||
|
except ImportError:
|
||||||
|
print("Missing dependency. Install it with: pip install pyserial", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
START_RE = re.compile(rb"===PCAP-DUMP-START:(\d+)===\n")
|
||||||
|
END_MARKER = b"\n===PCAP-DUMP-END===\n"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
parser.add_argument("port", help="Serial port the device is on, e.g. COM5")
|
||||||
|
parser.add_argument("-b", "--baud", type=int, default=115200, help="Baud rate (default: 115200)")
|
||||||
|
parser.add_argument("-o", "--outdir", default="recordings", help="Output directory (default: ./recordings)")
|
||||||
|
parser.add_argument("-t", "--timeout", type=float, default=15.0, help="Seconds to wait for the dump to start")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
import os
|
||||||
|
os.makedirs(args.outdir, exist_ok=True)
|
||||||
|
|
||||||
|
print(f"Opening {args.port} @ {args.baud}...")
|
||||||
|
with serial.Serial(args.port, args.baud, timeout=1) as ser:
|
||||||
|
# Nudge the console in case there's stale input, then request the dump.
|
||||||
|
ser.reset_input_buffer()
|
||||||
|
ser.write(b"\r\n")
|
||||||
|
ser.write(b"pcap -f dump --dump\r\n")
|
||||||
|
|
||||||
|
print("Waiting for dump to start...")
|
||||||
|
buf = b""
|
||||||
|
match = None
|
||||||
|
deadline = datetime.datetime.now() + datetime.timedelta(seconds=args.timeout)
|
||||||
|
while datetime.datetime.now() < deadline:
|
||||||
|
chunk = ser.read(256)
|
||||||
|
if chunk:
|
||||||
|
buf += chunk
|
||||||
|
match = START_RE.search(buf)
|
||||||
|
if match:
|
||||||
|
break
|
||||||
|
if not match:
|
||||||
|
print("Timed out waiting for '===PCAP-DUMP-START:...===' marker.\n"
|
||||||
|
"Check that: the firmware is built with the Memory pcap destination, a capture was\n"
|
||||||
|
"actually taken ('sniffer -P' then 'sniffer --stop'), and no other program (like\n"
|
||||||
|
"idf.py monitor) is holding the serial port open.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
length = int(match.group(1))
|
||||||
|
print(f"Dump starting, {length} bytes expected.")
|
||||||
|
|
||||||
|
# Anything after the marker in our buffer is already part of the payload.
|
||||||
|
payload = buf[match.end():]
|
||||||
|
remaining = length - len(payload)
|
||||||
|
while remaining > 0:
|
||||||
|
chunk = ser.read(min(remaining, 4096))
|
||||||
|
if not chunk:
|
||||||
|
print(f"Serial read timed out with {remaining} bytes still missing.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
payload += chunk
|
||||||
|
remaining -= len(chunk)
|
||||||
|
|
||||||
|
# Drain (and sanity-check) the trailing end marker, but don't fail hard if it's not exact.
|
||||||
|
tail = ser.read(len(END_MARKER))
|
||||||
|
if tail != END_MARKER:
|
||||||
|
print("Warning: end marker didn't match exactly - payload may still be fine.", file=sys.stderr)
|
||||||
|
|
||||||
|
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
outpath = os.path.join(args.outdir, f"capture_{timestamp}.pcap")
|
||||||
|
with open(outpath, "wb") as f:
|
||||||
|
f.write(payload)
|
||||||
|
|
||||||
|
print(f"Saved {len(payload)} bytes to {outpath}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Continuously listens on the ITS-G5 receiver's serial console and writes every captured packet into a
|
||||||
|
live-growing .pcap file, with no console commands needed on the device side.
|
||||||
|
|
||||||
|
The firmware streams every packet it captures out over the same serial connection the console runs on,
|
||||||
|
automatically, as soon as the sniffer is running (which happens on boot by default). Each packet is framed
|
||||||
|
with plain-text markers so this script can pull the binary pcap bytes out of the stream even though
|
||||||
|
regular log lines are interleaved with it:
|
||||||
|
|
||||||
|
===PCAP-LIVE-HEADER:<len>===\\n<24 raw bytes>\\n (sent once, the pcap global header)
|
||||||
|
===PCAP-LIVE-PKT:<len>===\\n<raw bytes>\\n (sent once per captured packet)
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python live_capture.py COM5
|
||||||
|
|
||||||
|
Runs until you press Ctrl+C. Writes to recordings/capture_<timestamp>.pcap, flushing after every packet
|
||||||
|
so you can open the file in Wireshark while it's still being written (use "File > Open" again, or
|
||||||
|
Wireshark's own "Follow" won't auto-refresh but re-opening will show the latest packets).
|
||||||
|
|
||||||
|
Install dependency once: pip install pyserial
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import datetime
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
try:
|
||||||
|
import serial
|
||||||
|
except ImportError:
|
||||||
|
print("Missing dependency. Install it with: pip install pyserial", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# \r? because the ESP console emits CRLF: on Windows the markers arrive as
|
||||||
|
# "===PCAP-LIVE-PKT:310===\r\n", which never matched a bare \n and left the capture silently
|
||||||
|
# empty while the device was streaming perfectly well.
|
||||||
|
HEADER_RE = re.compile(rb"===PCAP-LIVE-HEADER:(\d+)===\r?\n")
|
||||||
|
PKT_RE = re.compile(rb"===PCAP-LIVE-PKT:(\d+)===\r?\n")
|
||||||
|
|
||||||
|
LINKTYPE_ETHERNET = 1
|
||||||
|
LINKTYPE_IEEE802_11_RADIOTAP = 127
|
||||||
|
|
||||||
|
|
||||||
|
def mac_str(b):
|
||||||
|
return ":".join(f"{x:02x}" for x in b)
|
||||||
|
|
||||||
|
|
||||||
|
def build_default_pcap_header(link_type):
|
||||||
|
"""Synthesizes the same 24-byte global pcap header the firmware would have sent, for when we
|
||||||
|
connect after the device's one-time header already went out (see the race note in main())."""
|
||||||
|
header = bytearray(24)
|
||||||
|
header[0:4] = bytes([0xD4, 0xC3, 0xB2, 0xA1]) # magic (LE bytes of 0xA1B2C3D4)
|
||||||
|
header[4:6] = (2).to_bytes(2, "little") # major version
|
||||||
|
header[6:8] = (4).to_bytes(2, "little") # minor version
|
||||||
|
header[16:20] = (0x40000).to_bytes(4, "little") # snaplen
|
||||||
|
header[20:24] = link_type.to_bytes(4, "little")
|
||||||
|
return bytes(header)
|
||||||
|
|
||||||
|
|
||||||
|
def summarize_packet(link_type, record_bytes, index):
|
||||||
|
"""Best-effort human-readable one-line summary of a captured packet, for live feedback.
|
||||||
|
record_bytes is the raw 16-byte pcap record header followed by the captured frame."""
|
||||||
|
seconds = int.from_bytes(record_bytes[0:4], "little")
|
||||||
|
microseconds = int.from_bytes(record_bytes[4:8], "little")
|
||||||
|
cap_len = int.from_bytes(record_bytes[8:12], "little")
|
||||||
|
frame = record_bytes[16:]
|
||||||
|
ts = f"{seconds}.{microseconds:06d}"
|
||||||
|
|
||||||
|
if link_type == LINKTYPE_IEEE802_11_RADIOTAP and len(frame) >= 24:
|
||||||
|
radiotap_len = int.from_bytes(frame[2:4], "little")
|
||||||
|
rssi = frame[8] - 256 if frame[8] >= 128 else frame[8]
|
||||||
|
station_id = int.from_bytes(frame[16:24], "little")
|
||||||
|
mac_frame = frame[radiotap_len:]
|
||||||
|
if len(mac_frame) >= 16:
|
||||||
|
dst = mac_str(mac_frame[4:10])
|
||||||
|
src = mac_str(mac_frame[10:16])
|
||||||
|
else:
|
||||||
|
dst = src = "?"
|
||||||
|
station = f"{station_id:012x}" if station_id else "unknown"
|
||||||
|
return (f"#{index:<5} [{ts}] len={cap_len:<5} rssi={rssi:>4}dBm "
|
||||||
|
f"station={station} {src} -> {dst}")
|
||||||
|
|
||||||
|
if link_type == LINKTYPE_ETHERNET and len(frame) >= 14:
|
||||||
|
dst = mac_str(frame[0:6])
|
||||||
|
src = mac_str(frame[6:12])
|
||||||
|
ethertype = int.from_bytes(frame[12:14], "big")
|
||||||
|
return f"#{index:<5} [{ts}] len={cap_len:<5} eth {src} -> {dst} type=0x{ethertype:04x}"
|
||||||
|
|
||||||
|
return f"#{index:<5} [{ts}] len={cap_len:<5} (unrecognized frame format)"
|
||||||
|
|
||||||
|
|
||||||
|
def undo_crlf(chunk, state):
|
||||||
|
"""Undo the CR the device console inserts before every LF.
|
||||||
|
|
||||||
|
ESP-IDF's newlib console converts LF to CRLF on its way out, and that happens to every 0x0A
|
||||||
|
byte of the binary pcap stream too, not only to log text. Each inserted CR shifts everything
|
||||||
|
after it, so pcap record headers and captured frames alike come out corrupt. This is the
|
||||||
|
"byte inserted mid-frame" seen in older recordings; with DENM traffic on air it wrecks most
|
||||||
|
of a capture (measured 2026-09-14: a 787 KB file parsed cleanly for only 82 records).
|
||||||
|
|
||||||
|
Dropping one CR immediately before each LF undoes it exactly, provided it is done on the raw
|
||||||
|
stream before any framing and a trailing CR is carried across read boundaries. CR and LF are
|
||||||
|
written as byte values here so the transformation cannot be confused with an escape.
|
||||||
|
"""
|
||||||
|
CR, LF = bytes([13]), bytes([10])
|
||||||
|
if state["pending_cr"]:
|
||||||
|
chunk = CR + chunk
|
||||||
|
state["pending_cr"] = False
|
||||||
|
if chunk.endswith(CR):
|
||||||
|
chunk = chunk[:-1]
|
||||||
|
state["pending_cr"] = True
|
||||||
|
return chunk.replace(CR + LF, LF)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
parser.add_argument("port", help="Serial port the device is on, e.g. COM5")
|
||||||
|
parser.add_argument("-b", "--baud", type=int, default=115200, help="Baud rate (default: 115200)")
|
||||||
|
parser.add_argument("-o", "--outdir", default="recordings", help="Output directory (default: ./recordings)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
os.makedirs(args.outdir, exist_ok=True)
|
||||||
|
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
outpath = os.path.join(args.outdir, f"capture_{timestamp}.pcap")
|
||||||
|
|
||||||
|
print(f"Opening {args.port} @ {args.baud}...")
|
||||||
|
print(f"Writing live capture to {outpath}")
|
||||||
|
print("Press Ctrl+C to stop.")
|
||||||
|
|
||||||
|
header_written = False
|
||||||
|
link_type = None
|
||||||
|
packet_count = 0
|
||||||
|
buf = b""
|
||||||
|
total_bytes = 0
|
||||||
|
last_status = time.monotonic()
|
||||||
|
printed_raw_preview = False
|
||||||
|
|
||||||
|
crlf_state = {"pending_cr": False}
|
||||||
|
|
||||||
|
with serial.Serial(args.port, args.baud, timeout=1) as ser, open(outpath, "wb") as outfile:
|
||||||
|
def read_bytes(n):
|
||||||
|
return undo_crlf(ser.read(n), crlf_state)
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
chunk = read_bytes(256)
|
||||||
|
if chunk:
|
||||||
|
buf += chunk
|
||||||
|
total_bytes += len(chunk)
|
||||||
|
|
||||||
|
now = time.monotonic()
|
||||||
|
if now - last_status >= 2:
|
||||||
|
last_status = now
|
||||||
|
print(f"[diagnostic] {total_bytes} raw bytes received so far, "
|
||||||
|
f"{packet_count} packets recognized, header_written={header_written}")
|
||||||
|
if total_bytes > 0 and not printed_raw_preview and not header_written and not PKT_RE.search(buf):
|
||||||
|
# We're getting bytes but none of them look like our markers - show a preview
|
||||||
|
# so we can tell whether this is plain log text (markers just haven't shown up
|
||||||
|
# yet), garbage (baud/port mismatch), or something else entirely.
|
||||||
|
preview = buf[:200]
|
||||||
|
print(f"[diagnostic] no markers matched yet - raw preview: {preview!r}")
|
||||||
|
printed_raw_preview = True
|
||||||
|
elif total_bytes == 0:
|
||||||
|
print("[diagnostic] zero bytes received from the port at all - this points at "
|
||||||
|
"the wrong COM port, another program holding the port, or a port that "
|
||||||
|
"isn't actually wired to the console/sniffer output.")
|
||||||
|
|
||||||
|
# The device only sends the global header once, right when the sniffer first starts
|
||||||
|
# (typically within a second or two of boot). If this script connects even slightly
|
||||||
|
# late - very likely right after a fresh flash, since esptool itself resets the board -
|
||||||
|
# that header is already gone before we ever see it. Rather than blocking forever
|
||||||
|
# waiting for a header that's never coming, look for whichever marker shows up first.
|
||||||
|
header_match = None if header_written else HEADER_RE.search(buf)
|
||||||
|
pkt_match = PKT_RE.search(buf)
|
||||||
|
|
||||||
|
if header_match and (not pkt_match or header_match.start() < pkt_match.start()):
|
||||||
|
length = int(header_match.group(1))
|
||||||
|
buf = buf[header_match.end():]
|
||||||
|
while len(buf) < length:
|
||||||
|
buf += read_bytes(length - len(buf))
|
||||||
|
header_bytes = buf[:length]
|
||||||
|
outfile.write(header_bytes)
|
||||||
|
outfile.flush()
|
||||||
|
buf = buf[length:]
|
||||||
|
header_written = True
|
||||||
|
if length >= 24:
|
||||||
|
link_type = int.from_bytes(header_bytes[20:24], "little")
|
||||||
|
print(f"Got pcap global header (link type {link_type}) - device is streaming.\n")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not header_written and pkt_match:
|
||||||
|
link_type = LINKTYPE_IEEE802_11_RADIOTAP
|
||||||
|
outfile.write(build_default_pcap_header(link_type))
|
||||||
|
outfile.flush()
|
||||||
|
header_written = True
|
||||||
|
print("Note: missed the device's one-time pcap header (it was likely sent before "
|
||||||
|
"this script connected, e.g. right after a flash/reset) - assuming WLAN "
|
||||||
|
"radiotap capture and writing a default header instead.\n")
|
||||||
|
# fall through and process pkt_match below, don't discard this packet
|
||||||
|
|
||||||
|
if not pkt_match:
|
||||||
|
# Keep the buffer from growing unbounded while waiting for a marker, but don't
|
||||||
|
# discard anything - a marker could be split across reads.
|
||||||
|
if len(buf) > 65536:
|
||||||
|
buf = buf[-4096:]
|
||||||
|
continue
|
||||||
|
|
||||||
|
length = int(pkt_match.group(1))
|
||||||
|
buf = buf[pkt_match.end():]
|
||||||
|
while len(buf) < length:
|
||||||
|
buf += read_bytes(length - len(buf))
|
||||||
|
record_bytes = buf[:length]
|
||||||
|
outfile.write(record_bytes)
|
||||||
|
outfile.flush()
|
||||||
|
buf = buf[length:]
|
||||||
|
packet_count += 1
|
||||||
|
print(summarize_packet(link_type, record_bytes, packet_count))
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print(f"\nStopped. {packet_count} packets saved to {outpath}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -139,8 +139,8 @@ share no code. The requirement is satisfied twice, by different means.
|
|||||||
| 11.1 | Orientation-Independent Sensor Strategy | **Done** | `SensorRepository.kt` (magnitude-based) | — |
|
| 11.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.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.3 | Trip Recording Architecture | **Done** | `TripRepository.kt`, `TripRecordingService.kt` *(cited)* | — |
|
||||||
| 11.4 | Data Model | **Done** | `data/db/` Room entities *(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 | **Done** | `TripHistoryScreen.kt`, `TripReviewScreen.kt` | — |
|
| 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 |
|
| 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.
|
**Correction note (11.2).** Four `EventDetectorTest` cases had been failing since the initial commit.
|
||||||
@@ -149,6 +149,26 @@ described stimuli the detector cannot physically see, because they ignored the s
|
|||||||
rolling standard-deviation window. Tests corrected, assertions unchanged, detector untouched. This is
|
rolling standard-deviation window. Tests corrected, assertions unchanged, detector untouched. This is
|
||||||
worth reporting — it is a finding about test design, not a defect.
|
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
|
## 12. Future Architecture & Open Design Questions
|
||||||
|
|
||||||
| § | Title | Status | Notes |
|
| § | Title | Status | Notes |
|
||||||
|
|||||||
@@ -0,0 +1,508 @@
|
|||||||
|
<mxfile host="Electron" agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) draw.io/22.1.2 Chrome/114.0.5735.289 Electron/25.9.4 Safari/537.36" modified="2026-09-10T12:53:19.876Z" etag="qSD0sUq7Dcw_rxL1Y7Bl" version="22.1.2" type="device">
|
||||||
|
<diagram id="90a13364-a465-7bf4-72fc-28e22215d7a0" name="Seite-1">
|
||||||
|
<mxGraphModel dx="1678" dy="1125" grid="1" gridSize="10" guides="1" tooltips="1" connect="0" arrows="1" fold="1" page="1" pageScale="1.5" pageWidth="1169" pageHeight="826" background="none" math="0" shadow="0">
|
||||||
|
<root>
|
||||||
|
<mxCell id="0" style=";html=1;" />
|
||||||
|
<mxCell id="1" style=";html=1;" parent="0" />
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-92" value="" style="rounded=0;whiteSpace=wrap;html=1;dashed=1;" parent="1" vertex="1">
|
||||||
|
<mxGeometry x="950" y="960" width="70" height="80" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="3a17f1ce550125da-2" value="Mobile Phone" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||||
|
<mxGeometry x="290" y="280" width="650" height="780" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="3a17f1ce550125da-10" value="ESP32-C5" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||||
|
<mxGeometry x="1040" y="540" width="580" height="520" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-4" value="<font style="">ESPAR<br>Antenna</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;" parent="1" vertex="1">
|
||||||
|
<mxGeometry x="1150" y="320" width="110" height="90" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-11" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.5;entryY=0;entryDx=0;entryDy=0;exitX=0.499;exitY=0.954;exitDx=0;exitDy=0;exitPerimeter=0;" parent="1" source="Iy5nDPde1Y9wQlhL-YeU-10" target="3a17f1ce550125da-2" edge="1">
|
||||||
|
<mxGeometry relative="1" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-30" value="static map data" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontColor=#B3B3B3;" parent="Iy5nDPde1Y9wQlhL-YeU-11" connectable="0" vertex="1">
|
||||||
|
<mxGeometry x="-0.2209" y="2" relative="1" as="geometry">
|
||||||
|
<mxPoint x="-8" y="3" as="offset" />
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-10" value="<font style="font-size: 18px;">GeoServer</font>" style="ellipse;shape=cloud;whiteSpace=wrap;html=1;align=center;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||||
|
<mxGeometry x="540" y="130" width="150" height="100" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-12" value="<font style="">PoTi</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||||
|
<mxGeometry x="780" y="670" width="150" height="110" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-13" value="<font style="">GNSS</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||||
|
<mxGeometry x="793" y="700" width="60" height="30" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-14" value="<font style="">IMU</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||||
|
<mxGeometry x="863" y="700" width="60" height="30" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-15" value="<font style="">Time Source</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||||
|
<mxGeometry x="793" y="740" width="132" height="30" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-16" value="<font style="">HMI Support</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||||
|
<mxGeometry x="440" y="670" width="171" height="110" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-17" value="<font style="">Display</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||||
|
<mxGeometry x="450" y="700" width="70" height="30" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-18" value="Haptic" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||||
|
<mxGeometry x="530" y="700" width="70" height="30" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-19" value="<font style="">Speaker</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||||
|
<mxGeometry x="489.5" y="740" width="78" height="30" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-84" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="Iy5nDPde1Y9wQlhL-YeU-21" target="Iy5nDPde1Y9wQlhL-YeU-33" edge="1">
|
||||||
|
<mxGeometry relative="1" as="geometry">
|
||||||
|
<Array as="points">
|
||||||
|
<mxPoint x="700" y="770" />
|
||||||
|
<mxPoint x="650" y="770" />
|
||||||
|
</Array>
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-85" value="encoded<br>VAM" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-84" vertex="1" connectable="0">
|
||||||
|
<mxGeometry x="0.01" y="1" relative="1" as="geometry">
|
||||||
|
<mxPoint y="-12" as="offset" />
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-21" value="<font style="">VBS</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||||
|
<mxGeometry x="440" y="500" width="410" height="160" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-22" value="<font style="">Encode</font><div><font style="">VAM</font></div>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||||
|
<mxGeometry x="651.75" y="600" width="68.5" height="52" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-23" value="<font style="">Transmission</font><div><font style="">Management</font></div>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||||
|
<mxGeometry x="730" y="600" width="111.5" height="52" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-24" value="GeoServer to<br>area-risk mapping" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||||
|
<mxGeometry x="527" y="370" width="149" height="60" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-29" value="<font style="font-size: 20px;">VRU-Tx ITS-S Architecture of micrOBU</font>" style="text;html=1;whiteSpace=wrap;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;rounded=0;labelBorderColor=default;" parent="1" vertex="1">
|
||||||
|
<mxGeometry x="850" y="154" width="223" height="52" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-31" value="VRU Basic Service Management" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||||
|
<mxGeometry x="567.5" y="530" width="203" height="52" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-32" value="<font style="">GeoNetworking</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||||
|
<mxGeometry x="685" y="947" width="150" height="30" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-33" value="<font style="">BTP-B</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||||
|
<mxGeometry x="606.5" y="807" width="77" height="30" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-53" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.75;entryY=1;entryDx=0;entryDy=0;endArrow=classic;endFill=1;startArrow=classic;startFill=1;" parent="1" source="Iy5nDPde1Y9wQlhL-YeU-34" target="Iy5nDPde1Y9wQlhL-YeU-4" edge="1">
|
||||||
|
<mxGeometry relative="1" as="geometry">
|
||||||
|
<Array as="points">
|
||||||
|
<mxPoint x="1270" y="480" />
|
||||||
|
<mxPoint x="1232" y="480" />
|
||||||
|
</Array>
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-147" value="RF Signal" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-53" vertex="1" connectable="0">
|
||||||
|
<mxGeometry x="0.4593" y="1" relative="1" as="geometry">
|
||||||
|
<mxPoint x="9" y="27" as="offset" />
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-34" value="<font style="">ITS-G5 radio<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||||
|
<mxGeometry x="1230" y="568" width="140" height="32" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-38" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.25;entryY=1;entryDx=0;entryDy=0;" parent="1" source="Iy5nDPde1Y9wQlhL-YeU-39" target="Iy5nDPde1Y9wQlhL-YeU-4" edge="1">
|
||||||
|
<mxGeometry relative="1" as="geometry">
|
||||||
|
<Array as="points">
|
||||||
|
<mxPoint x="1100" y="480" />
|
||||||
|
<mxPoint x="1178" y="480" />
|
||||||
|
</Array>
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-41" value="Steering Signal" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-38" vertex="1" connectable="0">
|
||||||
|
<mxGeometry x="0.2737" y="-1" relative="1" as="geometry">
|
||||||
|
<mxPoint x="39" y="8" as="offset" />
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-39" value="<font style="">Antenna<br>Steering<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||||
|
<mxGeometry x="1060" y="746" width="80" height="50" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-40" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;innerLoopWaypoints=1;exitX=0.5;exitY=1;exitDx=0;exitDy=0;" parent="1" source="3a17f1ce550125da-10" target="3a17f1ce550125da-10" edge="1">
|
||||||
|
<mxGeometry relative="1" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-1" value="" style="endArrow=none;html=1;rounded=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0;" parent="1" edge="1">
|
||||||
|
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||||
|
<mxPoint x="430" y="790" as="sourcePoint" />
|
||||||
|
<mxPoint x="940" y="790" as="targetPoint" />
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-3" value="<font style="">BLE</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||||
|
<mxGeometry x="520" y="900" width="330" height="150" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-7" value="<font style="">ATT/ GATT</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||||
|
<mxGeometry x="685" y="927" width="150" height="30" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-8" value="<font style="">L2CAP</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||||
|
<mxGeometry x="685" y="968" width="150" height="30" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-9" value="<font style="">BLE Link Layer</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||||
|
<mxGeometry x="685" y="1010" width="150" height="30" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-10" value="<strong data-end="7298" data-start="7253">≤512 B GATT attribute/application message</strong><br data-end="7301" data-start="7298"><br/><em data-end="7372" data-start="7303">L2CAP / Link Layer segmentation and reassembly handled by BLE stack</em>" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" parent="1" vertex="1">
|
||||||
|
<mxGeometry x="530" y="930" width="140" height="110" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-34" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.5;entryY=1;entryDx=0;entryDy=0;" parent="1" source="ynnYWYYo9pkcd0MtkCud-15" target="ynnYWYYo9pkcd0MtkCud-32" edge="1">
|
||||||
|
<mxGeometry relative="1" as="geometry">
|
||||||
|
<Array as="points">
|
||||||
|
<mxPoint x="1215" y="890" />
|
||||||
|
<mxPoint x="1215" y="890" />
|
||||||
|
</Array>
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-151" value="TX Message" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-34" vertex="1" connectable="0">
|
||||||
|
<mxGeometry x="-0.2929" y="-2" relative="1" as="geometry">
|
||||||
|
<mxPoint as="offset" />
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-116" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;fontColor=#B3B3B3;strokeColor=#B3B3B3;" parent="1" source="ynnYWYYo9pkcd0MtkCud-15" target="ynnYWYYo9pkcd0MtkCud-3" edge="1">
|
||||||
|
<mxGeometry relative="1" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-118" value="BLE packet" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontColor=#B3B3B3;" parent="ynnYWYYo9pkcd0MtkCud-116" vertex="1" connectable="0">
|
||||||
|
<mxGeometry x="0.0818" y="1" relative="1" as="geometry">
|
||||||
|
<mxPoint x="6" y="1" as="offset" />
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-15" value="<font style="">BLE</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||||
|
<mxGeometry x="1130" y="900" width="180" height="150" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-16" value="<font style="">ATT/ GATT</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||||
|
<mxGeometry x="1145" y="930" width="150" height="30" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-17" value="<font style="">L2CAP</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||||
|
<mxGeometry x="1145" y="970" width="150" height="30" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-18" value="<font style="">BLE Link Layer</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||||
|
<mxGeometry x="1145" y="1010" width="150" height="30" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-22" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" parent="1" source="ynnYWYYo9pkcd0MtkCud-9" target="ynnYWYYo9pkcd0MtkCud-18" edge="1">
|
||||||
|
<mxGeometry relative="1" as="geometry">
|
||||||
|
<Array as="points">
|
||||||
|
<mxPoint x="1127" y="1025" />
|
||||||
|
</Array>
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-42" value="segmented<br>BLE packets" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-22" vertex="1" connectable="0">
|
||||||
|
<mxGeometry x="-0.5098" y="-1" relative="1" as="geometry">
|
||||||
|
<mxPoint x="74" y="-1" as="offset" />
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-30" value="<font style="">GeoNetworking SHB</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||||
|
<mxGeometry x="531" y="846" width="177" height="30" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-90" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;endArrow=none;endFill=0;" parent="1" source="ynnYWYYo9pkcd0MtkCud-31" target="ynnYWYYo9pkcd0MtkCud-30" edge="1">
|
||||||
|
<mxGeometry relative="1" as="geometry">
|
||||||
|
<Array as="points">
|
||||||
|
<mxPoint x="710" y="861" />
|
||||||
|
<mxPoint x="710" y="861" />
|
||||||
|
</Array>
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-31" value="<font style="">synced transmission dir.</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||||
|
<mxGeometry x="720.25" y="846" width="210" height="30" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-144" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="ynnYWYYo9pkcd0MtkCud-32" target="ynnYWYYo9pkcd0MtkCud-141" edge="1">
|
||||||
|
<mxGeometry relative="1" as="geometry">
|
||||||
|
<Array as="points">
|
||||||
|
<mxPoint x="1185" y="830" />
|
||||||
|
<mxPoint x="1185" y="830" />
|
||||||
|
</Array>
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-150" value="Transmission<br>Power" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-144" vertex="1" connectable="0">
|
||||||
|
<mxGeometry x="-0.3161" relative="1" as="geometry">
|
||||||
|
<mxPoint y="-3" as="offset" />
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-145" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="ynnYWYYo9pkcd0MtkCud-32" target="Iy5nDPde1Y9wQlhL-YeU-39" edge="1">
|
||||||
|
<mxGeometry relative="1" as="geometry">
|
||||||
|
<Array as="points">
|
||||||
|
<mxPoint x="1100" y="830" />
|
||||||
|
<mxPoint x="1100" y="830" />
|
||||||
|
</Array>
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-149" value="Transmission<br>Direction" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-145" vertex="1" connectable="0">
|
||||||
|
<mxGeometry x="-0.3251" y="1" relative="1" as="geometry">
|
||||||
|
<mxPoint x="1" y="-3" as="offset" />
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-32" value="<font style="">Message Splitter<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||||
|
<mxGeometry x="1060" y="837" width="310" height="30" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-48" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.5;entryY=1;entryDx=0;entryDy=0;" parent="1" source="ynnYWYYo9pkcd0MtkCud-44" target="ynnYWYYo9pkcd0MtkCud-45" edge="1">
|
||||||
|
<mxGeometry relative="1" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-129" value="MPDU" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-48" vertex="1" connectable="0">
|
||||||
|
<mxGeometry x="-0.4731" y="2" relative="1" as="geometry">
|
||||||
|
<mxPoint x="2" y="-4" as="offset" />
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-44" value="<font style="">MAC<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||||
|
<mxGeometry x="1229" y="698" width="140" height="30" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-49" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;" parent="1" source="ynnYWYYo9pkcd0MtkCud-45" target="Iy5nDPde1Y9wQlhL-YeU-34" edge="1">
|
||||||
|
<mxGeometry relative="1" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-130" value="PPDU" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-49" vertex="1" connectable="0">
|
||||||
|
<mxGeometry x="-0.5724" y="-1" relative="1" as="geometry">
|
||||||
|
<mxPoint x="-1" y="-7" as="offset" />
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-127" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#B3B3B3;" parent="1" source="ynnYWYYo9pkcd0MtkCud-45" target="ynnYWYYo9pkcd0MtkCud-126" edge="1">
|
||||||
|
<mxGeometry relative="1" as="geometry">
|
||||||
|
<Array as="points">
|
||||||
|
<mxPoint x="1439" y="640" />
|
||||||
|
</Array>
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-45" value="<font style="">PHY<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||||
|
<mxGeometry x="1229" y="630" width="140" height="30" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-50" value="" style="verticalLabelPosition=bottom;shadow=0;dashed=0;align=center;html=1;verticalAlign=top;shape=mxgraph.electrical.radio.aerial_-_antenna_1;" parent="1" vertex="1">
|
||||||
|
<mxGeometry x="1187" y="280" width="30" height="40" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-56" value="802.11p @ 5.9GHz" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="1" vertex="1" connectable="0">
|
||||||
|
<mxGeometry x="1250.0006451612903" y="310" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-57" value="" style="endArrow=none;html=1;rounded=0;entryX=1;entryY=0.75;entryDx=0;entryDy=0;" parent="1" source="3a17f1ce550125da-2" edge="1">
|
||||||
|
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||||
|
<mxPoint x="590" y="790" as="sourcePoint" />
|
||||||
|
<mxPoint x="940" y="790" as="targetPoint" />
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-68" value="" style="endArrow=none;html=1;rounded=0;exitX=0;exitY=0.5;exitDx=0;exitDy=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0;" parent="1" edge="1">
|
||||||
|
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||||
|
<mxPoint x="431" y="490" as="sourcePoint" />
|
||||||
|
<mxPoint x="941" y="490" as="targetPoint" />
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-70" value="if rx enabled" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||||
|
<mxGeometry x="720" y="320" width="205" height="160" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-71" value="GLOSA" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||||
|
<mxGeometry x="730" y="355" width="180" height="30" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-72" value="Collision Warning" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||||
|
<mxGeometry x="730" y="396" width="180" height="30" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-73" value="Recorder/ Logger" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||||
|
<mxGeometry x="730" y="435" width="180" height="30" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-79" value="Decode<br>VAM" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||||
|
<mxGeometry x="451.5" y="600" width="68.5" height="52" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-80" value="Reception<br>Management" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||||
|
<mxGeometry x="531" y="600" width="111.5" height="52" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-82" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.71;entryY=1.005;entryDx=0;entryDy=0;entryPerimeter=0;" parent="1" source="Iy5nDPde1Y9wQlhL-YeU-12" target="Iy5nDPde1Y9wQlhL-YeU-21" edge="1">
|
||||||
|
<mxGeometry relative="1" as="geometry">
|
||||||
|
<mxPoint x="850" y="610" as="targetPoint" />
|
||||||
|
<Array as="points">
|
||||||
|
<mxPoint x="731" y="725" />
|
||||||
|
</Array>
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-83" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.559;entryY=1.01;entryDx=0;entryDy=0;entryPerimeter=0;" parent="1" source="Iy5nDPde1Y9wQlhL-YeU-16" target="Iy5nDPde1Y9wQlhL-YeU-21" edge="1">
|
||||||
|
<mxGeometry relative="1" as="geometry">
|
||||||
|
<Array as="points">
|
||||||
|
<mxPoint x="670" y="725" />
|
||||||
|
<mxPoint x="670" y="680" />
|
||||||
|
<mxPoint x="669" y="680" />
|
||||||
|
</Array>
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-86" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="Iy5nDPde1Y9wQlhL-YeU-33" target="ynnYWYYo9pkcd0MtkCud-30" edge="1">
|
||||||
|
<mxGeometry relative="1" as="geometry">
|
||||||
|
<mxPoint x="596" y="840" as="targetPoint" />
|
||||||
|
<Array as="points">
|
||||||
|
<mxPoint x="570" y="822" />
|
||||||
|
</Array>
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-93" value="encrypted" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;" parent="1" vertex="1">
|
||||||
|
<mxGeometry x="946" y="935" width="80" height="30" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-95" value="<font style="">Platform<br>Security<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||||
|
<mxGeometry x="1500" y="580" width="110" height="470" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-99" value="<font style="">Secure<br>Boot<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||||
|
<mxGeometry x="1510" y="837" width="90" height="48" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-102" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;fontColor=#B3B3B3;strokeColor=#B3B3B3;" parent="1" source="ynnYWYYo9pkcd0MtkCud-100" target="ynnYWYYo9pkcd0MtkCud-15" edge="1">
|
||||||
|
<mxGeometry relative="1" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-153" value="BLE Credentials" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontColor=#B3B3B3;" parent="ynnYWYYo9pkcd0MtkCud-102" vertex="1" connectable="0">
|
||||||
|
<mxGeometry x="0.5722" y="1" relative="1" as="geometry">
|
||||||
|
<mxPoint x="52" y="9" as="offset" />
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-100" value="<font style="">BLE<br>credentials<br>config<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||||
|
<mxGeometry x="1510" y="965" width="90" height="73" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-101" value="<font style="">Debug<br>lockdown<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||||
|
<mxGeometry x="1510" y="902" width="90" height="48" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-106" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="ynnYWYYo9pkcd0MtkCud-103" target="Iy5nDPde1Y9wQlhL-YeU-21" edge="1">
|
||||||
|
<mxGeometry relative="1" as="geometry">
|
||||||
|
<Array as="points">
|
||||||
|
<mxPoint x="430" y="580" />
|
||||||
|
<mxPoint x="430" y="580" />
|
||||||
|
</Array>
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-103" value="<font style="">Platform<br>Security<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||||
|
<mxGeometry x="300" y="300" width="120" height="740" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-105" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="ynnYWYYo9pkcd0MtkCud-104" target="ynnYWYYo9pkcd0MtkCud-3" edge="1">
|
||||||
|
<mxGeometry relative="1" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-104" value="<font style="">BLE<br>credentials<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||||
|
<mxGeometry x="310" y="982" width="100" height="48" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-108" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=1.008;entryY=0.559;entryDx=0;entryDy=0;entryPerimeter=0;" parent="1" source="Iy5nDPde1Y9wQlhL-YeU-12" target="Iy5nDPde1Y9wQlhL-YeU-33" edge="1">
|
||||||
|
<mxGeometry relative="1" as="geometry">
|
||||||
|
<Array as="points">
|
||||||
|
<mxPoint x="850" y="824" />
|
||||||
|
<mxPoint x="684" y="824" />
|
||||||
|
</Array>
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-109" value="PCI" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-108" vertex="1" connectable="0">
|
||||||
|
<mxGeometry x="-0.0782" y="2" relative="1" as="geometry">
|
||||||
|
<mxPoint as="offset" />
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-110" value="<font style="">LLC/SNAP<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||||
|
<mxGeometry x="1230" y="760" width="140" height="30" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-111" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="ynnYWYYo9pkcd0MtkCud-32" target="ynnYWYYo9pkcd0MtkCud-110" edge="1">
|
||||||
|
<mxGeometry relative="1" as="geometry">
|
||||||
|
<mxPoint x="1300" y="810" as="targetPoint" />
|
||||||
|
<Array as="points">
|
||||||
|
<mxPoint x="1300" y="830" />
|
||||||
|
<mxPoint x="1300" y="830" />
|
||||||
|
</Array>
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-112" value="GNPDU" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-111" vertex="1" connectable="0">
|
||||||
|
<mxGeometry x="-0.1526" relative="1" as="geometry">
|
||||||
|
<mxPoint as="offset" />
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-113" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.502;entryY=1;entryDx=0;entryDy=0;entryPerimeter=0;" parent="1" source="ynnYWYYo9pkcd0MtkCud-110" target="ynnYWYYo9pkcd0MtkCud-44" edge="1">
|
||||||
|
<mxGeometry relative="1" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-114" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.297;entryY=-0.004;entryDx=0;entryDy=0;entryPerimeter=0;" parent="1" source="ynnYWYYo9pkcd0MtkCud-30" target="ynnYWYYo9pkcd0MtkCud-3" edge="1">
|
||||||
|
<mxGeometry relative="1" as="geometry">
|
||||||
|
<Array as="points">
|
||||||
|
<mxPoint x="618" y="890" />
|
||||||
|
<mxPoint x="618" y="890" />
|
||||||
|
</Array>
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-115" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="ynnYWYYo9pkcd0MtkCud-31" target="ynnYWYYo9pkcd0MtkCud-3" edge="1">
|
||||||
|
<mxGeometry relative="1" as="geometry">
|
||||||
|
<Array as="points">
|
||||||
|
<mxPoint x="810" y="896" />
|
||||||
|
<mxPoint x="810" y="896" />
|
||||||
|
</Array>
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-119" value="<font style="">Certificate<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||||
|
<mxGeometry x="310" y="720" width="100" height="33" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-122" value="<font style="">Private Key Handling<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||||
|
<mxGeometry x="310" y="920" width="100" height="50" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-123" value="<font style="">Signer Selection<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||||
|
<mxGeometry x="310" y="770" width="100" height="50" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-125" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="ynnYWYYo9pkcd0MtkCud-124" target="ynnYWYYo9pkcd0MtkCud-30" edge="1">
|
||||||
|
<mxGeometry relative="1" as="geometry">
|
||||||
|
<Array as="points">
|
||||||
|
<mxPoint x="470" y="865" />
|
||||||
|
<mxPoint x="470" y="865" />
|
||||||
|
</Array>
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-124" value="<font style="">Secured<br>Message<br>Creation<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||||
|
<mxGeometry x="310" y="837" width="100" height="70" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-126" value="<font style="">DCC-ACC Calculation<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||||
|
<mxGeometry x="1390" y="655" width="97" height="55" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-128" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;jumpStyle=arc;fontColor=#B3B3B3;strokeColor=#B3B3B3;" parent="1" source="ynnYWYYo9pkcd0MtkCud-126" target="ynnYWYYo9pkcd0MtkCud-15" edge="1">
|
||||||
|
<mxGeometry relative="1" as="geometry">
|
||||||
|
<mxPoint x="1129" y="930" as="targetPoint" />
|
||||||
|
<Array as="points">
|
||||||
|
<mxPoint x="1440" y="930" />
|
||||||
|
</Array>
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-152" value="DCC Feedback" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontColor=#B3B3B3;" parent="ynnYWYYo9pkcd0MtkCud-128" vertex="1" connectable="0">
|
||||||
|
<mxGeometry x="0.7303" relative="1" as="geometry">
|
||||||
|
<mxPoint x="13" as="offset" />
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-131" value="" style="endArrow=none;html=1;rounded=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0;" parent="1" edge="1">
|
||||||
|
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||||
|
<mxPoint x="430" y="793" as="sourcePoint" />
|
||||||
|
<mxPoint x="940" y="793" as="targetPoint" />
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-133" value="" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||||
|
<mxGeometry x="847" y="787" width="6" height="9" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-136" value="" style="endArrow=none;html=1;rounded=0;exitX=0;exitY=0.5;exitDx=0;exitDy=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0;" parent="1" edge="1">
|
||||||
|
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||||
|
<mxPoint x="431" y="883.78" as="sourcePoint" />
|
||||||
|
<mxPoint x="941" y="883.78" as="targetPoint" />
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-137" value="" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||||
|
<mxGeometry x="647" y="787" width="6" height="9" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-140" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="ynnYWYYo9pkcd0MtkCud-139" target="Iy5nDPde1Y9wQlhL-YeU-21" edge="1">
|
||||||
|
<mxGeometry relative="1" as="geometry">
|
||||||
|
<Array as="points">
|
||||||
|
<mxPoint x="895" y="580" />
|
||||||
|
</Array>
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-139" value="<font style="">DCC-<br>FAC<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||||
|
<mxGeometry x="865" y="500" width="60" height="50" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-142" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="ynnYWYYo9pkcd0MtkCud-141" target="Iy5nDPde1Y9wQlhL-YeU-34" edge="1">
|
||||||
|
<mxGeometry relative="1" as="geometry">
|
||||||
|
<Array as="points">
|
||||||
|
<mxPoint x="1185" y="584" />
|
||||||
|
</Array>
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-148" value="TX power <br>config" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-142" vertex="1" connectable="0">
|
||||||
|
<mxGeometry x="-0.4822" y="1" relative="1" as="geometry">
|
||||||
|
<mxPoint as="offset" />
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-141" value="<font style="">Radio<br>Control<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||||
|
<mxGeometry x="1150" y="746" width="70" height="50" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-155" value="" style="endArrow=none;html=1;rounded=0;exitX=0;exitY=0.5;exitDx=0;exitDy=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0;" parent="1" edge="1">
|
||||||
|
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||||
|
<mxPoint x="431" y="887" as="sourcePoint" />
|
||||||
|
<mxPoint x="941" y="887" as="targetPoint" />
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-154" value="" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||||
|
<mxGeometry x="615" y="881" width="6" height="9" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="ynnYWYYo9pkcd0MtkCud-157" value="" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||||
|
<mxGeometry x="807" y="881" width="6" height="9" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
</root>
|
||||||
|
</mxGraphModel>
|
||||||
|
</diagram>
|
||||||
|
</mxfile>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 237 KiB |
Binary file not shown.
Binary file not shown.
@@ -51,3 +51,49 @@ Known gaps, tracked as TODOs in the source: no real GNSS (lat/long hardcoded
|
|||||||
0), no real time source (detectionTime/referenceTime hardcoded 0, decodes as
|
0), no real time source (detectionTime/referenceTime hardcoded 0, decodes as
|
||||||
2004-01-01), fixed (non-rotating) pseudonym MAC, SHB instead of GeoBroadcast
|
2004-01-01), fixed (non-rotating) pseudonym MAC, SHB instead of GeoBroadcast
|
||||||
(no multi-hop forwarding), unsecured (no IEEE 1609.2 signing).
|
(no multi-hop forwarding), unsecured (no IEEE 1609.2 signing).
|
||||||
|
|
||||||
|
## Running it as a bench beacon
|
||||||
|
|
||||||
|
This firmware needs no phone: it beacons a CAM every second by itself
|
||||||
|
(`TX_INTERVAL_MS`) from station `0x0BADC0DE` (195936478), stationType 5
|
||||||
|
(passengerCar), at the hardcoded bench position, under the fixed MAC
|
||||||
|
`02:00:00:00:00:01`, on 5900 MHz. That makes it the quickest way to put known,
|
||||||
|
repeatable traffic on air, and it is how the 4-bit `yawRateConfidence` encoding
|
||||||
|
was confirmed over the air on 2026-09-14.
|
||||||
|
|
||||||
|
A board with only one USB-C port is fine. This firmware's console is on UART0,
|
||||||
|
so such a board shows no log output, but nothing here needs the console.
|
||||||
|
|
||||||
|
Flash it from the toolchain terminal (ESP-IDF 5.5.4, see the table above):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd C:\Users\Ashin\AndroidStudioProjects\MicrOBU\obu-cam-transmistter
|
||||||
|
idf.py -p COM10 -b 921600 flash
|
||||||
|
```
|
||||||
|
|
||||||
|
Or flash the existing build without any toolchain terminal:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd obu-cam-transmistter\build
|
||||||
|
C:\Espressif\python_env\idf5.5_py3.11_env\Scripts\python.exe -m esptool --chip esp32c5 -p COM10 -b 921600 write_flash --flash_mode dio --flash_freq 80m --flash_size 2MB 0x2000 bootloader/bootloader.bin 0x8000 partition_table/partition-table.bin 0x10000 obu_firmware.bin
|
||||||
|
```
|
||||||
|
|
||||||
|
It starts beaconing as soon as it boots, so there is nothing to start by hand,
|
||||||
|
and unplugging it is how you stop it.
|
||||||
|
|
||||||
|
**It transmits under the same MAC as the phone's CAM pinger**, so on air the two
|
||||||
|
are told apart by station ID (195936478 here, 999999 for the pinger), never by
|
||||||
|
source address.
|
||||||
|
|
||||||
|
To see what it is sending, capture on the sniffer board and decode:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd capture
|
||||||
|
py -3.11 live_capture.py COM8
|
||||||
|
py -3.11 ..\obu-firmware\test\pcap_gn_tally.py recordings\capture_<timestamp>.pcap
|
||||||
|
```
|
||||||
|
|
||||||
|
The tally lists it as SHB / port 2001 / lifetime `0x05`. For the message itself,
|
||||||
|
decode the payload with `asn1tools` against `asn1/cam_1_4_1.asn` +
|
||||||
|
`asn1/cdd_1_3_1_1.asn`; re-encoding must return the identical bytes. On
|
||||||
|
2026-09-14, 72 of 72 frames did.
|
||||||
|
|||||||
@@ -117,9 +117,13 @@ int cam_encode(const cam_fields_t *f, uint8_t *buf, size_t buf_len)
|
|||||||
bw_put_bits(&bw, 0, 1); // extension bit: value is in the root list
|
bw_put_bits(&bw, 0, 1); // extension bit: value is in the root list
|
||||||
bw_put_bits(&bw, 2, 2); // unavailable(2)
|
bw_put_bits(&bw, 2, 2); // unavailable(2)
|
||||||
// YawRate: YawRateValue(-32766..32767)->16 (offset from -32766),
|
// YawRate: YawRateValue(-32766..32767)->16 (offset from -32766),
|
||||||
// YawRateConfidence ENUM 8 values -> 3 bits
|
// YawRateConfidence ENUM with NINE values, degSec-000-01(0) .. unavailable(8)
|
||||||
|
// (cdd_1_3_1_1.asn) -> 4 bits. This wrote 3 bits with value 7, one bit short and the
|
||||||
|
// wrong symbol (7 is outOfRange), so every field after it shifted for any
|
||||||
|
// standards-compliant receiver. The app's CamUperCodec.kt fixed the same line on
|
||||||
|
// 2026-08-20; this copy was missed until 2026-09-11.
|
||||||
bw_put_bits(&bw, 32767 - (uint32_t)(-32766), 16); // yawRateValue: unavailable(32767)
|
bw_put_bits(&bw, 32767 - (uint32_t)(-32766), 16); // yawRateValue: unavailable(32767)
|
||||||
bw_put_bits(&bw, 7, 3); // yawRateConfidence: unavailable(7)
|
bw_put_bits(&bw, 8, 4); // yawRateConfidence: unavailable(8)
|
||||||
|
|
||||||
// ---- LowFrequencyContainer ---- CHOICE { basicVehicleContainerLowFrequency,
|
// ---- LowFrequencyContainer ---- CHOICE { basicVehicleContainerLowFrequency,
|
||||||
// ... } - EXTENSIBLE, 1 root alternative (index needs 0 bits).
|
// ... } - EXTENSIBLE, 1 root alternative (index needs 0 bits).
|
||||||
|
|||||||
@@ -19,7 +19,10 @@ int geonet_wrap_shb(const uint8_t *its_payload, int its_len,
|
|||||||
// ---- GN Basic Header (4 bytes) ---- (EN 302 636-4-1 clause 9.6)
|
// ---- GN Basic Header (4 bytes) ---- (EN 302 636-4-1 clause 9.6)
|
||||||
*p++ = (uint8_t)((1 << 4) | 1); // version=1, NextHeader=1 (Common Header, unsecured)
|
*p++ = (uint8_t)((1 << 4) | 1); // version=1, NextHeader=1 (Common Header, unsecured)
|
||||||
*p++ = 0x00; // reserved
|
*p++ = 0x00; // reserved
|
||||||
*p++ = 0x83; // lifetime (~60s in the base/multiplier encoding) - tune if needed
|
// Lifetime: multiplier in the upper 6 bits, base in the lower 2 (0 = 50 ms, 1 = 1 s, 2 = 10 s,
|
||||||
|
// 3 = 100 s). 0x05 = 1 x 1 s, what real stations send their CAMs with. Was 0x83, commented as
|
||||||
|
// ~60 s but decoding to 32 x 100 s = 3200 s. See obu-firmware's geonet.c.
|
||||||
|
*p++ = 0x05;
|
||||||
*p++ = 1; // remaining hop limit = 1 (SHB single-hop; matches CAM in the Rust reference)
|
*p++ = 1; // remaining hop limit = 1 (SHB single-hop; matches CAM in the Rust reference)
|
||||||
|
|
||||||
// ---- GN Common Header (8 bytes) ---- (clause 9.7)
|
// ---- GN Common Header (8 bytes) ---- (clause 9.7)
|
||||||
|
|||||||
@@ -122,9 +122,13 @@ int cam_encode(const cam_fields_t *f, uint8_t *buf, size_t buf_len)
|
|||||||
bw_put_bits(&bw, 0, 1); // extension bit: value is in the root list
|
bw_put_bits(&bw, 0, 1); // extension bit: value is in the root list
|
||||||
bw_put_bits(&bw, 2, 2); // unavailable(2)
|
bw_put_bits(&bw, 2, 2); // unavailable(2)
|
||||||
// YawRate: YawRateValue(-32766..32767)->16 (offset from -32766),
|
// YawRate: YawRateValue(-32766..32767)->16 (offset from -32766),
|
||||||
// YawRateConfidence ENUM 8 values -> 3 bits
|
// YawRateConfidence ENUM with NINE values, degSec-000-01(0) .. unavailable(8)
|
||||||
|
// (cdd_1_3_1_1.asn) -> 4 bits. This wrote 3 bits with value 7, one bit short and the
|
||||||
|
// wrong symbol (7 is outOfRange), so every field after it shifted for any
|
||||||
|
// standards-compliant receiver. The app's CamUperCodec.kt fixed the same line on
|
||||||
|
// 2026-08-20; this reference copy was missed until 2026-09-11.
|
||||||
bw_put_bits(&bw, 32767 - (uint32_t)(-32766), 16); // yawRateValue: unavailable(32767)
|
bw_put_bits(&bw, 32767 - (uint32_t)(-32766), 16); // yawRateValue: unavailable(32767)
|
||||||
bw_put_bits(&bw, 7, 3); // yawRateConfidence: unavailable(7)
|
bw_put_bits(&bw, 8, 4); // yawRateConfidence: unavailable(8)
|
||||||
|
|
||||||
// ---- LowFrequencyContainer ---- CHOICE { basicVehicleContainerLowFrequency,
|
// ---- LowFrequencyContainer ---- CHOICE { basicVehicleContainerLowFrequency,
|
||||||
// ... } - EXTENSIBLE, 1 root alternative (index needs 0 bits).
|
// ... } - EXTENSIBLE, 1 root alternative (index needs 0 bits).
|
||||||
|
|||||||
+39
-23
@@ -1,9 +1,23 @@
|
|||||||
#include "geonet.h"
|
#include "geonet.h"
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
|
|
||||||
|
// GeoNetworking is big-endian throughout, unlike this project's serial framing.
|
||||||
|
static void put_be16(uint8_t **p, uint16_t v)
|
||||||
|
{
|
||||||
|
*(*p)++ = (uint8_t)(v >> 8);
|
||||||
|
*(*p)++ = (uint8_t)(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void put_be32(uint8_t **p, uint32_t v)
|
||||||
|
{
|
||||||
|
*(*p)++ = (uint8_t)(v >> 24);
|
||||||
|
*(*p)++ = (uint8_t)(v >> 16);
|
||||||
|
*(*p)++ = (uint8_t)(v >> 8);
|
||||||
|
*(*p)++ = (uint8_t)(v);
|
||||||
|
}
|
||||||
|
|
||||||
int geonet_wrap_shb(const uint8_t *its_payload, int its_len,
|
int geonet_wrap_shb(const uint8_t *its_payload, int its_len,
|
||||||
const uint8_t mac[6], uint8_t station_type,
|
const gn_lpv_t *lpv,
|
||||||
int32_t latitude_tenmicrodeg, int32_t longitude_tenmicrodeg,
|
|
||||||
uint16_t btp_dest_port,
|
uint16_t btp_dest_port,
|
||||||
uint8_t *out, size_t out_len)
|
uint8_t *out, size_t out_len)
|
||||||
{
|
{
|
||||||
@@ -19,7 +33,12 @@ int geonet_wrap_shb(const uint8_t *its_payload, int its_len,
|
|||||||
// ---- GN Basic Header (4 bytes) ---- (EN 302 636-4-1 clause 9.6)
|
// ---- GN Basic Header (4 bytes) ---- (EN 302 636-4-1 clause 9.6)
|
||||||
*p++ = (uint8_t)((1 << 4) | 1); // version=1, NextHeader=1 (Common Header, unsecured)
|
*p++ = (uint8_t)((1 << 4) | 1); // version=1, NextHeader=1 (Common Header, unsecured)
|
||||||
*p++ = 0x00; // reserved
|
*p++ = 0x00; // reserved
|
||||||
*p++ = 0x83; // lifetime (~60s in the base/multiplier encoding) - tune if needed
|
// Lifetime: multiplier in the upper 6 bits, base in the lower 2 (0 = 50 ms, 1 = 1 s, 2 = 10 s,
|
||||||
|
// 3 = 100 s). 0x05 = 1 x 1 s, which is what every other station in
|
||||||
|
// its-g5-receiver-firmware/recordings sends its CAMs with. This was 0x83, commented as ~60 s but
|
||||||
|
// decoding to 32 x 100 s = 3200 s, beyond the 600 s itsGnMaxPacketLifetime a sender may use at
|
||||||
|
// all. A DENM would want a longer one (the captured GeoBroadcast DENMs use 0x79, 30 s).
|
||||||
|
*p++ = 0x05;
|
||||||
*p++ = 1; // remaining hop limit = 1 (SHB single-hop; matches CAM in the Rust reference)
|
*p++ = 1; // remaining hop limit = 1 (SHB single-hop; matches CAM in the Rust reference)
|
||||||
|
|
||||||
// ---- GN Common Header (8 bytes) ---- (clause 9.7)
|
// ---- GN Common Header (8 bytes) ---- (clause 9.7)
|
||||||
@@ -50,27 +69,24 @@ int geonet_wrap_shb(const uint8_t *its_payload, int its_len,
|
|||||||
// defined to BE the link-layer (802.11) address - so this must match
|
// defined to BE the link-layer (802.11) address - so this must match
|
||||||
// the source address dot11p_build_frame uses, not just "look similar."
|
// the source address dot11p_build_frame uses, not just "look similar."
|
||||||
uint8_t gn_addr[8];
|
uint8_t gn_addr[8];
|
||||||
gn_addr[0] = (uint8_t)((0 << 7) | ((station_type & 0x1F) << 2)); // M=0, ST=station_type, top 2 reserved bits=0
|
gn_addr[0] = (uint8_t)((0 << 7) | ((lpv->station_type & 0x1F) << 2)); // M=0, ST=station_type, top 2 reserved bits=0
|
||||||
gn_addr[1] = 0x00; // remaining 8 reserved bits
|
gn_addr[1] = 0x00; // remaining 8 reserved bits
|
||||||
memcpy(&gn_addr[2], mac, 6); // MID = link-layer address
|
memcpy(&gn_addr[2], lpv->mac, 6); // MID = link-layer address
|
||||||
memcpy(p, gn_addr, 8); p += 8;
|
memcpy(p, gn_addr, 8); p += 8;
|
||||||
// Timestamp (4 bytes, ms since 2004-01-01 mod 2^32) - placeholder 0,
|
// TST (4 bytes): when the position below was acquired, ms, TimestampIts mod 2^32.
|
||||||
// same caveat as detectionTime in denm.c.
|
put_be32(&p, lpv->tst_ms);
|
||||||
memset(p, 0, 4); p += 4;
|
// Latitude/Longitude (4+4 bytes, signed, 1/10 microdegree) - fixed-width binary fields, not
|
||||||
// Latitude/Longitude (4+4 bytes, signed, big-endian, 1/10 microdegree) -
|
// UPER bit-packed like the CAM payload's own position.
|
||||||
// fixed-width binary fields, not UPER bit-packed.
|
put_be32(&p, (uint32_t)lpv->lat_tenmicrodeg);
|
||||||
uint32_t lat_u = (uint32_t)latitude_tenmicrodeg;
|
put_be32(&p, (uint32_t)lpv->lon_tenmicrodeg);
|
||||||
*p++ = (uint8_t)(lat_u >> 24); *p++ = (uint8_t)(lat_u >> 16);
|
// PAI (1 bit) + Speed (15 bits, signed, 0.01 m/s). Clamped, not masked: a 15-bit value that
|
||||||
*p++ = (uint8_t)(lat_u >> 8); *p++ = (uint8_t)(lat_u);
|
// overflows wraps its sign bit and reads as travelling backwards at speed.
|
||||||
uint32_t lon_u = (uint32_t)longitude_tenmicrodeg;
|
int32_t speed = lpv->speed_cms;
|
||||||
*p++ = (uint8_t)(lon_u >> 24); *p++ = (uint8_t)(lon_u >> 16);
|
if (speed > 16383) speed = 16383;
|
||||||
*p++ = (uint8_t)(lon_u >> 8); *p++ = (uint8_t)(lon_u);
|
if (speed < -16384) speed = -16384;
|
||||||
// PAI(1 bit) + Speed(15 bits), packed into 2 bytes: 0 = PAI false,
|
put_be16(&p, (uint16_t)(((lpv->pai ? 1u : 0u) << 15) | ((uint16_t)speed & 0x7FFFu)));
|
||||||
// speed 0 - which is actually correct semantics for a STATIONARY
|
// Heading (16 bits, 0.1 degree from north, clockwise, 0..3599).
|
||||||
// vehicle beacon, not just a placeholder.
|
put_be16(&p, (uint16_t)(lpv->heading_decideg % 3600u));
|
||||||
*p++ = 0x00; *p++ = 0x00;
|
|
||||||
// Heading (16 bits, 0.1 degree units): 0 = due north / unavailable
|
|
||||||
*p++ = 0x00; *p++ = 0x00;
|
|
||||||
// Reserved (4 bytes) - clause 9.8.4: the SHB extended header is the 24-byte Source Position
|
// Reserved (4 bytes) - clause 9.8.4: the SHB extended header is the 24-byte Source Position
|
||||||
// Vector FOLLOWED BY a 4-byte reserved field (media-dependent data), 28 bytes in total. These
|
// Vector FOLLOWED BY a 4-byte reserved field (media-dependent data), 28 bytes in total. These
|
||||||
// four bytes were missing, which is why a standards-compliant receiver read our CAM payload's
|
// four bytes were missing, which is why a standards-compliant receiver read our CAM payload's
|
||||||
@@ -84,7 +100,7 @@ int geonet_wrap_shb(const uint8_t *its_payload, int its_len,
|
|||||||
*p++ = (uint8_t)(btp_dest_port & 0xFF);
|
*p++ = (uint8_t)(btp_dest_port & 0xFF);
|
||||||
*p++ = 0x00; *p++ = 0x00; // destination port info, unused for BTP-B
|
*p++ = 0x00; *p++ = 0x00; // destination port info, unused for BTP-B
|
||||||
|
|
||||||
// ---- ITS payload (DENM UPER bytes) ----
|
// ---- ITS payload (CAM UPER bytes from the phone) ----
|
||||||
memcpy(p, its_payload, its_len);
|
memcpy(p, its_payload, its_len);
|
||||||
p += its_len;
|
p += its_len;
|
||||||
|
|
||||||
|
|||||||
+43
-29
@@ -1,43 +1,57 @@
|
|||||||
#ifndef GEONET_H
|
#ifndef GEONET_H
|
||||||
#define GEONET_H
|
#define GEONET_H
|
||||||
|
#include <stdbool.h>
|
||||||
#include <stdint.h>
|
#include <stdint.h>
|
||||||
#include <stddef.h>
|
#include <stddef.h>
|
||||||
|
|
||||||
// Wraps an ITS application payload (e.g. from denm_encode) with a minimal
|
// The variable content of a GeoNetworking Long Position Vector (ETSI EN 302 636-4-1 clause
|
||||||
// GeoNetworking Basic Header + Common Header + Single-Hop-Broadcast
|
// 9.5.2): who the sender is and where it was. This is the Source Position Vector every
|
||||||
// extended header (HeaderType=TSB(5), HeaderSubtype=SINGLE_HOP(0), per
|
// GeoNetworking packet from this firmware carries.
|
||||||
// ETSI EN 302 636-4-1 table 9), then prepends a BTP-B header addressed to
|
|
||||||
// the DENM service port (2002).
|
|
||||||
//
|
//
|
||||||
// `mac` is the 6-byte pseudonym/link-layer address - pass the SAME address
|
// Every field here used to be a compile-time constant: the bench coordinates, speed 0, heading 0,
|
||||||
// you hand to dot11p_build_frame's src address, since GN_ADDR's MID field
|
// timestamp 0, passengerCar, and one fixed MAC. The phone never told the firmware where it was,
|
||||||
// (the last 6 bytes of the 8-byte GN_ADDR) is defined to BE that
|
// so the GN layer described a stationary car parked at the bench while the CAM inside it
|
||||||
// link-layer address (EN 302 636-4-1 clause 9.5.1). `station_type` is the
|
// described a moving cyclist somewhere else. The phone now supplies these values with each frame
|
||||||
// 5-bit ITS-S type from the same clause (5 = passengerCar) and gets packed
|
// (SERIAL_MSG_CAM_TX_PV in serial_link.h) and this firmware only lays them out on the wire.
|
||||||
// into GN_ADDR alongside the address.
|
typedef struct {
|
||||||
|
// Pseudonym. Written into GN_ADDR's MID field here AND, by dot11p_build_frame, into the
|
||||||
|
// 802.11 source address. Clause 9.5.1 defines the MID as the link-layer address, so the two
|
||||||
|
// must be the same six bytes; taking both from this one field is what keeps them identical
|
||||||
|
// when the pseudonym rotates.
|
||||||
|
uint8_t mac[6];
|
||||||
|
// ITS-S type, TS 102 894-2 StationType (2 = cyclist). Only the low 5 bits fit in GN_ADDR.
|
||||||
|
uint8_t station_type;
|
||||||
|
// Position Accuracy Indicator.
|
||||||
|
bool pai;
|
||||||
|
// TST: the moment lat/lon were acquired, in ms, as TimestampIts modulo 2^32.
|
||||||
|
uint32_t tst_ms;
|
||||||
|
// 1/10 microdegree, signed.
|
||||||
|
int32_t lat_tenmicrodeg;
|
||||||
|
int32_t lon_tenmicrodeg;
|
||||||
|
// 0.01 m/s. The wire field is 15-bit signed, so this is clamped to -16384..16383 on encode.
|
||||||
|
int16_t speed_cms;
|
||||||
|
// 0.1 degree from north, clockwise. Wrapped into 0..3599 on encode.
|
||||||
|
uint16_t heading_decideg;
|
||||||
|
} gn_lpv_t;
|
||||||
|
|
||||||
|
// Wraps an ITS application payload (the CAM UPER bytes the phone built) in a GeoNetworking Basic
|
||||||
|
// Header + Common Header + Single-Hop-Broadcast extended header (HeaderType=TSB(5),
|
||||||
|
// HeaderSubtype=SINGLE_HOP(0), EN 302 636-4-1 table 9), then a BTP-B header addressed to
|
||||||
|
// `btp_dest_port`.
|
||||||
//
|
//
|
||||||
// `latitude_tenmicrodeg`/`longitude_tenmicrodeg` go into the Source Long
|
// Single-hop broadcast is the correct packet type for CAM, which ETSI defines as never forwarded,
|
||||||
// Position Vector (clause 9.5.2) as plain 32-bit signed big-endian fields -
|
// so it has no destination area and no sequence number. A future DENM transmit path would need
|
||||||
// NOT UPER bit-packed like the DENM payload's position fields, this is a
|
// GeoBroadcast (HeaderType=4) instead, which this function does not build.
|
||||||
// fixed-width binary protocol. Pass the SAME values you gave denm_encode's
|
|
||||||
// eventPosition, so the GN-layer position and the DENM's own claimed
|
|
||||||
// position agree.
|
|
||||||
//
|
//
|
||||||
// Deliberate simplification: real DENM dissemination normally uses
|
// `lpv` supplies the Source Position Vector. Hand the SAME lpv->mac to dot11p_build_frame as its
|
||||||
// GeoBroadcast (GBC, HeaderType=4) so RSUs/OBUs can forward it across an
|
// source address, or the GN and 802.11 layers will name two different senders.
|
||||||
// area - that needs a sequence number + geo-area fields this skeleton
|
|
||||||
// doesn't build yet. Single-hop broadcast is simpler and is the
|
|
||||||
// best-tested decode path in the receiver firmware you already have
|
|
||||||
// working (same extended header shape as CAM). Fine for a single-vehicle
|
|
||||||
// beacon; revisit if you need real multi-hop forwarding later.
|
|
||||||
//
|
//
|
||||||
// `btp_dest_port` is the BTP-B destination port for the service being carried
|
// `btp_dest_port` is the BTP-B destination port (ETSI TS 103 248): 2001 = CAM, 2002 = DENM,
|
||||||
// (ETSI TS 103 248): 2001 = CAM, 2002 = DENM, 2003 = MAPEM, 2004 = SPATEM, ...
|
// 2003 = MAPEM, 2004 = SPATEM.
|
||||||
//
|
//
|
||||||
// Returns bytes written, or -1 if out buffer too small.
|
// Returns bytes written, or -1 if the out buffer is too small.
|
||||||
int geonet_wrap_shb(const uint8_t *its_payload, int its_len,
|
int geonet_wrap_shb(const uint8_t *its_payload, int its_len,
|
||||||
const uint8_t mac[6], uint8_t station_type,
|
const gn_lpv_t *lpv,
|
||||||
int32_t latitude_tenmicrodeg, int32_t longitude_tenmicrodeg,
|
|
||||||
uint16_t btp_dest_port,
|
uint16_t btp_dest_port,
|
||||||
uint8_t *out, size_t out_len);
|
uint8_t *out, size_t out_len);
|
||||||
|
|
||||||
|
|||||||
+131
-15
@@ -28,9 +28,20 @@
|
|||||||
#define GN_HEADER_TYPE_TSB (5) // Topologically-Scoped Broadcast
|
#define GN_HEADER_TYPE_TSB (5) // Topologically-Scoped Broadcast
|
||||||
#define GN_HEADER_SUBTYPE_SINGLE_HOP (0)
|
#define GN_HEADER_SUBTYPE_SINGLE_HOP (0)
|
||||||
|
|
||||||
#define GN_NEXT_HEADER_COMMON (1) // unsecured; 2 would be a secured packet
|
#define GN_NEXT_HEADER_COMMON (1) // unsecured: the Common Header follows
|
||||||
|
#define GN_NEXT_HEADER_SECURED (2) // a TS 103 097 envelope follows, Common Header inside it
|
||||||
#define GN_COMMON_NEXT_HEADER_BTP_B (2)
|
#define GN_COMMON_NEXT_HEADER_BTP_B (2)
|
||||||
|
|
||||||
|
// Common Header field (clause 9.7): length of everything after the GeoNetworking headers, i.e.
|
||||||
|
// the BTP-B header plus the ITS payload.
|
||||||
|
#define GN_COMMON_PAYLOAD_LEN_OFFSET (4)
|
||||||
|
|
||||||
|
// IEEE 1609.2 / TS 103 097 envelope, COER encoded - see unwrap_secured().
|
||||||
|
#define IEEE1609DOT2_VERSION (3)
|
||||||
|
#define CONTENT_TAG_UNSECURED_DATA (0x80) // Ieee1609Dot2Content CHOICE, context tag 0
|
||||||
|
#define CONTENT_TAG_SIGNED_DATA (0x81) // context tag 1
|
||||||
|
#define SIGNED_PAYLOAD_HAS_DATA (0x40) // SignedDataPayload preamble: `data` present
|
||||||
|
|
||||||
#define BTP_DEST_PORT_CAM (2001) // ETSI TS 103 248
|
#define BTP_DEST_PORT_CAM (2001) // ETSI TS 103 248
|
||||||
#define BTP_DEST_PORT_DENM (2002)
|
#define BTP_DEST_PORT_DENM (2002)
|
||||||
// NOTE the crossover: SPATEM is BTP port 2004 but ItsPduHeader messageID 4, while MAPEM is port
|
// NOTE the crossover: SPATEM is BTP port 2004 but ItsPduHeader messageID 4, while MAPEM is port
|
||||||
@@ -51,6 +62,90 @@ static uint16_t be16(const uint8_t *p)
|
|||||||
return (uint16_t)(((uint16_t)p[0] << 8) | (uint16_t)p[1]);
|
return (uint16_t)(((uint16_t)p[0] << 8) | (uint16_t)p[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// COER length determinant (ITU-T X.696): a first byte below 0x80 is the length itself; otherwise
|
||||||
|
// its low 7 bits count the big-endian length bytes that follow. Two of them cover anything this
|
||||||
|
// radio can deliver. Returns how many bytes the determinant occupies, or 0 if it does not fit in
|
||||||
|
// `avail` or uses a form this does not read.
|
||||||
|
static int coer_length(const uint8_t *p, int avail, int *len)
|
||||||
|
{
|
||||||
|
if (avail < 1) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (p[0] < 0x80) {
|
||||||
|
*len = p[0];
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
const int n = p[0] & 0x7F;
|
||||||
|
if (n < 1 || n > 2 || avail < 1 + n) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
int v = 0;
|
||||||
|
for (int i = 1; i <= n; i++) {
|
||||||
|
v = (v << 8) | p[i];
|
||||||
|
}
|
||||||
|
*len = v;
|
||||||
|
return 1 + n;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Locates the GeoNetworking packet inside a secured one. `offset` points just past the Basic
|
||||||
|
// Header. Returns the offset of the inner Common Header and sets *inner_end to where the envelope
|
||||||
|
// says the inner packet ends - which lies beyond frame_len if the capture was cut short - or
|
||||||
|
// returns -1 for anything this does not unwrap.
|
||||||
|
//
|
||||||
|
// The envelope is an Ieee1609Dot2Data (IEEE 1609.2, profiled by TS 103 097 v1.3.1 and later),
|
||||||
|
// COER encoded. A signed message starts:
|
||||||
|
//
|
||||||
|
// 03 protocolVersion 3
|
||||||
|
// 81 content = signedData
|
||||||
|
// 00 hashId (sha256; any one-byte value is accepted - the hash is not checked)
|
||||||
|
// 40 tbsData.payload preamble: `data` present (bit 6)
|
||||||
|
// 03 80 <len> payload.data: an Ieee1609Dot2Data holding unsecuredData of <len> bytes, which
|
||||||
|
// are the Common Header, extended header, BTP-B header and ITS payload
|
||||||
|
// ... headerInfo, signer, signature: not read
|
||||||
|
//
|
||||||
|
// The inner packet comes first inside tbsData, so it is found without parsing the certificate
|
||||||
|
// or the signature, and its explicit length is what separates it from them. The shape is
|
||||||
|
// measured, not only read from the standard: all 157 signed frames in
|
||||||
|
// capture_20260817_171055.pcap have it (150 CAM, 7 GeoBroadcast DENM; <len> in all three COER
|
||||||
|
// forms), and asn1tools decodes every one of them to the same unsecuredData. A top-level
|
||||||
|
// unsecuredData (03 80 <len>, no signature at all) is accepted too.
|
||||||
|
static int unwrap_secured(const uint8_t *frame, int offset, int frame_len,
|
||||||
|
int *inner_end, bool *is_signed)
|
||||||
|
{
|
||||||
|
const uint8_t *p = frame + offset;
|
||||||
|
const int avail = frame_len - offset;
|
||||||
|
int i;
|
||||||
|
|
||||||
|
if (avail < 2 || p[0] != IEEE1609DOT2_VERSION) {
|
||||||
|
return -1; // includes the legacy TS 103 097 v1.2.1 envelope, protocolVersion 2
|
||||||
|
}
|
||||||
|
if (p[1] == CONTENT_TAG_SIGNED_DATA) {
|
||||||
|
if (avail < 6 ||
|
||||||
|
p[2] >= 0x80 || // hashId: a one-byte enumerated value
|
||||||
|
!(p[3] & SIGNED_PAYLOAD_HAS_DATA) || // signs only a hash of data sent elsewhere
|
||||||
|
p[4] != IEEE1609DOT2_VERSION ||
|
||||||
|
p[5] != CONTENT_TAG_UNSECURED_DATA) { // nested signing or encryption
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
i = 6;
|
||||||
|
*is_signed = true;
|
||||||
|
} else if (p[1] == CONTENT_TAG_UNSECURED_DATA) {
|
||||||
|
i = 2;
|
||||||
|
*is_signed = false;
|
||||||
|
} else {
|
||||||
|
return -1; // encryptedData, certificate requests
|
||||||
|
}
|
||||||
|
|
||||||
|
int len;
|
||||||
|
const int used = coer_length(p + i, avail - i, &len);
|
||||||
|
if (used == 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
i += used;
|
||||||
|
*inner_end = offset + i + len;
|
||||||
|
return offset + i;
|
||||||
|
}
|
||||||
|
|
||||||
bool gn_unwrap_its(const uint8_t *frame, int frame_len, gn_rx_t *out)
|
bool gn_unwrap_its(const uint8_t *frame, int frame_len, gn_rx_t *out)
|
||||||
{
|
{
|
||||||
if (!frame || !out || frame_len < IEEE80211_HEADER_LEN) {
|
if (!frame || !out || frame_len < IEEE80211_HEADER_LEN) {
|
||||||
@@ -91,22 +186,33 @@ bool gn_unwrap_its(const uint8_t *frame, int frame_len, gn_rx_t *out)
|
|||||||
if (frame_len < offset + GN_BASIC_HEADER_LEN) {
|
if (frame_len < offset + GN_BASIC_HEADER_LEN) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// NextHeader distinguishes an unsecured packet (1 = Common Header follows) from a secured one
|
const uint8_t basic_next_header = frame[offset] & 0x0F;
|
||||||
// (2 = a TS 103 097 SecuredMessage follows, with the Common Header buried inside it at a
|
|
||||||
// variable offset). Checking this rather than blindly skipping means a secured packet is
|
|
||||||
// rejected cleanly instead of having its security envelope misread as a Common Header.
|
|
||||||
if ((frame[offset] & 0x0F) != GN_NEXT_HEADER_COMMON) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
offset += GN_BASIC_HEADER_LEN;
|
offset += GN_BASIC_HEADER_LEN;
|
||||||
|
|
||||||
|
// The headers from here on must end before `limit`: the end of the frame, or for a secured
|
||||||
|
// packet the end of the envelope's inner packet if that comes first.
|
||||||
|
int limit = frame_len;
|
||||||
|
int envelope_end = -1;
|
||||||
|
if (basic_next_header == GN_NEXT_HEADER_SECURED) {
|
||||||
|
offset = unwrap_secured(frame, offset, frame_len, &envelope_end, &out->signed_unverified);
|
||||||
|
if (offset < 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (envelope_end < limit) {
|
||||||
|
limit = envelope_end;
|
||||||
|
}
|
||||||
|
} else if (basic_next_header != GN_NEXT_HEADER_COMMON) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- GN Common Header (8 bytes) ----
|
// ---- GN Common Header (8 bytes) ----
|
||||||
if (frame_len < offset + GN_COMMON_HEADER_LEN) {
|
if (limit < offset + GN_COMMON_HEADER_LEN) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
uint8_t next_header = (frame[offset + 0] >> 4) & 0x0F;
|
uint8_t next_header = (frame[offset + 0] >> 4) & 0x0F;
|
||||||
uint8_t header_type = (frame[offset + 1] >> 4) & 0x0F;
|
uint8_t header_type = (frame[offset + 1] >> 4) & 0x0F;
|
||||||
uint8_t header_subtype = frame[offset + 1] & 0x0F;
|
uint8_t header_subtype = frame[offset + 1] & 0x0F;
|
||||||
|
const int gn_payload_len = be16(frame + offset + GN_COMMON_PAYLOAD_LEN_OFFSET);
|
||||||
if (next_header != GN_COMMON_NEXT_HEADER_BTP_B) {
|
if (next_header != GN_COMMON_NEXT_HEADER_BTP_B) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -126,7 +232,7 @@ bool gn_unwrap_its(const uint8_t *frame, int frame_len, gn_rx_t *out)
|
|||||||
} else {
|
} else {
|
||||||
return false; // Beacon / GeoUnicast / GeoAnycast / multi-hop TSB - see header comment
|
return false; // Beacon / GeoUnicast / GeoAnycast / multi-hop TSB - see header comment
|
||||||
}
|
}
|
||||||
if (frame_len < offset + ext_len) {
|
if (limit < offset + ext_len) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (is_gbc) {
|
if (is_gbc) {
|
||||||
@@ -138,7 +244,7 @@ bool gn_unwrap_its(const uint8_t *frame, int frame_len, gn_rx_t *out)
|
|||||||
offset += ext_len;
|
offset += ext_len;
|
||||||
|
|
||||||
// ---- BTP-B header (4 bytes) ----
|
// ---- BTP-B header (4 bytes) ----
|
||||||
if (frame_len < offset + BTP_B_HEADER_LEN) {
|
if (limit < offset + BTP_B_HEADER_LEN) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
uint16_t dest_port = be16(frame + offset);
|
uint16_t dest_port = be16(frame + offset);
|
||||||
@@ -146,16 +252,26 @@ bool gn_unwrap_its(const uint8_t *frame, int frame_len, gn_rx_t *out)
|
|||||||
dest_port != BTP_DEST_PORT_SPATEM) {
|
dest_port != BTP_DEST_PORT_SPATEM) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
offset += BTP_B_HEADER_LEN;
|
|
||||||
|
|
||||||
// ---- Whatever's left is the ITS UPER payload ----
|
// ---- ITS payload: exactly as long as the Common Header declares ----
|
||||||
int payload_len = frame_len - offset;
|
// Not "whatever is left of the frame": see "Payload bounds" in gn_unwrap.h for the 8 trailing
|
||||||
|
// bytes every received frame carries and the signature that follows a secured packet.
|
||||||
|
if (gn_payload_len <= BTP_B_HEADER_LEN) {
|
||||||
|
return false; // no ITS payload at all
|
||||||
|
}
|
||||||
|
const int payload_start = offset + BTP_B_HEADER_LEN;
|
||||||
|
const int payload_end = offset + gn_payload_len;
|
||||||
|
if (envelope_end >= 0 && payload_end > envelope_end) {
|
||||||
|
return false; // the inner packet claims more than its envelope holds
|
||||||
|
}
|
||||||
|
out->truncated = payload_end > frame_len;
|
||||||
|
const int payload_len = (out->truncated ? frame_len : payload_end) - payload_start;
|
||||||
if (payload_len <= 0) {
|
if (payload_len <= 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
out->btp_dest_port = dest_port;
|
out->btp_dest_port = dest_port;
|
||||||
out->payload = frame + offset;
|
out->payload = frame + payload_start;
|
||||||
out->payload_len = payload_len;
|
out->payload_len = payload_len;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,9 @@
|
|||||||
|
|
||||||
// Inverse of geonet_wrap_shb() + dot11p_build_frame(): takes a raw 802.11 frame as delivered by
|
// Inverse of geonet_wrap_shb() + dot11p_build_frame(): takes a raw 802.11 frame as delivered by
|
||||||
// the WiFi driver's promiscuous RX callback and strips 802.11 header -> LLC/SNAP ->
|
// the WiFi driver's promiscuous RX callback and strips 802.11 header -> LLC/SNAP ->
|
||||||
// GeoNetworking Basic/Common/extended header -> BTP-B header, leaving the ITS payload (a UPER
|
// GeoNetworking Basic Header -> [security envelope] -> Common/extended header -> BTP-B header,
|
||||||
// message) plus the metadata the phone needs to know what it received.
|
// leaving the ITS payload (a UPER message) plus the metadata the phone needs to know what it
|
||||||
|
// received.
|
||||||
//
|
//
|
||||||
// ---- Supported GeoNetworking header types --------------------------------------------------
|
// ---- Supported GeoNetworking header types --------------------------------------------------
|
||||||
// Two shapes, chosen by the Common Header's HeaderType, with DIFFERENT extended-header lengths:
|
// Two shapes, chosen by the Common Header's HeaderType, with DIFFERENT extended-header lengths:
|
||||||
@@ -28,6 +29,23 @@
|
|||||||
// Beacon, GeoUnicast, GeoAnycast and multi-hop TSB are still rejected - nothing this project
|
// Beacon, GeoUnicast, GeoAnycast and multi-hop TSB are still rejected - nothing this project
|
||||||
// talks to sends them, and each has its own extended-header length that would need measuring.
|
// talks to sends them, and each has its own extended-header length that would need measuring.
|
||||||
//
|
//
|
||||||
|
// ---- Secured packets -----------------------------------------------------------------------
|
||||||
|
// A Basic Header NextHeader of 2 means an ETSI TS 103 097 (IEEE 1609.2) envelope follows, with
|
||||||
|
// the Common Header onward inside it. Signed messages are unwrapped WITHOUT verifying the
|
||||||
|
// signature or the certificate - this firmware has no trust store - and are reported with
|
||||||
|
// signed_unverified set so the phone can tell. Most real traffic is signed: the 2026-08-17
|
||||||
|
// capture held 157 signed frames from 15 source MACs. Encrypted payloads, nested signing and the
|
||||||
|
// legacy v1.2.1 envelope are rejected. The layout is documented at unwrap_secured() in
|
||||||
|
// gn_unwrap.c. Before 2026-09-11 every secured packet was rejected.
|
||||||
|
//
|
||||||
|
// ---- Payload bounds ------------------------------------------------------------------------
|
||||||
|
// The payload is exactly as long as the Common Header's payload-length field says, minus the
|
||||||
|
// BTP-B header - not "the rest of the frame". After the message comes, in a signed packet, the
|
||||||
|
// signature; and every frame recorded through this chip's promiscuous RX API (~15 000 of them)
|
||||||
|
// ends in 8 more bytes that are not part of the 802.11 frame and not a valid FCS. Until
|
||||||
|
// 2026-09-11 those 8 bytes were forwarded to the phone as the tail of every message. UPER
|
||||||
|
// decoders stop where the message ends, which is why nothing visibly broke.
|
||||||
|
//
|
||||||
// ---- Accepted BTP-B ports (ETSI TS 103 248) ------------------------------------------------
|
// ---- Accepted BTP-B ports (ETSI TS 103 248) ------------------------------------------------
|
||||||
// 2001 (CAM), 2002 (DENM) and 2004 (SPATEM). MAPEM (2003) and the rest are deliberately not
|
// 2001 (CAM), 2002 (DENM) and 2004 (SPATEM). MAPEM (2003) and the rest are deliberately not
|
||||||
// accepted yet: the phone has no decoder for them, so forwarding would just burn serial
|
// accepted yet: the phone has no decoder for them, so forwarding would just burn serial
|
||||||
@@ -38,17 +56,11 @@
|
|||||||
// counted as an oversize drop rather than forwarded. The bench RSU trigger emits ~58-byte SPATEMs
|
// counted as an oversize drop rather than forwarded. The bench RSU trigger emits ~58-byte SPATEMs
|
||||||
// and is unaffected, but real road RSUs measured 555 bytes median and 1243 max (2026-03-18 drive,
|
// and is unaffected, but real road RSUs measured 555 bytes median and 1243 max (2026-03-18 drive,
|
||||||
// 79k messages), i.e. roughly 70% would be dropped. Raising the cap is deliberately deferred: it
|
// 79k messages), i.e. roughly 70% would be dropped. Raising the cap is deliberately deferred: it
|
||||||
// also requires enlarging RX_FRAME_MAX_LEN and moving rx_item_t off the WiFi callback stack,
|
// also requires enlarging main.c's RX_FRAME_MAX_LEN.
|
||||||
// which at that size would overflow it.
|
|
||||||
//
|
//
|
||||||
// ---- What is NOT handled -------------------------------------------------------------------
|
// No FCS/CRC check here: the WiFi driver has already validated the frame.
|
||||||
// Secured packets (GN Basic Header NextHeader=2, i.e. ETSI TS 103 097 signed messages). The
|
|
||||||
// units on this bench run with ItsGnSecurity=0 so everything observed is unsecured; a secured
|
|
||||||
// packet is rejected rather than mis-parsed.
|
|
||||||
//
|
|
||||||
// No FCS/CRC check: the WiFi driver has already validated and stripped it.
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
// BTP-B destination port, identifying the service: 2001 = CAM, 2002 = DENM.
|
// BTP-B destination port, identifying the service: 2001 = CAM, 2002 = DENM, 2004 = SPATEM.
|
||||||
uint16_t btp_dest_port;
|
uint16_t btp_dest_port;
|
||||||
|
|
||||||
// ITS payload (UPER message bytes). Points INTO the caller's `frame` buffer - NOT a copy, so
|
// ITS payload (UPER message bytes). Points INTO the caller's `frame` buffer - NOT a copy, so
|
||||||
@@ -64,11 +76,20 @@ typedef struct {
|
|||||||
int32_t geo_area_lat_tenmicrodeg;
|
int32_t geo_area_lat_tenmicrodeg;
|
||||||
int32_t geo_area_lon_tenmicrodeg;
|
int32_t geo_area_lon_tenmicrodeg;
|
||||||
uint16_t geo_area_distance_a_m;
|
uint16_t geo_area_distance_a_m;
|
||||||
|
|
||||||
|
// The packet arrived inside a TS 103 097 signed envelope. The signature was NOT checked.
|
||||||
|
bool signed_unverified;
|
||||||
|
|
||||||
|
// The frame ended before the payload its headers declare. On the board only main.c's
|
||||||
|
// RX_FRAME_MAX_LEN capture limit causes this (the driver drops frames that fail their FCS).
|
||||||
|
// payload/payload_len then cover just the part that arrived, so it must not be forwarded.
|
||||||
|
bool truncated;
|
||||||
} gn_rx_t;
|
} gn_rx_t;
|
||||||
|
|
||||||
// Returns true and fills *out if this was a well-formed, supported ITS frame. Returns false
|
// Returns true and fills *out if this was a well-formed, supported ITS frame - check `truncated`
|
||||||
// otherwise (wrong ethertype, secured, unsupported header type, unaccepted BTP port, truncated,
|
// before using the payload. Returns false otherwise (wrong ethertype, encrypted or unsupported
|
||||||
// or promiscuous-capture garbage) - all common and expected on an open-air capture, so the caller
|
// envelope, unsupported header type, unaccepted BTP port, headers cut short, or
|
||||||
|
// promiscuous-capture garbage) - all common and expected on an open-air capture, so the caller
|
||||||
// should treat false as "not for us", not as an error worth logging per frame.
|
// should treat false as "not for us", not as an error worth logging per frame.
|
||||||
bool gn_unwrap_its(const uint8_t *frame, int frame_len, gn_rx_t *out);
|
bool gn_unwrap_its(const uint8_t *frame, int frame_len, gn_rx_t *out);
|
||||||
|
|
||||||
|
|||||||
+85
-21
@@ -21,7 +21,9 @@
|
|||||||
static const char *TAG = "obu-tx";
|
static const char *TAG = "obu-tx";
|
||||||
|
|
||||||
// Phase 03: CAM is no longer built on this chip. The phone fuses its own GNSS+IMU, UPER-encodes
|
// Phase 03: CAM is no longer built on this chip. The phone fuses its own GNSS+IMU, UPER-encodes
|
||||||
// CAM itself, and hands the finished bytes down over serial_link (SERIAL_MSG_CAM_TX) - this
|
// CAM itself, and hands the finished bytes down over serial_link (SERIAL_MSG_CAM_TX_PV, together
|
||||||
|
// with the GeoNetworking position vector to send them under; plain SERIAL_MSG_CAM_TX from an app
|
||||||
|
// that predates it) - this
|
||||||
// firmware's job on transmit shrinks to "GeoNetworking/BTP-wrap + 802.11-wrap + key the PA the
|
// firmware's job on transmit shrinks to "GeoNetworking/BTP-wrap + 802.11-wrap + key the PA the
|
||||||
// instant a CAM arrives." There is no on-chip transmit timer anymore; the phone's send cadence
|
// instant a CAM arrives." There is no on-chip transmit timer anymore; the phone's send cadence
|
||||||
// (1 Hz baseline, faster near intersections/events - all decided app-side) IS the air cadence.
|
// (1 Hz baseline, faster near intersections/events - all decided app-side) IS the air cadence.
|
||||||
@@ -41,26 +43,25 @@ static const char *TAG = "obu-tx";
|
|||||||
#define TX_FREQ_MHZ 5900
|
#define TX_FREQ_MHZ 5900
|
||||||
|
|
||||||
// ---- CAM beacon profile (used for the GeoNetworking layer only now - see below) ----
|
// ---- CAM beacon profile (used for the GeoNetworking layer only now - see below) ----
|
||||||
#define STATION_TYPE 5 // passengerCar (TS 102 894-2 StationType) - matches gn_addr's ST field
|
#define STATION_TYPE 2 // cyclist (TS 102 894-2 StationType), legacy CAM_TX path only - see legacy_lpv()
|
||||||
#define BTP_PORT_CAM 2001 // BTP-B destination port for CAM (ETSI TS 103 248)
|
#define BTP_PORT_CAM 2001 // BTP-B destination port for CAM (ETSI TS 103 248)
|
||||||
|
|
||||||
// Bench location, hardcoded since there's no GNSS module wired in yet and the unit is genuinely
|
// Bench location, 53°33'16.8"N 10°01'20.6"E, in 1/10-microdegree units. Used only by the legacy
|
||||||
// stationary here: 53°33'16.8"N 10°01'20.6"E, in 1/10-microdegree units. Used ONLY for the
|
// SERIAL_MSG_CAM_TX path (see legacy_lpv), which carries no position of its own. A current app
|
||||||
// GeoNetworking Source Long Position Vector now (geonet_wrap_shb's own claimed position) - the
|
// sends SERIAL_MSG_CAM_TX_PV instead, and the GN Source Position Vector then comes from the
|
||||||
// CAM payload's own referencePosition comes from the phone's real GNSS and can legitimately
|
// phone's real fix, the same one the CAM payload's own referencePosition is built from.
|
||||||
// differ from this bench placeholder until the GN layer is also given a real position source.
|
|
||||||
// TODO: feed this from the phone too (e.g. a lightweight position update piggybacked on
|
|
||||||
// SERIAL_MSG_CAM_TX, or a new small message type) instead of a fixed bench location.
|
|
||||||
#define BENCH_LATITUDE_TENMICRODEG 535546667
|
#define BENCH_LATITUDE_TENMICRODEG 535546667
|
||||||
#define BENCH_LONGITUDE_TENMICRODEG 100223889
|
#define BENCH_LONGITUDE_TENMICRODEG 100223889
|
||||||
|
|
||||||
// Single source of truth for the pseudonym/link-layer address: used both as
|
// Link-layer address for the legacy SERIAL_MSG_CAM_TX path only. Locally-administered bit set
|
||||||
// the 802.11 source MAC (Addr2) and as GN_ADDR's MID field, since the GN
|
// (0x02), per normal MAC convention.
|
||||||
// spec defines those as being the same address. Locally-administered bit
|
//
|
||||||
// set (0x02) per normal MAC convention. Fixed/non-rotating for now - real
|
// This reverses the Phase 03 decision that the pseudonym is owned entirely by this firmware. That
|
||||||
// stacks rotate this every 5-15 min for privacy. Owned entirely by this firmware (not the
|
// was simplest while the address never changed, but a pseudonym only protects anyone if the
|
||||||
// phone) per the Phase 03 design decision - simplest given the phone never needs to know it.
|
// 802.11 address, the GN_ADDR MID and the CAM's stationID all change together, and the phone owns
|
||||||
static const uint8_t pseudonym_mac[6] = {0x02, 0x00, 0x00, 0x00, 0x00, 0x01};
|
// the stationID. One identity needs one owner, so with CAM_TX_PV the phone sends the address with
|
||||||
|
// every frame and rotates it, and this constant is only what the legacy path falls back to.
|
||||||
|
static const uint8_t LEGACY_MAC[6] = {0x02, 0x00, 0x00, 0x00, 0x00, 0x01};
|
||||||
|
|
||||||
// Undocumented libphy.a calls that push the radio into 802.11p OCB mode on
|
// Undocumented libphy.a calls that push the radio into 802.11p OCB mode on
|
||||||
// the 5.9 GHz ITS-G5 band. See docs/04-transmit-setup.md for source + what
|
// the 5.9 GHz ITS-G5 band. See docs/04-transmit-setup.md for source + what
|
||||||
@@ -77,6 +78,7 @@ extern void phy_change_channel(int freq_mhz, int bw_mode, int sec_chan_offset, i
|
|||||||
typedef struct {
|
typedef struct {
|
||||||
uint8_t data[SERIAL_LINK_MAX_PAYLOAD];
|
uint8_t data[SERIAL_LINK_MAX_PAYLOAD];
|
||||||
int len;
|
int len;
|
||||||
|
gn_lpv_t lpv; // the Source Position Vector this CAM goes out under
|
||||||
} cam_tx_item_t;
|
} cam_tx_item_t;
|
||||||
|
|
||||||
static QueueHandle_t s_tx_queue;
|
static QueueHandle_t s_tx_queue;
|
||||||
@@ -87,6 +89,23 @@ static QueueHandle_t s_tx_queue;
|
|||||||
// tx_radio_task below, off the UART parsing path entirely. xQueueSend with 0 timeout: if the
|
// tx_radio_task below, off the UART parsing path entirely. xQueueSend with 0 timeout: if the
|
||||||
// radio task is somehow behind, drop this CAM rather than stall UART frame parsing - the next
|
// radio task is somehow behind, drop this CAM rather than stall UART frame parsing - the next
|
||||||
// one is only ~1s (or less, at elevated rate) away regardless.
|
// one is only ~1s (or less, at elevated rate) away regardless.
|
||||||
|
// Source Position Vector for the legacy SERIAL_MSG_CAM_TX path, which carries no position of its
|
||||||
|
// own. Everything here describes the bench, not the rider: a fixed point, standing still, at an
|
||||||
|
// unknown time, under a fixed address. That is exactly why the phone now sends CAM_TX_PV. Kept so
|
||||||
|
// an app that predates it still transmits what it always did, except that the station type now
|
||||||
|
// says cyclist to agree with the CAM inside.
|
||||||
|
static void legacy_lpv(gn_lpv_t *lpv)
|
||||||
|
{
|
||||||
|
memcpy(lpv->mac, LEGACY_MAC, sizeof(lpv->mac));
|
||||||
|
lpv->station_type = STATION_TYPE;
|
||||||
|
lpv->pai = false;
|
||||||
|
lpv->tst_ms = 0;
|
||||||
|
lpv->lat_tenmicrodeg = BENCH_LATITUDE_TENMICRODEG;
|
||||||
|
lpv->lon_tenmicrodeg = BENCH_LONGITUDE_TENMICRODEG;
|
||||||
|
lpv->speed_cms = 0;
|
||||||
|
lpv->heading_decideg = 0;
|
||||||
|
}
|
||||||
|
|
||||||
static void on_cam_tx_from_phone(const uint8_t *cam_uper, int cam_len)
|
static void on_cam_tx_from_phone(const uint8_t *cam_uper, int cam_len)
|
||||||
{
|
{
|
||||||
if (cam_len <= 0 || cam_len > SERIAL_LINK_MAX_PAYLOAD) {
|
if (cam_len <= 0 || cam_len > SERIAL_LINK_MAX_PAYLOAD) {
|
||||||
@@ -96,6 +115,42 @@ static void on_cam_tx_from_phone(const uint8_t *cam_uper, int cam_len)
|
|||||||
cam_tx_item_t item;
|
cam_tx_item_t item;
|
||||||
item.len = cam_len;
|
item.len = cam_len;
|
||||||
memcpy(item.data, cam_uper, (size_t)cam_len);
|
memcpy(item.data, cam_uper, (size_t)cam_len);
|
||||||
|
legacy_lpv(&item.lpv);
|
||||||
|
if (xQueueSend(s_tx_queue, &item, 0) != pdTRUE) {
|
||||||
|
ESP_LOGW(TAG, "tx queue full, dropping CAM from phone");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static uint16_t le16(const uint8_t *p)
|
||||||
|
{
|
||||||
|
return (uint16_t)(p[0] | (p[1] << 8));
|
||||||
|
}
|
||||||
|
|
||||||
|
static uint32_t le32(const uint8_t *p)
|
||||||
|
{
|
||||||
|
return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24);
|
||||||
|
}
|
||||||
|
|
||||||
|
// SERIAL_MSG_CAM_TX_PV: the phone's CAM plus the position vector to send it under. The prefix
|
||||||
|
// layout is documented at SERIAL_MSG_CAM_TX_PV in serial_link.h. Same speed constraint as
|
||||||
|
// on_cam_tx_from_phone: decode, queue, return.
|
||||||
|
static void on_cam_tx_pv_from_phone(const uint8_t *prefix, const uint8_t *cam_uper, int cam_len)
|
||||||
|
{
|
||||||
|
if (cam_len <= 0 || cam_len > SERIAL_LINK_MAX_PAYLOAD) {
|
||||||
|
ESP_LOGW(TAG, "on_cam_tx_pv_from_phone: bad length %d", cam_len);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cam_tx_item_t item;
|
||||||
|
item.len = cam_len;
|
||||||
|
memcpy(item.data, cam_uper, (size_t)cam_len);
|
||||||
|
memcpy(item.lpv.mac, prefix, sizeof(item.lpv.mac));
|
||||||
|
item.lpv.station_type = prefix[6];
|
||||||
|
item.lpv.pai = (prefix[7] & 0x01) != 0;
|
||||||
|
item.lpv.tst_ms = le32(prefix + 8);
|
||||||
|
item.lpv.lat_tenmicrodeg = (int32_t)le32(prefix + 12);
|
||||||
|
item.lpv.lon_tenmicrodeg = (int32_t)le32(prefix + 16);
|
||||||
|
item.lpv.speed_cms = (int16_t)le16(prefix + 20);
|
||||||
|
item.lpv.heading_decideg = le16(prefix + 22);
|
||||||
if (xQueueSend(s_tx_queue, &item, 0) != pdTRUE) {
|
if (xQueueSend(s_tx_queue, &item, 0) != pdTRUE) {
|
||||||
ESP_LOGW(TAG, "tx queue full, dropping CAM from phone");
|
ESP_LOGW(TAG, "tx queue full, dropping CAM from phone");
|
||||||
}
|
}
|
||||||
@@ -116,8 +171,7 @@ static void tx_radio_task(void *arg)
|
|||||||
// singleton, created once in app_main. Both wrap functions bounds-check against the size
|
// singleton, created once in app_main. Both wrap functions bounds-check against the size
|
||||||
// passed in and return <= 0 on overflow, so an oversized CAM is rejected, not written past.
|
// passed in and return <= 0 on overflow, so an oversized CAM is rejected, not written past.
|
||||||
static uint8_t gn_payload[SERIAL_LINK_MAX_PAYLOAD + 64];
|
static uint8_t gn_payload[SERIAL_LINK_MAX_PAYLOAD + 64];
|
||||||
int gn_len = geonet_wrap_shb(item.data, item.len, pseudonym_mac, STATION_TYPE,
|
int gn_len = geonet_wrap_shb(item.data, item.len, &item.lpv,
|
||||||
BENCH_LATITUDE_TENMICRODEG, BENCH_LONGITUDE_TENMICRODEG,
|
|
||||||
BTP_PORT_CAM, gn_payload, sizeof(gn_payload));
|
BTP_PORT_CAM, gn_payload, sizeof(gn_payload));
|
||||||
if (gn_len <= 0) {
|
if (gn_len <= 0) {
|
||||||
ESP_LOGW(TAG, "geonet_wrap_shb failed (cam_len=%d)", item.len);
|
ESP_LOGW(TAG, "geonet_wrap_shb failed (cam_len=%d)", item.len);
|
||||||
@@ -125,7 +179,9 @@ static void tx_radio_task(void *arg)
|
|||||||
}
|
}
|
||||||
|
|
||||||
static uint8_t frame[SERIAL_LINK_MAX_PAYLOAD + 192];
|
static uint8_t frame[SERIAL_LINK_MAX_PAYLOAD + 192];
|
||||||
int frame_len = dot11p_build_frame(gn_payload, gn_len, pseudonym_mac, frame,
|
// Source address from the same lpv the GN header was built from, so the 802.11 and
|
||||||
|
// GeoNetworking layers always name the same sender, including across a pseudonym change.
|
||||||
|
int frame_len = dot11p_build_frame(gn_payload, gn_len, item.lpv.mac, frame,
|
||||||
sizeof(frame), false);
|
sizeof(frame), false);
|
||||||
if (frame_len <= 0) {
|
if (frame_len <= 0) {
|
||||||
ESP_LOGW(TAG, "dot11p_build_frame failed (gn_len=%d)", gn_len);
|
ESP_LOGW(TAG, "dot11p_build_frame failed (gn_len=%d)", gn_len);
|
||||||
@@ -224,8 +280,16 @@ static void rx_forward_task(void *arg)
|
|||||||
// returning false here is the common case, not an error, so it isn't logged per frame.
|
// returning false here is the common case, not an error, so it isn't logged per frame.
|
||||||
gn_rx_t rx;
|
gn_rx_t rx;
|
||||||
if (gn_unwrap_its(item.data, item.len, &rx)) {
|
if (gn_unwrap_its(item.data, item.len, &rx)) {
|
||||||
|
if (rx.truncated) {
|
||||||
|
// Longer than the RX_FRAME_MAX_LEN bytes captured above, so it cannot be forwarded
|
||||||
|
// whole - and at that size it could not cross the serial link either. Counted as
|
||||||
|
// an oversize drop, as it was when the cut-off frame still reached
|
||||||
|
// serial_link_send_v2x_rx() and failed the size check there.
|
||||||
|
serial_link_note_oversize_drop(rx.btp_dest_port);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
serial_link_send_v2x_rx(rx.btp_dest_port, item.rssi,
|
serial_link_send_v2x_rx(rx.btp_dest_port, item.rssi,
|
||||||
rx.has_geo_area,
|
rx.has_geo_area, rx.signed_unverified,
|
||||||
rx.geo_area_lat_tenmicrodeg,
|
rx.geo_area_lat_tenmicrodeg,
|
||||||
rx.geo_area_lon_tenmicrodeg,
|
rx.geo_area_lon_tenmicrodeg,
|
||||||
rx.geo_area_distance_a_m,
|
rx.geo_area_distance_a_m,
|
||||||
@@ -339,7 +403,7 @@ void app_main(void)
|
|||||||
|
|
||||||
xTaskCreate(tx_radio_task, "tx_radio", 4096, NULL, 6, NULL);
|
xTaskCreate(tx_radio_task, "tx_radio", 4096, NULL, 6, NULL);
|
||||||
xTaskCreate(rx_forward_task, "rx_forward", 4096, NULL, 5, NULL);
|
xTaskCreate(rx_forward_task, "rx_forward", 4096, NULL, 5, NULL);
|
||||||
serial_link_init(on_cam_tx_from_phone);
|
serial_link_init(on_cam_tx_from_phone, on_cam_tx_pv_from_phone);
|
||||||
|
|
||||||
ESP_LOGW(TAG, "OCB @ %d MHz - TX/RX armed, driven by serial_link (no on-chip TX timer)",
|
ESP_LOGW(TAG, "OCB @ %d MHz - TX/RX armed, driven by serial_link (no on-chip TX timer)",
|
||||||
TX_FREQ_MHZ);
|
TX_FREQ_MHZ);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ static const char *TAG = "serial_link";
|
|||||||
#define SYNC1 0x55
|
#define SYNC1 0x55
|
||||||
|
|
||||||
static serial_link_cam_tx_cb_t s_on_cam_tx;
|
static serial_link_cam_tx_cb_t s_on_cam_tx;
|
||||||
|
static serial_link_cam_tx_pv_cb_t s_on_cam_tx_pv;
|
||||||
|
|
||||||
// ---- Counters reported to the phone in every heartbeat (see SERIAL_MSG_STATUS in the header).
|
// ---- Counters reported to the phone in every heartbeat (see SERIAL_MSG_STATUS in the header).
|
||||||
// Saturating rather than wrapping: "65535 drops" reads as "lots and still going", whereas a wrap
|
// Saturating rather than wrapping: "65535 drops" reads as "lots and still going", whereas a wrap
|
||||||
@@ -37,6 +38,13 @@ void serial_link_note_tx_failure(void)
|
|||||||
bump(&s_tx_failures);
|
bump(&s_tx_failures);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void serial_link_note_oversize_drop(uint16_t btp_dest_port)
|
||||||
|
{
|
||||||
|
bump(&s_oversize_drops);
|
||||||
|
ESP_LOGW(TAG, "port %u message larger than the RX capture buffer, total oversize drops %u",
|
||||||
|
btp_dest_port, s_oversize_drops);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- CRC-16/CCITT-FALSE (poly 0x1021, init 0xFFFF, no reflect, no xorout) ----
|
// ---- CRC-16/CCITT-FALSE (poly 0x1021, init 0xFFFF, no reflect, no xorout) ----
|
||||||
// Bytewise (no table) - frames here are at most SERIAL_LINK_MAX_PAYLOAD + 3 bytes, so table
|
// Bytewise (no table) - frames here are at most SERIAL_LINK_MAX_PAYLOAD + 3 bytes, so table
|
||||||
// lookup isn't worth the flash/RAM tradeoff. MUST match the Kotlin-side implementation exactly
|
// lookup isn't worth the flash/RAM tradeoff. MUST match the Kotlin-side implementation exactly
|
||||||
@@ -113,7 +121,7 @@ static bool send_frame(uint8_t type, const uint8_t *payload, int len)
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool serial_link_send_v2x_rx(uint16_t btp_dest_port, int8_t rssi,
|
bool serial_link_send_v2x_rx(uint16_t btp_dest_port, int8_t rssi,
|
||||||
bool has_geo_area,
|
bool has_geo_area, bool signed_unverified,
|
||||||
int32_t geo_area_lat_tenmicrodeg,
|
int32_t geo_area_lat_tenmicrodeg,
|
||||||
int32_t geo_area_lon_tenmicrodeg,
|
int32_t geo_area_lon_tenmicrodeg,
|
||||||
uint16_t geo_area_distance_a_m,
|
uint16_t geo_area_distance_a_m,
|
||||||
@@ -137,7 +145,7 @@ bool serial_link_send_v2x_rx(uint16_t btp_dest_port, int8_t rssi,
|
|||||||
s_v2x_payload[0] = (uint8_t)(btp_dest_port & 0xFF);
|
s_v2x_payload[0] = (uint8_t)(btp_dest_port & 0xFF);
|
||||||
s_v2x_payload[1] = (uint8_t)((btp_dest_port >> 8) & 0xFF);
|
s_v2x_payload[1] = (uint8_t)((btp_dest_port >> 8) & 0xFF);
|
||||||
s_v2x_payload[2] = (uint8_t)rssi;
|
s_v2x_payload[2] = (uint8_t)rssi;
|
||||||
s_v2x_payload[3] = has_geo_area ? 0x01 : 0x00;
|
s_v2x_payload[3] = (uint8_t)((has_geo_area ? 0x01 : 0x00) | (signed_unverified ? 0x02 : 0x00));
|
||||||
uint32_t lat = (uint32_t)geo_area_lat_tenmicrodeg;
|
uint32_t lat = (uint32_t)geo_area_lat_tenmicrodeg;
|
||||||
uint32_t lon = (uint32_t)geo_area_lon_tenmicrodeg;
|
uint32_t lon = (uint32_t)geo_area_lon_tenmicrodeg;
|
||||||
s_v2x_payload[4] = (uint8_t)(lat & 0xFF);
|
s_v2x_payload[4] = (uint8_t)(lat & 0xFF);
|
||||||
@@ -157,9 +165,9 @@ bool serial_link_send_v2x_rx(uint16_t btp_dest_port, int8_t rssi,
|
|||||||
|
|
||||||
bool serial_link_send_status(uint8_t status)
|
bool serial_link_send_status(uint8_t status)
|
||||||
{
|
{
|
||||||
// [status:1][oversize_drops:2 LE][tx_failures:2 LE][rx_crc_errors:2 LE] - keep in lockstep
|
// [status:1][oversize_drops:2 LE][tx_failures:2 LE][rx_crc_errors:2 LE][capabilities:1] -
|
||||||
// with EspLinkStatus.parse() in the app's SerialFrame.kt.
|
// keep in lockstep with EspLinkStatus.parse() in the app's SerialFrame.kt.
|
||||||
uint8_t payload[7];
|
uint8_t payload[8];
|
||||||
payload[0] = status;
|
payload[0] = status;
|
||||||
payload[1] = (uint8_t)(s_oversize_drops & 0xFF);
|
payload[1] = (uint8_t)(s_oversize_drops & 0xFF);
|
||||||
payload[2] = (uint8_t)((s_oversize_drops >> 8) & 0xFF);
|
payload[2] = (uint8_t)((s_oversize_drops >> 8) & 0xFF);
|
||||||
@@ -167,6 +175,9 @@ bool serial_link_send_status(uint8_t status)
|
|||||||
payload[4] = (uint8_t)((s_tx_failures >> 8) & 0xFF);
|
payload[4] = (uint8_t)((s_tx_failures >> 8) & 0xFF);
|
||||||
payload[5] = (uint8_t)(s_rx_crc_errors & 0xFF);
|
payload[5] = (uint8_t)(s_rx_crc_errors & 0xFF);
|
||||||
payload[6] = (uint8_t)((s_rx_crc_errors >> 8) & 0xFF);
|
payload[6] = (uint8_t)((s_rx_crc_errors >> 8) & 0xFF);
|
||||||
|
// What this firmware accepts. The app reads it to decide whether it may send CAM_TX_PV, which
|
||||||
|
// is what lets a new app keep working against firmware that predates that message.
|
||||||
|
payload[7] = SERIAL_CAP_CAM_TX_PV;
|
||||||
return send_frame(SERIAL_MSG_STATUS, payload, sizeof(payload));
|
return send_frame(SERIAL_MSG_STATUS, payload, sizeof(payload));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,9 +273,19 @@ static void rx_task(void *arg)
|
|||||||
uint16_t crc_calc = crc16_ccitt_false(crc_buf, (size_t)(3 + len));
|
uint16_t crc_calc = crc16_ccitt_false(crc_buf, (size_t)(3 + len));
|
||||||
|
|
||||||
if (crc_calc == crc_recv) {
|
if (crc_calc == crc_recv) {
|
||||||
if (type == SERIAL_MSG_CAM_TX && s_on_cam_tx) {
|
if (type == SERIAL_MSG_CAM_TX) {
|
||||||
s_on_cam_tx(payload, len);
|
if (s_on_cam_tx) s_on_cam_tx(payload, len);
|
||||||
} else if (type != SERIAL_MSG_CAM_TX) {
|
} else if (type == SERIAL_MSG_CAM_TX_PV) {
|
||||||
|
// A frame that is all prefix has nothing to transmit.
|
||||||
|
if (len > SERIAL_CAM_TX_PV_PREFIX_LEN) {
|
||||||
|
if (s_on_cam_tx_pv) {
|
||||||
|
s_on_cam_tx_pv(payload, payload + SERIAL_CAM_TX_PV_PREFIX_LEN,
|
||||||
|
len - SERIAL_CAM_TX_PV_PREFIX_LEN);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ESP_LOGW(TAG, "rx: CAM_TX_PV of %u bytes carries no CAM, ignoring", len);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
ESP_LOGW(TAG, "rx: unexpected frame type 0x%02x from phone, ignoring", type);
|
ESP_LOGW(TAG, "rx: unexpected frame type 0x%02x from phone, ignoring", type);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -279,9 +300,11 @@ static void rx_task(void *arg)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void serial_link_init(serial_link_cam_tx_cb_t on_cam_tx)
|
void serial_link_init(serial_link_cam_tx_cb_t on_cam_tx,
|
||||||
|
serial_link_cam_tx_pv_cb_t on_cam_tx_pv)
|
||||||
{
|
{
|
||||||
s_on_cam_tx = on_cam_tx;
|
s_on_cam_tx = on_cam_tx;
|
||||||
|
s_on_cam_tx_pv = on_cam_tx_pv;
|
||||||
|
|
||||||
s_tx_mutex = xSemaphoreCreateMutex();
|
s_tx_mutex = xSemaphoreCreateMutex();
|
||||||
if (!s_tx_mutex) {
|
if (!s_tx_mutex) {
|
||||||
|
|||||||
@@ -37,10 +37,13 @@
|
|||||||
// [0..1] btp_dest_port uint16 LE 2001 = CAM, 2002 = DENM (ETSI TS 103 248)
|
// [0..1] btp_dest_port uint16 LE 2001 = CAM, 2002 = DENM (ETSI TS 103 248)
|
||||||
// [2] rssi int8 dBm, from the promiscuous RX metadata
|
// [2] rssi int8 dBm, from the promiscuous RX metadata
|
||||||
// [3] flags uint8 bit0: geo area fields below are valid
|
// [3] flags uint8 bit0: geo area fields below are valid
|
||||||
|
// bit1: arrived signed (TS 103 097), signature NOT
|
||||||
|
// verified. An app that tests only bit0 ignores it.
|
||||||
// [4..7] geo_area_lat int32 LE 1/10 microdegree, GeoBroadcast destination area
|
// [4..7] geo_area_lat int32 LE 1/10 microdegree, GeoBroadcast destination area
|
||||||
// [8..11] geo_area_lon int32 LE 1/10 microdegree
|
// [8..11] geo_area_lon int32 LE 1/10 microdegree
|
||||||
// [12..13] geo_area_dist uint16 LE Distance A, metres (relevance radius for a circle)
|
// [12..13] geo_area_dist uint16 LE Distance A, metres (relevance radius for a circle)
|
||||||
// [14..] UPER message bytes
|
// [14..] UPER message bytes - exactly the message. Before 2026-09-11 they were followed by
|
||||||
|
// the 8 bytes the chip's promiscuous RX appends (gn_unwrap.h, "Payload bounds").
|
||||||
//
|
//
|
||||||
// All prefix fields are LITTLE-endian, matching this framing's own length field - note the
|
// All prefix fields are LITTLE-endian, matching this framing's own length field - note the
|
||||||
// GeoNetworking wire format they came from is big-endian, so gn_unwrap.c converts.
|
// GeoNetworking wire format they came from is big-endian, so gn_unwrap.c converts.
|
||||||
@@ -49,20 +52,52 @@
|
|||||||
// message's own ItsPduHeader.stationID is the meaningful identifier.
|
// message's own ItsPduHeader.stationID is the meaningful identifier.
|
||||||
// SERIAL_MSG_STATUS (0x03), ESP32 -> phone: heartbeat + counters, sent at 1 Hz so the phone can
|
// SERIAL_MSG_STATUS (0x03), ESP32 -> phone: heartbeat + counters, sent at 1 Hz so the phone can
|
||||||
// distinguish "link idle" from "link dead" independent of CAM traffic (the app's watchdog in
|
// distinguish "link idle" from "link dead" independent of CAM traffic (the app's watchdog in
|
||||||
// UsbSerialTransport.kt declares the link dead after 3 missed beats). Payload is 7 bytes:
|
// UsbSerialTransport.kt declares the link dead after 3 missed beats). Payload is 8 bytes:
|
||||||
// [status:1][oversize_drops:2 LE][tx_failures:2 LE][rx_crc_errors:2 LE]
|
// [status:1][oversize_drops:2 LE][tx_failures:2 LE][rx_crc_errors:2 LE][capabilities:1]
|
||||||
// status 0 = ok. The counters are free-running totals since boot, saturating at 0xFFFF.
|
// status 0 = ok. The counters are free-running totals since boot, saturating at 0xFFFF.
|
||||||
|
// capabilities is a bitmask of the SERIAL_CAP_* flags below. It was appended as byte 7 rather
|
||||||
|
// than inserted, so an app that predates it, and reads only the first 7 bytes, is unaffected.
|
||||||
// They exist because the alternative - ESP_LOGW on the flashing port - is invisible to the
|
// They exist because the alternative - ESP_LOGW on the flashing port - is invisible to the
|
||||||
// phone, which is the only thing watching during a bench session. Mirrored by EspLinkStatus
|
// phone, which is the only thing watching during a bench session. Mirrored by EspLinkStatus
|
||||||
// in the app's SerialFrame.kt.
|
// in the app's SerialFrame.kt.
|
||||||
#define SERIAL_MSG_CAM_TX 0x01
|
#define SERIAL_MSG_CAM_TX 0x01
|
||||||
#define SERIAL_MSG_CAM_RX 0x02
|
#define SERIAL_MSG_CAM_RX 0x02
|
||||||
#define SERIAL_MSG_STATUS 0x03
|
#define SERIAL_MSG_STATUS 0x03
|
||||||
#define SERIAL_MSG_V2X_RX 0x04
|
#define SERIAL_MSG_V2X_RX 0x04
|
||||||
|
#define SERIAL_MSG_CAM_TX_PV 0x05
|
||||||
|
|
||||||
// Size of the V2X_RX prefix documented above. Must match the app's SerialFrame.kt.
|
// Size of the V2X_RX prefix documented above. Must match the app's SerialFrame.kt.
|
||||||
#define SERIAL_V2X_RX_PREFIX_LEN 14
|
#define SERIAL_V2X_RX_PREFIX_LEN 14
|
||||||
|
|
||||||
|
// SERIAL_MSG_CAM_TX_PV (0x05), phone -> ESP32: a CAM together with the GeoNetworking Source
|
||||||
|
// Position Vector to transmit it under. Payload is a fixed 24-byte prefix, then the CAM UPER:
|
||||||
|
//
|
||||||
|
// [0..5] mac 6 bytes pseudonym: the 802.11 source address AND the GN_ADDR MID
|
||||||
|
// [6] station_type uint8 TS 102 894-2 StationType (2 = cyclist)
|
||||||
|
// [7] flags uint8 bit0: PAI, position accuracy indicator
|
||||||
|
// [8..11] tst uint32 LE ms at which lat/lon were acquired, TimestampIts mod 2^32
|
||||||
|
// [12..15] lat int32 LE 1/10 microdegree
|
||||||
|
// [16..19] lon int32 LE 1/10 microdegree
|
||||||
|
// [20..21] speed int16 LE 0.01 m/s
|
||||||
|
// [22..23] heading uint16 LE 0.1 degree from north, clockwise, 0..3599
|
||||||
|
// [24..] CAM UPER bytes
|
||||||
|
//
|
||||||
|
// Little-endian like the rest of this framing; geonet.c converts to GeoNetworking's big-endian.
|
||||||
|
// Every prefix field is something the phone already has when it builds the CAM, and none of it
|
||||||
|
// can be known on this chip, which has no GNSS and no clock source on the OCB channel. Before
|
||||||
|
// this message existed the GN header carried fixed placeholders instead (see main.c).
|
||||||
|
//
|
||||||
|
// A new type rather than a redefined CAM_TX, so app and firmware can be updated independently:
|
||||||
|
// - old app, new firmware: the app sends CAM_TX, which is handled exactly as before.
|
||||||
|
// - new app, old firmware: the app sends CAM_TX_PV only once the heartbeat advertises
|
||||||
|
// SERIAL_CAP_CAM_TX_PV, and an old heartbeat carries no such bit, so it stays on CAM_TX.
|
||||||
|
// Redefining CAM_TX would instead have double-wrapped every frame in one of those combinations
|
||||||
|
// and sent one with no GN header in the other, silently, since neither side checks versions.
|
||||||
|
#define SERIAL_CAM_TX_PV_PREFIX_LEN 24
|
||||||
|
|
||||||
|
// Capability bits, carried in byte 7 of the SERIAL_MSG_STATUS payload.
|
||||||
|
#define SERIAL_CAP_CAM_TX_PV 0x01
|
||||||
|
|
||||||
// USB Serial/JTAG has no baud rate or GPIO pins to configure - it's a fixed on-chip USB device
|
// USB Serial/JTAG has no baud rate or GPIO pins to configure - it's a fixed on-chip USB device
|
||||||
// controller wired directly to the native USB-C port's D+/D- lines in silicon. RX/TX buffer
|
// controller wired directly to the native USB-C port's D+/D- lines in silicon. RX/TX buffer
|
||||||
// sizes for usb_serial_jtag_driver_install() (see serial_link.c) are sized generously relative
|
// sizes for usb_serial_jtag_driver_install() (see serial_link.c) are sized generously relative
|
||||||
@@ -83,24 +118,34 @@
|
|||||||
// Raised from 160 to 512: 160 was reasoned from cam.c's 96-byte encode buffer, which only ever
|
// Raised from 160 to 512: 160 was reasoned from cam.c's 96-byte encode buffer, which only ever
|
||||||
// described OUR OWN minimal CAM. A third-party CAM off the air carrying a path-history or
|
// described OUR OWN minimal CAM. A third-party CAM off the air carrying a path-history or
|
||||||
// special-vehicle container comfortably exceeds it, and those stations would then never reach the
|
// special-vehicle container comfortably exceeds it, and those stations would then never reach the
|
||||||
// phone at all. 512 clears any realistic CAM; the real upstream ceiling on the RX path is
|
// phone at all. 512 clears any realistic CAM. Our own CAM is 43 bytes of UPER.
|
||||||
// rx_item_t.data (400 bytes) in main.c, so nothing larger can get here anyway.
|
//
|
||||||
|
// This, not the radio side, is the ceiling on the RX path. main.c captures up to RX_FRAME_MAX_LEN
|
||||||
|
// (800) bytes per frame, sized for the CiT One's 528-byte DENM, so a larger ITS payload
|
||||||
|
// does arrive here. serial_link_send_v2x_rx() then drops anything above this minus its 14-byte
|
||||||
|
// prefix and counts it in the heartbeat's oversize-drop counter.
|
||||||
#define SERIAL_LINK_MAX_PAYLOAD 512
|
#define SERIAL_LINK_MAX_PAYLOAD 512
|
||||||
|
|
||||||
// Initializes the USB Serial/JTAG driver and its background RX-framing and 1 Hz heartbeat tasks.
|
// Initializes the USB Serial/JTAG driver and its background RX-framing and 1 Hz heartbeat tasks.
|
||||||
// Call once from app_main, after nvs/event loop init. `on_cam_tx` is invoked (from the RX task's
|
// Call once from app_main, after nvs/event loop init. Both callbacks run in the RX task's context,
|
||||||
// context - keep it fast, it blocks the next frame's parsing) whenever a complete, checksummed
|
// so keep them fast: they block the next frame's parsing.
|
||||||
// SERIAL_MSG_CAM_TX frame arrives from the phone.
|
// on_cam_tx a complete, checksummed SERIAL_MSG_CAM_TX frame: bare CAM UPER.
|
||||||
|
// on_cam_tx_pv a complete, checksummed SERIAL_MSG_CAM_TX_PV frame, already checked to carry at
|
||||||
|
// least one CAM byte after its prefix: the 24-byte prefix, then the CAM UPER.
|
||||||
typedef void (*serial_link_cam_tx_cb_t)(const uint8_t *cam_uper, int cam_len);
|
typedef void (*serial_link_cam_tx_cb_t)(const uint8_t *cam_uper, int cam_len);
|
||||||
void serial_link_init(serial_link_cam_tx_cb_t on_cam_tx);
|
typedef void (*serial_link_cam_tx_pv_cb_t)(const uint8_t *prefix,
|
||||||
|
const uint8_t *cam_uper, int cam_len);
|
||||||
|
void serial_link_init(serial_link_cam_tx_cb_t on_cam_tx,
|
||||||
|
serial_link_cam_tx_pv_cb_t on_cam_tx_pv);
|
||||||
|
|
||||||
// Sends a SERIAL_MSG_V2X_RX frame: the metadata prefix plus the UPER bytes gn_unwrap.c extracted
|
// Sends a SERIAL_MSG_V2X_RX frame: the metadata prefix plus the UPER bytes gn_unwrap.c extracted
|
||||||
// from an over-the-air frame. Pass has_geo_area=false and zeroes for the area fields when the
|
// from an over-the-air frame. Pass has_geo_area=false and zeroes for the area fields when the
|
||||||
// source frame carried no destination area (i.e. it was single-hop broadcast, not GeoBroadcast).
|
// source frame carried no destination area (i.e. it was single-hop broadcast, not GeoBroadcast).
|
||||||
|
// signed_unverified is gn_rx_t's flag of the same name; it sets bit1 of the prefix flags.
|
||||||
// Returns true if the frame was written to the USB endpoint - not an end-to-end ack, the phone
|
// Returns true if the frame was written to the USB endpoint - not an end-to-end ack, the phone
|
||||||
// may still drop it.
|
// may still drop it.
|
||||||
bool serial_link_send_v2x_rx(uint16_t btp_dest_port, int8_t rssi,
|
bool serial_link_send_v2x_rx(uint16_t btp_dest_port, int8_t rssi,
|
||||||
bool has_geo_area,
|
bool has_geo_area, bool signed_unverified,
|
||||||
int32_t geo_area_lat_tenmicrodeg,
|
int32_t geo_area_lat_tenmicrodeg,
|
||||||
int32_t geo_area_lon_tenmicrodeg,
|
int32_t geo_area_lon_tenmicrodeg,
|
||||||
uint16_t geo_area_distance_a_m,
|
uint16_t geo_area_distance_a_m,
|
||||||
@@ -115,4 +160,9 @@ bool serial_link_send_status(uint8_t status);
|
|||||||
// otherwise indistinguishable, from the phone's side, from one that transmitted fine.
|
// otherwise indistinguishable, from the phone's side, from one that transmitted fine.
|
||||||
void serial_link_note_tx_failure(void);
|
void serial_link_note_tx_failure(void);
|
||||||
|
|
||||||
|
// Counts an ITS message that cannot be forwarded because it is too large, in the same heartbeat
|
||||||
|
// counter serial_link_send_v2x_rx() uses for its own size check. For main.c's rx_forward_task,
|
||||||
|
// whose capture buffer is smaller than the largest frames on air.
|
||||||
|
void serial_link_note_oversize_drop(uint16_t btp_dest_port);
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Host-side tests for obu-firmware. See README.md: needs gcc and make on PATH (MSYS2 UCRT64), and
|
||||||
|
# asn1tools under `py -3.11` for the replay check.
|
||||||
|
#
|
||||||
|
# make build and run everything: chain, replay, fuzz
|
||||||
|
# make chain | replay | fuzz one of them
|
||||||
|
# make fuzz FUZZ_ITER=50000000 FUZZ_SEED=7 a longer or different fuzz run
|
||||||
|
# make clean remove build/
|
||||||
|
#
|
||||||
|
# The firmware sources are compiled straight from ../../main, never copied.
|
||||||
|
|
||||||
|
CC = gcc
|
||||||
|
PYTHON = python
|
||||||
|
PYTHON_ASN1 = py -3.11
|
||||||
|
FW = ../../main
|
||||||
|
BUILD = build
|
||||||
|
EXE = $(if $(filter Windows_NT,$(OS)),.exe,)
|
||||||
|
# Captures live in capture/recordings/ since 2026-09-14; older ones are still in the
|
||||||
|
# receiver checkout beside this repo.
|
||||||
|
RECORDINGS = $(wildcard ../../../capture/recordings/*.pcap ../../../its-g5-receiver-firmware/recordings/*.pcap)
|
||||||
|
FUZZ_ITER = 2000000
|
||||||
|
FUZZ_SEED = 1
|
||||||
|
|
||||||
|
# -Werror: these sources must stay warning-free on the host compiler too.
|
||||||
|
# UBSan in trap mode needs no runtime library, so it works on MinGW; a trap shows up as a crash.
|
||||||
|
CFLAGS = -std=c11 -O2 -g -Wall -Wextra -Wpedantic -Werror -I$(FW) \
|
||||||
|
-fsanitize=undefined -fsanitize-undefined-trap-on-error
|
||||||
|
|
||||||
|
FW_SRCS = $(FW)/geonet.c $(FW)/dot11p.c $(FW)/gn_unwrap.c
|
||||||
|
DEPS = test_util.c test_util.h $(FW_SRCS) $(wildcard $(FW)/*.h)
|
||||||
|
|
||||||
|
.PHONY: all check chain replay fuzz clean
|
||||||
|
|
||||||
|
all: check
|
||||||
|
|
||||||
|
check: chain replay fuzz
|
||||||
|
|
||||||
|
$(BUILD):
|
||||||
|
mkdir -p $@
|
||||||
|
|
||||||
|
$(BUILD)/%$(EXE): %.c $(DEPS) | $(BUILD)
|
||||||
|
$(CC) $(CFLAGS) -o $@ $< test_util.c $(FW_SRCS)
|
||||||
|
|
||||||
|
# The pcap goes through pcap_gn_tally.py as a second, independent parser of the same frames.
|
||||||
|
chain: $(BUILD)/test_chain$(EXE)
|
||||||
|
$(BUILD)/test_chain$(EXE) $(BUILD)/test_chain.pcap
|
||||||
|
$(PYTHON) ../pcap_gn_tally.py $(BUILD)/test_chain.pcap
|
||||||
|
|
||||||
|
replay: $(BUILD)/test_replay$(EXE)
|
||||||
|
ifeq ($(RECORDINGS),)
|
||||||
|
@echo "replay: no recordings in ../../../its-g5-receiver-firmware/recordings, skipped"
|
||||||
|
else
|
||||||
|
$(BUILD)/test_replay$(EXE) $(BUILD)/replay.tsv $(RECORDINGS)
|
||||||
|
$(PYTHON_ASN1) check_replay.py $(BUILD)/replay.tsv $(RECORDINGS)
|
||||||
|
endif
|
||||||
|
|
||||||
|
fuzz: $(BUILD)/fuzz_gn_unwrap$(EXE)
|
||||||
|
$(BUILD)/fuzz_gn_unwrap$(EXE) $(FUZZ_ITER) $(FUZZ_SEED) $(RECORDINGS)
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -rf $(BUILD)
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
# Host-side tests for obu-firmware
|
||||||
|
|
||||||
|
**Status (2026-09-11):** the chain test, the capture replay and the fuzzer are written and pass;
|
||||||
|
results under "Running". Toolchain: MSYS2 UCRT64 gcc, Option B below (chosen and
|
||||||
|
installed 2026-09-11).
|
||||||
|
|
||||||
|
`geonet.c`, `dot11p.c` and `gn_unwrap.c` include nothing but standard C headers, so they compile
|
||||||
|
unmodified on a PC. That makes three things possible without a board: a TX -> RX round trip
|
||||||
|
through our own code, replaying real captures through the RX parser, and fuzzing the parser that
|
||||||
|
reads untrusted radio bytes. ESP-IDF only builds `main/` (and `components/`), so nothing under
|
||||||
|
`test/` ever affects the firmware image.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
obu-firmware/
|
||||||
|
├── main/ firmware sources; the tests compile these directly, never copies
|
||||||
|
└── test/
|
||||||
|
├── pcap_gn_tally.py GN header fields per station over .pcap captures (Python only)
|
||||||
|
└── host/
|
||||||
|
├── README.md this file
|
||||||
|
├── Makefile `make`: builds ../../main/{geonet,dot11p,gn_unwrap}.c + tests, runs them
|
||||||
|
├── test_chain.c geonet_wrap_shb -> dot11p_build_frame -> gn_unwrap_its, byte-checked
|
||||||
|
├── test_util.c/.h shared: guard page, crash report, pcap read/write
|
||||||
|
├── test_replay.c every recorded frame through gn_unwrap_its, results to a TSV
|
||||||
|
├── check_replay.py re-derives each result independently, then asn1tools on the messages
|
||||||
|
├── fuzz_gn_unwrap.c mutation fuzzer; each input ends against a no-access guard page, so
|
||||||
|
│ an over-read faults (no ASan on MinGW)
|
||||||
|
└── build/ compiler output; already ignored by the repo's `build/` rule
|
||||||
|
```
|
||||||
|
|
||||||
|
The repo's `vanetza/` folder is a gitignored reading copy only; it is not built. `check_replay.py`
|
||||||
|
reads the IEEE 1609.2 ASN.1 modules from it (they carry no licence header, so they are not copied
|
||||||
|
into `asn1/`). Without it, signed frames are still replayed but not checked independently.
|
||||||
|
|
||||||
|
## Running
|
||||||
|
|
||||||
|
From PowerShell, with MSYS2 on PATH for the session (Option B step 3):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:PATH = "C:\msys64\ucrt64\bin;C:\msys64\usr\bin;$env:PATH"
|
||||||
|
cd C:\Users\Ashin\AndroidStudioProjects\MicrOBU\obu-firmware\test\host
|
||||||
|
make
|
||||||
|
```
|
||||||
|
|
||||||
|
`make` runs three things (`make chain`, `make replay`, `make fuzz` run one). A non-zero exit, or a
|
||||||
|
`CRASH ... during: <what>` line (from the fuzzer, followed by the input as hex), is a failure.
|
||||||
|
|
||||||
|
- **chain** (`test_chain.c`): frames built by the firmware's own TX code, checked byte by byte
|
||||||
|
against EN 302 636-4-1 and parsed back. Covers the CAM layout (non-QoS and QoS), Source Position
|
||||||
|
Vector edges, output-buffer bounds, a 512-byte payload through `main.c`'s buffer sizes,
|
||||||
|
hand-built GeoBroadcast frames in all three shapes, signed frames in all three COER length
|
||||||
|
forms plus a top-level unsecuredData, one-byte mutations that must be rejected or accepted, the
|
||||||
|
Common Header's payload length as the message boundary, the 8-byte RX trailer, and every
|
||||||
|
truncation length of each frame against the guard page. `pcap_gn_tally.py` then reads the
|
||||||
|
frames back as a second parser; `build/test_chain.pcap` opens in Wireshark too.
|
||||||
|
2026-09-11: 1731 checks, 0 failed.
|
||||||
|
- **replay** (`test_replay.c` + `check_replay.py`): every record in
|
||||||
|
`its-g5-receiver-firmware/recordings/*.pcap` through `gn_unwrap_its`, each cut to `main.c`'s
|
||||||
|
800-byte capture buffer as on the board. `check_replay.py` re-derives each result on its own
|
||||||
|
(its own GN/BTP parse; the security envelope decoded by asn1tools from the IEEE 1609.2 modules),
|
||||||
|
compares record by record, then decodes and re-encodes every distinct message with asn1tools -
|
||||||
|
a byte-identical re-encode is only possible when the message was cut at exactly the right byte.
|
||||||
|
Needs `py -3.11` with asn1tools. 2026-09-11: 15 145 records, 15 131 accepted (10 831 CAM,
|
||||||
|
4 300 DENM; 157 of them signed); C and Python agree on every record; 11 043 of the 11 106
|
||||||
|
distinct messages re-encode byte-identically. The other 63 fail the same way with the old 8
|
||||||
|
trailing bytes put back, so the boundary is not the cause: 5 are our own CAMs from before the
|
||||||
|
2026-08-20 yawRateConfidence fix, 56 come from the CiT One and 1 from another station (see
|
||||||
|
`TODO.md`), and 1 uses an extension asn1tools cannot re-encode.
|
||||||
|
- **fuzz** (`fuzz_gn_unwrap.c`): random edits of every recorded frame, each run against the guard
|
||||||
|
page; an over-read crashes, an accepted payload outside its input fails. Default 2 000 000
|
||||||
|
iterations (about 2 s); `make fuzz FUZZ_ITER=50000000 FUZZ_SEED=7` for a longer run.
|
||||||
|
2026-09-11: 50 000 000 iterations, no crash.
|
||||||
|
|
||||||
|
Not covered: `main.c` (serial prefix parsing, queues) and `serial_link.c`, which need ESP-IDF;
|
||||||
|
and the phone's encoder, whose bytes are opaque here (asn1tools and the app's golden test cover
|
||||||
|
it).
|
||||||
|
|
||||||
|
## Option A (not used): WSL2 + Ubuntu 24.04
|
||||||
|
|
||||||
|
Kept as the fallback if AddressSanitizer or libFuzzer are ever needed; Option B has neither.
|
||||||
|
|
||||||
|
1. In **PowerShell as Administrator**:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
wsl --install -d Ubuntu-24.04
|
||||||
|
```
|
||||||
|
|
||||||
|
Reboot if it asks. Ubuntu then opens and asks for a Linux username and password (separate from
|
||||||
|
the Windows account). Checked 2026-09-11: Hyper-V is already running on this PC, so no BIOS
|
||||||
|
change should be needed. If the install says virtualization is disabled, enable Intel VT-x /
|
||||||
|
AMD SVM in the BIOS.
|
||||||
|
|
||||||
|
2. Confirm it is WSL **2**: `wsl -l -v` should list `Ubuntu-24.04` with VERSION `2`.
|
||||||
|
|
||||||
|
3. Inside Ubuntu, the compilers:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install -y build-essential clang cmake ninja-build git pkg-config python3
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Smoke test: the firmware sources compile on the host (expect no output):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/c/Users/Ashin/AndroidStudioProjects/MicrOBU/obu-firmware/test/host
|
||||||
|
cc -std=c11 -Wall -Wextra -fsyntax-only ../../main/geonet.c ../../main/dot11p.c ../../main/gn_unwrap.c
|
||||||
|
```
|
||||||
|
|
||||||
|
## Option B (chosen): native Windows gcc via MSYS2
|
||||||
|
|
||||||
|
Enough for the round-trip test, the capture replay and the guard-page fuzzer. No libFuzzer and no
|
||||||
|
AddressSanitizer with MinGW gcc, which is why the fuzzer uses a guard page instead.
|
||||||
|
|
||||||
|
1. In PowerShell: `winget install -e --id MSYS2.MSYS2` (installs to `C:\msys64`).
|
||||||
|
2. Open **MSYS2 UCRT64** from the Start menu and run `pacman -Syu`. If the window closes, reopen
|
||||||
|
it and run `pacman -Syu` again. Then:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pacman -S --needed mingw-w64-ucrt-x86_64-gcc make
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **`C:\msys64\ucrt64\bin` must be on PATH.** Calling `C:\msys64\ucrt64\bin\gcc.exe` by its full
|
||||||
|
path alone exits 1 with no message, because gcc's compiler stages load their DLLs from that
|
||||||
|
folder. `make` lives on the MSYS side, in `C:\msys64\usr\bin`. Either work inside the
|
||||||
|
**MSYS2 UCRT64** shell, which has both, or put them on PATH for the current session:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:PATH = "C:\msys64\ucrt64\bin;C:\msys64\usr\bin;$env:PATH" # PowerShell
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PATH=/c/msys64/ucrt64/bin:/c/msys64/usr/bin:$PATH # Git Bash
|
||||||
|
```
|
||||||
|
|
||||||
|
Adding them to the user PATH permanently also works; it was deliberately not done by setup.
|
||||||
|
4. Smoke test (expect no output):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd C:\Users\Ashin\AndroidStudioProjects\MicrOBU\obu-firmware\test\host
|
||||||
|
gcc -std=c11 -Wall -Wextra -fsyntax-only ../../main/geonet.c ../../main/dot11p.c ../../main/gn_unwrap.c
|
||||||
|
```
|
||||||
|
|
||||||
|
Installed on this PC 2026-09-11: MSYS2 20260611, gcc 16.2.0 (UCRT64), GNU Make 4.4.1. All three
|
||||||
|
firmware sources compile with `-std=c11 -O2 -Wall -Wextra -Wpedantic` and no warnings.
|
||||||
|
|
||||||
|
## Line endings
|
||||||
|
|
||||||
|
The repo runs with `core.autocrlf=true`, so Windows checkouts have CRLF line endings. C compilers
|
||||||
|
don't care; shell scripts run from WSL do (`bash: $'\r': command not found`). When the first `.sh`
|
||||||
|
file lands here, add `*.sh text eol=lf` to a root `.gitattributes` (none exists yet).
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Independent check of test_replay's output. See README.md.
|
||||||
|
|
||||||
|
For every recorded frame this works out on its own what gn_unwrap_its should have produced -
|
||||||
|
GeoNetworking and BTP parsed here from EN 302 636-4-1, the TS 103 097 security envelope decoded by
|
||||||
|
asn1tools from the IEEE 1609.2 ASN.1 modules rather than by hand - and compares that with what the
|
||||||
|
C code did, record by record. Then it decodes every distinct extracted message with asn1tools and
|
||||||
|
re-encodes it. Only a message cut at exactly the right byte re-encodes to the same bytes, so this
|
||||||
|
is what proves that no trailer or signature bytes came along with it.
|
||||||
|
|
||||||
|
py -3.11 check_replay.py replay.tsv capture.pcap [capture.pcap ...]
|
||||||
|
|
||||||
|
Needs asn1tools (installed for Python 3.11 on this PC). Message modules come from the repo's
|
||||||
|
asn1/; the IEEE 1609.2 ones from asn1/ or, failing that, the gitignored vanetza/ checkout.
|
||||||
|
Exit status 1 if the C code and this disagree about any record.
|
||||||
|
"""
|
||||||
|
import collections
|
||||||
|
import pathlib
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import asn1tools
|
||||||
|
|
||||||
|
ROOT = pathlib.Path(__file__).resolve().parents[3]
|
||||||
|
PORT_NAMES = {2001: "CAM", 2002: "DENM", 2004: "SPATEM"}
|
||||||
|
# main.c's RX_FRAME_MAX_LEN: the most of any frame the board hands to gn_unwrap_its. test_replay
|
||||||
|
# cuts frames to it, so this does too.
|
||||||
|
RX_FRAME_MAX_LEN = 800
|
||||||
|
|
||||||
|
|
||||||
|
def compile_specs():
|
||||||
|
a = ROOT / "asn1"
|
||||||
|
uper = {
|
||||||
|
2001: asn1tools.compile_files([str(a / "cam_1_4_1.asn"), str(a / "cdd_1_3_1_1.asn")], "uper"),
|
||||||
|
2002: asn1tools.compile_files([str(a / "denm_1_3_1.asn"), str(a / "cdd_1_3_1_1.asn")], "uper"),
|
||||||
|
2004: asn1tools.compile_files(
|
||||||
|
[str(a / n) for n in ("spatem_2_2_1.asn", "mapem_2_2_1.asn", "dsrc_2_2_1.asn", "cdd_2_2_1.asn")],
|
||||||
|
"uper"),
|
||||||
|
}
|
||||||
|
names = ("IEEE1609dot2BaseTypes.asn", "IEEE1609dot2.asn")
|
||||||
|
for d in (a, ROOT / "vanetza" / "asn1"):
|
||||||
|
if all((d / n).exists() for n in names):
|
||||||
|
return uper, asn1tools.compile_files([str(d / n) for n in names], "oer"), d
|
||||||
|
return uper, None, None
|
||||||
|
|
||||||
|
|
||||||
|
def frames(path):
|
||||||
|
"""(record index, 802.11 frame) for every record, numbered the way test_util.c numbers them."""
|
||||||
|
d = pathlib.Path(path).read_bytes()
|
||||||
|
if len(d) < 24:
|
||||||
|
return
|
||||||
|
magic = struct.unpack("<I", d[:4])[0]
|
||||||
|
e = "<" if magic in (0xA1B2C3D4, 0xA1B23C4D) else ">"
|
||||||
|
link = struct.unpack(e + "I", d[20:24])[0]
|
||||||
|
if link not in (105, 127):
|
||||||
|
return
|
||||||
|
off, index = 24, 0
|
||||||
|
while off + 16 <= len(d):
|
||||||
|
incl = struct.unpack(e + "I", d[off + 8:off + 12])[0]
|
||||||
|
if incl > len(d) - off - 16:
|
||||||
|
break
|
||||||
|
pkt = d[off + 16:off + 16 + incl]
|
||||||
|
off += 16 + incl
|
||||||
|
if link == 127:
|
||||||
|
rl = pkt[2] | pkt[3] << 8 if len(pkt) >= 4 else len(pkt) + 1
|
||||||
|
if rl > len(pkt):
|
||||||
|
index += 1
|
||||||
|
continue
|
||||||
|
pkt = pkt[rl:]
|
||||||
|
yield index, pkt
|
||||||
|
index += 1
|
||||||
|
|
||||||
|
|
||||||
|
def open_envelope(sec, env):
|
||||||
|
"""(inner GeoNetworking packet, signed) of a TS 103 097 envelope per asn1tools, or None."""
|
||||||
|
try:
|
||||||
|
m = sec.decode("Ieee1609Dot2Data", env)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if m["protocolVersion"] != 3:
|
||||||
|
return None
|
||||||
|
kind, content = m["content"]
|
||||||
|
if kind == "unsecuredData":
|
||||||
|
return content, False
|
||||||
|
if kind == "signedData":
|
||||||
|
data = content["tbsData"]["payload"].get("data")
|
||||||
|
if data and data["protocolVersion"] == 3 and data["content"][0] == "unsecuredData":
|
||||||
|
return data["content"][1], True
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def u16(b):
|
||||||
|
return int.from_bytes(b, "big")
|
||||||
|
|
||||||
|
|
||||||
|
def s32(b):
|
||||||
|
return int.from_bytes(b, "big", signed=True)
|
||||||
|
|
||||||
|
|
||||||
|
def expect(f, sec):
|
||||||
|
"""What gn_unwrap_its should report for frame f: None, or a dict matching test_replay's row.
|
||||||
|
Second value: how many bytes after the message the pre-2026-09-11 code would have forwarded."""
|
||||||
|
if len(f) < 24 or (f[0] >> 2) & 3 != 2 or f[1] & 3 == 3:
|
||||||
|
return None, None
|
||||||
|
o = 24 + (2 if f[0] & 0x80 else 0)
|
||||||
|
if f[o:o + 8] != b"\xaa\xaa\x03\x00\x00\x00\x89\x47" or len(f) < o + 12:
|
||||||
|
return None, None
|
||||||
|
nh = f[o + 8] & 0x0F
|
||||||
|
o += 12
|
||||||
|
if nh == 2:
|
||||||
|
if sec is None:
|
||||||
|
return "unchecked", None
|
||||||
|
opened = open_envelope(sec, f[o:])
|
||||||
|
if opened is None:
|
||||||
|
return None, None
|
||||||
|
region, signed = opened
|
||||||
|
elif nh == 1:
|
||||||
|
region, signed = f[o:], False
|
||||||
|
else:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
if len(region) < 8 or region[0] >> 4 != 2:
|
||||||
|
return None, None
|
||||||
|
ht, hst, pl = region[1] >> 4, region[1] & 0x0F, u16(region[4:6])
|
||||||
|
if ht == 5 and hst == 0:
|
||||||
|
ext, area = 28, None
|
||||||
|
elif ht == 4:
|
||||||
|
ext = 44
|
||||||
|
else:
|
||||||
|
return None, None
|
||||||
|
if len(region) < 8 + ext + 4:
|
||||||
|
return None, None
|
||||||
|
if ht == 4:
|
||||||
|
a = 8 + 28
|
||||||
|
area = (s32(region[a:a + 4]), s32(region[a + 4:a + 8]), u16(region[a + 8:a + 10]))
|
||||||
|
port = u16(region[8 + ext:8 + ext + 2])
|
||||||
|
if port not in PORT_NAMES or pl <= 4:
|
||||||
|
return None, None
|
||||||
|
start, end = 8 + ext + 4, 8 + ext + pl
|
||||||
|
if signed is not None and nh == 2 and end > len(region):
|
||||||
|
return None, None # the inner packet claims more than its envelope holds
|
||||||
|
payload = region[start:min(end, len(region))]
|
||||||
|
if not payload:
|
||||||
|
return None, None
|
||||||
|
old_extra = len(region) - end if nh == 1 else None
|
||||||
|
return dict(port=port, signed=signed, truncated=end > len(region), area=area, payload=payload), old_extra
|
||||||
|
|
||||||
|
|
||||||
|
def read_tsv(path):
|
||||||
|
rows = {}
|
||||||
|
with open(path, encoding="ascii") as fh:
|
||||||
|
next(fh)
|
||||||
|
for line in fh:
|
||||||
|
c = line.rstrip("\n").split("\t")
|
||||||
|
key = (c[0], int(c[1]))
|
||||||
|
if c[2] == "0":
|
||||||
|
rows[key] = None
|
||||||
|
else:
|
||||||
|
rows[key] = dict(port=int(c[3]), signed=c[4] == "1", truncated=c[5] == "1",
|
||||||
|
area=(int(c[7]), int(c[8]), int(c[9])) if c[6] == "1" else None,
|
||||||
|
payload=bytes.fromhex(c[10]))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def describe(r):
|
||||||
|
if r is None:
|
||||||
|
return "rejected"
|
||||||
|
return "port %d signed %s truncated %s area %s %d bytes" % (
|
||||||
|
r["port"], r["signed"], r["truncated"], r["area"], len(r["payload"]))
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv):
|
||||||
|
if len(argv) < 2:
|
||||||
|
sys.exit(__doc__)
|
||||||
|
got = read_tsv(argv[0])
|
||||||
|
uper, sec, sec_dir = compile_specs()
|
||||||
|
if sec is None:
|
||||||
|
print("WARNING: IEEE 1609.2 modules not found; secured frames are not checked independently")
|
||||||
|
|
||||||
|
stats, extra, disagreements, messages = collections.Counter(), collections.Counter(), [], {}
|
||||||
|
for path in argv[1:]:
|
||||||
|
for index, f in frames(path):
|
||||||
|
key = (path, index)
|
||||||
|
stats["records"] += 1
|
||||||
|
stats["capped"] += len(f) > RX_FRAME_MAX_LEN
|
||||||
|
want, old_extra = expect(f[:RX_FRAME_MAX_LEN], sec)
|
||||||
|
if key not in got:
|
||||||
|
disagreements.append((key, "no row from test_replay"))
|
||||||
|
continue
|
||||||
|
have = got.pop(key)
|
||||||
|
if want == "unchecked":
|
||||||
|
stats["secured, unchecked"] += 1
|
||||||
|
continue
|
||||||
|
if want != have:
|
||||||
|
disagreements.append((key, "C: %s | independent: %s" % (describe(have), describe(want))))
|
||||||
|
continue
|
||||||
|
if want:
|
||||||
|
stats["accepted"] += 1
|
||||||
|
stats[PORT_NAMES[want["port"]]] += 1
|
||||||
|
stats["signed"] += want["signed"]
|
||||||
|
stats["truncated"] += want["truncated"]
|
||||||
|
if old_extra is not None:
|
||||||
|
extra[old_extra] += 1
|
||||||
|
messages.setdefault((want["port"], want["payload"]), key)
|
||||||
|
for key in got:
|
||||||
|
disagreements.append((key, "row from test_replay for a record this did not see"))
|
||||||
|
|
||||||
|
print("check_replay: %d records (%d cut to %d bytes), %d accepted (CAM %d, DENM %d, SPATEM %d; "
|
||||||
|
"%d signed, %d truncated)"
|
||||||
|
% (stats["records"], stats["capped"], RX_FRAME_MAX_LEN, stats["accepted"], stats["CAM"],
|
||||||
|
stats["DENM"], stats["SPATEM"], stats["signed"], stats["truncated"]))
|
||||||
|
if sec_dir:
|
||||||
|
print(" envelopes decoded with asn1tools using %s" % sec_dir.relative_to(ROOT))
|
||||||
|
print(" C vs independent parse: %s" % ("agree on every record" if not disagreements
|
||||||
|
else "%d DISAGREEMENTS" % len(disagreements)))
|
||||||
|
for key, why in disagreements[:15]:
|
||||||
|
print(" %s record %d: %s" % (pathlib.Path(key[0]).name, key[1], why))
|
||||||
|
|
||||||
|
outcome, failures = collections.Counter(), []
|
||||||
|
for (port, payload), key in messages.items():
|
||||||
|
name, spec = PORT_NAMES[port], uper[port]
|
||||||
|
try:
|
||||||
|
decoded = spec.decode(name, payload)
|
||||||
|
except Exception as e:
|
||||||
|
outcome[(name, "does not decode")] += 1
|
||||||
|
failures.append((key, name, len(payload), str(e)[:110]))
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
encoded = spec.encode(name, decoded)
|
||||||
|
except Exception as e:
|
||||||
|
# asn1tools decodes an alternative from a later module version (a CHOICE extension)
|
||||||
|
# as (None, None) and then cannot encode it back. Says nothing about where the message
|
||||||
|
# was cut, so it is reported apart from real mismatches.
|
||||||
|
outcome[(name, "decodes; uses a newer extension")] += 1
|
||||||
|
failures.append((key, name, len(payload), "cannot re-encode: " + str(e)[:90]))
|
||||||
|
continue
|
||||||
|
if encoded == payload:
|
||||||
|
outcome[(name, "re-encodes byte-identically")] += 1
|
||||||
|
else:
|
||||||
|
outcome[(name, "re-encodes differently")] += 1
|
||||||
|
failures.append((key, name, len(payload), "re-encoded to different bytes"))
|
||||||
|
print(" asn1tools on the %d distinct extracted messages:" % len(messages))
|
||||||
|
for (name, what), n in sorted(outcome.items()):
|
||||||
|
print(" %-6s %-28s %d" % (name, what, n))
|
||||||
|
for key, name, n, why in failures[:15]:
|
||||||
|
print(" %s record %d, %s %d bytes: %s" % (pathlib.Path(key[0]).name, key[1], name, n, why))
|
||||||
|
print(" bytes the pre-2026-09-11 code forwarded after each unsecured message: %s"
|
||||||
|
% dict(sorted(extra.items())))
|
||||||
|
return 1 if disagreements else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main(sys.argv[1:]))
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
// Mutation fuzzer for gn_unwrap_its, the one function in this firmware that parses bytes from the
|
||||||
|
// air. See README.md.
|
||||||
|
//
|
||||||
|
// Seeds are every recorded frame in the pcaps given, plus frames built by the firmware's own TX
|
||||||
|
// code. Each iteration takes a seed, applies 1-4 random edits - bit flips, random bytes, boundary
|
||||||
|
// bytes such as COER length markers, 16-bit length fields, truncation, insertion, deletion,
|
||||||
|
// appended bytes - mostly within the first 128 bytes where the headers are, and runs gn_unwrap_its
|
||||||
|
// on the result placed against the guard page. A read past the end crashes and prints the input;
|
||||||
|
// an accepted frame whose payload is not inside the input is reported the same way. MinGW has
|
||||||
|
// neither libFuzzer nor AddressSanitizer, hence this rather than coverage guidance. The same seed
|
||||||
|
// gives the same run.
|
||||||
|
//
|
||||||
|
// Usage: fuzz_gn_unwrap iterations seed [capture.pcap ...]
|
||||||
|
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#include "dot11p.h"
|
||||||
|
#include "geonet.h"
|
||||||
|
#include "gn_unwrap.h"
|
||||||
|
#include "test_util.h"
|
||||||
|
|
||||||
|
#define MAX_FRAME 2048 // within the guard page's one page, and above any 802.11 frame
|
||||||
|
|
||||||
|
static uint8_t **s_seeds;
|
||||||
|
static int *s_seed_len;
|
||||||
|
static int s_nseeds;
|
||||||
|
static int s_seed_cap;
|
||||||
|
|
||||||
|
static void add_seed(const uint8_t *f, int len)
|
||||||
|
{
|
||||||
|
if (len <= 0 || len > MAX_FRAME) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (s_nseeds == s_seed_cap) {
|
||||||
|
s_seed_cap = s_seed_cap ? 2 * s_seed_cap : 1024;
|
||||||
|
s_seeds = realloc(s_seeds, (size_t)s_seed_cap * sizeof *s_seeds);
|
||||||
|
s_seed_len = realloc(s_seed_len, (size_t)s_seed_cap * sizeof *s_seed_len);
|
||||||
|
if (!s_seeds || !s_seed_len) {
|
||||||
|
fprintf(stderr, "out of memory\n");
|
||||||
|
exit(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s_seeds[s_nseeds] = malloc((size_t)len);
|
||||||
|
if (!s_seeds[s_nseeds]) {
|
||||||
|
fprintf(stderr, "out of memory\n");
|
||||||
|
exit(2);
|
||||||
|
}
|
||||||
|
memcpy(s_seeds[s_nseeds], f, (size_t)len);
|
||||||
|
s_seed_len[s_nseeds++] = len;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void on_frame(const uint8_t *f, int len, int index, void *ctx)
|
||||||
|
{
|
||||||
|
(void)index;
|
||||||
|
(void)ctx;
|
||||||
|
add_seed(f, len);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void add_own_seeds(void)
|
||||||
|
{
|
||||||
|
static const uint8_t cam[] = {0x02, 0x02, 0x00, 0x0f, 0x42, 0x3f, 0x37, 0x00, 0x40, 0x2a, 0xb2};
|
||||||
|
gn_lpv_t lpv;
|
||||||
|
memset(&lpv, 0, sizeof lpv);
|
||||||
|
lpv.mac[0] = 0x02;
|
||||||
|
lpv.station_type = 2;
|
||||||
|
uint8_t gn[256];
|
||||||
|
uint8_t f[512];
|
||||||
|
const int gn_len = geonet_wrap_shb(cam, (int)sizeof cam, &lpv, 2001, gn, sizeof gn);
|
||||||
|
for (int qos = 0; qos <= 1; qos++) {
|
||||||
|
add_seed(f, dot11p_build_frame(gn, gn_len, lpv.mac, f, sizeof f, qos));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static uint64_t s_rng;
|
||||||
|
|
||||||
|
static uint64_t rnd(void)
|
||||||
|
{
|
||||||
|
s_rng ^= s_rng << 13;
|
||||||
|
s_rng ^= s_rng >> 7;
|
||||||
|
s_rng ^= s_rng << 17;
|
||||||
|
return s_rng;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int below(int n)
|
||||||
|
{
|
||||||
|
return n <= 0 ? 0 : (int)(rnd() % (uint64_t)n);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Three times in four inside the headers, otherwise anywhere.
|
||||||
|
static int pick_pos(int len)
|
||||||
|
{
|
||||||
|
return below(4) ? below(len < 128 ? len : 128) : below(len);
|
||||||
|
}
|
||||||
|
|
||||||
|
static const uint8_t k_bytes[] = {0x00, 0x01, 0x02, 0x03, 0x05, 0x10, 0x12, 0x20,
|
||||||
|
0x40, 0x50, 0x7F, 0x80, 0x81, 0x82, 0x83, 0xFF};
|
||||||
|
static const uint16_t k_words[] = {0x0000, 0x0001, 0x0003, 0x0004, 0x0005, 0x007F,
|
||||||
|
0x0080, 0x00FF, 0x0100, 0x7FFF, 0x8000, 0xFFFF};
|
||||||
|
|
||||||
|
static void mutate(uint8_t *b, int *len)
|
||||||
|
{
|
||||||
|
const int edits = 1 + below(4);
|
||||||
|
for (int e = 0; e < edits; e++) {
|
||||||
|
const int n = *len;
|
||||||
|
switch (below(8)) {
|
||||||
|
case 0: // flip a bit
|
||||||
|
if (n) {
|
||||||
|
b[pick_pos(n)] ^= (uint8_t)(1u << below(8));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 1: // random byte
|
||||||
|
if (n) {
|
||||||
|
b[pick_pos(n)] = (uint8_t)rnd();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 2: // boundary byte
|
||||||
|
if (n) {
|
||||||
|
b[pick_pos(n)] = k_bytes[below((int)sizeof k_bytes)];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 3: // truncate
|
||||||
|
*len = below(n + 1);
|
||||||
|
break;
|
||||||
|
case 4: { // append
|
||||||
|
const int add = 1 + below(16);
|
||||||
|
if (n + add <= MAX_FRAME) {
|
||||||
|
for (int i = 0; i < add; i++) {
|
||||||
|
b[n + i] = (uint8_t)rnd();
|
||||||
|
}
|
||||||
|
*len = n + add;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 5: // insert a byte
|
||||||
|
if (n < MAX_FRAME) {
|
||||||
|
const int p = below(n + 1);
|
||||||
|
memmove(b + p + 1, b + p, (size_t)(n - p));
|
||||||
|
b[p] = (uint8_t)rnd();
|
||||||
|
*len = n + 1;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 6: // delete a byte
|
||||||
|
if (n) {
|
||||||
|
const int p = below(n);
|
||||||
|
memmove(b + p, b + p + 1, (size_t)(n - p - 1));
|
||||||
|
*len = n - 1;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default: // boundary 16-bit big-endian value, e.g. a length field
|
||||||
|
if (n >= 2) {
|
||||||
|
const int p = pick_pos(n - 1);
|
||||||
|
const uint16_t v = k_words[below((int)(sizeof k_words / sizeof k_words[0]))];
|
||||||
|
b[p] = (uint8_t)(v >> 8);
|
||||||
|
b[p + 1] = (uint8_t)v;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(int argc, char **argv)
|
||||||
|
{
|
||||||
|
if (argc < 3) {
|
||||||
|
fprintf(stderr, "usage: fuzz_gn_unwrap iterations seed [capture.pcap ...]\n");
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
const unsigned long long iterations = strtoull(argv[1], NULL, 10);
|
||||||
|
s_rng = (strtoull(argv[2], NULL, 10) * 0x9E3779B97F4A7C15ull) | 1;
|
||||||
|
|
||||||
|
tu_install_crash_handler();
|
||||||
|
tu_guard_init();
|
||||||
|
for (int i = 3; i < argc; i++) {
|
||||||
|
if (tu_pcap_foreach(argv[i], on_frame, NULL) < 0) {
|
||||||
|
fprintf(stderr, "cannot read %s as a pcap\n", argv[i]);
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
add_own_seeds();
|
||||||
|
|
||||||
|
static uint8_t buf[MAX_FRAME];
|
||||||
|
int len = 0;
|
||||||
|
tu_set_crash_input(buf, &len);
|
||||||
|
unsigned long long accepted = 0, signed_frames = 0, truncated = 0;
|
||||||
|
for (unsigned long long it = 0; it < iterations; it++) {
|
||||||
|
const int s = below(s_nseeds);
|
||||||
|
len = s_seed_len[s];
|
||||||
|
memcpy(buf, s_seeds[s], (size_t)len);
|
||||||
|
mutate(buf, &len);
|
||||||
|
tu_set_context("fuzz iteration %llu (seed %s), mutated from seed frame %d", it, argv[2], s);
|
||||||
|
|
||||||
|
const uint8_t *g = tu_guarded(buf, len);
|
||||||
|
gn_rx_t rx;
|
||||||
|
if (gn_unwrap_its(g, len, &rx)) {
|
||||||
|
accepted++;
|
||||||
|
signed_frames += rx.signed_unverified;
|
||||||
|
truncated += rx.truncated;
|
||||||
|
const uintptr_t lo = (uintptr_t)g;
|
||||||
|
const uintptr_t p = (uintptr_t)rx.payload;
|
||||||
|
if (p < lo || rx.payload_len <= 0 || p + (uintptr_t)rx.payload_len > lo + (uintptr_t)len) {
|
||||||
|
fprintf(stderr, "FAIL during %s: accepted payload lies outside the input\ninput (%d bytes): ",
|
||||||
|
tu_context(), len);
|
||||||
|
for (int i = 0; i < len; i++) {
|
||||||
|
fprintf(stderr, "%02x", buf[i]);
|
||||||
|
}
|
||||||
|
fputc('\n', stderr);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
printf("fuzz_gn_unwrap: %llu iterations from %d seed frames, %llu accepted (%llu signed, "
|
||||||
|
"%llu truncated), no crash\n",
|
||||||
|
iterations, s_nseeds, accepted, signed_frames, truncated);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,730 @@
|
|||||||
|
// Host-side chain test for obu-firmware. See README.md in this folder.
|
||||||
|
//
|
||||||
|
// Builds frames with the firmware's own TX code (geonet_wrap_shb -> dot11p_build_frame) and parses
|
||||||
|
// them back with its own RX code (gn_unwrap_its), all compiled from ../../main unmodified.
|
||||||
|
//
|
||||||
|
// Two kinds of check, on purpose. The round trip proves TX and RX agree with each other. The byte
|
||||||
|
// checks at fixed offsets prove they agree with EN 302 636-4-1, and only those catch a mistake made
|
||||||
|
// the same way on both sides: the missing 4-byte SHB field (fixed 2026-08-13) round-tripped fine
|
||||||
|
// between two ESP32s and was wrong against every other station. So expected values here come from
|
||||||
|
// the standard, vanetza's serializers and real captures - never from reading geonet.c.
|
||||||
|
//
|
||||||
|
// Every frame handed to gn_unwrap_its is first copied so that its last byte sits right before a
|
||||||
|
// no-access page (test_util.c): reading even one byte past a frame crashes the test instead of
|
||||||
|
// passing quietly. MinGW has no AddressSanitizer; this covers what matters for a radio parser.
|
||||||
|
//
|
||||||
|
// Usage: test_chain [out.pcap]
|
||||||
|
// With a path, also writes every test frame to a pcap (linktype 105, bare 802.11) so a second,
|
||||||
|
// independent parser can read them: ../pcap_gn_tally.py, or Wireshark. The GeoBroadcast and
|
||||||
|
// signed frames carry stub payloads/signatures; their headers are real.
|
||||||
|
|
||||||
|
#include <stdarg.h>
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#include "dot11p.h"
|
||||||
|
#include "geonet.h"
|
||||||
|
#include "gn_unwrap.h"
|
||||||
|
#include "serial_link.h" // SERIAL_LINK_MAX_PAYLOAD only; serial_link.c itself needs ESP-IDF
|
||||||
|
#include "test_util.h"
|
||||||
|
|
||||||
|
// Same buffer sizes as tx_radio_task in main.c. Keep them in step with it.
|
||||||
|
#define GN_BUF_LEN (SERIAL_LINK_MAX_PAYLOAD + 64)
|
||||||
|
#define FRAME_BUF_LEN (SERIAL_LINK_MAX_PAYLOAD + 192)
|
||||||
|
|
||||||
|
// ---- Checks ---------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
static int s_checks;
|
||||||
|
static int s_failures;
|
||||||
|
|
||||||
|
static void check(bool ok, int line, const char *fmt, ...)
|
||||||
|
{
|
||||||
|
s_checks++;
|
||||||
|
if (ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
s_failures++;
|
||||||
|
va_list ap;
|
||||||
|
va_start(ap, fmt);
|
||||||
|
fprintf(stderr, "FAIL line %d [%s]: ", line, tu_context());
|
||||||
|
vfprintf(stderr, fmt, ap);
|
||||||
|
fputc('\n', stderr);
|
||||||
|
va_end(ap);
|
||||||
|
}
|
||||||
|
#define CHECK(cond, ...) check((cond), __LINE__, __VA_ARGS__)
|
||||||
|
|
||||||
|
// ---- Test data ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
static uint16_t rd16(const uint8_t *p)
|
||||||
|
{
|
||||||
|
return (uint16_t)((p[0] << 8) | p[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
static uint32_t rd32(const uint8_t *p)
|
||||||
|
{
|
||||||
|
return ((uint32_t)p[0] << 24) | ((uint32_t)p[1] << 16) | ((uint32_t)p[2] << 8) | p[3];
|
||||||
|
}
|
||||||
|
|
||||||
|
// The app's reference CAM: the expected bytes of CamEncodeGoldenTest.kt (stationID 999999), which
|
||||||
|
// asn1tools decodes and re-encodes byte-identically. The chain treats it as opaque bytes. Not taken
|
||||||
|
// from the old captures on purpose: our own CAMs there predate the 2026-08-20 yawRateConfidence fix
|
||||||
|
// and do not decode.
|
||||||
|
static const uint8_t k_cam[] = {
|
||||||
|
0x02, 0x02, 0x00, 0x0f, 0x42, 0x3f, 0x37, 0x00, 0x40, 0x2a, 0xb2, 0x15, 0xaf, 0x6e, 0x28, 0x64,
|
||||||
|
0x77, 0xdf, 0xff, 0xff, 0xfc, 0x23, 0xb7, 0x74, 0x3e, 0x00, 0x27, 0xff, 0xc0, 0xd0, 0xfe, 0x01,
|
||||||
|
0x18, 0x32, 0x93, 0x37, 0xfe, 0xeb, 0xff, 0xf6, 0x00, 0x00, 0x00,
|
||||||
|
};
|
||||||
|
#define CAM_LEN ((int)sizeof k_cam)
|
||||||
|
|
||||||
|
static const uint8_t k_mac[6] = {0x02, 0x11, 0x22, 0x33, 0x44, 0x55};
|
||||||
|
static const uint8_t k_bcast[6] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};
|
||||||
|
static const uint8_t k_llc_snap_gn[8] = {0xAA, 0xAA, 0x03, 0x00, 0x00, 0x00, 0x89, 0x47};
|
||||||
|
|
||||||
|
// The 8 bytes that follow the 802.11 frame in every frame recorded through the ESP32-C5's
|
||||||
|
// promiscuous RX (these from capture_20260817_171055.pcap). See gn_unwrap.h, "Payload bounds".
|
||||||
|
static const uint8_t k_rx_trailer[8] = {0xc8, 0x01, 0x00, 0x00, 0x88, 0x00, 0x00, 0x00};
|
||||||
|
|
||||||
|
// Header lengths from EN 302 636-4-1 / IEEE 802.11, used to compute every offset below.
|
||||||
|
enum {
|
||||||
|
MAC_HDR = 24, QOS_CTRL = 2, LLC_SNAP = 8, GN_BASIC = 4, GN_COMMON = 8,
|
||||||
|
SHB_EXT = 28, // Source Position Vector (24) + DCC-MCO / reserved (4)
|
||||||
|
GBC_EXT = 44, // SN (2) + reserved (2) + SO PV (24) + area (12) + reserved (4)
|
||||||
|
BTP_B = 4,
|
||||||
|
GN_COMMON_PAYLOAD_LEN_FIELD = 4, // offset of the payload-length field in the Common Header
|
||||||
|
};
|
||||||
|
// Where the ITS payload starts in a non-QoS SHB frame.
|
||||||
|
#define SHB_PAYLOAD_OFFSET (MAC_HDR + LLC_SNAP + GN_BASIC + GN_COMMON + SHB_EXT + BTP_B)
|
||||||
|
|
||||||
|
static gn_lpv_t test_lpv(void)
|
||||||
|
{
|
||||||
|
gn_lpv_t lpv;
|
||||||
|
memset(&lpv, 0, sizeof lpv);
|
||||||
|
memcpy(lpv.mac, k_mac, sizeof lpv.mac);
|
||||||
|
lpv.station_type = 2; // cyclist
|
||||||
|
lpv.pai = true;
|
||||||
|
lpv.tst_ms = 0x89ABCDEFu;
|
||||||
|
lpv.lat_tenmicrodeg = 535546667; // the bench, 53.5546667 N
|
||||||
|
lpv.lon_tenmicrodeg = -10022389; // negative on purpose: the sign has to survive
|
||||||
|
lpv.speed_cms = 1234;
|
||||||
|
lpv.heading_decideg = 2700;
|
||||||
|
return lpv;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The TX path exactly as tx_radio_task runs it. Returns the frame length, or <= 0 on failure.
|
||||||
|
static int build(const uint8_t *payload, int len, const gn_lpv_t *lpv, uint16_t port, bool qos,
|
||||||
|
uint8_t *frame, size_t frame_size)
|
||||||
|
{
|
||||||
|
static uint8_t gn[GN_BUF_LEN];
|
||||||
|
int gn_len = geonet_wrap_shb(payload, len, lpv, port, gn, sizeof gn);
|
||||||
|
if (gn_len <= 0) {
|
||||||
|
return gn_len;
|
||||||
|
}
|
||||||
|
return dot11p_build_frame(gn, gn_len, lpv->mac, frame, frame_size, qos);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every prefix of a frame, each ending against the guard page. Up to the end of the BTP-B header
|
||||||
|
// nothing may be accepted; a frame cut inside the payload is accepted as truncated with what
|
||||||
|
// arrived; from `payload_end` on, the payload is exactly the declared one whatever follows it.
|
||||||
|
static void sweep(const char *name, const uint8_t *f, int len, int payload_start, int payload_end)
|
||||||
|
{
|
||||||
|
for (int n = 0; n <= len; n++) {
|
||||||
|
tu_set_context("truncation sweep, %s, %d of %d bytes", name, n, len);
|
||||||
|
const uint8_t *g = tu_guarded(f, n);
|
||||||
|
gn_rx_t rx;
|
||||||
|
const bool ok = gn_unwrap_its(g, n, &rx);
|
||||||
|
if (n <= payload_start) {
|
||||||
|
CHECK(!ok, "%s cut to %d bytes was accepted", name, n);
|
||||||
|
} else if (n < payload_end) {
|
||||||
|
CHECK(ok && rx.truncated && rx.payload == g + payload_start &&
|
||||||
|
rx.payload_len == n - payload_start,
|
||||||
|
"%s cut to %d bytes: ok=%d truncated=%d payload_len=%d", name, n, ok,
|
||||||
|
ok && rx.truncated, ok ? rx.payload_len : -1);
|
||||||
|
} else {
|
||||||
|
CHECK(ok && !rx.truncated && rx.payload == g + payload_start &&
|
||||||
|
rx.payload_len == payload_end - payload_start,
|
||||||
|
"%s at %d bytes: ok=%d truncated=%d payload_len=%d", name, n, ok,
|
||||||
|
ok && rx.truncated, ok ? rx.payload_len : -1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- SHB: layout, position vector, bounds ---------------------------------------------------
|
||||||
|
|
||||||
|
// Every byte of a CAM frame against the standard, then back through the RX path.
|
||||||
|
static void test_shb_layout(bool qos)
|
||||||
|
{
|
||||||
|
tu_set_context("SHB layout, qos=%d", qos);
|
||||||
|
const gn_lpv_t lpv = test_lpv();
|
||||||
|
uint8_t f[FRAME_BUF_LEN];
|
||||||
|
const int len = build(k_cam, CAM_LEN, &lpv, 2001, qos, f, sizeof f);
|
||||||
|
|
||||||
|
const int llc = MAC_HDR + (qos ? QOS_CTRL : 0);
|
||||||
|
const int basic = llc + LLC_SNAP;
|
||||||
|
const int common = basic + GN_BASIC;
|
||||||
|
const int shb = common + GN_COMMON;
|
||||||
|
const int btp = shb + SHB_EXT;
|
||||||
|
const int pay = btp + BTP_B;
|
||||||
|
CHECK(len == pay + CAM_LEN, "frame length %d, expected %d", len, pay + CAM_LEN);
|
||||||
|
if (len != pay + CAM_LEN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 802.11 MAC header
|
||||||
|
CHECK(f[0] == (qos ? 0x88 : 0x08) && f[1] == 0x00, "frame control %02x %02x", f[0], f[1]);
|
||||||
|
CHECK(memcmp(f + 4, k_bcast, 6) == 0, "addr1 must be broadcast");
|
||||||
|
CHECK(memcmp(f + 10, k_mac, 6) == 0, "addr2 must be the pseudonym");
|
||||||
|
CHECK(memcmp(f + 16, k_bcast, 6) == 0, "addr3 (BSSID) must be the OCB wildcard");
|
||||||
|
if (qos) {
|
||||||
|
CHECK(f[24] == 0 && f[25] == 0, "QoS control %02x %02x", f[24], f[25]);
|
||||||
|
}
|
||||||
|
CHECK(memcmp(f + llc, k_llc_snap_gn, 8) == 0, "LLC/SNAP with EtherType 0x8947");
|
||||||
|
|
||||||
|
// GN Basic Header
|
||||||
|
CHECK(f[basic + 0] == 0x11, "basic: version 1 + NextHeader 1 (Common, unsecured), got %02x",
|
||||||
|
f[basic + 0]);
|
||||||
|
CHECK(f[basic + 1] == 0x00, "basic: reserved, got %02x", f[basic + 1]);
|
||||||
|
// Multiplier 1 in the upper 6 bits, base 1 (= 1 s) in the lower 2: 1 s, what every real station
|
||||||
|
// in the recordings sends its CAMs with. Was 0x83 = 3200 s until 2026-09-11.
|
||||||
|
CHECK(f[basic + 2] == 0x05, "basic: lifetime 0x05 (1 s), got 0x%02x", f[basic + 2]);
|
||||||
|
CHECK(f[basic + 3] == 1, "basic: remaining hop limit 1, got %d", f[basic + 3]);
|
||||||
|
|
||||||
|
// GN Common Header
|
||||||
|
CHECK(f[common + 0] == 0x20, "common: NextHeader 2 (BTP-B), got %02x", f[common + 0]);
|
||||||
|
CHECK(f[common + 1] == 0x50, "common: HeaderType 5 (TSB) / subtype 0 (single hop), got %02x",
|
||||||
|
f[common + 1]);
|
||||||
|
CHECK(f[common + 2] == 0x02, "common: traffic class TC-ID 2 (DP2, CAM), got %02x", f[common + 2]);
|
||||||
|
CHECK(f[common + 3] == 0x80, "common: flags = mobile, got %02x", f[common + 3]);
|
||||||
|
CHECK(rd16(f + common + 4) == BTP_B + CAM_LEN,
|
||||||
|
"common: payload length must count BTP-B + payload only, got %d", rd16(f + common + 4));
|
||||||
|
CHECK(f[common + 6] == 1, "common: maximum hop limit 1, got %d", f[common + 6]);
|
||||||
|
CHECK(f[common + 7] == 0, "common: reserved, got %02x", f[common + 7]);
|
||||||
|
|
||||||
|
// SHB extended header: Source Position Vector, then 4 bytes DCC-MCO / reserved
|
||||||
|
// GN_ADDR: M flag bit 15, station type bits 14..10 (vanetza geonet/address.cpp), MID.
|
||||||
|
CHECK(rd16(f + shb) == (2u << 10), "GN_ADDR: M=0, station type 2, got %04x", rd16(f + shb));
|
||||||
|
CHECK(memcmp(f + shb + 2, k_mac, 6) == 0, "GN_ADDR MID must equal the 802.11 source address");
|
||||||
|
CHECK(rd32(f + shb + 8) == 0x89ABCDEFu, "TST, got %08lx", (unsigned long)rd32(f + shb + 8));
|
||||||
|
CHECK((int32_t)rd32(f + shb + 12) == 535546667, "latitude, got %ld",
|
||||||
|
(long)(int32_t)rd32(f + shb + 12));
|
||||||
|
CHECK((int32_t)rd32(f + shb + 16) == -10022389, "longitude, got %ld",
|
||||||
|
(long)(int32_t)rd32(f + shb + 16));
|
||||||
|
CHECK(rd16(f + shb + 20) == (0x8000 | 1234), "PAI bit 15 + speed 1234, got %04x",
|
||||||
|
rd16(f + shb + 20));
|
||||||
|
CHECK(rd16(f + shb + 22) == 2700, "heading, got %d", rd16(f + shb + 22));
|
||||||
|
CHECK(rd32(f + shb + 24) == 0, "DCC-MCO / reserved: present and zero, got %08lx",
|
||||||
|
(unsigned long)rd32(f + shb + 24));
|
||||||
|
|
||||||
|
// BTP-B, payload
|
||||||
|
CHECK(rd16(f + btp) == 2001, "BTP-B destination port, got %d", rd16(f + btp));
|
||||||
|
CHECK(rd16(f + btp + 2) == 0, "BTP-B destination port info, got %d", rd16(f + btp + 2));
|
||||||
|
CHECK(memcmp(f + pay, k_cam, (size_t)CAM_LEN) == 0, "payload bytes");
|
||||||
|
|
||||||
|
// ...and back through the RX path
|
||||||
|
const uint8_t *g = tu_guarded(f, len);
|
||||||
|
gn_rx_t rx;
|
||||||
|
const bool ok = gn_unwrap_its(g, len, &rx);
|
||||||
|
CHECK(ok, "gn_unwrap_its rejected our own frame");
|
||||||
|
if (ok) {
|
||||||
|
CHECK(rx.btp_dest_port == 2001, "RX port %d", rx.btp_dest_port);
|
||||||
|
CHECK(!rx.has_geo_area, "RX reports a geo area for an SHB frame");
|
||||||
|
CHECK(!rx.signed_unverified, "RX reports an unsecured frame as signed");
|
||||||
|
CHECK(!rx.truncated, "RX reports a whole frame as truncated");
|
||||||
|
CHECK(rx.payload == g + pay, "RX payload offset %d, expected %d", (int)(rx.payload - g), pay);
|
||||||
|
CHECK(rx.payload_len == CAM_LEN, "RX payload length %d, expected %d", rx.payload_len, CAM_LEN);
|
||||||
|
CHECK(rx.payload_len == CAM_LEN && memcmp(rx.payload, k_cam, (size_t)CAM_LEN) == 0,
|
||||||
|
"RX payload bytes differ from what was sent");
|
||||||
|
}
|
||||||
|
tu_pcap_add(f, len);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Source Position Vector's packed fields at their edges.
|
||||||
|
static void test_pv_encoding(void)
|
||||||
|
{
|
||||||
|
// Offsets inside the GeoNetworking packet (no 802.11 header): SO PV starts after Basic + Common.
|
||||||
|
const int pv = GN_BASIC + GN_COMMON;
|
||||||
|
uint8_t gn[GN_BUF_LEN];
|
||||||
|
|
||||||
|
// Speed is 15-bit signed (geonet.h), clamped rather than wrapped; PAI is bit 15.
|
||||||
|
static const struct { int16_t speed; bool pai; uint16_t expect; } speeds[] = {
|
||||||
|
{0, false, 0x0000}, {1234, true, 0x84D2}, {16383, false, 0x3FFF},
|
||||||
|
{16384, false, 0x3FFF}, {32767, false, 0x3FFF}, {-100, false, 0x7F9C},
|
||||||
|
{-16384, false, 0x4000}, {-32768, true, 0xC000},
|
||||||
|
};
|
||||||
|
for (size_t i = 0; i < sizeof speeds / sizeof speeds[0]; i++) {
|
||||||
|
tu_set_context("PV speed %d pai %d", speeds[i].speed, speeds[i].pai);
|
||||||
|
gn_lpv_t lpv = test_lpv();
|
||||||
|
lpv.speed_cms = speeds[i].speed;
|
||||||
|
lpv.pai = speeds[i].pai;
|
||||||
|
const int n = geonet_wrap_shb(k_cam, CAM_LEN, &lpv, 2001, gn, sizeof gn);
|
||||||
|
CHECK(n > 0 && rd16(gn + pv + 20) == speeds[i].expect, "speed %d pai %d: got %04x, expected %04x",
|
||||||
|
speeds[i].speed, speeds[i].pai, rd16(gn + pv + 20), speeds[i].expect);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heading is 0..3599 tenths of a degree, wrapped.
|
||||||
|
static const struct { uint16_t in, expect; } headings[] = {
|
||||||
|
{0, 0}, {2700, 2700}, {3599, 3599}, {3600, 0}, {3601, 1}, {65535, 735},
|
||||||
|
};
|
||||||
|
for (size_t i = 0; i < sizeof headings / sizeof headings[0]; i++) {
|
||||||
|
tu_set_context("PV heading %d", headings[i].in);
|
||||||
|
gn_lpv_t lpv = test_lpv();
|
||||||
|
lpv.heading_decideg = headings[i].in;
|
||||||
|
const int n = geonet_wrap_shb(k_cam, CAM_LEN, &lpv, 2001, gn, sizeof gn);
|
||||||
|
CHECK(n > 0 && rd16(gn + pv + 22) == headings[i].expect, "heading %d: got %d, expected %d",
|
||||||
|
headings[i].in, rd16(gn + pv + 22), headings[i].expect);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Station type has 5 bits; anything larger must not spill into the M flag.
|
||||||
|
static const struct { uint8_t in; uint16_t expect; } types[] = {
|
||||||
|
{2, 0x0800}, {15, 0x3C00}, {0xFF, 0x7C00},
|
||||||
|
};
|
||||||
|
for (size_t i = 0; i < sizeof types / sizeof types[0]; i++) {
|
||||||
|
tu_set_context("PV station type %d", types[i].in);
|
||||||
|
gn_lpv_t lpv = test_lpv();
|
||||||
|
lpv.station_type = types[i].in;
|
||||||
|
const int n = geonet_wrap_shb(k_cam, CAM_LEN, &lpv, 2001, gn, sizeof gn);
|
||||||
|
CHECK(n > 0 && rd16(gn + pv) == types[i].expect, "station type %d: GN_ADDR %04x, expected %04x",
|
||||||
|
types[i].in, rd16(gn + pv), types[i].expect);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output buffers: an exact fit works and writes nothing past its end; one byte less is refused.
|
||||||
|
static void test_bounds(void)
|
||||||
|
{
|
||||||
|
const gn_lpv_t lpv = test_lpv();
|
||||||
|
const int gn_len = GN_BASIC + GN_COMMON + SHB_EXT + BTP_B + CAM_LEN;
|
||||||
|
|
||||||
|
tu_set_context("geonet_wrap_shb bounds");
|
||||||
|
CHECK(geonet_wrap_shb(k_cam, CAM_LEN, &lpv, 2001, tu_guard_buf(gn_len), (size_t)gn_len) == gn_len,
|
||||||
|
"geonet_wrap_shb: exact-size buffer must fit");
|
||||||
|
CHECK(geonet_wrap_shb(k_cam, CAM_LEN, &lpv, 2001, tu_guard_buf(gn_len - 1), (size_t)gn_len - 1) == -1,
|
||||||
|
"geonet_wrap_shb: one byte short must be refused");
|
||||||
|
|
||||||
|
uint8_t gn[GN_BUF_LEN];
|
||||||
|
geonet_wrap_shb(k_cam, CAM_LEN, &lpv, 2001, gn, sizeof gn);
|
||||||
|
for (int qos = 0; qos <= 1; qos++) {
|
||||||
|
tu_set_context("dot11p_build_frame bounds, qos=%d", qos);
|
||||||
|
const int frame_len = MAC_HDR + (qos ? QOS_CTRL : 0) + LLC_SNAP + gn_len;
|
||||||
|
CHECK(dot11p_build_frame(gn, gn_len, k_mac, tu_guard_buf(frame_len), (size_t)frame_len, qos) ==
|
||||||
|
frame_len,
|
||||||
|
"dot11p_build_frame qos=%d: exact-size buffer must fit", qos);
|
||||||
|
CHECK(dot11p_build_frame(gn, gn_len, k_mac, tu_guard_buf(frame_len - 1), (size_t)frame_len - 1,
|
||||||
|
qos) == -1,
|
||||||
|
"dot11p_build_frame qos=%d: one byte short must be refused", qos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The largest CAM the serial link can carry must survive the whole chain in main.c's buffers.
|
||||||
|
static void test_max_payload(void)
|
||||||
|
{
|
||||||
|
static uint8_t big[SERIAL_LINK_MAX_PAYLOAD];
|
||||||
|
for (int i = 0; i < SERIAL_LINK_MAX_PAYLOAD; i++) {
|
||||||
|
big[i] = (uint8_t)(i * 7 + 3);
|
||||||
|
}
|
||||||
|
for (int qos = 0; qos <= 1; qos++) {
|
||||||
|
tu_set_context("max payload %d, qos=%d", SERIAL_LINK_MAX_PAYLOAD, qos);
|
||||||
|
const gn_lpv_t lpv = test_lpv();
|
||||||
|
uint8_t f[FRAME_BUF_LEN];
|
||||||
|
const int hdr = MAC_HDR + (qos ? QOS_CTRL : 0) + LLC_SNAP + GN_BASIC + GN_COMMON + SHB_EXT + BTP_B;
|
||||||
|
const int len = build(big, SERIAL_LINK_MAX_PAYLOAD, &lpv, 2001, qos, f, sizeof f);
|
||||||
|
CHECK(len == hdr + SERIAL_LINK_MAX_PAYLOAD, "qos=%d: frame length %d, expected %d", qos, len,
|
||||||
|
hdr + SERIAL_LINK_MAX_PAYLOAD);
|
||||||
|
if (len <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
gn_rx_t rx;
|
||||||
|
const uint8_t *g = tu_guarded(f, len);
|
||||||
|
const bool ok = gn_unwrap_its(g, len, &rx);
|
||||||
|
CHECK(ok && !rx.truncated && rx.payload_len == SERIAL_LINK_MAX_PAYLOAD &&
|
||||||
|
memcmp(rx.payload, big, SERIAL_LINK_MAX_PAYLOAD) == 0,
|
||||||
|
"qos=%d: max-size payload did not round-trip", qos);
|
||||||
|
tu_pcap_add(f, len);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- GeoBroadcast ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// A GeoBroadcast DENM frame laid out by hand from EN 302 636-4-1 clause 9.8.5, since the firmware
|
||||||
|
// has no GBC builder and real RSUs send DENM this way. QoS Data, lifetime 0x79 (30 s) and the
|
||||||
|
// 44-byte extended header all as seen from the CiT One in the recordings.
|
||||||
|
static const uint8_t k_rsu_mac[6] = {0x02, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE};
|
||||||
|
static const uint8_t k_denm_stub[] = {0x02, 0x01, 0x00, 0x00, 0x30, 0x39, 0xDE, 0xAD, 0xBE,
|
||||||
|
0xEF, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08};
|
||||||
|
#define DENM_STUB_LEN ((int)sizeof k_denm_stub)
|
||||||
|
#define GBC_PAYLOAD_OFFSET (MAC_HDR + QOS_CTRL + LLC_SNAP + GN_BASIC + GN_COMMON + GBC_EXT + BTP_B)
|
||||||
|
|
||||||
|
static uint8_t *put(uint8_t *p, const void *src, int n)
|
||||||
|
{
|
||||||
|
memcpy(p, src, (size_t)n);
|
||||||
|
return p + n;
|
||||||
|
}
|
||||||
|
|
||||||
|
static uint8_t *put16(uint8_t *p, uint16_t v)
|
||||||
|
{
|
||||||
|
*p++ = (uint8_t)(v >> 8);
|
||||||
|
*p++ = (uint8_t)v;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
static uint8_t *put32(uint8_t *p, uint32_t v)
|
||||||
|
{
|
||||||
|
p = put16(p, (uint16_t)(v >> 16));
|
||||||
|
return put16(p, (uint16_t)v);
|
||||||
|
}
|
||||||
|
|
||||||
|
static int build_gbc(uint8_t subtype, int32_t area_lat, int32_t area_lon, uint16_t dist_a, uint8_t *f)
|
||||||
|
{
|
||||||
|
uint8_t *p = f;
|
||||||
|
// 802.11 QoS Data: FC, duration, addr1..3, sequence control, QoS control
|
||||||
|
p = put16(p, 0x8800);
|
||||||
|
p = put16(p, 0);
|
||||||
|
p = put(p, k_bcast, 6);
|
||||||
|
p = put(p, k_rsu_mac, 6);
|
||||||
|
p = put(p, k_bcast, 6);
|
||||||
|
p = put16(p, 0x1000);
|
||||||
|
p = put16(p, 0);
|
||||||
|
p = put(p, k_llc_snap_gn, 8);
|
||||||
|
// Basic: version 1 / NH common, reserved, lifetime 30 s, RHL 10
|
||||||
|
const uint8_t basic[4] = {0x11, 0x00, 0x79, 10};
|
||||||
|
p = put(p, basic, 4);
|
||||||
|
// Common: NH BTP-B, HT 4 (GBC) / subtype = area shape, TC 1, flags 0 (stationary), PL, MHL, reserved
|
||||||
|
const uint8_t common[4] = {0x20, (uint8_t)(0x40 | subtype), 0x01, 0x00};
|
||||||
|
p = put(p, common, 4);
|
||||||
|
p = put16(p, (uint16_t)(BTP_B + DENM_STUB_LEN));
|
||||||
|
*p++ = 10;
|
||||||
|
*p++ = 0;
|
||||||
|
// GBC extended header: SN, reserved, SO PV (GN_ADDR with station type 15 = RSU, MID, TST, lat,
|
||||||
|
// lon, PAI/speed, heading), area centre lat/lon, DistanceA, DistanceB, angle, reserved
|
||||||
|
p = put16(p, 0x1234);
|
||||||
|
p = put16(p, 0);
|
||||||
|
p = put16(p, 15u << 10);
|
||||||
|
p = put(p, k_rsu_mac, 6);
|
||||||
|
p = put32(p, 1000);
|
||||||
|
p = put32(p, 535540100);
|
||||||
|
p = put32(p, 100220100);
|
||||||
|
p = put16(p, 0);
|
||||||
|
p = put16(p, 0);
|
||||||
|
p = put32(p, (uint32_t)area_lat);
|
||||||
|
p = put32(p, (uint32_t)area_lon);
|
||||||
|
p = put16(p, dist_a);
|
||||||
|
p = put16(p, subtype ? 40 : 0);
|
||||||
|
p = put16(p, subtype ? 900 : 0);
|
||||||
|
p = put16(p, 0);
|
||||||
|
// BTP-B: DENM
|
||||||
|
p = put16(p, 2002);
|
||||||
|
p = put16(p, 0);
|
||||||
|
p = put(p, k_denm_stub, DENM_STUB_LEN);
|
||||||
|
return (int)(p - f);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_gbc_rx(void)
|
||||||
|
{
|
||||||
|
static const struct { uint8_t subtype; int32_t lat, lon; uint16_t dist_a; } areas[] = {
|
||||||
|
{0, 535540000, 100220000, 250}, // circle around the bench
|
||||||
|
{1, -338600000, 1512100000, 1000}, // rectangle; southern and far-eastern: signs and range
|
||||||
|
{2, 535540000, -100220000, 65535}, // ellipse; western, largest DistanceA
|
||||||
|
};
|
||||||
|
for (size_t i = 0; i < sizeof areas / sizeof areas[0]; i++) {
|
||||||
|
tu_set_context("GBC subtype %d", areas[i].subtype);
|
||||||
|
uint8_t f[256];
|
||||||
|
const int len = build_gbc(areas[i].subtype, areas[i].lat, areas[i].lon, areas[i].dist_a, f);
|
||||||
|
CHECK(len == GBC_PAYLOAD_OFFSET + DENM_STUB_LEN, "GBC frame length %d", len);
|
||||||
|
gn_rx_t rx;
|
||||||
|
const uint8_t *g = tu_guarded(f, len);
|
||||||
|
const bool ok = gn_unwrap_its(g, len, &rx);
|
||||||
|
CHECK(ok, "GBC subtype %d rejected", areas[i].subtype);
|
||||||
|
if (ok) {
|
||||||
|
CHECK(rx.btp_dest_port == 2002, "GBC port %d", rx.btp_dest_port);
|
||||||
|
CHECK(rx.has_geo_area, "GBC area missing");
|
||||||
|
CHECK(!rx.signed_unverified && !rx.truncated, "GBC flags signed=%d truncated=%d",
|
||||||
|
rx.signed_unverified, rx.truncated);
|
||||||
|
CHECK(rx.geo_area_lat_tenmicrodeg == areas[i].lat, "area lat %ld, expected %ld",
|
||||||
|
(long)rx.geo_area_lat_tenmicrodeg, (long)areas[i].lat);
|
||||||
|
CHECK(rx.geo_area_lon_tenmicrodeg == areas[i].lon, "area lon %ld, expected %ld",
|
||||||
|
(long)rx.geo_area_lon_tenmicrodeg, (long)areas[i].lon);
|
||||||
|
CHECK(rx.geo_area_distance_a_m == areas[i].dist_a, "DistanceA %d, expected %d",
|
||||||
|
rx.geo_area_distance_a_m, areas[i].dist_a);
|
||||||
|
CHECK(rx.payload == g + GBC_PAYLOAD_OFFSET && rx.payload_len == DENM_STUB_LEN &&
|
||||||
|
memcmp(rx.payload, k_denm_stub, (size_t)DENM_STUB_LEN) == 0,
|
||||||
|
"GBC payload offset %d length %d", (int)(rx.payload - g), rx.payload_len);
|
||||||
|
}
|
||||||
|
tu_pcap_add(f, len);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- What gets rejected, and where the payload ends -----------------------------------------
|
||||||
|
|
||||||
|
// One-byte changes to a good CAM frame: what must be dropped, and what must still pass.
|
||||||
|
static void test_rejections(void)
|
||||||
|
{
|
||||||
|
const gn_lpv_t lpv = test_lpv();
|
||||||
|
uint8_t good[FRAME_BUF_LEN];
|
||||||
|
const int len = build(k_cam, CAM_LEN, &lpv, 2001, false, good, sizeof good);
|
||||||
|
|
||||||
|
// Offsets into that non-QoS frame: 802.11 0, LLC/SNAP 24, Basic 32, Common 36, SHB 44, BTP-B 72.
|
||||||
|
static const struct {
|
||||||
|
const char *what;
|
||||||
|
int offset;
|
||||||
|
uint8_t value;
|
||||||
|
bool accept;
|
||||||
|
uint16_t port;
|
||||||
|
} cases[] = {
|
||||||
|
{"management frame (beacon)", 0, 0x80, false, 0},
|
||||||
|
{"WDS (ToDS and FromDS)", 1, 0x03, false, 0},
|
||||||
|
{"LLC DSAP not 0xAA", 24, 0xAB, false, 0},
|
||||||
|
{"EtherType not 0x8947", 30, 0x08, false, 0},
|
||||||
|
{"Basic NextHeader 2 with no security envelope", 32, 0x12, false, 0},
|
||||||
|
{"Basic NextHeader 0 (any)", 32, 0x10, false, 0},
|
||||||
|
{"Common NextHeader 1 (BTP-A)", 36, 0x10, false, 0},
|
||||||
|
{"Beacon (HeaderType 1)", 37, 0x10, false, 0},
|
||||||
|
{"GeoUnicast (HeaderType 2)", 37, 0x20, false, 0},
|
||||||
|
{"multi-hop TSB (HeaderType 5, subtype 1)", 37, 0x51, false, 0},
|
||||||
|
{"BTP port 2003 (MAPEM, not accepted yet)", 73, 0xD3, false, 0},
|
||||||
|
{"BTP port 2018 (VAM, not accepted yet)", 73, 0xE2, false, 0},
|
||||||
|
{"BTP port 2002 (DENM)", 73, 0xD2, true, 2002},
|
||||||
|
{"BTP port 2004 (SPATEM)", 73, 0xD4, true, 2004},
|
||||||
|
};
|
||||||
|
for (size_t i = 0; i < sizeof cases / sizeof cases[0]; i++) {
|
||||||
|
tu_set_context("mutation: %s", cases[i].what);
|
||||||
|
uint8_t f[FRAME_BUF_LEN];
|
||||||
|
memcpy(f, good, (size_t)len);
|
||||||
|
f[cases[i].offset] = cases[i].value;
|
||||||
|
gn_rx_t rx;
|
||||||
|
const bool ok = gn_unwrap_its(tu_guarded(f, len), len, &rx);
|
||||||
|
CHECK(ok == cases[i].accept, "%s: %s", cases[i].what, ok ? "accepted" : "rejected");
|
||||||
|
if (ok && cases[i].accept) {
|
||||||
|
CHECK(rx.btp_dest_port == cases[i].port, "%s: port %d", cases[i].what, rx.btp_dest_port);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Common Header's payload length, not the end of the frame, decides where the message stops.
|
||||||
|
static void test_payload_length(void)
|
||||||
|
{
|
||||||
|
const gn_lpv_t lpv = test_lpv();
|
||||||
|
uint8_t good[FRAME_BUF_LEN];
|
||||||
|
const int len = build(k_cam, CAM_LEN, &lpv, 2001, false, good, sizeof good);
|
||||||
|
const int pl = MAC_HDR + LLC_SNAP + GN_BASIC + GN_COMMON_PAYLOAD_LEN_FIELD;
|
||||||
|
|
||||||
|
static const struct { const char *what; uint16_t value; bool accept; bool truncated; int payload_len; }
|
||||||
|
cases[] = {
|
||||||
|
{"one byte less than sent", BTP_B + CAM_LEN - 1, true, false, CAM_LEN - 1},
|
||||||
|
{"BTP-B header only, no payload", BTP_B, false, false, 0},
|
||||||
|
{"shorter than the BTP-B header", BTP_B - 1, false, false, 0},
|
||||||
|
{"zero", 0, false, false, 0},
|
||||||
|
{"far more than arrived", 0xFFFF, true, true, CAM_LEN},
|
||||||
|
};
|
||||||
|
for (size_t i = 0; i < sizeof cases / sizeof cases[0]; i++) {
|
||||||
|
tu_set_context("Common payload length: %s", cases[i].what);
|
||||||
|
uint8_t f[FRAME_BUF_LEN];
|
||||||
|
memcpy(f, good, (size_t)len);
|
||||||
|
f[pl] = (uint8_t)(cases[i].value >> 8);
|
||||||
|
f[pl + 1] = (uint8_t)cases[i].value;
|
||||||
|
gn_rx_t rx;
|
||||||
|
const uint8_t *g = tu_guarded(f, len);
|
||||||
|
const bool ok = gn_unwrap_its(g, len, &rx);
|
||||||
|
CHECK(ok == cases[i].accept, "%s: %s", cases[i].what, ok ? "accepted" : "rejected");
|
||||||
|
if (ok && cases[i].accept) {
|
||||||
|
CHECK(rx.truncated == cases[i].truncated && rx.payload == g + SHB_PAYLOAD_OFFSET &&
|
||||||
|
rx.payload_len == cases[i].payload_len,
|
||||||
|
"%s: truncated=%d payload_len=%d", cases[i].what, rx.truncated, rx.payload_len);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The 8 bytes the chip's promiscuous RX appends to every frame must not reach the phone.
|
||||||
|
static void test_trailer(void)
|
||||||
|
{
|
||||||
|
uint8_t f[FRAME_BUF_LEN];
|
||||||
|
for (int qos = 0; qos <= 1; qos++) {
|
||||||
|
tu_set_context("RX trailer after an SHB frame, qos=%d", qos);
|
||||||
|
const gn_lpv_t lpv = test_lpv();
|
||||||
|
const int len = build(k_cam, CAM_LEN, &lpv, 2001, qos, f, sizeof f);
|
||||||
|
memcpy(f + len, k_rx_trailer, sizeof k_rx_trailer);
|
||||||
|
gn_rx_t rx;
|
||||||
|
const bool ok = gn_unwrap_its(tu_guarded(f, len + 8), len + 8, &rx);
|
||||||
|
CHECK(ok && !rx.truncated && rx.payload_len == CAM_LEN &&
|
||||||
|
memcmp(rx.payload, k_cam, (size_t)CAM_LEN) == 0,
|
||||||
|
"qos=%d: payload_len %d with the trailer, expected %d", qos, ok ? rx.payload_len : -1,
|
||||||
|
CAM_LEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
tu_set_context("RX trailer after a GBC frame");
|
||||||
|
const int len = build_gbc(0, 535540000, 100220000, 250, f);
|
||||||
|
memcpy(f + len, k_rx_trailer, sizeof k_rx_trailer);
|
||||||
|
gn_rx_t rx;
|
||||||
|
const bool ok = gn_unwrap_its(tu_guarded(f, len + 8), len + 8, &rx);
|
||||||
|
CHECK(ok && !rx.truncated && rx.payload_len == DENM_STUB_LEN,
|
||||||
|
"GBC: payload_len %d with the trailer, expected %d", ok ? rx.payload_len : -1, DENM_STUB_LEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Secured (TS 103 097) -------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Stand-in for what follows the inner packet in a real signed frame: headerInfo, signer and
|
||||||
|
// signature, 94 bytes with a digest signer. Its content is never read.
|
||||||
|
#define SIG_STUB_LEN 94
|
||||||
|
|
||||||
|
static int put_coer_length(int len, uint8_t *out)
|
||||||
|
{
|
||||||
|
if (len < 0x80) {
|
||||||
|
out[0] = (uint8_t)len;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (len <= 0xFF) {
|
||||||
|
out[0] = 0x81;
|
||||||
|
out[1] = (uint8_t)len;
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
out[0] = 0x82;
|
||||||
|
out[1] = (uint8_t)(len >> 8);
|
||||||
|
out[2] = (uint8_t)len;
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A secured CAM frame shaped like the recorded ones: our own SHB packet with the Basic Header's
|
||||||
|
// NextHeader set to 2 and everything after the Basic Header wrapped as `prefix` + COER length +
|
||||||
|
// inner packet, then the signature stand-in. Sets where the ITS payload starts and ends.
|
||||||
|
static int build_secured(const uint8_t *prefix, int prefix_len, const uint8_t *payload,
|
||||||
|
int payload_len, uint8_t *f, int *payload_start, int *payload_end)
|
||||||
|
{
|
||||||
|
const gn_lpv_t lpv = test_lpv();
|
||||||
|
uint8_t plain[FRAME_BUF_LEN];
|
||||||
|
const int plain_len = build(payload, payload_len, &lpv, 2001, false, plain, sizeof plain);
|
||||||
|
const int basic = MAC_HDR + LLC_SNAP;
|
||||||
|
const int inner = basic + GN_BASIC; // the Common Header onward
|
||||||
|
const int inner_len = plain_len - inner;
|
||||||
|
|
||||||
|
int n = inner;
|
||||||
|
memcpy(f, plain, (size_t)inner);
|
||||||
|
f[basic] = (uint8_t)((f[basic] & 0xF0) | 2); // Basic Header NextHeader: secured
|
||||||
|
memcpy(f + n, prefix, (size_t)prefix_len);
|
||||||
|
n += prefix_len;
|
||||||
|
n += put_coer_length(inner_len, f + n);
|
||||||
|
memcpy(f + n, plain + inner, (size_t)inner_len);
|
||||||
|
*payload_start = n + GN_COMMON + SHB_EXT + BTP_B;
|
||||||
|
n += inner_len;
|
||||||
|
*payload_end = n;
|
||||||
|
for (int i = 0; i < SIG_STUB_LEN; i++) {
|
||||||
|
f[n++] = (uint8_t)(0xA5 ^ i);
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
static const uint8_t k_signed_prefix[] = {0x03, 0x81, 0x00, 0x40, 0x03, 0x80};
|
||||||
|
static const uint8_t k_unsecured_prefix[] = {0x03, 0x80};
|
||||||
|
|
||||||
|
static void test_secured(void)
|
||||||
|
{
|
||||||
|
static uint8_t big[300];
|
||||||
|
for (int i = 0; i < (int)sizeof big; i++) {
|
||||||
|
big[i] = (uint8_t)(i * 13 + 1);
|
||||||
|
}
|
||||||
|
static const struct { const char *what; bool is_signed; int payload_len; } cases[] = {
|
||||||
|
{"signed, short length form", true, CAM_LEN}, // inner packet 83 bytes
|
||||||
|
{"signed, 1-byte long length form", true, 100}, // 140
|
||||||
|
{"signed, 2-byte long length form", true, 300}, // 340
|
||||||
|
{"top-level unsecuredData", false, CAM_LEN},
|
||||||
|
};
|
||||||
|
for (size_t c = 0; c < sizeof cases / sizeof cases[0]; c++) {
|
||||||
|
tu_set_context("secured: %s", cases[c].what);
|
||||||
|
const uint8_t *payload = cases[c].payload_len == CAM_LEN ? k_cam : big;
|
||||||
|
uint8_t f[FRAME_BUF_LEN];
|
||||||
|
int start, end;
|
||||||
|
const int len = cases[c].is_signed
|
||||||
|
? build_secured(k_signed_prefix, (int)sizeof k_signed_prefix, payload,
|
||||||
|
cases[c].payload_len, f, &start, &end)
|
||||||
|
: build_secured(k_unsecured_prefix, (int)sizeof k_unsecured_prefix, payload,
|
||||||
|
cases[c].payload_len, f, &start, &end);
|
||||||
|
gn_rx_t rx;
|
||||||
|
const uint8_t *g = tu_guarded(f, len);
|
||||||
|
const bool ok = gn_unwrap_its(g, len, &rx);
|
||||||
|
CHECK(ok, "%s: rejected", cases[c].what);
|
||||||
|
if (ok) {
|
||||||
|
CHECK(rx.signed_unverified == cases[c].is_signed, "%s: signed_unverified %d",
|
||||||
|
cases[c].what, rx.signed_unverified);
|
||||||
|
CHECK(!rx.truncated && rx.btp_dest_port == 2001, "%s: truncated %d port %d",
|
||||||
|
cases[c].what, rx.truncated, rx.btp_dest_port);
|
||||||
|
CHECK(rx.payload == g + start && rx.payload_len == cases[c].payload_len &&
|
||||||
|
memcmp(rx.payload, payload, (size_t)cases[c].payload_len) == 0,
|
||||||
|
"%s: payload offset %d length %d, expected %d / %d", cases[c].what,
|
||||||
|
(int)(rx.payload - g), rx.payload_len, start, cases[c].payload_len);
|
||||||
|
}
|
||||||
|
tu_pcap_add(f, len);
|
||||||
|
sweep(cases[c].what, f, len, start, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
// One-byte changes to the signed, short-form frame. `env` is where the envelope starts.
|
||||||
|
uint8_t good[FRAME_BUF_LEN];
|
||||||
|
int start, end;
|
||||||
|
const int len = build_secured(k_signed_prefix, (int)sizeof k_signed_prefix, k_cam, CAM_LEN,
|
||||||
|
good, &start, &end);
|
||||||
|
const int env = MAC_HDR + LLC_SNAP + GN_BASIC;
|
||||||
|
const int inner = env + (int)sizeof k_signed_prefix + 1; // + one length byte
|
||||||
|
static const struct { const char *what; int offset; uint8_t value; } bad[] = {
|
||||||
|
{"legacy envelope, protocolVersion 2", 0, 0x02},
|
||||||
|
{"encryptedData", 1, 0x82},
|
||||||
|
{"hashId not a one-byte value", 2, 0x80},
|
||||||
|
{"no data, only extDataHash (preamble 0x20)", 3, 0x20},
|
||||||
|
{"inner protocolVersion 2", 4, 0x02},
|
||||||
|
{"nested signedData", 5, 0x81},
|
||||||
|
{"length form with no length bytes (0x80)", 6, 0x80},
|
||||||
|
{"length form with 3 length bytes (0x83)", 6, 0x83},
|
||||||
|
{"envelope too short for the Common Header", 6, 0x05},
|
||||||
|
};
|
||||||
|
for (size_t i = 0; i < sizeof bad / sizeof bad[0]; i++) {
|
||||||
|
tu_set_context("secured mutation: %s", bad[i].what);
|
||||||
|
uint8_t f[FRAME_BUF_LEN];
|
||||||
|
memcpy(f, good, (size_t)len);
|
||||||
|
f[env + bad[i].offset] = bad[i].value;
|
||||||
|
gn_rx_t rx;
|
||||||
|
CHECK(!gn_unwrap_its(tu_guarded(f, len), len, &rx), "%s: accepted", bad[i].what);
|
||||||
|
}
|
||||||
|
|
||||||
|
tu_set_context("secured mutation: inner packet claims more than its envelope holds");
|
||||||
|
uint8_t f[FRAME_BUF_LEN];
|
||||||
|
memcpy(f, good, (size_t)len);
|
||||||
|
f[inner + GN_COMMON_PAYLOAD_LEN_FIELD + 1]++; // Common Header payload length, low byte
|
||||||
|
gn_rx_t rx;
|
||||||
|
CHECK(!gn_unwrap_its(tu_guarded(f, len), len, &rx), "payload overrunning its envelope: accepted");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_truncation(void)
|
||||||
|
{
|
||||||
|
const gn_lpv_t lpv = test_lpv();
|
||||||
|
uint8_t f[FRAME_BUF_LEN];
|
||||||
|
for (int qos = 0; qos <= 1; qos++) {
|
||||||
|
const int len = build(k_cam, CAM_LEN, &lpv, 2001, qos, f, sizeof f);
|
||||||
|
const int start = MAC_HDR + (qos ? QOS_CTRL : 0) + LLC_SNAP + GN_BASIC + GN_COMMON + SHB_EXT + BTP_B;
|
||||||
|
sweep(qos ? "SHB QoS" : "SHB", f, len, start, len);
|
||||||
|
}
|
||||||
|
const int len = build_gbc(0, 535540000, 100220000, 250, f);
|
||||||
|
sweep("GBC", f, len, GBC_PAYLOAD_OFFSET, len);
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(int argc, char **argv)
|
||||||
|
{
|
||||||
|
tu_install_crash_handler();
|
||||||
|
tu_guard_init();
|
||||||
|
if (argc > 1 && !tu_pcap_open(argv[1])) {
|
||||||
|
fprintf(stderr, "cannot write %s\n", argv[1]);
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
test_shb_layout(false);
|
||||||
|
test_shb_layout(true);
|
||||||
|
test_pv_encoding();
|
||||||
|
test_bounds();
|
||||||
|
test_max_payload();
|
||||||
|
test_gbc_rx();
|
||||||
|
test_rejections();
|
||||||
|
test_payload_length();
|
||||||
|
test_trailer();
|
||||||
|
test_secured();
|
||||||
|
test_truncation();
|
||||||
|
|
||||||
|
tu_pcap_close();
|
||||||
|
printf("test_chain: %d checks, %d failed\n", s_checks, s_failures);
|
||||||
|
return s_failures ? 1 : 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
// Replays recorded air traffic through gn_unwrap_its, one pcap record at a time and exactly as the
|
||||||
|
// promiscuous RX callback hands each frame over (the recordings come through the same API), and
|
||||||
|
// writes what came out to a TSV that check_replay.py verifies independently. See README.md.
|
||||||
|
//
|
||||||
|
// Usage: test_replay out.tsv capture.pcap [capture.pcap ...]
|
||||||
|
//
|
||||||
|
// One row per pcap record:
|
||||||
|
// file, record, accepted, port, signed, truncated, area, area_lat, area_lon, area_dist, payload
|
||||||
|
// with everything after `accepted` empty for rejected frames and the payload in hex. Each frame
|
||||||
|
// is placed against the guard page, and an accepted payload that is not inside its frame is a
|
||||||
|
// failure here already.
|
||||||
|
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
|
||||||
|
#include "gn_unwrap.h"
|
||||||
|
#include "test_util.h"
|
||||||
|
|
||||||
|
// main.c's RX_FRAME_MAX_LEN: the promiscuous callback copies at most this many bytes of a frame,
|
||||||
|
// so it is the most gn_unwrap_its ever sees on the board. Keep in step with main.c.
|
||||||
|
#define RX_FRAME_MAX_LEN 800
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
FILE *out;
|
||||||
|
const char *path;
|
||||||
|
long records, capped, accepted, cam, denm, spatem, signed_frames, truncated, bad;
|
||||||
|
} replay_t;
|
||||||
|
|
||||||
|
static void on_frame(const uint8_t *frame, int len, int index, void *ctx)
|
||||||
|
{
|
||||||
|
replay_t *r = ctx;
|
||||||
|
r->records++;
|
||||||
|
if (len > RX_FRAME_MAX_LEN) {
|
||||||
|
len = RX_FRAME_MAX_LEN;
|
||||||
|
r->capped++;
|
||||||
|
}
|
||||||
|
tu_set_context("replay %s record %d (%d bytes)", r->path, index, len);
|
||||||
|
const uint8_t *g = tu_guarded(frame, len);
|
||||||
|
gn_rx_t rx;
|
||||||
|
const bool ok = gn_unwrap_its(g, len, &rx);
|
||||||
|
fprintf(r->out, "%s\t%d\t%d", r->path, index, ok);
|
||||||
|
if (ok) {
|
||||||
|
const uintptr_t lo = (uintptr_t)g;
|
||||||
|
const uintptr_t p = (uintptr_t)rx.payload;
|
||||||
|
if (p < lo || rx.payload_len <= 0 || p + (uintptr_t)rx.payload_len > lo + (uintptr_t)len) {
|
||||||
|
fprintf(stderr, "FAIL %s: payload outside the frame\n", tu_context());
|
||||||
|
r->bad++;
|
||||||
|
} else {
|
||||||
|
fprintf(r->out, "\t%u\t%d\t%d\t%d\t%ld\t%ld\t%u\t", rx.btp_dest_port, rx.signed_unverified,
|
||||||
|
rx.truncated, rx.has_geo_area, (long)rx.geo_area_lat_tenmicrodeg,
|
||||||
|
(long)rx.geo_area_lon_tenmicrodeg, rx.geo_area_distance_a_m);
|
||||||
|
for (int i = 0; i < rx.payload_len; i++) {
|
||||||
|
fprintf(r->out, "%02x", rx.payload[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r->accepted++;
|
||||||
|
r->cam += rx.btp_dest_port == 2001;
|
||||||
|
r->denm += rx.btp_dest_port == 2002;
|
||||||
|
r->spatem += rx.btp_dest_port == 2004;
|
||||||
|
r->signed_frames += rx.signed_unverified;
|
||||||
|
r->truncated += rx.truncated;
|
||||||
|
}
|
||||||
|
fputc('\n', r->out);
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(int argc, char **argv)
|
||||||
|
{
|
||||||
|
if (argc < 3) {
|
||||||
|
fprintf(stderr, "usage: test_replay out.tsv capture.pcap [capture.pcap ...]\n");
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
tu_install_crash_handler();
|
||||||
|
tu_guard_init();
|
||||||
|
|
||||||
|
replay_t r = {0};
|
||||||
|
r.out = fopen(argv[1], "w");
|
||||||
|
if (!r.out) {
|
||||||
|
fprintf(stderr, "cannot write %s\n", argv[1]);
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
fprintf(r.out, "file\trecord\taccepted\tport\tsigned\ttruncated\tarea\tarea_lat\tarea_lon\tarea_dist\tpayload\n");
|
||||||
|
|
||||||
|
int unreadable = 0;
|
||||||
|
for (int i = 2; i < argc; i++) {
|
||||||
|
r.path = argv[i];
|
||||||
|
if (tu_pcap_foreach(argv[i], on_frame, &r) < 0) {
|
||||||
|
fprintf(stderr, "cannot read %s as a pcap\n", argv[i]);
|
||||||
|
unreadable++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fclose(r.out);
|
||||||
|
|
||||||
|
printf("test_replay: %ld records (%ld cut to %d bytes as main.c does), %ld accepted (CAM %ld, "
|
||||||
|
"DENM %ld, SPATEM %ld; %ld signed, %ld truncated), %ld bad\n",
|
||||||
|
r.records, r.capped, RX_FRAME_MAX_LEN, r.accepted, r.cam, r.denm, r.spatem,
|
||||||
|
r.signed_frames, r.truncated, r.bad);
|
||||||
|
return (r.bad || unreadable) ? 1 : 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
#ifndef _WIN32
|
||||||
|
#define _DEFAULT_SOURCE // MAP_ANONYMOUS under -std=c11
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#include "test_util.h"
|
||||||
|
|
||||||
|
#include <stdarg.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#ifdef _WIN32
|
||||||
|
#include <windows.h>
|
||||||
|
#else
|
||||||
|
#include <signal.h>
|
||||||
|
#include <sys/mman.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// ---- Crash reporting ------------------------------------------------------------------------
|
||||||
|
|
||||||
|
static char s_context[200] = "startup";
|
||||||
|
static const uint8_t *s_crash_bytes;
|
||||||
|
static const int *s_crash_len;
|
||||||
|
|
||||||
|
void tu_set_context(const char *fmt, ...)
|
||||||
|
{
|
||||||
|
va_list ap;
|
||||||
|
va_start(ap, fmt);
|
||||||
|
vsnprintf(s_context, sizeof s_context, fmt, ap);
|
||||||
|
va_end(ap);
|
||||||
|
}
|
||||||
|
|
||||||
|
const char *tu_context(void)
|
||||||
|
{
|
||||||
|
return s_context;
|
||||||
|
}
|
||||||
|
|
||||||
|
void tu_set_crash_input(const uint8_t *bytes, const int *len)
|
||||||
|
{
|
||||||
|
s_crash_bytes = bytes;
|
||||||
|
s_crash_len = len;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void report_crash(const char *what)
|
||||||
|
{
|
||||||
|
fprintf(stderr, "CRASH (%s) during: %s\n", what, s_context);
|
||||||
|
if (s_crash_bytes && s_crash_len) {
|
||||||
|
fprintf(stderr, "input (%d bytes): ", *s_crash_len);
|
||||||
|
for (int i = 0; i < *s_crash_len; i++) {
|
||||||
|
fprintf(stderr, "%02x", s_crash_bytes[i]);
|
||||||
|
}
|
||||||
|
fputc('\n', stderr);
|
||||||
|
}
|
||||||
|
fflush(stderr);
|
||||||
|
}
|
||||||
|
|
||||||
|
#ifdef _WIN32
|
||||||
|
static LONG WINAPI on_crash(EXCEPTION_POINTERS *info)
|
||||||
|
{
|
||||||
|
char what[40];
|
||||||
|
snprintf(what, sizeof what, "exception 0x%08lx",
|
||||||
|
(unsigned long)info->ExceptionRecord->ExceptionCode);
|
||||||
|
report_crash(what);
|
||||||
|
return EXCEPTION_EXECUTE_HANDLER; // terminate, with the exception code as the exit status
|
||||||
|
}
|
||||||
|
|
||||||
|
void tu_install_crash_handler(void)
|
||||||
|
{
|
||||||
|
SetUnhandledExceptionFilter(on_crash);
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
static void on_crash(int sig)
|
||||||
|
{
|
||||||
|
char what[40];
|
||||||
|
snprintf(what, sizeof what, "signal %d", sig);
|
||||||
|
report_crash(what); // not async-signal-safe, but the process is ending anyway
|
||||||
|
_exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
void tu_install_crash_handler(void)
|
||||||
|
{
|
||||||
|
signal(SIGSEGV, on_crash);
|
||||||
|
signal(SIGBUS, on_crash);
|
||||||
|
signal(SIGILL, on_crash); // -fsanitize-undefined-trap-on-error traps with an illegal instruction
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// ---- Guard page -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
static uint8_t *s_guard_end; // first byte of the no-access page
|
||||||
|
static size_t s_page_size;
|
||||||
|
|
||||||
|
void tu_guard_init(void)
|
||||||
|
{
|
||||||
|
#ifdef _WIN32
|
||||||
|
SYSTEM_INFO si;
|
||||||
|
GetSystemInfo(&si);
|
||||||
|
s_page_size = si.dwPageSize;
|
||||||
|
uint8_t *base = VirtualAlloc(NULL, 2 * s_page_size, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);
|
||||||
|
DWORD old;
|
||||||
|
if (!base || !VirtualProtect(base + s_page_size, s_page_size, PAGE_NOACCESS, &old)) {
|
||||||
|
fprintf(stderr, "guard page setup failed\n");
|
||||||
|
exit(2);
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
s_page_size = (size_t)sysconf(_SC_PAGESIZE);
|
||||||
|
uint8_t *base = mmap(NULL, 2 * s_page_size, PROT_READ | PROT_WRITE,
|
||||||
|
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
|
||||||
|
if (base == MAP_FAILED || mprotect(base + s_page_size, s_page_size, PROT_NONE) != 0) {
|
||||||
|
fprintf(stderr, "guard page setup failed\n");
|
||||||
|
exit(2);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
s_guard_end = base + s_page_size;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t *tu_guard_buf(int len)
|
||||||
|
{
|
||||||
|
if (len < 0 || (size_t)len > s_page_size) {
|
||||||
|
fprintf(stderr, "tu_guard_buf(%d) exceeds one page\n", len);
|
||||||
|
exit(2);
|
||||||
|
}
|
||||||
|
return s_guard_end - len;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uint8_t *tu_guarded(const uint8_t *frame, int len)
|
||||||
|
{
|
||||||
|
uint8_t *dst = tu_guard_buf(len);
|
||||||
|
if (len > 0) {
|
||||||
|
memcpy(dst, frame, (size_t)len);
|
||||||
|
}
|
||||||
|
return dst;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- pcap -----------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
static uint32_t rd_le32(const uint8_t *p)
|
||||||
|
{
|
||||||
|
return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24);
|
||||||
|
}
|
||||||
|
|
||||||
|
static uint32_t rd_be32(const uint8_t *p)
|
||||||
|
{
|
||||||
|
return ((uint32_t)p[0] << 24) | ((uint32_t)p[1] << 16) | ((uint32_t)p[2] << 8) | (uint32_t)p[3];
|
||||||
|
}
|
||||||
|
|
||||||
|
int tu_pcap_foreach(const char *path, tu_frame_fn fn, void *ctx)
|
||||||
|
{
|
||||||
|
FILE *f = fopen(path, "rb");
|
||||||
|
if (!f) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
fseek(f, 0, SEEK_END);
|
||||||
|
const long size = ftell(f);
|
||||||
|
fseek(f, 0, SEEK_SET);
|
||||||
|
if (size < 24) {
|
||||||
|
fclose(f);
|
||||||
|
return size == 0 ? 0 : -1; // the recordings include empty files
|
||||||
|
}
|
||||||
|
uint8_t *d = malloc((size_t)size);
|
||||||
|
if (!d || fread(d, 1, (size_t)size, f) != (size_t)size) {
|
||||||
|
fclose(f);
|
||||||
|
free(d);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
fclose(f);
|
||||||
|
|
||||||
|
const uint32_t magic = rd_le32(d);
|
||||||
|
const bool swapped = magic == 0xD4C3B2A1u || magic == 0x4D3CB2A1u;
|
||||||
|
if (!swapped && magic != 0xA1B2C3D4u && magic != 0xA1B23C4Du) {
|
||||||
|
free(d);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
uint32_t (*u32)(const uint8_t *) = swapped ? rd_be32 : rd_le32;
|
||||||
|
const uint32_t linktype = u32(d + 20);
|
||||||
|
if (linktype != 127 && linktype != 105) {
|
||||||
|
free(d);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
long off = 24;
|
||||||
|
int index = 0;
|
||||||
|
while (off + 16 <= size) {
|
||||||
|
const uint32_t incl = u32(d + off + 8);
|
||||||
|
if (incl > (uint32_t)(size - off - 16)) {
|
||||||
|
break; // last record cut off
|
||||||
|
}
|
||||||
|
const uint8_t *pkt = d + off + 16;
|
||||||
|
int len = (int)incl;
|
||||||
|
off += 16 + (long)incl;
|
||||||
|
if (linktype == 127) {
|
||||||
|
const int rt_len = len >= 4 ? (pkt[2] | (pkt[3] << 8)) : len + 1; // always little-endian
|
||||||
|
if (rt_len > len) {
|
||||||
|
index++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
pkt += rt_len;
|
||||||
|
len -= rt_len;
|
||||||
|
}
|
||||||
|
fn(pkt, len, index++, ctx);
|
||||||
|
}
|
||||||
|
free(d);
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
static FILE *s_pcap_out;
|
||||||
|
|
||||||
|
static void put_le32(uint8_t *p, uint32_t v)
|
||||||
|
{
|
||||||
|
p[0] = (uint8_t)v;
|
||||||
|
p[1] = (uint8_t)(v >> 8);
|
||||||
|
p[2] = (uint8_t)(v >> 16);
|
||||||
|
p[3] = (uint8_t)(v >> 24);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool tu_pcap_open(const char *path)
|
||||||
|
{
|
||||||
|
// Global header: magic, version 2.4, zone 0, sigfigs 0, snaplen 65535, linktype 105.
|
||||||
|
static const uint8_t hdr[24] = {
|
||||||
|
0xD4, 0xC3, 0xB2, 0xA1, 0x02, 0x00, 0x04, 0x00, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||||
|
0xFF, 0xFF, 0x00, 0x00, 105, 0, 0, 0,
|
||||||
|
};
|
||||||
|
s_pcap_out = fopen(path, "wb");
|
||||||
|
return s_pcap_out && fwrite(hdr, 1, sizeof hdr, s_pcap_out) == sizeof hdr;
|
||||||
|
}
|
||||||
|
|
||||||
|
void tu_pcap_add(const uint8_t *frame, int len)
|
||||||
|
{
|
||||||
|
static uint32_t seq;
|
||||||
|
if (!s_pcap_out || len <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
uint8_t rec[16];
|
||||||
|
put_le32(rec + 0, seq++); // timestamp seconds: just the frame's sequence number
|
||||||
|
put_le32(rec + 4, 0);
|
||||||
|
put_le32(rec + 8, (uint32_t)len);
|
||||||
|
put_le32(rec + 12, (uint32_t)len);
|
||||||
|
fwrite(rec, 1, sizeof rec, s_pcap_out);
|
||||||
|
fwrite(frame, 1, (size_t)len, s_pcap_out);
|
||||||
|
}
|
||||||
|
|
||||||
|
void tu_pcap_close(void)
|
||||||
|
{
|
||||||
|
if (s_pcap_out) {
|
||||||
|
fclose(s_pcap_out);
|
||||||
|
s_pcap_out = NULL;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
#ifndef TEST_UTIL_H
|
||||||
|
#define TEST_UTIL_H
|
||||||
|
// Shared by the host tests: crash reporting, the guard page, and pcap reading and writing.
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
// What the test is doing right now, printed if it crashes. printf-style.
|
||||||
|
void tu_set_context(const char *fmt, ...);
|
||||||
|
const char *tu_context(void);
|
||||||
|
// Also print these bytes as hex if it crashes (the fuzzer's current input). NULL to clear.
|
||||||
|
void tu_set_crash_input(const uint8_t *bytes, const int *len);
|
||||||
|
void tu_install_crash_handler(void);
|
||||||
|
|
||||||
|
// One read-write page followed by one no-access page.
|
||||||
|
void tu_guard_init(void);
|
||||||
|
// A buffer of exactly `len` bytes (at most one page) whose end touches the no-access page.
|
||||||
|
uint8_t *tu_guard_buf(int len);
|
||||||
|
// Copy of `frame` whose last byte is the last readable one: reading past it crashes.
|
||||||
|
const uint8_t *tu_guarded(const uint8_t *frame, int len);
|
||||||
|
|
||||||
|
// Calls `fn` for every record of a pcap file, with its 802.11 frame. Linktype 127 (radiotap,
|
||||||
|
// what its-g5-receiver-firmware records) has the radiotap header removed; linktype 105 is passed
|
||||||
|
// as is. The frame is otherwise exactly as captured, including the 8 bytes the ESP32-C5's
|
||||||
|
// promiscuous RX appends - which is what obu-firmware's callback receives through the same API.
|
||||||
|
// `index` counts every record, including ones skipped for a malformed radiotap header. Returns
|
||||||
|
// the number of records (0 for an empty file), or -1 if the file can't be read or isn't a pcap
|
||||||
|
// of those linktypes.
|
||||||
|
typedef void (*tu_frame_fn)(const uint8_t *frame, int len, int index, void *ctx);
|
||||||
|
int tu_pcap_foreach(const char *path, tu_frame_fn fn, void *ctx);
|
||||||
|
|
||||||
|
// Writes frames to a pcap, linktype 105 (bare 802.11).
|
||||||
|
bool tu_pcap_open(const char *path);
|
||||||
|
void tu_pcap_add(const uint8_t *frame, int len);
|
||||||
|
void tu_pcap_close(void);
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Tally GeoNetworking header fields per sending station across .pcap captures.
|
||||||
|
|
||||||
|
Written to check the GN lifetime byte on air (see TODO.md), and it answers the general question
|
||||||
|
"what do real stations put in this header" too: one row per source MAC, packet type, BTP port and
|
||||||
|
lifetime byte, with a frame count.
|
||||||
|
|
||||||
|
python obu-firmware/test/pcap_gn_tally.py its-g5-receiver-firmware/recordings/*.pcap
|
||||||
|
|
||||||
|
Handles linktype 127 (radiotap, what its-g5-receiver-firmware records) and 105 (bare 802.11).
|
||||||
|
Standard library only. Pseudonym MACs rotate, so one vehicle can appear as several rows. The
|
||||||
|
pcap-over-serial dump path corrupts roughly 0.3% of frames, so a stray odd row is tooling noise.
|
||||||
|
"""
|
||||||
|
import collections
|
||||||
|
import glob
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
|
||||||
|
LLC_SNAP_GN = b"\xaa\xaa\x03\x00\x00\x00\x89\x47"
|
||||||
|
|
||||||
|
# (HeaderType, HeaderSubtype) from the GN Common Header -> name, EN 302 636-4-1 table 9.
|
||||||
|
HEADER_TYPES = {
|
||||||
|
(1, 0): "beacon",
|
||||||
|
(4, 0): "GBC-circle",
|
||||||
|
(4, 1): "GBC-rect",
|
||||||
|
(4, 2): "GBC-ellipse",
|
||||||
|
(5, 0): "SHB",
|
||||||
|
}
|
||||||
|
# Extended header length, i.e. the distance from the end of the Common Header to BTP-B.
|
||||||
|
EXT_LEN = {"SHB": 28, "GBC-circle": 44, "GBC-rect": 44, "GBC-ellipse": 44}
|
||||||
|
|
||||||
|
|
||||||
|
def lifetime_seconds(raw):
|
||||||
|
# Multiplier in the upper 6 bits, base in the lower 2: 50 ms, 1 s, 10 s, 100 s.
|
||||||
|
return (raw >> 2) * (0.05, 1, 10, 100)[raw & 3]
|
||||||
|
|
||||||
|
|
||||||
|
def frames(path):
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
data = f.read()
|
||||||
|
if len(data) < 24:
|
||||||
|
return
|
||||||
|
magic = struct.unpack("<I", data[:4])[0]
|
||||||
|
endian = "<" if magic in (0xA1B2C3D4, 0xA1B23C4D) else ">"
|
||||||
|
linktype = struct.unpack(endian + "I", data[20:24])[0]
|
||||||
|
off = 24
|
||||||
|
while off + 16 <= len(data):
|
||||||
|
incl = struct.unpack(endian + "I", data[off + 8:off + 12])[0]
|
||||||
|
pkt = data[off + 16:off + 16 + incl]
|
||||||
|
off += 16 + incl
|
||||||
|
if linktype == 127:
|
||||||
|
if len(pkt) < 4:
|
||||||
|
continue
|
||||||
|
pkt = pkt[struct.unpack("<H", pkt[2:4])[0]:]
|
||||||
|
elif linktype != 105:
|
||||||
|
continue
|
||||||
|
yield pkt
|
||||||
|
|
||||||
|
|
||||||
|
def gn_fields(f):
|
||||||
|
if len(f) < 24 or (f[0] >> 2) & 3 != 2:
|
||||||
|
return None # Data frames only
|
||||||
|
o = 24 + (2 if f[0] & 0x80 else 0) # QoS Data carries a 2-byte QoS Control field
|
||||||
|
if f[o:o + 8] != LLC_SNAP_GN or len(f) < o + 12:
|
||||||
|
return None
|
||||||
|
o += 8
|
||||||
|
src = f[10:16].hex(":")
|
||||||
|
version, next_header, lifetime = f[o] >> 4, f[o] & 0x0F, f[o + 2]
|
||||||
|
port = "-"
|
||||||
|
if next_header == 2:
|
||||||
|
kind = "secured" # Common Header is inside the security envelope
|
||||||
|
elif next_header == 1 and len(f) >= o + 12:
|
||||||
|
c = o + 4
|
||||||
|
ht = (f[c + 1] >> 4, f[c + 1] & 0x0F)
|
||||||
|
kind = HEADER_TYPES.get(ht, "type %d/%d" % ht)
|
||||||
|
ext = EXT_LEN.get(kind)
|
||||||
|
btp = c + 8 + (ext or 0)
|
||||||
|
if ext and len(f) >= btp + 2:
|
||||||
|
port = str(struct.unpack(">H", f[btp:btp + 2])[0])
|
||||||
|
else:
|
||||||
|
kind = "nh=%d" % next_header
|
||||||
|
return src, version, kind, port, lifetime
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv):
|
||||||
|
# PowerShell does not expand wildcards itself, so do it here.
|
||||||
|
paths = [p for arg in argv for p in (glob.glob(arg) or [arg])]
|
||||||
|
if not paths:
|
||||||
|
sys.exit(__doc__)
|
||||||
|
tally = collections.Counter()
|
||||||
|
for path in paths:
|
||||||
|
for frame in frames(path):
|
||||||
|
fields = gn_fields(frame)
|
||||||
|
if fields:
|
||||||
|
tally[fields] += 1
|
||||||
|
print("%-17s %3s %-11s %5s %8s %8s %7s"
|
||||||
|
% ("source", "ver", "packet", "port", "lifetime", "seconds", "frames"))
|
||||||
|
for (src, ver, kind, port, lt), n in sorted(tally.items()):
|
||||||
|
print("%-17s %3d %-11s %5s %8s %8g %7d"
|
||||||
|
% (src, ver, kind, port, "0x%02x" % lt, lifetime_seconds(lt), n))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main(sys.argv[1:])
|
||||||
Reference in New Issue
Block a user