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/studiobot.xml
|
||||
|
||||
# Python bytecode caches, e.g. from importing obu-firmware/test/host/check_replay.py.
|
||||
__pycache__/
|
||||
|
||||
# ESP-IDF rewrites sdkconfig on every build and keeps the previous one here.
|
||||
# sdkconfig.defaults is the real, intentional configuration; these two are output.
|
||||
sdkconfig.old
|
||||
@@ -38,7 +41,11 @@ sdkconfig.old
|
||||
# Third-party working copies kept beside the project, not part of it. The ASN.1
|
||||
# modules this project actually needs are vendored under asn1/ instead.
|
||||
/C-ITS-Parser/
|
||||
/vanetza/
|
||||
|
||||
# Office lock files. Word/Excel create these beside a document while it is open
|
||||
# and remove them on close, so they are transient and machine-local.
|
||||
~$*
|
||||
|
||||
# Captures are large data files, not source (see capture/README.md).
|
||||
/capture/recordings/
|
||||
|
||||
@@ -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.
|
||||
|
||||
**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.
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
if (trip != null) {
|
||||
TripReviewScreen(
|
||||
trip = trip,
|
||||
viewModel = tripViewModel,
|
||||
)
|
||||
TripReviewScreen(trip = trip)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.hawhamburg.micr0bu.data
|
||||
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import com.hawhamburg.micr0bu.domain.asn1.ItsTime
|
||||
import java.time.DateTimeException
|
||||
|
||||
/**
|
||||
* Puts the timestamps this phone transmits on GNSS time instead of its own wall clock.
|
||||
*
|
||||
* ## Why
|
||||
* Every CAM carries a generationDeltaTime and every GeoNetworking header a TST, and receivers use
|
||||
* them to judge how fresh a message is and in what order messages came. Both used to come straight
|
||||
* from `System.currentTimeMillis()`, so they were only as good as the phone's clock setting. On
|
||||
* 2026-09-10 the bench phone was 24 minutes fast: automatic time had no source (no SIM, and the
|
||||
* lab Wi-Fi has no internet time), so it had not set the clock once in 69 hours, and every CAM
|
||||
* went out stamped 24 minutes in the future. A bike-mounted phone on the road is in exactly that
|
||||
* position. GNSS time depends on none of it.
|
||||
*
|
||||
* ## How
|
||||
* [SystemClock.currentGnssTimeClock] (API 29, this app's minSdk) is a UTC clock the platform keeps
|
||||
* synchronised from GNSS fixes. One reading of it taken alongside the wall clock gives the wall
|
||||
* clock's error, which is then applied to the fix's own timestamp. When GNSS time is unavailable,
|
||||
* typically indoors before any satellite fix since boot, the wall clock is used unchanged.
|
||||
*
|
||||
* Which clock is in use is logged whenever it changes, with the measured error, so a capture shows
|
||||
* where a given run's timestamps came from.
|
||||
*
|
||||
* Only the transmit path uses this. Everything else in the app stays on the wall clock, because
|
||||
* received messages, sensor samples and trip records are all stamped with it and must stay
|
||||
* comparable with one another.
|
||||
*/
|
||||
object GnssTimeSource {
|
||||
private const val TAG = "GnssTimeSource"
|
||||
|
||||
/** Whether the last correction used GNSS time; null before the first. For change-only logging. */
|
||||
@Volatile private var lastUsedGnss: Boolean? = null
|
||||
|
||||
/** [systemMs], a wall-clock reading, moved onto GNSS time where GNSS time is available. */
|
||||
fun correct(systemMs: Long): Long {
|
||||
val systemNow = System.currentTimeMillis()
|
||||
val gnssNow = try {
|
||||
SystemClock.currentGnssTimeClock().millis()
|
||||
} catch (e: DateTimeException) {
|
||||
null
|
||||
}
|
||||
noteSource(gnssNow, systemNow)
|
||||
return ItsTime.onGnssTime(systemMs, gnssNow, systemNow)
|
||||
}
|
||||
|
||||
private fun noteSource(gnssNow: Long?, systemNow: Long) {
|
||||
val usingGnss = gnssNow != null
|
||||
if (lastUsedGnss == usingGnss) return
|
||||
lastUsedGnss = usingGnss
|
||||
if (gnssNow != null) {
|
||||
Log.i(TAG, "transmit timestamps now on GNSS time; phone clock is " +
|
||||
"${"%+.1f".format((systemNow - gnssNow) / 1000.0)} s off")
|
||||
} else {
|
||||
Log.w(TAG, "GNSS time unavailable, transmit timestamps fall back to the phone clock, " +
|
||||
"which has no automatic time source without a SIM or internet")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package com.hawhamburg.micr0bu.data
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.content.FileProvider
|
||||
import com.hawhamburg.micr0bu.data.db.DetectedEventEntity
|
||||
import com.hawhamburg.micr0bu.data.db.RecordedTripEntity
|
||||
import com.hawhamburg.micr0bu.data.db.V2xMessageEntity
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -23,12 +22,15 @@ fun tripFileName(trip: RecordedTripEntity): String =
|
||||
|
||||
/**
|
||||
* Builds a single combined CSV for one trip: the raw sensor samples recorded alongside it, the
|
||||
* events the detector fired, the GPS track, and every V2X message seen during the ride — all in
|
||||
* one file, ordered by time.
|
||||
* GPS track, and every V2X message seen during the ride — all in one file, ordered by time.
|
||||
*
|
||||
* Detected manoeuvres are deliberately absent. The detector exists to raise the CAM transmit
|
||||
* rate (see EventDetector's KDoc); its output is not retained, so there is nothing to export
|
||||
* beyond the per-trip count in the header.
|
||||
*
|
||||
* **Why one file rather than a zip of tables.** The point of the export is correlation: what was
|
||||
* the bike doing when that CAM arrived, what did the detector make of it. Splitting those into
|
||||
* separate files pushes the join onto whoever opens it. A leading `type` column keeps the rows
|
||||
* the bike doing when that CAM arrived. Splitting those into separate files pushes the join
|
||||
* onto whoever opens it. A leading `type` column keeps the rows
|
||||
* distinguishable, which is the same shape the existing session CSV already uses, so the two
|
||||
* remain readable by the same tooling.
|
||||
*
|
||||
@@ -44,7 +46,6 @@ fun tripFileName(trip: RecordedTripEntity): String =
|
||||
suspend fun buildTripCsv(
|
||||
context: Context,
|
||||
trip: RecordedTripEntity,
|
||||
events: List<DetectedEventEntity>,
|
||||
v2xMessages: List<V2xMessageEntity>,
|
||||
): String = withContext(Dispatchers.IO) {
|
||||
buildString {
|
||||
@@ -59,7 +60,6 @@ suspend fun buildTripCsv(
|
||||
appendLine()
|
||||
appendLine(
|
||||
"type,timestamp_ms,timestamp_iso,lat,lon,speed_ms,heading_deg," +
|
||||
"event_type,confidence,peak_accel,peak_gyro,duration_ms," +
|
||||
"station_id,station_type,is_own,yaw_rate_dps,rssi_dbm"
|
||||
)
|
||||
|
||||
@@ -68,16 +68,6 @@ suspend fun buildTripCsv(
|
||||
appendLine(
|
||||
"gps,${point.timestamp},${isoUtc.format(Date(point.timestamp))}," +
|
||||
"${point.lat},${point.lon},,," +
|
||||
",,,,," +
|
||||
",,,"
|
||||
)
|
||||
}
|
||||
|
||||
for (e in events) {
|
||||
appendLine(
|
||||
"event,${e.timestamp},${isoUtc.format(Date(e.timestamp))}," +
|
||||
"${e.latitude},${e.longitude},${e.speedMps},," +
|
||||
"${e.type},${e.confidence},${e.peakAccelMagnitude},${e.peakGyroMagnitude},${e.durationMs}," +
|
||||
",,,,"
|
||||
)
|
||||
}
|
||||
@@ -86,14 +76,13 @@ suspend fun buildTripCsv(
|
||||
appendLine(
|
||||
"v2x,${m.timestamp},${isoUtc.format(Date(m.timestamp))}," +
|
||||
"${m.latitude},${m.longitude},${m.speedMps},${m.headingDeg}," +
|
||||
",,,,," +
|
||||
"${m.stationId},${m.stationType},${m.isOwn},${m.yawRateDps ?: ""},${m.rssiDbm ?: ""}"
|
||||
)
|
||||
}
|
||||
|
||||
// Raw sensor samples, copied verbatim from the session CSV. Appended last rather than
|
||||
// merge-sorted in: a long ride is hundreds of thousands of rows, and sorting them against
|
||||
// the (comparatively tiny) event/V2X sets in memory would defeat the streaming that
|
||||
// the (comparatively tiny) V2X set in memory would defeat the streaming that
|
||||
// CsvExporter deliberately does. Each row carries its own timestamp, so sort on load.
|
||||
val sessionCsv = trip.sessionId?.let { File(File(context.filesDir, "sessions"), "$it.csv") }
|
||||
if (sessionCsv != null && sessionCsv.exists()) {
|
||||
@@ -110,12 +99,11 @@ suspend fun buildTripCsv(
|
||||
suspend fun shareTripCsv(
|
||||
context: Context,
|
||||
trip: RecordedTripEntity,
|
||||
events: List<DetectedEventEntity>,
|
||||
v2xMessages: List<V2xMessageEntity>,
|
||||
) {
|
||||
val fileName = tripFileName(trip)
|
||||
val cacheFile = File(context.cacheDir, fileName)
|
||||
val csv = buildTripCsv(context, trip, events, v2xMessages)
|
||||
val csv = buildTripCsv(context, trip, v2xMessages)
|
||||
|
||||
withContext(Dispatchers.IO) { cacheFile.writeText(csv) }
|
||||
|
||||
|
||||
@@ -3,11 +3,9 @@ package com.hawhamburg.micr0bu.data
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.hawhamburg.micr0bu.data.db.AppDatabase
|
||||
import com.hawhamburg.micr0bu.data.db.DetectedEventEntity
|
||||
import com.hawhamburg.micr0bu.data.db.RecordedTripEntity
|
||||
import com.hawhamburg.micr0bu.data.db.V2xMessageEntity
|
||||
import com.hawhamburg.micr0bu.domain.cam.Cam
|
||||
import com.hawhamburg.micr0bu.domain.detection.DetectedEvent
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import java.io.File
|
||||
@@ -15,7 +13,7 @@ import java.io.File
|
||||
private const val TAG = "TripRepository"
|
||||
|
||||
/**
|
||||
* Repository that abstracts Room access for trips and detected events.
|
||||
* Repository that abstracts Room access for trips and V2X messages.
|
||||
*
|
||||
* All suspend functions are safe to call from a coroutine running on any
|
||||
* dispatcher; Room executes the actual SQL on its own I/O thread pool.
|
||||
@@ -81,14 +79,11 @@ class TripRepository(db: AppDatabase, private val context: Context) {
|
||||
* One-shot snapshots for export. The Flow-returning variants above stay observable for the UI;
|
||||
* an export wants a value it can write out, not a stream it has to unsubscribe from.
|
||||
*/
|
||||
suspend fun getEventsForTripOnce(tripId: Long): List<DetectedEventEntity> =
|
||||
dao.getEventsForTrip(tripId).first()
|
||||
|
||||
suspend fun getV2xMessagesForTripOnce(tripId: Long): List<V2xMessageEntity> =
|
||||
dao.getV2xMessagesForTrip(tripId).first()
|
||||
|
||||
/**
|
||||
* Deletes a trip and everything belonging to it: detected events and V2X messages go via the
|
||||
* Deletes a trip and everything belonging to it: V2X messages go via the
|
||||
* schema's CASCADE foreign keys, and the CSV recorded alongside it is removed here.
|
||||
*
|
||||
* The CSV is a plain file outside the database, so nothing deletes it implicitly - before
|
||||
@@ -109,32 +104,6 @@ class TripRepository(db: AppDatabase, private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Events ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Persists a domain [DetectedEvent] for the given [tripId].
|
||||
* Converts the domain model to the Room entity.
|
||||
*/
|
||||
suspend fun insertEvent(tripId: Long, event: DetectedEvent) =
|
||||
dao.insertEvent(
|
||||
DetectedEventEntity(
|
||||
tripId = tripId,
|
||||
timestamp = event.timestamp,
|
||||
type = event.type.name,
|
||||
confidence = event.confidence.name,
|
||||
latitude = event.latitude,
|
||||
longitude = event.longitude,
|
||||
speedMps = event.speedMps.toFloat(),
|
||||
peakAccelMagnitude = event.peakAccelMagnitude.toFloat(),
|
||||
peakGyroMagnitude = event.peakGyroMagnitude.toFloat(),
|
||||
durationMs = event.durationMs,
|
||||
)
|
||||
)
|
||||
|
||||
/** Emits events for [tripId] ordered by timestamp, updating whenever the DB changes. */
|
||||
fun getEventsForTrip(tripId: Long): Flow<List<DetectedEventEntity>> =
|
||||
dao.getEventsForTrip(tripId)
|
||||
|
||||
// ── V2X messages (Phase 03) ──────────────────────────────────────────────────
|
||||
// Retention policy: only ever called while a trip is actively recording — see
|
||||
// V2xMessageEntity's KDoc and CamUseCaseRepository.processedCam's collector in
|
||||
|
||||
@@ -32,6 +32,7 @@ import com.hawhamburg.micr0bu.domain.spat.SpatEvent
|
||||
import com.hawhamburg.micr0bu.domain.usecase.UseCaseAlert
|
||||
import com.hawhamburg.micr0bu.domain.usecase.UseCaseDetectionEngine
|
||||
import com.hawhamburg.micr0bu.domain.usecase.UseCaseType
|
||||
import com.hawhamburg.micr0bu.service.CamPinger
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -104,6 +105,8 @@ class CamUseCaseRepository @Inject constructor(
|
||||
private val usbSerialTransport: UsbSerialTransport,
|
||||
private val camCodec: RealAsn1UperCodec,
|
||||
private val obuHardwarePrefs: ObuHardwarePreferences,
|
||||
private val pseudonymManager: PseudonymManager,
|
||||
private val camPinger: CamPinger,
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
@@ -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
|
||||
// path has no such topic, so it comes from the same persisted value CamTransmitLoop puts
|
||||
// in outgoing CAMs.
|
||||
// path has no such topic, so it follows the current transmit pseudonym, the same one
|
||||
// CamTransmitLoop puts in outgoing CAMs, across every rotation.
|
||||
//
|
||||
// Without this the ID stayed null on the ESP32 path and the self-heard-TX filter in
|
||||
// [handleCamFromSerial] never fired - so the phone's own CAMs, which the ESP32 hears back
|
||||
@@ -321,10 +324,13 @@ class CamUseCaseRepository @Inject constructor(
|
||||
// sitting exactly on top of the ego position, fed into the detection engine as a
|
||||
// collision partner for itself.
|
||||
scope.launch {
|
||||
obuHardwarePrefs.obuHardwareFlow.collect { hardware ->
|
||||
combine(obuHardwarePrefs.obuHardwareFlow, pseudonymManager.currentFlow) { hardware, pseudonym ->
|
||||
hardware to pseudonym
|
||||
}.collect { (hardware, pseudonym) ->
|
||||
currentHardware = hardware
|
||||
if (hardware == ObuHardware.ESP32_C5) {
|
||||
_ownStationId.value = obuHardwarePrefs.getOrCreateOwnStationId()
|
||||
// currentFlow re-emits on every rotation, so this tracks the live identity.
|
||||
_ownStationId.value = (pseudonym ?: pseudonymManager.current()).stationId
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -339,11 +345,17 @@ class CamUseCaseRepository @Inject constructor(
|
||||
* recognised as our own rather than tracked as another road user. Also drives the OWN/REMOTE
|
||||
* badges in the raw message list.
|
||||
*
|
||||
* The rule lives in [OwnStationIds], which explains why there are two such ids and what goes
|
||||
* wrong when only one of them is checked.
|
||||
* The rule lives in [OwnStationIds], which explains which ids count and what goes wrong when
|
||||
* one is missed. The set passed in holds the current transmit pseudonym and the ones it most
|
||||
* recently replaced, plus, on the CiT One path, the OBU's own id from obu_gnss. The bench
|
||||
* ping id counts only while this phone's own pinger is running.
|
||||
*/
|
||||
fun isOwnStationId(stationId: Long): Boolean =
|
||||
OwnStationIds.isOwn(stationId, _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
|
||||
@@ -392,9 +404,11 @@ class CamUseCaseRepository @Inject constructor(
|
||||
|
||||
private fun handleCam(payload: String, timestamp: Long) {
|
||||
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
|
||||
// ego state either: the ping is built from the same phone GNSS the engine already has.
|
||||
if (cam.stationId == OwnStationIds.BENCH_PING) return
|
||||
// This phone's own bench ping, relayed back by the CiT One's radio: not a road user, and not
|
||||
// ego state either, since it is built from the same phone GNSS the engine already has.
|
||||
// Only while this phone is the one pinging, though. Another phone's pings carry the same
|
||||
// fixed id and are genuine remote traffic to this one.
|
||||
if (cam.stationId == OwnStationIds.BENCH_PING && camPinger.benchPingIsOurs()) return
|
||||
if (cam.isOwn) {
|
||||
// Third fallback - the CAM topic's own low-rate entry. onOwnCam() keeps whichever
|
||||
// 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 = [
|
||||
SessionEntity::class,
|
||||
RecordedTripEntity::class,
|
||||
DetectedEventEntity::class,
|
||||
V2xMessageEntity::class,
|
||||
],
|
||||
version = 4,
|
||||
version = 5,
|
||||
exportSchema = false,
|
||||
)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
@@ -34,13 +33,29 @@ abstract class AppDatabase : RoomDatabase() {
|
||||
AppDatabase::class.java,
|
||||
"micr0bu.db",
|
||||
)
|
||||
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4)
|
||||
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5)
|
||||
.build()
|
||||
.also { INSTANCE = it }
|
||||
}
|
||||
|
||||
// ── Migrations ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Drops `detected_events`. The cyclist event detector still runs, but its output is now
|
||||
* consumed only by the CAM transmit-rate policy (see EventDetector's KDoc) and is no
|
||||
* longer persisted, displayed, or exported, so the table had no reader left.
|
||||
*
|
||||
* `trips.eventCount` is deliberately kept. Dropping a column means recreating `trips`
|
||||
* and copying every recorded ride across, which is real risk for one unused integer;
|
||||
* the service still writes an accurate count into it and the CSV header still reports it.
|
||||
*/
|
||||
private val MIGRATION_4_5 = object : Migration(4, 5) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
database.execSQL("DROP INDEX IF EXISTS `index_detected_events_tripId`")
|
||||
database.execSQL("DROP TABLE IF EXISTS `detected_events`")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Two additions:
|
||||
* - `trips.sessionId` links a trip to the CSV recording session captured alongside it, so
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
package com.hawhamburg.micr0bu.data.db
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
/**
|
||||
* One detected cyclist event (braking / turning / stopping) linked to a
|
||||
* [RecordedTripEntity] via the [tripId] foreign key.
|
||||
*
|
||||
* [type] and [confidence] are stored as the enum name strings so that the
|
||||
* database remains human-readable.
|
||||
*/
|
||||
@Entity(
|
||||
tableName = "detected_events",
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = RecordedTripEntity::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["tripId"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
)
|
||||
],
|
||||
)
|
||||
data class DetectedEventEntity(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
val id: Long = 0,
|
||||
|
||||
@ColumnInfo(index = true)
|
||||
val tripId: Long,
|
||||
|
||||
/** Wall-clock epoch ms of the first qualifying sensor frame. */
|
||||
val timestamp: Long,
|
||||
|
||||
/** EventType.name — one of BRAKING, TURNING, STOPPING. */
|
||||
val type: String,
|
||||
|
||||
/** Confidence.name — one of HIGH, MEDIUM, LOW. */
|
||||
val confidence: String,
|
||||
|
||||
val latitude: Double,
|
||||
val longitude: Double,
|
||||
val speedMps: Float,
|
||||
val peakAccelMagnitude: Float,
|
||||
val peakGyroMagnitude: Float,
|
||||
|
||||
/** Duration from first qualifying frame to emission (ms). */
|
||||
val durationMs: Long,
|
||||
)
|
||||
@@ -27,17 +27,6 @@ interface TripDao {
|
||||
@Query("DELETE FROM trips WHERE id = :id")
|
||||
suspend fun deleteTripById(id: Long)
|
||||
|
||||
// ── Events ────────────────────────────────────────────────────────────────
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertEvent(event: DetectedEventEntity)
|
||||
|
||||
@Query("SELECT * FROM detected_events WHERE tripId = :tripId ORDER BY timestamp ASC")
|
||||
fun getEventsForTrip(tripId: Long): Flow<List<DetectedEventEntity>>
|
||||
|
||||
@Query("SELECT COUNT(*) FROM detected_events WHERE tripId = :tripId")
|
||||
suspend fun getEventCountForTrip(tripId: Long): Int
|
||||
|
||||
// ── V2X messages (Phase 03) ──────────────────────────────────────────────────
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
|
||||
@@ -6,12 +6,13 @@ import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.hawhamburg.micr0bu.data.transport.ObuHardware
|
||||
import com.hawhamburg.micr0bu.domain.cam.Pseudonym
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.random.Random
|
||||
|
||||
private val Context.obuHardwareDataStore by preferencesDataStore(name = "obu_hardware_prefs")
|
||||
|
||||
@@ -26,7 +27,11 @@ class ObuHardwarePreferences @Inject constructor(
|
||||
) {
|
||||
private object Keys {
|
||||
val OBU_HARDWARE = stringPreferencesKey("obu_hardware")
|
||||
// The current transmit pseudonym. Three keys, but only ever read or written together;
|
||||
// see loadPseudonym.
|
||||
val OWN_STATION_ID = longPreferencesKey("own_station_id")
|
||||
val OWN_MAC = stringPreferencesKey("own_mac")
|
||||
val OWN_PSEUDONYM_CREATED_MS = longPreferencesKey("own_pseudonym_created_ms")
|
||||
}
|
||||
|
||||
val obuHardwareFlow: Flow<ObuHardware> = context.obuHardwareDataStore.data.map { prefs ->
|
||||
@@ -37,31 +42,36 @@ class ObuHardwarePreferences @Inject constructor(
|
||||
context.obuHardwareDataStore.edit { prefs -> prefs[Keys.OBU_HARDWARE] = hardware.id }
|
||||
}
|
||||
|
||||
/** This device's own CAM StationID, or null if one hasn't been assigned yet. */
|
||||
val ownStationIdFlow: Flow<Long?> = context.obuHardwareDataStore.data.map { prefs ->
|
||||
prefs[Keys.OWN_STATION_ID]
|
||||
/**
|
||||
* The transmit pseudonym last saved by [savePseudonym], or null if there is none.
|
||||
*
|
||||
* All three parts must be present. An install from before pseudonym rotation has a station ID
|
||||
* but no MAC or creation time, and loads as null so that a complete new pseudonym is
|
||||
* generated. Keeping the old ID alongside a fresh MAC would be exactly the partial rotation
|
||||
* [Pseudonym] exists to rule out.
|
||||
*
|
||||
* Only [com.hawhamburg.micr0bu.data.cam.PseudonymManager] should call this: it is the one
|
||||
* owner of the phone's transmit identity.
|
||||
*/
|
||||
suspend fun loadPseudonym(): Pseudonym? {
|
||||
val prefs = context.obuHardwareDataStore.data.first()
|
||||
val stationId = prefs[Keys.OWN_STATION_ID] ?: return null
|
||||
val mac = prefs[Keys.OWN_MAC]?.let(::macFromHex) ?: return null
|
||||
val createdAtMs = prefs[Keys.OWN_PSEUDONYM_CREATED_MS] ?: return null
|
||||
return Pseudonym(stationId, mac, createdAtMs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns this device's own CAM StationID, generating and persisting a random one on first
|
||||
* call.
|
||||
*
|
||||
* Replaces the previous hardcoded 0: receivers key on StationID to track a station across
|
||||
* successive CAMs, so every MicrOBU broadcasting 0 makes two units in the same area
|
||||
* indistinguishable to any receiver — including this app's own detection engine, which
|
||||
* dedupes remote stations by ID. Random rather than derived from a hardware identifier both
|
||||
* because ETSI expects station IDs to be pseudonymous and because Android hardware IDs aren't
|
||||
* readable without privileged permissions on modern versions.
|
||||
*
|
||||
* Range is 1..2^32-2: StationID is INTEGER(0..4294967295), and 0 is avoided so leftover
|
||||
* placeholder traffic stays distinguishable from a real assignment.
|
||||
*/
|
||||
suspend fun getOrCreateOwnStationId(): Long {
|
||||
val prefs = context.obuHardwareDataStore.edit { p ->
|
||||
if (p[Keys.OWN_STATION_ID] == null) {
|
||||
p[Keys.OWN_STATION_ID] = Random.nextLong(1L, 0xFFFF_FFFEL)
|
||||
}
|
||||
/** Persists [pseudonym] in a single edit, so a crash can never leave half an identity stored. */
|
||||
suspend fun savePseudonym(pseudonym: Pseudonym) {
|
||||
context.obuHardwareDataStore.edit { p ->
|
||||
p[Keys.OWN_STATION_ID] = pseudonym.stationId
|
||||
p[Keys.OWN_MAC] = pseudonym.mac.joinToString("") { "%02x".format(it) }
|
||||
p[Keys.OWN_PSEUDONYM_CREATED_MS] = pseudonym.createdAtMs
|
||||
}
|
||||
return prefs[Keys.OWN_STATION_ID]!!
|
||||
}
|
||||
|
||||
private fun macFromHex(hex: String): ByteArray? =
|
||||
if (hex.length != 12) null
|
||||
else runCatching { ByteArray(6) { i -> hex.substring(2 * i, 2 * i + 2).toInt(16).toByte() } }
|
||||
.getOrNull()
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
package com.hawhamburg.micr0bu.data.transport
|
||||
|
||||
import com.hawhamburg.micr0bu.domain.asn1.ItsTime
|
||||
import com.hawhamburg.micr0bu.domain.cam.Cam
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.roundToLong
|
||||
|
||||
/**
|
||||
* Binary framing for the phone <-> ESP32-C5 link (Phase 03). Kotlin counterpart of the
|
||||
* firmware's `obu-firmware/main/serial_link.c`/`.h` — frame shape and CRC algorithm MUST stay
|
||||
@@ -23,6 +28,15 @@ object SerialFrameType {
|
||||
/** ESP32 -> phone: periodic heartbeat + drop counters, independent of CAM traffic.
|
||||
* Payload layout is [EspLinkStatus] — see its KDoc. */
|
||||
const val STATUS: Int = 0x03
|
||||
|
||||
/**
|
||||
* Phone -> ESP32: a CAM together with the GeoNetworking Source Position Vector to transmit it
|
||||
* under. Payload is the [GnPositionVector.PREFIX_SIZE]-byte [GnPositionVector] prefix, then
|
||||
* the CAM UPER. Sent only to firmware whose heartbeat advertises
|
||||
* [EspLinkStatus.supportsCamTxPv]; `serial_link.h` explains why this is a new type rather
|
||||
* than a changed [CAM_TX].
|
||||
*/
|
||||
const val CAM_TX_PV: Int = 0x05
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,10 +73,22 @@ data class EspLinkStatus(
|
||||
val txFailures: Int,
|
||||
/** Frames from the phone the firmware dropped on CRC mismatch. */
|
||||
val rxCrcErrors: Int,
|
||||
/**
|
||||
* What the firmware accepts, as `SERIAL_CAP_*` bits from `serial_link.h`. Byte 7 of the
|
||||
* payload; 0 for firmware that predates it and sends only 7 bytes, which is exactly the answer
|
||||
* the phone needs from such firmware: it accepts nothing beyond the original messages.
|
||||
*/
|
||||
val capabilities: Int = 0,
|
||||
) {
|
||||
/** True when the firmware accepts [SerialFrameType.CAM_TX_PV]. */
|
||||
val supportsCamTxPv: Boolean get() = capabilities and CAP_CAM_TX_PV != 0
|
||||
|
||||
companion object {
|
||||
const val PAYLOAD_SIZE = 7
|
||||
|
||||
/** Mirrors `SERIAL_CAP_CAM_TX_PV` in `serial_link.h`. */
|
||||
const val CAP_CAM_TX_PV = 0x01
|
||||
|
||||
/** Returns null if [payload] isn't a well-formed status payload (e.g. older firmware). */
|
||||
fun parse(payload: ByteArray): EspLinkStatus? {
|
||||
if (payload.size < PAYLOAD_SIZE) return null
|
||||
@@ -72,6 +98,7 @@ data class EspLinkStatus(
|
||||
oversizeDrops = u16(1),
|
||||
txFailures = u16(3),
|
||||
rxCrcErrors = u16(5),
|
||||
capabilities = if (payload.size > PAYLOAD_SIZE) payload[7].toInt() and 0xFF else 0,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -161,6 +188,116 @@ data class V2xRxFrame(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The GeoNetworking Source Position Vector content sent with each CAM: the 24-byte little-endian
|
||||
* prefix of a [SerialFrameType.CAM_TX_PV] payload. Must stay in lockstep with the layout at
|
||||
* `SERIAL_MSG_CAM_TX_PV` in `serial_link.h`, which the firmware decodes into `gn_lpv_t`.
|
||||
*
|
||||
* Every field is something the ESP32-C5 cannot know by itself, since it has no GNSS and no clock
|
||||
* on the OCB channel. That is why its GN header used to carry fixed bench placeholders instead,
|
||||
* describing a stationary car at the bench while the CAM inside described the moving rider.
|
||||
*/
|
||||
data class GnPositionVector(
|
||||
/** Pseudonym, 6 bytes: both the 802.11 source address and the GN_ADDR MID. */
|
||||
val mac: ByteArray,
|
||||
/** TS 102 894-2 StationType. */
|
||||
val stationType: Int,
|
||||
/** Position Accuracy Indicator. */
|
||||
val pai: Boolean,
|
||||
/** TimestampIts at which the position was acquired; reduced modulo 2^32 on the wire. */
|
||||
val tstMs: Long,
|
||||
/** 1/10 microdegree. */
|
||||
val latTenMicroDeg: Int,
|
||||
/** 1/10 microdegree. */
|
||||
val lonTenMicroDeg: Int,
|
||||
/** 0.01 m/s, within the GN field's 15-bit signed range. */
|
||||
val speedCms: Int,
|
||||
/** 0.1 degree from north, clockwise, 0..3599. */
|
||||
val headingDeciDeg: Int,
|
||||
) {
|
||||
init {
|
||||
require(mac.size == 6) { "a MAC is 6 bytes, got ${mac.size}" }
|
||||
}
|
||||
|
||||
/** The 24-byte prefix, little-endian like the rest of this framing. */
|
||||
fun toSerialPrefix(): ByteArray {
|
||||
val out = ByteArray(PREFIX_SIZE)
|
||||
mac.copyInto(out, destinationOffset = 0)
|
||||
out[6] = stationType.toByte()
|
||||
out[7] = (if (pai) 0x01 else 0x00).toByte()
|
||||
putLe(out, 8, tstMs, 4)
|
||||
putLe(out, 12, latTenMicroDeg.toLong(), 4)
|
||||
putLe(out, 16, lonTenMicroDeg.toLong(), 4)
|
||||
putLe(out, 20, speedCms.toLong(), 2)
|
||||
putLe(out, 22, headingDeciDeg.toLong(), 2)
|
||||
return out
|
||||
}
|
||||
|
||||
// Generated equals/hashCode would compare the MAC array by identity.
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is GnPositionVector) return false
|
||||
return mac.contentEquals(other.mac) && stationType == other.stationType &&
|
||||
pai == other.pai && tstMs == other.tstMs && latTenMicroDeg == other.latTenMicroDeg &&
|
||||
lonTenMicroDeg == other.lonTenMicroDeg && speedCms == other.speedCms &&
|
||||
headingDeciDeg == other.headingDeciDeg
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var h = mac.contentHashCode()
|
||||
for (v in listOf(stationType, pai.hashCode(), tstMs.hashCode(), latTenMicroDeg,
|
||||
lonTenMicroDeg, speedCms, headingDeciDeg)) h = 31 * h + v
|
||||
return h
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val PREFIX_SIZE = 24
|
||||
|
||||
/** The GN speed field is 15-bit signed, in 0.01 m/s. */
|
||||
const val SPEED_MIN_CMS = -16384
|
||||
const val SPEED_MAX_CMS = 16383
|
||||
|
||||
/**
|
||||
* Largest Android horizontal accuracy, in metres, that still sets the Position Accuracy
|
||||
* Indicator.
|
||||
*
|
||||
* EN 302 636-4-1 sets PAI when the 95% semi-major confidence is below itsGnPaiInterval / 2,
|
||||
* and itsGnPaiInterval defaults to 80 m, so the bound is 40 m at 95%. Android reports a 68%
|
||||
* radius instead, and for a circular 2-D error the 95% radius is about 1.62 times the 68%
|
||||
* one, so 40 m becomes about 24.7 m on Android's scale.
|
||||
*/
|
||||
const val PAI_MAX_ACCURACY_M = 24.7f
|
||||
|
||||
/**
|
||||
* The position vector for [cam], built from the same values the CAM payload carries, so
|
||||
* the two layers of one frame describe the same station at the same moment. [accuracyM] is
|
||||
* Android's horizontal accuracy; null or 0 means unknown and leaves PAI clear.
|
||||
*/
|
||||
fun fromCam(cam: Cam, accuracyM: Float?, mac: ByteArray): GnPositionVector =
|
||||
GnPositionVector(
|
||||
mac = mac,
|
||||
stationType = cam.stationType,
|
||||
pai = accuracyM != null && accuracyM > 0f && accuracyM <= PAI_MAX_ACCURACY_M,
|
||||
tstMs = ItsTime.timestampIts(cam.timestamp),
|
||||
// Same rounding as CamUperCodec's referencePosition, so the GN position and the
|
||||
// CAM's own position agree to the last digit.
|
||||
latTenMicroDeg = (cam.latitude * 1e7).roundToLong().toInt(),
|
||||
lonTenMicroDeg = (cam.longitude * 1e7).roundToLong().toInt(),
|
||||
// Clamped, never wrapped: a wrapped 15-bit speed flips sign and reads as reversing.
|
||||
speedCms = if (cam.speedMps.isFinite()) {
|
||||
(cam.speedMps * 100).roundToInt().coerceIn(SPEED_MIN_CMS, SPEED_MAX_CMS)
|
||||
} else 0,
|
||||
headingDeciDeg = if (cam.headingDeg.isFinite()) {
|
||||
Math.floorMod((cam.headingDeg * 10).roundToInt(), 3600)
|
||||
} else 0,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun putLe(out: ByteArray, offset: Int, value: Long, bytes: Int) {
|
||||
for (i in 0 until bytes) out[offset + i] = ((value ushr (8 * i)) and 0xFF).toByte()
|
||||
}
|
||||
|
||||
data class DecodedFrame(val type: Int, val payload: ByteArray)
|
||||
|
||||
object SerialFrameEncoder {
|
||||
|
||||
@@ -329,12 +329,14 @@ class UsbSerialTransport @Inject constructor(
|
||||
prev.oversizeDrops != status.oversizeDrops ||
|
||||
prev.txFailures != status.txFailures ||
|
||||
prev.rxCrcErrors != status.rxCrcErrors ||
|
||||
prev.status != status.status
|
||||
prev.status != status.status ||
|
||||
prev.capabilities != status.capabilities
|
||||
) {
|
||||
Log.i(TAG, "ESP32 counters: status=${status.status} " +
|
||||
"oversizeDrops=${status.oversizeDrops} " +
|
||||
"txFailures=${status.txFailures} " +
|
||||
"rxCrcErrors=${status.rxCrcErrors}")
|
||||
"rxCrcErrors=${status.rxCrcErrors} " +
|
||||
"capabilities=${status.capabilities}")
|
||||
}
|
||||
_linkStatus.value = status
|
||||
}
|
||||
@@ -386,6 +388,25 @@ class UsbSerialTransport @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/** Which frame type the last CAM went out as, so a change of path is logged once, not per CAM. */
|
||||
@Volatile private var lastTxWithPositionVector: Boolean? = null
|
||||
|
||||
/**
|
||||
* Logs whenever CAMs switch between [SerialFrameType.CAM_TX_PV] and legacy
|
||||
* [SerialFrameType.CAM_TX]. Without it, "the GN header still says bench" has no visible cause
|
||||
* in a logcat capture: it looks identical whether the firmware is old or the phone is.
|
||||
*/
|
||||
private fun noteTxPath(withPositionVector: Boolean, requested: Boolean) {
|
||||
if (lastTxWithPositionVector == withPositionVector) return
|
||||
lastTxWithPositionVector = withPositionVector
|
||||
Log.i(TAG, when {
|
||||
withPositionVector -> "CAM TX path: CAM_TX_PV, GN position vector supplied by the phone"
|
||||
requested -> "CAM TX path: legacy CAM_TX, firmware has not advertised CAM_TX_PV yet; " +
|
||||
"GN position vector is the firmware's bench placeholder"
|
||||
else -> "CAM TX path: legacy CAM_TX, no position vector supplied"
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes [camUperBytes] as a [SerialFrameType.CAM_TX] frame and writes it to the port.
|
||||
* No-op (returns false) if not currently connected — callers (the CAM transmit loop) should
|
||||
@@ -394,15 +415,28 @@ class UsbSerialTransport @Inject constructor(
|
||||
* [consecutiveWriteFailures] so they can't stay invisible.
|
||||
*
|
||||
* Blocking: writes with a 200 ms timeout, so call from a background dispatcher.
|
||||
*
|
||||
* [positionVector], when given, travels with the CAM as a [SerialFrameType.CAM_TX_PV] frame so
|
||||
* the ESP32 builds the GeoNetworking Source Position Vector from real values. It is used only
|
||||
* once the heartbeat advertises [EspLinkStatus.supportsCamTxPv]. Until then, and against
|
||||
* firmware that predates it, the CAM goes out as a plain [SerialFrameType.CAM_TX] exactly as
|
||||
* before and the GN header carries the firmware's bench placeholders. Neither mixed-version
|
||||
* combination breaks transmission; `serial_link.h` explains why.
|
||||
*/
|
||||
fun sendCamTx(camUperBytes: ByteArray): Boolean {
|
||||
fun sendCamTx(camUperBytes: ByteArray, positionVector: GnPositionVector? = null): Boolean {
|
||||
val p = port
|
||||
if (p == null) {
|
||||
_consecutiveWriteFailures.update { it + 1 }
|
||||
return false
|
||||
}
|
||||
return try {
|
||||
val frame = SerialFrameEncoder.encode(SerialFrameType.CAM_TX, camUperBytes)
|
||||
val pv = positionVector?.takeIf { _linkStatus.value?.supportsCamTxPv == true }
|
||||
noteTxPath(withPositionVector = pv != null, requested = positionVector != null)
|
||||
val frame = if (pv != null) {
|
||||
SerialFrameEncoder.encode(SerialFrameType.CAM_TX_PV, pv.toSerialPrefix() + camUperBytes)
|
||||
} else {
|
||||
SerialFrameEncoder.encode(SerialFrameType.CAM_TX, camUperBytes)
|
||||
}
|
||||
p.write(frame, /* timeout ms */ 200)
|
||||
_consecutiveWriteFailures.value = 0
|
||||
true
|
||||
|
||||
@@ -34,9 +34,6 @@ object CamUperCodec {
|
||||
/** Encode buffer size — matches `cam.c`'s `cam_payload[96]`, the known-sufficient size. */
|
||||
private const val ENCODE_BUFFER_BYTES = 96
|
||||
|
||||
// TimestampIts epoch: 2004-01-01T00:00:00Z, in Unix epoch milliseconds.
|
||||
private const val TS_ITS_EPOCH_MS = 1_072_915_200_000L
|
||||
|
||||
// ASN.1 "unavailable" sentinel values, straight from the CAM/ITS-Container modules (also
|
||||
// documented inline in cam.c against each field).
|
||||
private const val HEADING_UNAVAILABLE = 3601
|
||||
@@ -47,9 +44,13 @@ object CamUperCodec {
|
||||
private const val ACCEL_UNAVAILABLE = 161
|
||||
private const val YAW_RATE_UNAVAILABLE = 32767
|
||||
|
||||
/** Converts a wall-clock epoch-ms timestamp to a UPER GenerationDeltaTime (TimestampIts mod 65536). */
|
||||
/**
|
||||
* Converts a wall-clock epoch-ms timestamp to a UPER GenerationDeltaTime (TimestampIts mod
|
||||
* 65536). Goes through [ItsTime], the same rule the GeoNetworking TST uses, so the two
|
||||
* timestamps in one transmitted frame cannot disagree.
|
||||
*/
|
||||
fun generationDeltaTime(epochMs: Long): Int {
|
||||
val itsMs = epochMs - TS_ITS_EPOCH_MS
|
||||
val itsMs = ItsTime.timestampIts(epochMs)
|
||||
// floorMod so this stays well-defined even for epochMs before the ITS epoch (shouldn't
|
||||
// happen with a real clock, but avoids a negative/UB result if it ever does).
|
||||
return Math.floorMod(itsMs, 65536L).toInt()
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.hawhamburg.micr0bu.domain.asn1
|
||||
|
||||
/**
|
||||
* ITS time, as used by every timestamp this app puts on the air.
|
||||
*
|
||||
* TimestampIts (ETSI TS 102 894-2) counts milliseconds from 2004-01-01T00:00:00Z. Two fields in a
|
||||
* single transmitted frame come from it: the CAM's generationDeltaTime (modulo 65536) and the
|
||||
* GeoNetworking Source Position Vector's TST (modulo 2^32). A receiver can compare the two, so
|
||||
* they must follow one rule. Both go through here so they cannot drift apart.
|
||||
*
|
||||
* **Which clock.** The input should be GNSS time, not the phone's wall clock. A phone with no SIM
|
||||
* and no internet time has no automatic time source at all, and the bench phone was found 24
|
||||
* minutes fast that way. `GnssTimeSource` moves a timestamp onto GNSS time, using [onGnssTime],
|
||||
* before it gets here.
|
||||
*
|
||||
* **Open question: leap seconds.** This is Unix time minus the 2004 epoch, with no leap-second
|
||||
* term. If TimestampIts is read as TAI-based, the correct value is currently 5 s higher, for the
|
||||
* five leap seconds inserted since 2004. Whichever reading turns out right, it is changed here and
|
||||
* nowhere else. Settling it needs a frame from a third-party stack with a trusted clock, such as
|
||||
* the RSU's CAM compared against GNSS time, and no such traffic was on air when this was written.
|
||||
*/
|
||||
object ItsTime {
|
||||
/** 2004-01-01T00:00:00Z in Unix epoch milliseconds. */
|
||||
const val EPOCH_MS = 1_072_915_200_000L
|
||||
|
||||
/** TimestampIts for wall-clock [epochMs], before any modulo is applied. */
|
||||
fun timestampIts(epochMs: Long): Long = epochMs - EPOCH_MS
|
||||
|
||||
/**
|
||||
* Moves [systemMs], a reading of this phone's wall clock, onto GNSS time, using one pair of
|
||||
* simultaneous readings of both clocks: [gnssNowMs] and [systemNowMs]. Their difference is the
|
||||
* wall clock's error, whatever caused it, and the age of [systemMs] is preserved. Returns
|
||||
* [systemMs] unchanged when there is no GNSS reading.
|
||||
*
|
||||
* Pure so the arithmetic can be tested apart from the Android clock API, which is where the
|
||||
* readings come from (see `GnssTimeSource`).
|
||||
*/
|
||||
fun onGnssTime(systemMs: Long, gnssNowMs: Long?, systemNowMs: Long): Long =
|
||||
if (gnssNowMs == null) systemMs else systemMs + (gnssNowMs - systemNowMs)
|
||||
}
|
||||
@@ -5,26 +5,27 @@ package com.hawhamburg.micr0bu.domain.cam
|
||||
* user when a frame comes back off the air.
|
||||
*
|
||||
* ## Why this exists
|
||||
* On the ESP32-C5 path the radio receives promiscuously, so it hears the phone's own
|
||||
* transmissions. Anything that decodes received CAMs has to recognise them, or the phone tracks
|
||||
* itself: a station sitting exactly on top of the ego position, moving at the ego's own speed and
|
||||
* heading, handed to [com.hawhamburg.micr0bu.domain.usecase.UseCaseDetectionEngine] as a
|
||||
* collision partner for itself.
|
||||
* A receiver that fails to recognise its own transmissions tracks itself: a station sitting exactly
|
||||
* on top of the ego position, moving at the ego's own speed and heading, handed to
|
||||
* [com.hawhamburg.micr0bu.domain.usecase.UseCaseDetectionEngine] as a collision partner for itself.
|
||||
* The phone's own frames can come back to it off the air, for example relayed by the CiT One's
|
||||
* radio when a phone is connected to both OBUs at once.
|
||||
*
|
||||
* ## Why two IDs
|
||||
* The phone transmits under two different station IDs by design:
|
||||
* ## Which IDs count
|
||||
* - The current transmit pseudonym used by [com.hawhamburg.micr0bu.service.CamTransmitLoop], and
|
||||
* the one or two it most recently replaced. The pseudonym rotates every ten minutes (see
|
||||
* [Pseudonym]), and a frame sent just before a rotation can come back just after it, so a
|
||||
* retired ID has to stay ours for a while. `PseudonymManager.ownStationIds()` supplies these.
|
||||
* - On the CiT One path, the OBU's own ID learned from obu_gnss.
|
||||
* - [BENCH_PING], but only while this phone's own pinger is running or has just stopped. See
|
||||
* [benchPingIsOurs].
|
||||
*
|
||||
* - [com.hawhamburg.micr0bu.service.CamTransmitLoop] uses the persisted per-install ID from
|
||||
* `ObuHardwarePreferences.getOrCreateOwnStationId()`, which is the real identity this station
|
||||
* presents to the world.
|
||||
* - [com.hawhamburg.micr0bu.service.CamPinger] uses [BENCH_PING], a fixed and recognisable value,
|
||||
* so manual bench pings stay identifiable in captures and cannot be confused with the
|
||||
* recording-driven stream when both run at once.
|
||||
*
|
||||
* 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.
|
||||
* ## Why the bench ID is conditional
|
||||
* It used to count as ours unconditionally, on every phone, and that hid other phones' pings. On
|
||||
* the 2026-09-10 bench one phone pinged through an ESP32 while a second phone watched through the
|
||||
* CiT One, and the watcher silently discarded every ping as its own frame heard back, although it
|
||||
* had sent none. A fixed ID shared by every MicrOBU is only ours on the phone actually using it.
|
||||
* The one case this cannot resolve is two phones pinging at the same time: each hides the other.
|
||||
*/
|
||||
object OwnStationIds {
|
||||
|
||||
@@ -34,21 +35,47 @@ object OwnStationIds {
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* [persistedOwnId] is the per-install station ID, or null before it has been loaded. Station
|
||||
* ID 0 is never ours: it is the "not known yet" placeholder used while the ego identity is
|
||||
* still being resolved, and matching on it would swallow real traffic.
|
||||
* [ownIds] is every non-bench ID currently counted as ours: the current and recently retired
|
||||
* transmit pseudonyms, plus the CiT One's own ID on that path. [benchPingIsOurs] says whether
|
||||
* [BENCH_PING] is ours right now; see the function of the same name.
|
||||
*
|
||||
* [BENCH_PING] counts as ours unconditionally, not merely while the pinger is running. A
|
||||
* time-windowed check would still let a frame transmitted moments before Stop arrive
|
||||
* 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.
|
||||
* Station ID 0 is never ours: it is the "not known yet" placeholder used while the ego
|
||||
* identity is still being resolved, and matching on it would swallow real traffic.
|
||||
*/
|
||||
fun isOwn(stationId: Long, persistedOwnId: Long?): Boolean {
|
||||
fun isOwn(stationId: Long, ownIds: Set<Long>, benchPingIsOurs: Boolean): Boolean {
|
||||
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
|
||||
* Android's convention is counter-clockwise around +Z; converted to the clockwise-positive
|
||||
* yaw rate convention already used by [Cam.yawRateDps] to match OBU/remote CAM data.
|
||||
* @param stationId this device's own station ID, from
|
||||
* [com.hawhamburg.micr0bu.data.mqtt.ObuHardwarePreferences.getOrCreateOwnStationId] — a
|
||||
* persisted random value, not a placeholder. Receivers use it to track this station across
|
||||
* successive CAMs, so it must be stable for the life of the install and distinct per device.
|
||||
* @param stationId the station ID to transmit under: the current pseudonym from
|
||||
* [com.hawhamburg.micr0bu.data.cam.PseudonymManager], or the bench pinger's fixed ID.
|
||||
* Receivers track a station across successive CAMs by this ID, which is why it only ever
|
||||
* changes in a coordinated rotation together with the link-layer address.
|
||||
* @param longitudinalAccelMps2 along-track acceleration, signed (positive = accelerating).
|
||||
* Derived from successive GNSS speed samples by [com.hawhamburg.micr0bu.service.CamTransmitLoop]
|
||||
* rather than from the accelerometer: CAM wants acceleration along the direction of travel,
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.hawhamburg.micr0bu.domain.cam
|
||||
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* The identity this phone transmits under on the ESP32-C5 path: the CAM stationID, and the
|
||||
* link-layer address the firmware writes into both the GeoNetworking GN_ADDR and the 802.11
|
||||
* source address.
|
||||
*
|
||||
* ## Why the two change together
|
||||
* A pseudonym only makes a station harder to follow if every identifier on the frame changes at
|
||||
* the same moment. Rotating the address while keeping the stationID, or the reverse, leaves the
|
||||
* unchanged one as a stable handle, so a receiver loses nothing and the rotation buys nothing.
|
||||
* Holding both in one value that is only ever replaced whole makes a partial rotation impossible
|
||||
* to express.
|
||||
*
|
||||
* ## Why every [ROTATION_INTERVAL_MS]
|
||||
* Real ITS stacks change pseudonym every few minutes, 5 to 15 being typical, and the CiT One was
|
||||
* seen rotating its station ID twice within one bench session. Ten minutes sits in that range.
|
||||
*
|
||||
* ## A limit worth stating
|
||||
* Nothing this app transmits is signed (there is no ETSI TS 103 097 security), so rotation gives
|
||||
* nominal unlinkability at best: an unsigned frame's content can still be correlated across a
|
||||
* change. This is the correct behaviour to build on, not a privacy guarantee.
|
||||
*/
|
||||
data class Pseudonym(
|
||||
val stationId: Long,
|
||||
/** Six bytes, locally administered and unicast. See [generate]. */
|
||||
val mac: ByteArray,
|
||||
/** Wall-clock ms this pseudonym was created, for [isExpired]. */
|
||||
val createdAtMs: Long,
|
||||
) {
|
||||
init {
|
||||
require(mac.size == 6) { "a MAC is 6 bytes, got ${mac.size}" }
|
||||
}
|
||||
|
||||
/**
|
||||
* True once this pseudonym has been in use for [intervalMs], or if the clock has moved back
|
||||
* past its creation time. The second case rotates rather than trusting a creation time that
|
||||
* now lies in the future, which would otherwise pin one identity until the clock caught up.
|
||||
*/
|
||||
fun isExpired(nowMs: Long, intervalMs: Long = ROTATION_INTERVAL_MS): Boolean =
|
||||
nowMs < createdAtMs || nowMs - createdAtMs >= intervalMs
|
||||
|
||||
// Generated equals/hashCode would compare the MAC array by identity, so two pseudonyms with
|
||||
// the same bytes would compare unequal.
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is Pseudonym) return false
|
||||
return stationId == other.stationId && createdAtMs == other.createdAtMs &&
|
||||
mac.contentEquals(other.mac)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int =
|
||||
31 * (31 * stationId.hashCode() + mac.contentHashCode()) + createdAtMs.hashCode()
|
||||
|
||||
companion object {
|
||||
const val ROTATION_INTERVAL_MS = 10 * 60_000L
|
||||
|
||||
/**
|
||||
* A fresh identity. StationID is INTEGER(0..4294967295); 0 is avoided because it is the
|
||||
* "not yet known" placeholder elsewhere in this app, and [OwnStationIds.BENCH_PING] is
|
||||
* avoided so a rider can never be mistaken for the bench pinger.
|
||||
*
|
||||
* The MAC is random with the locally-administered bit set and the group bit clear. A
|
||||
* source address must never be a group address, and a random one must not claim a real
|
||||
* vendor's OUI. [OwnStationIds.BENCH_PING_MAC] is excluded for the same reason as the ID.
|
||||
*/
|
||||
fun generate(nowMs: Long, random: Random = Random.Default): Pseudonym {
|
||||
var stationId: Long
|
||||
do {
|
||||
stationId = random.nextLong(1L, 0xFFFF_FFFEL)
|
||||
} while (stationId == OwnStationIds.BENCH_PING)
|
||||
|
||||
var mac: ByteArray
|
||||
do {
|
||||
mac = random.nextBytes(6)
|
||||
mac[0] = ((mac[0].toInt() and 0xFC) or 0x02).toByte()
|
||||
} while (mac.contentEquals(OwnStationIds.BENCH_PING_MAC))
|
||||
|
||||
return Pseudonym(stationId, mac, nowMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,26 @@ package com.hawhamburg.micr0bu.domain.detection
|
||||
* All detection thresholds in one place.
|
||||
*
|
||||
* Pass a custom instance to [EventDetector] to tune behaviour without
|
||||
* recompiling. The defaults match the Phase A specification.
|
||||
* recompiling.
|
||||
*
|
||||
* **These defaults are the values the app actually runs.** They are *not* the
|
||||
* Phase A specification figures. Phase A specified a more sensitive detector;
|
||||
* running it on a real bicycle over-triggered, so every signal threshold was
|
||||
* raised and every sustain requirement lengthened. Those tuned values used to
|
||||
* live as literals in `TripRecordingService`'s constructor, which meant the
|
||||
* unit tests exercised the Phase A defaults and nothing exercised what shipped.
|
||||
* They are the defaults now so that there is exactly one configuration.
|
||||
*
|
||||
* The original Phase A figures, kept for provenance:
|
||||
* `brakingSpeedDropThreshold` 0.5, `brakingAccelStdDevThreshold` 1.2,
|
||||
* `brakingSustainedFrames` 15, `turningGyroMeanThreshold` 0.4,
|
||||
* `turningBearingChangeThreshold` 10.0, `turningSustainedFrames` 20,
|
||||
* `stoppingSpeedThreshold` 0.5, `stoppingFrames` 100,
|
||||
* `stoppingAccelStdDevThreshold` 0.15.
|
||||
*
|
||||
* What motivated each change was never recorded, and the effect on the
|
||||
* false-positive and false-negative rates has never been measured. That
|
||||
* remains open; sensitivity is deliberately unchanged by this consolidation.
|
||||
*/
|
||||
data class DetectionConfig(
|
||||
|
||||
@@ -17,42 +36,51 @@ data class DetectionConfig(
|
||||
* Minimum speed drop (m/s) from the reference speed at braking onset for
|
||||
* a frame to qualify as a braking frame.
|
||||
*/
|
||||
val brakingSpeedDropThreshold: Double = 0.5,
|
||||
val brakingSpeedDropThreshold: Double = 1.0,
|
||||
|
||||
/** Minimum accel std-dev (m/s²) required for a frame to count as braking. */
|
||||
val brakingAccelStdDevThreshold: Double = 1.2,
|
||||
val brakingAccelStdDevThreshold: Double = 1.8,
|
||||
|
||||
/** Consecutive braking frames required before an event is emitted. */
|
||||
val brakingSustainedFrames: Int = 15,
|
||||
val brakingSustainedFrames: Int = 25,
|
||||
|
||||
/**
|
||||
* Peak speed-drop rate (m/s per GPS update ≈ m/s²) above which the braking
|
||||
* confidence is upgraded from MEDIUM to HIGH.
|
||||
* Peak *cumulative* speed drop (m/s) from the onset reference speed above
|
||||
* which the braking confidence is upgraded from MEDIUM to HIGH.
|
||||
*
|
||||
* This is a total drop for the episode, not a rate. It was previously
|
||||
* named `brakingHighConfidenceRate` and documented as "m/s per GPS update
|
||||
* ≈ m/s²", but the quantity it is compared against in
|
||||
* [EventDetector.detectBraking] has always been the cumulative drop, which
|
||||
* grows for as long as the episode lasts. The name was wrong, not the
|
||||
* comparison: "the rider lost more than this much speed in one braking
|
||||
* episode" is a coherent criterion, so the name was corrected to match the
|
||||
* behaviour rather than the other way round. Detector output is unchanged.
|
||||
*/
|
||||
val brakingHighConfidenceRate: Double = 1.5,
|
||||
val brakingHighConfidencePeakDrop: Double = 1.5,
|
||||
|
||||
// ── TURNING ───────────────────────────────────────────────────────────────
|
||||
/** Minimum gyro mean (rad/s) required for a frame to qualify as turning. */
|
||||
val turningGyroMeanThreshold: Double = 0.4,
|
||||
val turningGyroMeanThreshold: Double = 0.6,
|
||||
|
||||
/** Bearing-change rate (°/s) that must be exceeded when speed is above the
|
||||
* minimum threshold for a HIGH-confidence turning confirmation. */
|
||||
val turningBearingChangeThreshold: Double = 10.0,
|
||||
val turningBearingChangeThreshold: Double = 15.0,
|
||||
|
||||
/** GPS speed (m/s) above which the bearing-change criterion is enforced. */
|
||||
val turningMinSpeedThreshold: Double = 2.0,
|
||||
|
||||
/** Consecutive turning frames required before an event is emitted. */
|
||||
val turningSustainedFrames: Int = 20,
|
||||
val turningSustainedFrames: Int = 30,
|
||||
|
||||
// ── STOPPING ─────────────────────────────────────────────────────────────
|
||||
/** GPS speed (m/s) below which a frame is considered a potential stop. */
|
||||
val stoppingSpeedThreshold: Double = 0.5,
|
||||
val stoppingSpeedThreshold: Double = 0.3,
|
||||
|
||||
/** Consecutive stop frames required (> this value) before an event is emitted.
|
||||
* At 50 Hz, 100 frames ≈ 2 s. */
|
||||
val stoppingFrames: Int = 100,
|
||||
* At 50 Hz, 150 frames ≈ 3 s. */
|
||||
val stoppingFrames: Int = 150,
|
||||
|
||||
/** Maximum accel std-dev (m/s²) allowed for a frame to count as stationary. */
|
||||
val stoppingAccelStdDevThreshold: Double = 0.15,
|
||||
val stoppingAccelStdDevThreshold: Double = 0.10,
|
||||
)
|
||||
|
||||
@@ -14,6 +14,16 @@ import kotlin.math.abs
|
||||
* to [events] (a hot [SharedFlow]). Debounce is implemented with
|
||||
* consecutive-frame counters, not timers.
|
||||
*
|
||||
* **Who consumes this.** The detector's live consumer is the CAM transmit-rate
|
||||
* policy: [com.hawhamburg.micr0bu.service.TripRecordingService] forwards every
|
||||
* emitted event to
|
||||
* [com.hawhamburg.micr0bu.service.CamTransmitLoop.onDetectedEvent], which
|
||||
* raises the CAM rate from 1 Hz to the elevated rate for a hold window so that
|
||||
* nearby stations get denser updates *through* a manoeuvre rather than only at
|
||||
* the instant it was detected. These thresholds therefore govern a V2X
|
||||
* behaviour, not a statistic. Events are also persisted per trip for offline
|
||||
* analysis and CSV export, but nothing in the UI displays them.
|
||||
*
|
||||
* GPS updates at 1 Hz whilst sensors fire at ~50 Hz. [speedMps] and
|
||||
* [bearingChangeDegPerSec] should be the values from the last known GPS fix;
|
||||
* the detector compares speed against a *reference speed at braking onset*
|
||||
@@ -37,7 +47,7 @@ class EventDetector(private val config: DetectionConfig = DetectionConfig()) {
|
||||
private var brakingFrames = 0
|
||||
private var brakingOnsetSpeed = 0.0 // reference speed when braking started
|
||||
private var brakingStartTime = 0L
|
||||
private var peakBrakingDrop = 0.0 // peak speed drop observed during this window
|
||||
private var peakBrakingDrop = 0.0 // peak CUMULATIVE drop from onset speed, m/s (not a rate)
|
||||
private var peakAccelBraking = 0.0
|
||||
|
||||
// ── Turning state ─────────────────────────────────────────────────────────
|
||||
@@ -124,7 +134,7 @@ class EventDetector(private val config: DetectionConfig = DetectionConfig()) {
|
||||
|
||||
if (brakingFrames == config.brakingSustainedFrames) {
|
||||
val confidence =
|
||||
if (peakBrakingDrop > config.brakingHighConfidenceRate) Confidence.HIGH
|
||||
if (peakBrakingDrop > config.brakingHighConfidencePeakDrop) Confidence.HIGH
|
||||
else Confidence.MEDIUM
|
||||
|
||||
_events.tryEmit(
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package com.hawhamburg.micr0bu.service
|
||||
|
||||
import android.content.Context
|
||||
import android.os.SystemClock
|
||||
import com.hawhamburg.micr0bu.data.GnssReading
|
||||
import com.hawhamburg.micr0bu.data.GnssTimeSource
|
||||
import com.hawhamburg.micr0bu.data.SensorRepository
|
||||
import com.hawhamburg.micr0bu.data.transport.GnPositionVector
|
||||
import com.hawhamburg.micr0bu.data.transport.UsbSerialTransport
|
||||
import com.hawhamburg.micr0bu.domain.asn1.RealAsn1UperCodec
|
||||
import com.hawhamburg.micr0bu.domain.cam.OwnStationIds
|
||||
@@ -68,6 +71,20 @@ class CamPinger @Inject constructor(
|
||||
/** False while the pinger is running but has no GNSS fix yet — nothing is being transmitted. */
|
||||
val hasFix: StateFlow<Boolean> = _hasFix.asStateFlow()
|
||||
|
||||
/** [SystemClock.elapsedRealtime] when the pinger last stopped, or null if it never ran. */
|
||||
@Volatile private var stoppedAtElapsedMs: Long? = null
|
||||
|
||||
/**
|
||||
* True while station [OwnStationIds.BENCH_PING] on air is this phone's own ping: while the
|
||||
* pinger runs, and briefly after it stops, so a frame sent just before Stop is not taken for a
|
||||
* stranger. Uses elapsed realtime, so changing the wall clock cannot move the window.
|
||||
*
|
||||
* Otherwise that ID belongs to someone else, typically another MicrOBU phone pinging on the
|
||||
* same bench, and must be shown like any remote station. See [OwnStationIds.benchPingIsOurs].
|
||||
*/
|
||||
fun benchPingIsOurs(): Boolean =
|
||||
OwnStationIds.benchPingIsOurs(_isActive.value, stoppedAtElapsedMs, SystemClock.elapsedRealtime())
|
||||
|
||||
fun start() {
|
||||
if (job?.isActive == true) return
|
||||
_sentCount.value = 0
|
||||
@@ -87,13 +104,17 @@ class CamPinger @Inject constructor(
|
||||
_hasFix.value = gnss != null
|
||||
if (gnss != null) {
|
||||
val cam = PhoneCamBuilder.build(
|
||||
gnss = gnss,
|
||||
// Stamped on GNSS time rather than the phone clock; see GnssTimeSource.
|
||||
gnss = gnss.copy(timestamp = GnssTimeSource.correct(gnss.timestamp)),
|
||||
gyroZRadPerSec = latestGyroZ,
|
||||
stationId = OwnStationIds.BENCH_PING,
|
||||
longitudinalAccelMps2 = longitudinalAccel(gnss),
|
||||
)
|
||||
val bytes = codec.encodeCam(cam)
|
||||
if (usbSerialTransport.sendCamTx(bytes)) {
|
||||
// Fixed bench identity on every layer, the link-layer address included, so a ping
|
||||
// stays recognisable in a capture and never rotates.
|
||||
val pv = GnPositionVector.fromCam(cam, gnss.accuracyM, OwnStationIds.BENCH_PING_MAC)
|
||||
if (usbSerialTransport.sendCamTx(bytes, pv)) {
|
||||
_sentCount.update { it + 1 }
|
||||
}
|
||||
}
|
||||
@@ -120,6 +141,9 @@ class CamPinger @Inject constructor(
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
// Only a real stop opens the grace window. stop() is also called unconditionally on
|
||||
// teardown, and that must not make a phone that never pinged claim 999999 for a while.
|
||||
if (_isActive.value) stoppedAtElapsedMs = SystemClock.elapsedRealtime()
|
||||
job?.cancel()
|
||||
job = null
|
||||
_isActive.value = false
|
||||
|
||||
@@ -2,8 +2,11 @@ package com.hawhamburg.micr0bu.service
|
||||
|
||||
import android.content.Context
|
||||
import com.hawhamburg.micr0bu.data.GnssReading
|
||||
import com.hawhamburg.micr0bu.data.GnssTimeSource
|
||||
import com.hawhamburg.micr0bu.data.SensorRepository
|
||||
import com.hawhamburg.micr0bu.data.cam.PseudonymManager
|
||||
import com.hawhamburg.micr0bu.data.mqtt.ObuHardwarePreferences
|
||||
import com.hawhamburg.micr0bu.data.transport.GnPositionVector
|
||||
import com.hawhamburg.micr0bu.data.transport.ObuHardware
|
||||
import com.hawhamburg.micr0bu.data.transport.UsbSerialTransport
|
||||
import com.hawhamburg.micr0bu.domain.asn1.RealAsn1UperCodec
|
||||
@@ -49,6 +52,7 @@ class CamTransmitLoop @Inject constructor(
|
||||
private val obuHardwarePrefs: ObuHardwarePreferences,
|
||||
private val usbSerialTransport: UsbSerialTransport,
|
||||
private val codec: RealAsn1UperCodec,
|
||||
private val pseudonymManager: PseudonymManager,
|
||||
) {
|
||||
private val config = CamTransmitConfig()
|
||||
private val sensorRepository = SensorRepository(context)
|
||||
@@ -63,14 +67,6 @@ class CamTransmitLoop @Inject constructor(
|
||||
/** Previous GNSS fix, kept only to derive along-track acceleration — see [longitudinalAccel]. */
|
||||
@Volatile private var previousGnss: GnssReading? = null
|
||||
|
||||
/**
|
||||
* Own station id for the ESP32-C5 path, loaded once per [start] from
|
||||
* [ObuHardwarePreferences.getOrCreateOwnStationId]. 0 means "not loaded yet" — the loop waits
|
||||
* for the real value rather than beaconing as station 0, which would be indistinguishable
|
||||
* from every other MicrOBU to any receiver.
|
||||
*/
|
||||
@Volatile var stationId: Long = 0L
|
||||
|
||||
/**
|
||||
* Call when a braking/turning/stopping event fires during an active trip — bumps the CAM
|
||||
* rate to [CamTransmitConfig.elevatedRateHz] for [ELEVATED_HOLD_MS] so nearby stations get
|
||||
@@ -90,7 +86,6 @@ class CamTransmitLoop @Inject constructor(
|
||||
elevatedUntilMs = 0L
|
||||
previousGnss = null
|
||||
job = scope.launch {
|
||||
stationId = obuHardwarePrefs.getOrCreateOwnStationId()
|
||||
obuHardwarePrefs.obuHardwareFlow.collectLatest { hardware ->
|
||||
if (hardware != ObuHardware.ESP32_C5) return@collectLatest
|
||||
runTransmitLoop()
|
||||
@@ -111,9 +106,15 @@ class CamTransmitLoop @Inject constructor(
|
||||
while (true) {
|
||||
val gnss = latestGnss
|
||||
if (gnss != null) {
|
||||
val cam = PhoneCamBuilder.build(gnss, latestGyroZ, stationId, longitudinalAccel(gnss))
|
||||
// Asked for per CAM rather than once per trip: that is what lets a pseudonym
|
||||
// rotation fall cleanly between two frames instead of inside one.
|
||||
val pseudonym = pseudonymManager.current()
|
||||
// Stamped on GNSS time rather than the phone clock; see GnssTimeSource. Only the
|
||||
// outgoing CAM is: acceleration below still differences wall-clock samples.
|
||||
val fix = gnss.copy(timestamp = GnssTimeSource.correct(gnss.timestamp))
|
||||
val cam = PhoneCamBuilder.build(fix, latestGyroZ, pseudonym.stationId, longitudinalAccel(gnss))
|
||||
val bytes = codec.encodeCam(cam)
|
||||
usbSerialTransport.sendCamTx(bytes)
|
||||
usbSerialTransport.sendCamTx(bytes, GnPositionVector.fromCam(cam, gnss.accuracyM, pseudonym.mac))
|
||||
}
|
||||
delay((1000.0 / currentRateHz(gnss)).toLong())
|
||||
}
|
||||
|
||||
@@ -26,9 +26,7 @@ import com.hawhamburg.micr0bu.R
|
||||
import com.hawhamburg.micr0bu.data.TripRepository
|
||||
import com.hawhamburg.micr0bu.data.cam.CamUseCaseRepository
|
||||
import com.hawhamburg.micr0bu.data.db.AppDatabase
|
||||
import com.hawhamburg.micr0bu.domain.detection.DetectionConfig
|
||||
import com.hawhamburg.micr0bu.domain.detection.EventDetector
|
||||
import com.hawhamburg.micr0bu.domain.detection.EventType
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -108,19 +106,10 @@ class TripRecordingService : Service() {
|
||||
// V2xMessageEntity's KDoc for why nothing is retained outside of one.
|
||||
@Inject lateinit var camUseCaseRepository: CamUseCaseRepository
|
||||
private var v2xLoggingJob: Job? = null
|
||||
private val detector = EventDetector(
|
||||
DetectionConfig(
|
||||
brakingSpeedDropThreshold = 1.0,
|
||||
brakingAccelStdDevThreshold = 1.8,
|
||||
brakingSustainedFrames = 25,
|
||||
turningGyroMeanThreshold = 0.6,
|
||||
turningBearingChangeThreshold = 15.0,
|
||||
turningSustainedFrames = 30,
|
||||
stoppingSpeedThreshold = 0.3,
|
||||
stoppingFrames = 150,
|
||||
stoppingAccelStdDevThreshold = 0.10,
|
||||
)
|
||||
)
|
||||
// These nine thresholds used to be overridden here; they are now the DetectionConfig
|
||||
// defaults, so there is one configuration and the unit tests exercise it. Behaviour is
|
||||
// unchanged - see DetectionConfig's KDoc.
|
||||
private val detector = EventDetector()
|
||||
|
||||
// ── Sensor fusion state ───────────────────────────────────────────────────
|
||||
|
||||
@@ -153,10 +142,11 @@ class TripRecordingService : Service() {
|
||||
private val gpsTrackBuilder = StringBuilder("[")
|
||||
private var gpsPointCount = 0
|
||||
|
||||
// Event counts
|
||||
private var brakingCount = 0
|
||||
private var turningCount = 0
|
||||
private var stoppingCount = 0
|
||||
// Number of manoeuvres the detector fired during this trip. The only thing kept about
|
||||
// them: it fills the trips.eventCount column, which predates this change and cannot be
|
||||
// dropped without rebuilding the trips table. See EventDetector's KDoc for why the
|
||||
// detector still runs at all.
|
||||
private var detectedEventCount = 0
|
||||
|
||||
// ── SensorEventListener ───────────────────────────────────────────────────
|
||||
|
||||
@@ -253,9 +243,7 @@ class TripRecordingService : Service() {
|
||||
).also { it.acquire() }
|
||||
|
||||
detector.reset()
|
||||
brakingCount = 0
|
||||
turningCount = 0
|
||||
stoppingCount = 0
|
||||
detectedEventCount = 0
|
||||
distanceMetres = 0f
|
||||
prevLat = Double.NaN
|
||||
prevLon = Double.NaN
|
||||
@@ -273,35 +261,20 @@ class TripRecordingService : Service() {
|
||||
isRecording = true,
|
||||
currentTripId = currentTripId,
|
||||
elapsedSeconds = 0L,
|
||||
brakingCount = 0,
|
||||
turningCount = 0,
|
||||
stoppingCount = 0,
|
||||
currentSpeedMs = 0f,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Collect detector events and persist them
|
||||
// Collect detector events. The CAM transmit-rate policy is their only consumer:
|
||||
// detected manoeuvres are not persisted, exported, or displayed.
|
||||
serviceScope.launch {
|
||||
detector.events.collect { event ->
|
||||
detector.events.collect { _ ->
|
||||
if (currentTripId < 0) return@collect
|
||||
repository.insertEvent(currentTripId, event)
|
||||
// Bump the CAM transmit rate through the maneuver, not just at detection instant.
|
||||
// No-op on the CiT One path (see CamTransmitLoop's KDoc).
|
||||
camTransmitLoop.onDetectedEvent()
|
||||
when (event.type) {
|
||||
EventType.BRAKING -> brakingCount++
|
||||
EventType.TURNING -> turningCount++
|
||||
EventType.STOPPING -> stoppingCount++
|
||||
}
|
||||
TripServiceBus.update {
|
||||
copy(
|
||||
brakingCount = this@TripRecordingService.brakingCount,
|
||||
turningCount = this@TripRecordingService.turningCount,
|
||||
stoppingCount = this@TripRecordingService.stoppingCount,
|
||||
)
|
||||
}
|
||||
updateNotification()
|
||||
detectedEventCount++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,7 +329,7 @@ class TripRecordingService : Service() {
|
||||
v2xLoggingJob = null
|
||||
|
||||
val endTime = System.currentTimeMillis()
|
||||
val totalEvents = brakingCount + turningCount + stoppingCount
|
||||
val totalEvents = detectedEventCount
|
||||
|
||||
// Close GPS track JSON
|
||||
gpsTrackBuilder.append("]")
|
||||
@@ -454,10 +427,7 @@ class TripRecordingService : Service() {
|
||||
private fun buildNotification(elapsedSeconds: Long) =
|
||||
NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle("Recording trip")
|
||||
.setContentText(
|
||||
"⏱ ${formatElapsed(elapsedSeconds)} · " +
|
||||
"🚨 $brakingCount 🔄 $turningCount 🛑 $stoppingCount"
|
||||
)
|
||||
.setContentText("⏱ ${formatElapsed(elapsedSeconds)}")
|
||||
.setSmallIcon(R.mipmap.ic_launcher_foreground)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
|
||||
@@ -17,9 +17,6 @@ object TripServiceBus {
|
||||
val isRecording: Boolean = false,
|
||||
val currentTripId: Long = -1L,
|
||||
val elapsedSeconds: Long = 0L,
|
||||
val brakingCount: Int = 0,
|
||||
val turningCount: Int = 0,
|
||||
val stoppingCount: Int = 0,
|
||||
val currentSpeedMs: Float = 0f,
|
||||
)
|
||||
|
||||
|
||||
@@ -141,46 +141,6 @@ fun RecordingScreen(
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
// ── Event Detection Counters ─────────────────────────────────────────
|
||||
if (state.isRecording || tripServiceState.isRecording) {
|
||||
Text(
|
||||
stringResource(R.string.rec_events_detected),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.align(Alignment.Start),
|
||||
)
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
) {
|
||||
EventCountBadge(
|
||||
label = stringResource(R.string.rec_event_braking),
|
||||
count = tripServiceState.brakingCount,
|
||||
color = Color(0xFFFF5252),
|
||||
)
|
||||
EventCountBadge(
|
||||
label = stringResource(R.string.rec_event_turning),
|
||||
count = tripServiceState.turningCount,
|
||||
color = Color(0xFFFFB300),
|
||||
)
|
||||
EventCountBadge(
|
||||
label = stringResource(R.string.rec_event_stopping),
|
||||
count = tripServiceState.stoppingCount,
|
||||
color = Color(0xFF42A5F5),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
}
|
||||
|
||||
// ── CSV Session Log shortcut ─────────────────────────────────────────
|
||||
if (!state.isRecording) {
|
||||
OutlinedButton(
|
||||
@@ -228,25 +188,6 @@ fun RecordingScreen(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EventCountBadge(label: String, count: Int, color: Color) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = count.toString(),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = color,
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StreamRow(label: String, active: Boolean) {
|
||||
Row(
|
||||
|
||||
@@ -145,7 +145,6 @@ private fun TripCard(
|
||||
val durationSec = ((trip.endTime - trip.startTime) / 1000).coerceAtLeast(0)
|
||||
TripStatChip("⏱ ${formatDuration(durationSec)}")
|
||||
TripStatChip("📍 ${formatDistance(trip.distanceMetres)}")
|
||||
TripStatChip("🚨 ${trip.eventCount} events")
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onOpen) {
|
||||
|
||||
@@ -42,9 +42,7 @@ import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import com.hawhamburg.micr0bu.data.db.DetectedEventEntity
|
||||
import com.hawhamburg.micr0bu.data.db.RecordedTripEntity
|
||||
import com.hawhamburg.micr0bu.viewmodel.TripRecordingViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import org.osmdroid.config.Configuration
|
||||
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
|
||||
@@ -61,7 +59,6 @@ import java.util.Locale
|
||||
@Composable
|
||||
fun TripReviewScreen(
|
||||
trip: RecordedTripEntity,
|
||||
viewModel: TripRecordingViewModel,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
@@ -70,15 +67,8 @@ fun TripReviewScreen(
|
||||
// provider is ready before MapView is constructed in the factory block.
|
||||
initOsmReview(context)
|
||||
|
||||
LaunchedEffect(trip.id) { viewModel.loadTripEvents(trip.id) }
|
||||
val events by viewModel.selectedTripEvents.collectAsState()
|
||||
|
||||
val gpsPoints = remember(trip.gpsTrackJson) { parseGpsTrack(trip.gpsTrackJson) }
|
||||
|
||||
var selectedEvent by remember { mutableStateOf<DetectedEventEntity?>(null) }
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val mapViewRef = remember { mutableStateOf<MapView?>(null) }
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
|
||||
@@ -108,13 +98,6 @@ fun TripReviewScreen(
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
"🚨 ${events.count { it.type == "BRAKING" }} " +
|
||||
"🔄 ${events.count { it.type == "TURNING" }} " +
|
||||
"🛑 ${events.count { it.type == "STOPPING" }}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Map ───────────────────────────────────────────────────────────────
|
||||
@@ -141,33 +124,6 @@ fun TripReviewScreen(
|
||||
mv.overlays.add(polyline)
|
||||
}
|
||||
|
||||
// Event pins
|
||||
events.forEach { event ->
|
||||
val pinColor = when (event.type) {
|
||||
"BRAKING" -> Color(0xFFFF5252)
|
||||
"TURNING" -> Color(0xFFFFB300)
|
||||
"STOPPING" -> Color(0xFF42A5F5)
|
||||
else -> Color.Gray
|
||||
}
|
||||
val marker = Marker(mv).apply {
|
||||
position = GeoPoint(event.latitude, event.longitude)
|
||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
||||
title = "${event.type} (${event.confidence})"
|
||||
setOnMarkerClickListener { _, _ ->
|
||||
selectedEvent = event
|
||||
scope.launch { sheetState.show() }
|
||||
true
|
||||
}
|
||||
// Solid-circle pin in the event color
|
||||
icon = GradientDrawable().apply {
|
||||
shape = GradientDrawable.OVAL
|
||||
setColor(pinColor.toArgb())
|
||||
setSize(32, 32)
|
||||
}
|
||||
}
|
||||
mv.overlays.add(marker)
|
||||
}
|
||||
|
||||
// Auto-fit the camera to the track — deferred via post() so the
|
||||
// MapView has been measured before zoomToBoundingBox is called.
|
||||
// Calling it with width/height == 0 (before first layout) crashes osmdroid.
|
||||
@@ -193,82 +149,6 @@ fun TripReviewScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// ── Event detail bottom sheet ─────────────────────────────────────────────
|
||||
val ev = selectedEvent
|
||||
if (ev != null) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { selectedEvent = null },
|
||||
sheetState = sheetState,
|
||||
dragHandle = { BottomSheetDefaults.DragHandle() },
|
||||
) {
|
||||
EventDetailSheet(event = ev, onDismiss = {
|
||||
scope.launch { sheetState.hide() }.invokeOnCompletion { selectedEvent = null }
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Event detail sheet content ────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun EventDetailSheet(event: DetectedEventEntity, onDismiss: () -> Unit) {
|
||||
// Created here (not as a top-level static field) so it always uses the
|
||||
// current locale even if the user changes it while the app is running.
|
||||
val sdf = remember { SimpleDateFormat("HH:mm:ss", Locale.getDefault()) }
|
||||
|
||||
val accentColor = when (event.type) {
|
||||
"BRAKING" -> Color(0xFFFF5252)
|
||||
"TURNING" -> Color(0xFFFFB300)
|
||||
"STOPPING" -> Color(0xFF42A5F5)
|
||||
else -> MaterialTheme.colorScheme.primary
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.padding(horizontal = 20.dp).padding(bottom = 32.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
event.type.replaceFirstChar { it.titlecase() },
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = accentColor,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(Icons.Default.Close, contentDescription = "Close")
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
"Confidence: ${event.confidence}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
sdf.format(Date(event.timestamp)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
EventDetailRow("Speed", "%.1f m/s".format(event.speedMps))
|
||||
EventDetailRow("Peak accel", "%.2f m/s²".format(event.peakAccelMagnitude))
|
||||
EventDetailRow("Peak gyro", "%.3f rad/s".format(event.peakGyroMagnitude))
|
||||
EventDetailRow("Duration", "${event.durationMs} ms")
|
||||
EventDetailRow("Location", "%.5f°, %.5f°".format(event.latitude, event.longitude))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EventDetailRow(label: String, value: String) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(value, style = MaterialTheme.typography.bodyMedium, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Medium)
|
||||
}
|
||||
}
|
||||
|
||||
// ── GPS track parsing ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -9,7 +9,6 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.hawhamburg.micr0bu.data.TripRepository
|
||||
import com.hawhamburg.micr0bu.data.shareTripCsv
|
||||
import com.hawhamburg.micr0bu.data.db.AppDatabase
|
||||
import com.hawhamburg.micr0bu.data.db.DetectedEventEntity
|
||||
import com.hawhamburg.micr0bu.data.db.RecordedTripEntity
|
||||
import com.hawhamburg.micr0bu.service.TripRecordingService
|
||||
import com.hawhamburg.micr0bu.service.TripServiceBus
|
||||
@@ -66,20 +65,6 @@ class TripRecordingViewModel(application: Application) : AndroidViewModel(applic
|
||||
/** All recorded trips, newest first. */
|
||||
val trips: Flow<List<RecordedTripEntity>> = repository.getAllTrips()
|
||||
|
||||
// ── Trip review ───────────────────────────────────────────────────────────
|
||||
|
||||
private val _selectedTripEvents = MutableStateFlow<List<DetectedEventEntity>>(emptyList())
|
||||
val selectedTripEvents: StateFlow<List<DetectedEventEntity>> = _selectedTripEvents.asStateFlow()
|
||||
|
||||
/** Load events for [tripId] into [selectedTripEvents]. */
|
||||
fun loadTripEvents(tripId: Long) {
|
||||
viewModelScope.launch {
|
||||
repository.getEventsForTrip(tripId).collect { events ->
|
||||
_selectedTripEvents.value = events
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Recording control ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -132,7 +117,6 @@ class TripRecordingViewModel(application: Application) : AndroidViewModel(applic
|
||||
shareTripCsv(
|
||||
context = context,
|
||||
trip = trip,
|
||||
events = repository.getEventsForTripOnce(tripId),
|
||||
v2xMessages = repository.getV2xMessagesForTripOnce(tripId),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -292,10 +292,6 @@
|
||||
<string name="nav_trips">Fahrten</string>
|
||||
|
||||
<!-- Phase A: Recording screen event counters -->
|
||||
<string name="rec_events_detected">Erkannte Ereignisse</string>
|
||||
<string name="rec_event_braking">Bremsen</string>
|
||||
<string name="rec_event_turning">Abbiegen</string>
|
||||
<string name="rec_event_stopping">Anhalten</string>
|
||||
<string name="rec_stream_event_detection">Ereigniserkennung</string>
|
||||
<string name="rec_open_session_log">CSV-Sitzungsprotokoll</string>
|
||||
|
||||
|
||||
@@ -305,10 +305,6 @@
|
||||
<string name="nav_trips">Trips</string>
|
||||
|
||||
<!-- Phase A: Recording screen event counters -->
|
||||
<string name="rec_events_detected">Detected Events</string>
|
||||
<string name="rec_event_braking">Braking</string>
|
||||
<string name="rec_event_turning">Turning</string>
|
||||
<string name="rec_event_stopping">Stopping</string>
|
||||
<string name="rec_stream_event_detection">Event Detection</string>
|
||||
<string name="rec_open_session_log">CSV Session Log</string>
|
||||
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
package com.hawhamburg.micr0bu
|
||||
|
||||
import com.hawhamburg.micr0bu.data.transport.EspLinkStatus
|
||||
import com.hawhamburg.micr0bu.data.transport.GnPositionVector
|
||||
import com.hawhamburg.micr0bu.domain.asn1.ItsTime
|
||||
import com.hawhamburg.micr0bu.domain.cam.Cam
|
||||
import com.hawhamburg.micr0bu.domain.cam.StationType
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pins the phone side of SERIAL_MSG_CAM_TX_PV: the 24-byte prefix the ESP32 turns into the
|
||||
* GeoNetworking Source Position Vector, and the heartbeat capability bit that decides whether the
|
||||
* phone may send that message at all.
|
||||
*
|
||||
* ## Where the expected bytes come from
|
||||
* Not from this code. They were produced with Python's `struct.pack("<IiihH", ...)` from the
|
||||
* layout documented at SERIAL_MSG_CAM_TX_PV in `serial_link.h`, independently of this encoder, so
|
||||
* an agreement here is not an encoder agreeing with itself.
|
||||
*
|
||||
* That same `struct.pack` call is what the bench harness used on 2026-09-10 to drive an
|
||||
* ESP32-C5 over its native USB port with this message. The CiT One OBU, an independent
|
||||
* GeoNetworking stack, decoded every Source Position Vector field of the resulting
|
||||
* transmissions (station type, PAI, latitude, longitude, speed, heading and timestamp) back to
|
||||
* the values sent. These are bytes a third-party receiver has accepted on air, not only bytes
|
||||
* this app agrees with.
|
||||
*/
|
||||
class CamTxPvSerialTest {
|
||||
|
||||
private fun String.hexToBytes(): ByteArray =
|
||||
chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
|
||||
private fun ByteArray.u32le(at: Int): Long =
|
||||
(0 until 4).fold(0L) { acc, i -> acc or ((this[at + i].toLong() and 0xFF) shl (8 * i)) }
|
||||
|
||||
// ---- the wire layout -------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `encodes the prefix byte for byte`() {
|
||||
val pv = GnPositionVector(
|
||||
mac = "024d49435230".hexToBytes(),
|
||||
stationType = 2,
|
||||
pai = true,
|
||||
tstMs = 0x12345678L,
|
||||
latTenMicroDeg = 535_543_026,
|
||||
lonTenMicroDeg = 100_226_476,
|
||||
speedCms = 543,
|
||||
headingDeciDeg = 1234,
|
||||
)
|
||||
// 024d49435230 | 02 | 01 | 78563412 | f2bceb1f | ac55f905 | 1f02 | d204
|
||||
assertEquals("024d49435230020178563412f2bceb1fac55f9051f02d204", pv.toSerialPrefix().toHex())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `encodes negative, extreme and flag-clear values`() {
|
||||
// Southern and western hemisphere, full reverse speed, heading at its maximum, PAI clear:
|
||||
// the sign handling that a northern-hemisphere bench test never exercises.
|
||||
val pv = GnPositionVector(
|
||||
mac = "020000000001".hexToBytes(),
|
||||
stationType = 2,
|
||||
pai = false,
|
||||
tstMs = 0xFFFF_FFFFL,
|
||||
latTenMicroDeg = -335_543_026,
|
||||
lonTenMicroDeg = -100_226_476,
|
||||
speedCms = -16384,
|
||||
headingDeciDeg = 3599,
|
||||
)
|
||||
assertEquals("0200000000010200ffffffff0e0500ec54aa06fa00c00f0e", pv.toSerialPrefix().toHex())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the timestamp is reduced modulo 2^32 on the wire`() {
|
||||
// TimestampIts passed 2^32 ms about 49.7 days after its 2004 epoch, so every real value
|
||||
// today is wider than 32 bits and the reduction is the normal case, not an edge case.
|
||||
val pv = vectorAt(tstMs = 716_121_572_779L)
|
||||
assertEquals(3_157_001_643L, pv.toSerialPrefix().u32le(8))
|
||||
}
|
||||
|
||||
// ---- building it from a CAM ------------------------------------------------------------
|
||||
|
||||
private val cam = Cam(
|
||||
stationId = 1_234_567_890L,
|
||||
stationType = StationType.CYCLIST,
|
||||
latitude = 53.5543026,
|
||||
longitude = 10.0226476,
|
||||
speedMps = 5.43,
|
||||
headingDeg = 123.4,
|
||||
yawRateDps = null,
|
||||
accelerationMps2 = null,
|
||||
timestamp = 1_789_036_772_779L,
|
||||
isOwn = true,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `fromCam takes the same values the CAM payload carries`() {
|
||||
val pv = GnPositionVector.fromCam(cam, accuracyM = 5f, mac = "024d49435230".hexToBytes())
|
||||
assertEquals(2, pv.stationType)
|
||||
assertEquals(535_543_026, pv.latTenMicroDeg)
|
||||
assertEquals(100_226_476, pv.lonTenMicroDeg)
|
||||
assertEquals(543, pv.speedCms)
|
||||
assertEquals(1234, pv.headingDeciDeg)
|
||||
assertTrue(pv.pai)
|
||||
// The GN TST and the CAM's generationDeltaTime must follow one time rule.
|
||||
assertEquals(ItsTime.timestampIts(cam.timestamp), pv.tstMs)
|
||||
assertEquals(716_121_572_779L, pv.tstMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `speed is clamped to the 15-bit field, never wrapped`() {
|
||||
// A wrapped 15-bit speed flips its sign bit and reads as reversing at speed.
|
||||
assertEquals(16383, GnPositionVector.fromCam(cam.copy(speedMps = 400.0), 5f, mac).speedCms)
|
||||
assertEquals(-16384, GnPositionVector.fromCam(cam.copy(speedMps = -400.0), 5f, mac).speedCms)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `heading wraps into 0 to 3599`() {
|
||||
assertEquals(0, GnPositionVector.fromCam(cam.copy(headingDeg = 360.0), 5f, mac).headingDeciDeg)
|
||||
assertEquals(50, GnPositionVector.fromCam(cam.copy(headingDeg = 725.0), 5f, mac).headingDeciDeg)
|
||||
assertEquals(3590, GnPositionVector.fromCam(cam.copy(headingDeg = -1.0), 5f, mac).headingDeciDeg)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-finite speed or heading does not throw`() {
|
||||
val pv = GnPositionVector.fromCam(
|
||||
cam.copy(speedMps = Double.NaN, headingDeg = Double.POSITIVE_INFINITY), 5f, mac,
|
||||
)
|
||||
assertEquals(0, pv.speedCms)
|
||||
assertEquals(0, pv.headingDeciDeg)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PAI follows the horizontal accuracy`() {
|
||||
assertTrue(GnPositionVector.fromCam(cam, GnPositionVector.PAI_MAX_ACCURACY_M, mac).pai)
|
||||
assertFalse(GnPositionVector.fromCam(cam, 25f, mac).pai)
|
||||
// Android reports 0 when it has no accuracy estimate: unknown is not accurate.
|
||||
assertFalse(GnPositionVector.fromCam(cam, 0f, mac).pai)
|
||||
assertFalse(GnPositionVector.fromCam(cam, null, mac).pai)
|
||||
}
|
||||
|
||||
@Test(expected = IllegalArgumentException::class)
|
||||
fun `an address that is not six bytes is rejected`() {
|
||||
GnPositionVector.fromCam(cam, 5f, ByteArray(5))
|
||||
}
|
||||
|
||||
// ---- capability negotiation ------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `firmware that predates the capability byte advertises nothing`() {
|
||||
// Old firmware sends a 7-byte heartbeat. Reading that as "no CAM_TX_PV" is what keeps a
|
||||
// new app on the legacy message, which that firmware still understands.
|
||||
val status = EspLinkStatus.parse("00000000000000".hexToBytes())!!
|
||||
assertEquals(0, status.capabilities)
|
||||
assertFalse(status.supportsCamTxPv)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `firmware that advertises CAM_TX_PV is recognised`() {
|
||||
val status = EspLinkStatus.parse("0000000000000001".hexToBytes())!!
|
||||
assertTrue(status.supportsCamTxPv)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a capability byte without the CAM_TX_PV bit does not enable it`() {
|
||||
assertFalse(EspLinkStatus.parse("0000000000000002".hexToBytes())!!.supportsCamTxPv)
|
||||
}
|
||||
|
||||
private val mac = "024d49435230".hexToBytes()
|
||||
|
||||
private fun vectorAt(tstMs: Long) = GnPositionVector(
|
||||
mac = mac, stationType = 2, pai = false, tstMs = tstMs,
|
||||
latTenMicroDeg = 0, lonTenMicroDeg = 0, speedCms = 0, headingDeciDeg = 0,
|
||||
)
|
||||
|
||||
private fun ByteArray.toHex() = joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
@@ -21,34 +21,34 @@ import kotlin.math.sqrt
|
||||
*
|
||||
* No Android emulator required — all production classes have zero Android imports.
|
||||
*
|
||||
* The test [config] uses a smaller window and fewer sustained frames than the
|
||||
* production defaults so tests run in milliseconds without generating thousands
|
||||
* of synthetic samples.
|
||||
* The test [config] shortens only the window and the sustained-frame counts, so
|
||||
* tests run in milliseconds instead of generating thousands of synthetic
|
||||
* samples. Every *signal* threshold is inherited from [DetectionConfig]'s
|
||||
* defaults, which are the values the app actually runs — the two cannot drift
|
||||
* apart, which they previously did: the service overrode nine of the twelve
|
||||
* parameters and these tests validated the un-overridden ones.
|
||||
*
|
||||
* Accel-std-dev notes
|
||||
* -------------------
|
||||
* A production threshold of 1.2 m/s² requires genuine variability in the window.
|
||||
* In the "hard brake" tests we alternate between high and low accel values
|
||||
* (e.g. 3.5 / 0.5), which yields std dev ≈ 1.5 with a 10-sample window.
|
||||
* The braking accel-std-dev threshold of 1.8 m/s² requires genuine variability
|
||||
* in the window. In the "hard brake" tests we alternate between high and low
|
||||
* accel values (4.5 / 0.5), which yields a population std dev of |hi − lo| / 2
|
||||
* = 2.0 in a full window — above the threshold with margin.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class EventDetectorTest {
|
||||
|
||||
/** Tighter config so fewer frames are needed to trigger each event. */
|
||||
/**
|
||||
* Shortens the window and the sustained-frame counts so fewer synthetic frames are
|
||||
* needed per test. Every signal threshold is deliberately left at its default, so
|
||||
* these tests exercise the thresholds the app ships with. Do not restate a signal
|
||||
* threshold here — that is exactly how the two configurations drifted apart before.
|
||||
*/
|
||||
private val config = DetectionConfig(
|
||||
windowSize = 10,
|
||||
brakingSustainedFrames = 5,
|
||||
turningSustainedFrames = 8,
|
||||
stoppingFrames = 20,
|
||||
// Keep production thresholds for all signal values:
|
||||
brakingSpeedDropThreshold = 0.5,
|
||||
brakingAccelStdDevThreshold = 1.2,
|
||||
brakingHighConfidenceRate = 1.5,
|
||||
turningGyroMeanThreshold = 0.4,
|
||||
turningBearingChangeThreshold = 10.0,
|
||||
turningMinSpeedThreshold = 2.0,
|
||||
stoppingSpeedThreshold = 0.5,
|
||||
stoppingAccelStdDevThreshold = 0.15,
|
||||
)
|
||||
|
||||
private lateinit var detector: EventDetector
|
||||
@@ -71,12 +71,12 @@ class EventDetectorTest {
|
||||
|
||||
/**
|
||||
* Produces [n] frames with alternating accelMagnitude values of [hi] and [lo],
|
||||
* giving a population std dev of |hi - lo| / 2, which exceeds the production
|
||||
* threshold of 1.2 m/s² when hi=3.5 and lo=0.5 (std dev = 1.5).
|
||||
* giving a population std dev of |hi - lo| / 2, which exceeds the shipping
|
||||
* threshold of 1.8 m/s² when hi=4.5 and lo=0.5 (std dev = 2.0).
|
||||
*/
|
||||
private fun alternatingAccelFrames(
|
||||
n: Int,
|
||||
hi: Double = 3.5,
|
||||
hi: Double = 4.5,
|
||||
lo: Double = 0.5,
|
||||
speedMps: Double = 10.0,
|
||||
bearingChangeDps: Double = 0.0,
|
||||
@@ -139,12 +139,12 @@ class EventDetectorTest {
|
||||
@Test fun `hard brake with large speed drop has HIGH confidence`() = runCollecting { events ->
|
||||
// Variability established before the drop - see the note in the test above.
|
||||
alternatingAccelFrames(n = config.windowSize, speedMps = 10.0, timeOffset = 0)
|
||||
// Drop of 8 m/s > brakingHighConfidenceRate (1.5)
|
||||
// Drop of 8 m/s > brakingHighConfidencePeakDrop (1.5)
|
||||
alternatingAccelFrames(
|
||||
n = config.brakingSustainedFrames + 5,
|
||||
hi = 3.5,
|
||||
hi = 4.5,
|
||||
lo = 0.5,
|
||||
speedMps = 2.0, // drop from 10 → 8 m/s
|
||||
speedMps = 2.0, // drop from 10 → 2 m/s
|
||||
timeOffset = config.windowSize,
|
||||
)
|
||||
val braking = events.filter { it.type == EventType.BRAKING }
|
||||
@@ -159,12 +159,16 @@ class EventDetectorTest {
|
||||
@Test fun `moderate speed drop has MEDIUM confidence`() = runCollecting { events ->
|
||||
// Variability established before the drop - see `hard brake triggers BRAKING event`.
|
||||
alternatingAccelFrames(n = config.windowSize, speedMps = 3.0, timeOffset = 0)
|
||||
// Drop of 0.8 m/s — above speed-drop threshold (0.5) but below high-conf rate (1.5)
|
||||
// Drop of 1.2 m/s — above the speed-drop threshold (1.0) but below the
|
||||
// high-confidence peak drop (1.5), so this must land as MEDIUM. The window
|
||||
// between those two values is narrow at the shipping thresholds, which is
|
||||
// itself worth knowing: MEDIUM braking is only emitted for drops in
|
||||
// (1.0, 1.5] m/s.
|
||||
alternatingAccelFrames(
|
||||
n = config.brakingSustainedFrames + 5,
|
||||
hi = 3.5,
|
||||
hi = 4.5,
|
||||
lo = 0.5,
|
||||
speedMps = 2.2, // drop = 0.8 m/s
|
||||
speedMps = 1.8, // drop = 1.2 m/s
|
||||
timeOffset = config.windowSize,
|
||||
)
|
||||
val braking = events.filter { it.type == EventType.BRAKING }
|
||||
@@ -179,9 +183,9 @@ class EventDetectorTest {
|
||||
repeat(total) { i ->
|
||||
detector.processSample(
|
||||
accelMagnitude = 0.3,
|
||||
gyroMagnitude = 0.8, // mean → well above 0.4 threshold
|
||||
gyroMagnitude = 0.8, // mean → above the 0.6 threshold
|
||||
speedMps = 4.0, // above 2 m/s → bearing also checked
|
||||
bearingChangeDegPerSec = 15.0, // above 10 °/s → both signals agree
|
||||
bearingChangeDegPerSec = 20.0, // above 15 °/s → both signals agree
|
||||
latitude = 53.5,
|
||||
longitude = 10.0,
|
||||
timestamp = i * 20L,
|
||||
@@ -193,7 +197,7 @@ class EventDetectorTest {
|
||||
@Test fun `turning with both signals agreeing gets HIGH confidence`() = runCollecting { events ->
|
||||
val total = config.windowSize + config.turningSustainedFrames + 4
|
||||
repeat(total) { i ->
|
||||
detector.processSample(0.3, 0.8, 4.0, 15.0, 53.5, 10.0, i * 20L)
|
||||
detector.processSample(0.3, 0.8, 4.0, 20.0, 53.5, 10.0, i * 20L)
|
||||
}
|
||||
val turning = events.filter { it.type == EventType.TURNING }
|
||||
assertTrue(turning.isNotEmpty())
|
||||
@@ -205,9 +209,9 @@ class EventDetectorTest {
|
||||
repeat(total) { i ->
|
||||
detector.processSample(
|
||||
accelMagnitude = 0.2,
|
||||
gyroMagnitude = 0.6, // above gyro threshold
|
||||
gyroMagnitude = 0.9, // above the 0.6 gyro threshold
|
||||
speedMps = 1.0, // below 2 m/s → bearing not enforced
|
||||
bearingChangeDegPerSec = 3.0, // below bearing threshold
|
||||
bearingChangeDegPerSec = 3.0, // below the 15 °/s bearing threshold
|
||||
latitude = 53.5,
|
||||
longitude = 10.0,
|
||||
timestamp = i * 20L,
|
||||
@@ -253,7 +257,7 @@ class EventDetectorTest {
|
||||
// Speed stays at zero; occasional accel/gyro spikes from bag jostle
|
||||
repeat(50) { i ->
|
||||
val accel = if (i % 5 == 0) 1.8 else 0.3 // jitter but mean is below std-dev threshold
|
||||
val gyro = if (i % 7 == 0) 0.35 else 0.05 // occasional spike but mean stays < 0.4
|
||||
val gyro = if (i % 7 == 0) 0.35 else 0.05 // occasional spike but mean stays < 0.6
|
||||
detector.processSample(
|
||||
accelMagnitude = accel,
|
||||
gyroMagnitude = gyro,
|
||||
@@ -265,7 +269,7 @@ class EventDetectorTest {
|
||||
)
|
||||
}
|
||||
// speed = 0 → no speed drop possible → no BRAKING
|
||||
// gyro mean stays below 0.4 (only 1/7 frames spike to 0.35) → no TURNING
|
||||
// gyro mean stays below 0.6 (only 1/7 frames spike to 0.35) → no TURNING
|
||||
val unwanted = events.filter { it.type == EventType.BRAKING || it.type == EventType.TURNING }
|
||||
assertTrue("Bag movement must not trigger BRAKING or TURNING, got: $events", unwanted.isEmpty())
|
||||
}
|
||||
@@ -299,13 +303,12 @@ class EventDetectorTest {
|
||||
}
|
||||
// Second stop episode. Deliberately longer than the first: stopping also requires the
|
||||
// accel std dev to be BELOW a threshold, and the rolling window still holds the five
|
||||
// moving samples above. It takes 8 further frames for those to drain out far enough for
|
||||
// the std dev to fall under 0.15, and only then does the counter start. The first episode
|
||||
// needs no such allowance because the window begins empty.
|
||||
//
|
||||
// stoppingFrames + 5 was not enough - the second episode reached 17 of the 21 frames it
|
||||
// needs and silently emitted nothing, which is what made this test fail.
|
||||
repeat(config.stoppingFrames + 10) {
|
||||
// moving samples above. At the shipping threshold of 0.10 m/s² even a single 0.5 sample
|
||||
// left in a 10-sample window gives a std dev of ~0.14, so ALL five have to be evicted
|
||||
// before the counter can start - that is a full windowSize of stationary frames. Only
|
||||
// then do the 21 qualifying frames the event needs begin to accumulate. The first
|
||||
// episode needs no such allowance because the window begins empty.
|
||||
repeat(config.stoppingFrames + 20) {
|
||||
detector.processSample(0.02, 0.01, 0.1, 0.0, 53.5, 10.0, t++ * 20L)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.hawhamburg.micr0bu
|
||||
|
||||
import com.hawhamburg.micr0bu.domain.asn1.ItsTime
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pins the arithmetic that moves a transmit timestamp from the phone's wall clock onto GNSS time.
|
||||
*
|
||||
* The cases come from the 2026-09-10 bench session. The sending phone's clock was 1456 s fast
|
||||
* because it had no automatic time source, and every CAM it sent was stamped 24 minutes in the
|
||||
* future. After a manual correction it was 6 s slow. Both have to come out on GNSS time.
|
||||
*/
|
||||
class ItsTimeTest {
|
||||
|
||||
private val gnssNow = 1_789_038_922_000L
|
||||
|
||||
@Test
|
||||
fun `without a GNSS reading the wall-clock time is used unchanged`() {
|
||||
assertEquals(1_000L, ItsTime.onGnssTime(systemMs = 1_000L, gnssNowMs = null, systemNowMs = 5_000L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a phone clock running fast is pulled back onto GNSS time`() {
|
||||
val systemNow = gnssNow + 1_456_000L
|
||||
// A fix the wall clock stamped 0.8 s ago. It must still be 0.8 s old afterwards.
|
||||
val fix = systemNow - 800L
|
||||
assertEquals(gnssNow - 800L, ItsTime.onGnssTime(fix, gnssNow, systemNow))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a phone clock running slow is pushed forward onto GNSS time`() {
|
||||
val systemNow = gnssNow - 6_000L
|
||||
assertEquals(gnssNow - 250L, ItsTime.onGnssTime(systemNow - 250L, gnssNow, systemNow))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an accurate phone clock is left where it is`() {
|
||||
assertEquals(gnssNow - 40L, ItsTime.onGnssTime(gnssNow - 40L, gnssNow, gnssNow))
|
||||
}
|
||||
}
|
||||
@@ -1,66 +1,95 @@
|
||||
package com.hawhamburg.micr0bu
|
||||
|
||||
import com.hawhamburg.micr0bu.domain.cam.OwnStationIds
|
||||
import com.hawhamburg.micr0bu.domain.cam.OwnStationIds.BENCH_PING
|
||||
import com.hawhamburg.micr0bu.domain.cam.OwnStationIds.BENCH_PING_GRACE_MS
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pins the rule that decides whether a received CAM is one this phone sent.
|
||||
*
|
||||
* ## The bug this exists to prevent
|
||||
* The phone transmits under two station IDs: the persisted per-install one used by
|
||||
* `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 bugs this exists to prevent
|
||||
* Getting it wrong fails in two opposite directions, and each has happened:
|
||||
*
|
||||
* The filter originally checked only the persisted ID. Every bench ping therefore returned as a
|
||||
* remote road user sitting exactly on top of the ego position, moving at the ego's own speed and
|
||||
* heading, and was fed to the detection engine as a collision partner for itself. Nothing failed
|
||||
* loudly: the app simply raised use case alerts against itself for as long as the pinger ran.
|
||||
*
|
||||
* These tests are what should fail if a third transmit path is ever added without teaching this
|
||||
* rule about it.
|
||||
* - **Too narrow.** An own frame that is not recognised comes back as a remote road user sitting
|
||||
* exactly on the ego position, and is fed to the detection engine as a collision partner for
|
||||
* itself. That happened with the bench pinger's separate ID, and pseudonym rotation creates the
|
||||
* same risk for an ID that has just been retired.
|
||||
* - **Too wide.** On 2026-09-10 the bench ID counted as ours on every phone, so a phone watching
|
||||
* through the CiT One silently discarded another phone's pings as its own, although it had sent
|
||||
* none. Nothing appeared on its V2X screen while the broker was full of them.
|
||||
*/
|
||||
class OwnStationIdsTest {
|
||||
|
||||
private val 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
|
||||
fun `recognises the persisted transmit id`() {
|
||||
assertTrue(OwnStationIds.isOwn(persisted, persisted))
|
||||
fun `recognises the current transmit id`() {
|
||||
assertTrue(OwnStationIds.isOwn(current, ours, benchPingIsOurs = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recognises the bench ping id even though it is not the persisted one`() {
|
||||
// The regression. The pinger's id is deliberately different, which is exactly why a
|
||||
// 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))
|
||||
fun `recognises a recently retired id, so a frame sent just before a rotation is still ours`() {
|
||||
assertTrue(OwnStationIds.isOwn(retired, ours, benchPingIsOurs = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recognises the bench ping id before the persisted id has loaded`() {
|
||||
// The persisted id is read asynchronously, so it can still be null while the pinger is
|
||||
// already transmitting. The ping must be recognised as ours regardless.
|
||||
assertTrue(OwnStationIds.isOwn(OwnStationIds.BENCH_PING, null))
|
||||
fun `another phone's bench ping is shown, not swallowed as our own`() {
|
||||
// The 2026-09-10 regression: this phone is not pinging, so 999999 is someone else.
|
||||
assertFalse(OwnStationIds.isOwn(BENCH_PING, ours, benchPingIsOurs = false))
|
||||
assertFalse(OwnStationIds.isOwn(BENCH_PING, emptySet(), benchPingIsOurs = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `our own bench ping is recognised while we are pinging, even before any transmit id loads`() {
|
||||
assertTrue(OwnStationIds.isOwn(BENCH_PING, emptySet(), benchPingIsOurs = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `treats a genuine remote station as remote`() {
|
||||
assertFalse(OwnStationIds.isOwn(2_741_041_966L, persisted))
|
||||
assertFalse(OwnStationIds.isOwn(2_741_041_966L, null))
|
||||
assertFalse(OwnStationIds.isOwn(2_741_041_966L, ours, benchPingIsOurs = true))
|
||||
assertFalse(OwnStationIds.isOwn(2_741_041_966L, emptySet(), benchPingIsOurs = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `station id zero is never ours`() {
|
||||
// 0 is the "not resolved yet" placeholder for the ego identity. Matching on it would
|
||||
// swallow real traffic from any station that reported 0.
|
||||
assertFalse(OwnStationIds.isOwn(0L, null))
|
||||
assertFalse(OwnStationIds.isOwn(0L, 0L))
|
||||
assertFalse(OwnStationIds.isOwn(0L, setOf(0L), benchPingIsOurs = true))
|
||||
}
|
||||
|
||||
// ---- when the bench id is ours ---------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `the bench id is ours while the pinger runs`() {
|
||||
assertTrue(OwnStationIds.benchPingIsOurs(pingerActive = true, pingerStoppedAtMs = null, nowMs = 0L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the bench id is not ours on a phone that never pinged`() {
|
||||
assertFalse(OwnStationIds.benchPingIsOurs(pingerActive = false, pingerStoppedAtMs = null, nowMs = 50_000L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the bench id stays ours for the grace window after Stop, and not a moment longer`() {
|
||||
val stop = 100_000L
|
||||
assertTrue(OwnStationIds.benchPingIsOurs(false, stop, stop + BENCH_PING_GRACE_MS))
|
||||
assertFalse(OwnStationIds.benchPingIsOurs(false, stop, stop + BENCH_PING_GRACE_MS + 1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a clock reading before the stop time does not claim the bench id`() {
|
||||
assertFalse(OwnStationIds.benchPingIsOurs(false, pingerStoppedAtMs = 100_000L, nowMs = 99_000L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the bench MAC is a locally administered unicast address`() {
|
||||
// Bit 1 set, bit 0 clear. A source address must never be a group address.
|
||||
assertEquals(0x02, OwnStationIds.BENCH_PING_MAC[0].toInt() and 0x03)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.hawhamburg.micr0bu
|
||||
|
||||
import com.hawhamburg.micr0bu.domain.cam.OwnStationIds
|
||||
import com.hawhamburg.micr0bu.domain.cam.Pseudonym
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* Pins what a transmit pseudonym is allowed to look like, and when it rotates.
|
||||
*
|
||||
* The address rules matter on air, not just in the app: the ESP32 writes this MAC straight into
|
||||
* the 802.11 source address. A group (multicast) source address is invalid, and a random address
|
||||
* without the locally-administered bit claims to belong to a real hardware vendor.
|
||||
*/
|
||||
class PseudonymTest {
|
||||
|
||||
@Test
|
||||
fun `rotates every ten minutes`() {
|
||||
assertEquals(10 * 60_000L, Pseudonym.ROTATION_INTERVAL_MS)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `expires exactly at the rotation interval, not a millisecond before`() {
|
||||
val p = Pseudonym(stationId = 42L, mac = mac(0x02), createdAtMs = 1_000L)
|
||||
assertFalse(p.isExpired(1_000L + Pseudonym.ROTATION_INTERVAL_MS - 1))
|
||||
assertTrue(p.isExpired(1_000L + Pseudonym.ROTATION_INTERVAL_MS))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a clock that moved back past the creation time forces a rotation`() {
|
||||
// Otherwise a creation time now lying in the future would pin one identity until the
|
||||
// clock caught up, which after a large correction could be hours.
|
||||
val p = Pseudonym(stationId = 42L, mac = mac(0x02), createdAtMs = 1_000L)
|
||||
assertTrue(p.isExpired(999L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `generated addresses are locally administered unicast, whatever the random bytes`() {
|
||||
repeat(500) { seed ->
|
||||
val first = Pseudonym.generate(0L, Random(seed)).mac[0].toInt()
|
||||
assertEquals("seed $seed: bit 1 set, bit 0 clear", 0x02, first and 0x03)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `generated station ids stay in range`() {
|
||||
repeat(500) { seed ->
|
||||
val id = Pseudonym.generate(0L, Random(seed)).stationId
|
||||
assertTrue("seed $seed: $id", id in 1L until 0xFFFF_FFFEL)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `never generates the bench pinger's identity`() {
|
||||
// Scripted so the exclusion loops actually run: the first draw of each is the bench
|
||||
// value, which must be rejected in favour of the second.
|
||||
val random = ScriptedRandom(
|
||||
longs = ArrayDeque(listOf(OwnStationIds.BENCH_PING, 42L)),
|
||||
bytes = ArrayDeque(listOf(OwnStationIds.BENCH_PING_MAC, byteArrayOf(0x13, 1, 2, 3, 4, 5))),
|
||||
)
|
||||
val p = Pseudonym.generate(0L, random)
|
||||
assertEquals(42L, p.stationId)
|
||||
assertEquals("0x13 with the group bit cleared and the local bit set", 0x12, p.mac[0].toInt() and 0xFF)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a rotation replaces the station id and the address together`() {
|
||||
val a = Pseudonym.generate(0L, Random(1))
|
||||
val b = Pseudonym.generate(Pseudonym.ROTATION_INTERVAL_MS, Random(2))
|
||||
assertNotEquals(a.stationId, b.stationId)
|
||||
assertFalse(a.mac.contentEquals(b.mac))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `equality compares the address bytes, not the array instance`() {
|
||||
assertEquals(
|
||||
Pseudonym(7L, mac(0x02), 5L),
|
||||
Pseudonym(7L, mac(0x02), 5L),
|
||||
)
|
||||
}
|
||||
|
||||
private fun mac(first: Int) = byteArrayOf(first.toByte(), 0x11, 0x22, 0x33, 0x44, 0x55)
|
||||
|
||||
private class ScriptedRandom(
|
||||
private val longs: ArrayDeque<Long>,
|
||||
private val bytes: ArrayDeque<ByteArray>,
|
||||
) : Random() {
|
||||
override fun nextBits(bitCount: Int): Int = error("not used by Pseudonym.generate")
|
||||
override fun nextLong(from: Long, until: Long): Long = longs.removeFirst()
|
||||
override fun nextBytes(size: Int): ByteArray = bytes.removeFirst().copyOf()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
# Sniffer board and capture tooling
|
||||
|
||||
How to put an ESP32-C5 on the ITS-G5 channel as a passive sniffer, pull its captures onto this
|
||||
PC, and check what is on air. The sniffer firmware itself is the third-party
|
||||
`its-g5-receiver-firmware` checkout beside this repo; only the tooling and these notes are ours.
|
||||
|
||||
| File | What it does |
|
||||
|---|---|
|
||||
| `live_capture.py` | Streams the device's captures into a growing `.pcap` while it runs. The usual choice. |
|
||||
| `dump_pcap.py` | Pulls one capture out of the device's in-memory buffer after the fact. |
|
||||
| `../obu-firmware/test/pcap_gn_tally.py` | Tallies GeoNetworking headers per station over a `.pcap`. |
|
||||
|
||||
One-time: `pip install pyserial` (present in Python 3.11 on the bench PC, so `py -3.11` works).
|
||||
|
||||
## Which port
|
||||
|
||||
The sniffer firmware's console, and with it the pcap stream, goes out **UART0** - the board's
|
||||
USB-bridge port (a CH343, its own COM number), not the native USB-C port. A board with only one
|
||||
USB-C port cannot be used as a sniffer for this reason. On the bench this has been COM5 and, after
|
||||
a re-enumeration, COM8.
|
||||
|
||||
## Live capture (preferred)
|
||||
|
||||
```powershell
|
||||
cd capture
|
||||
py -3.11 live_capture.py COM8
|
||||
```
|
||||
|
||||
It writes `recordings/capture_<timestamp>.pcap` next to itself, flushing after every packet, so
|
||||
the file can be read while it grows. Stop it with Ctrl+C. Use `-o <dir>` to write elsewhere;
|
||||
`recordings/` is gitignored, since captures are large and are data rather than source. Captures
|
||||
taken before 2026-09-14 are still in `its-g5-receiver-firmware/recordings/`; the host tests read
|
||||
both directories.
|
||||
|
||||
### The CR insertion, and why captures used to be corrupt
|
||||
|
||||
ESP-IDF's newlib console converts LF to CRLF on its way out, and that applies to every `0x0a` byte
|
||||
of the **binary** pcap stream, not only to log text. Each inserted CR shifts everything after it,
|
||||
so pcap record headers and captured frames alike come out corrupt, and the file stops being
|
||||
parseable at the first occurrence.
|
||||
|
||||
Measured on 2026-09-14: a 787 KB capture parsed cleanly for only 82 of about 2000 records, and
|
||||
DENMs appeared on nonsense BTP ports because their payloads contain `0x0a` often. `undo_crlf()` in
|
||||
`live_capture.py` reverses it on the raw stream before any framing, which is exact; afterwards a
|
||||
capture parsed to EOF and DENMs read as port 2002 again.
|
||||
|
||||
**Captures taken before 2026-09-14 are truncated at their first corrupted record.** Anything
|
||||
measured from them is worth re-checking. `dump_pcap.py` reads the same console and has not been
|
||||
given the same treatment yet.
|
||||
|
||||
## Checking a capture
|
||||
|
||||
```powershell
|
||||
py -3.11 ..\obu-firmware\test\pcap_gn_tally.py recordings\capture_<timestamp>.pcap
|
||||
```
|
||||
|
||||
One row per station, packet type, BTP port and GN lifetime. For the messages themselves, decode
|
||||
the payloads with `asn1tools` against the modules in `../asn1/` and re-encode them: identical bytes
|
||||
mean the message was read exactly, wrong bytes mean it was not. `obu-firmware/test/check_replay.py`
|
||||
does this over a whole capture.
|
||||
|
||||
## Flashing the sniffer firmware
|
||||
|
||||
From the receiver checkout, with its **pinned** ESP-IDF (not the global 5.5.4 install):
|
||||
|
||||
```powershell
|
||||
cd its-g5-receiver-firmware
|
||||
git submodule update --init --recursive
|
||||
.\esp-idf\install.bat
|
||||
Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass
|
||||
.\esp-idf\export.ps1
|
||||
idf.py set-target esp32c5
|
||||
idf.py -p COM8 -b 921600 flash
|
||||
```
|
||||
|
||||
Its `sdkconfig` for a bare board (nothing wired) needs SPI Ethernet off, and the pcap destination
|
||||
set to Memory, both under `idf.py menuconfig`. Wired variants have ready-made configs in that
|
||||
checkout: `sdkconfig.proto-w5500`, `sdkconfig.proto-enc28j60`, `sdkconfig.proto-spi-eppp`.
|
||||
|
||||
To reflash a board that already has a built image, without a toolchain terminal:
|
||||
|
||||
```powershell
|
||||
cd its-g5-receiver-firmware\build
|
||||
C:\Espressif\python_env\idf5.5_py3.11_env\Scripts\python.exe -m esptool --chip esp32c5 -p COM8 -b 921600 write_flash --flash_mode dio --flash_freq 80m --flash_size 16MB 0x2000 bootloader/bootloader.bin 0x8000 partition_table/partition-table.bin 0x1e000 ota_data_initial.bin 0x20000 its-g5-receiver-firmware.bin
|
||||
```
|
||||
|
||||
## Pulling a capture after the fact
|
||||
|
||||
Only for the Memory destination, and the buffer is small (`SNIFFER_PCAP_MEMORY_SIZE`, 4096 bytes
|
||||
by default) - a smoke test, not a session. In the device console (`idf.py -p COM8 monitor`, exit
|
||||
with Ctrl+T then Ctrl+X):
|
||||
|
||||
```
|
||||
sniffer -P
|
||||
sniffer --stop
|
||||
```
|
||||
|
||||
Then, with the port free:
|
||||
|
||||
```powershell
|
||||
py -3.11 dump_pcap.py COM8
|
||||
```
|
||||
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Pulls a capture off the ITS-G5 receiver's in-memory pcap buffer over the existing USB serial
|
||||
connection and saves it as a real .pcap file on this machine.
|
||||
|
||||
Requires the firmware to be built with:
|
||||
Example Configuration -> Select destination to store pcap file -> Memory
|
||||
|
||||
Usage (typical):
|
||||
1. Close idf.py monitor (only one program can hold the COM port at a time).
|
||||
2. Run a capture on the device: `sniffer -P` ... let it run ... `sniffer --stop`
|
||||
3. python dump_pcap.py COM5
|
||||
|
||||
The device has no access to this computer's filesystem, so it can't write here directly. Instead,
|
||||
`pcap --dump` streams the raw pcap bytes back over the same serial link, wrapped in plain-text
|
||||
markers ("===PCAP-DUMP-START:<len>===" ... raw bytes ... "===PCAP-DUMP-END==="). This script finds
|
||||
those markers and writes just the raw bytes out as a .pcap file.
|
||||
|
||||
Install dependency once: pip install pyserial
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import re
|
||||
import sys
|
||||
|
||||
try:
|
||||
import serial
|
||||
except ImportError:
|
||||
print("Missing dependency. Install it with: pip install pyserial", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
START_RE = re.compile(rb"===PCAP-DUMP-START:(\d+)===\n")
|
||||
END_MARKER = b"\n===PCAP-DUMP-END===\n"
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("port", help="Serial port the device is on, e.g. COM5")
|
||||
parser.add_argument("-b", "--baud", type=int, default=115200, help="Baud rate (default: 115200)")
|
||||
parser.add_argument("-o", "--outdir", default="recordings", help="Output directory (default: ./recordings)")
|
||||
parser.add_argument("-t", "--timeout", type=float, default=15.0, help="Seconds to wait for the dump to start")
|
||||
args = parser.parse_args()
|
||||
|
||||
import os
|
||||
os.makedirs(args.outdir, exist_ok=True)
|
||||
|
||||
print(f"Opening {args.port} @ {args.baud}...")
|
||||
with serial.Serial(args.port, args.baud, timeout=1) as ser:
|
||||
# Nudge the console in case there's stale input, then request the dump.
|
||||
ser.reset_input_buffer()
|
||||
ser.write(b"\r\n")
|
||||
ser.write(b"pcap -f dump --dump\r\n")
|
||||
|
||||
print("Waiting for dump to start...")
|
||||
buf = b""
|
||||
match = None
|
||||
deadline = datetime.datetime.now() + datetime.timedelta(seconds=args.timeout)
|
||||
while datetime.datetime.now() < deadline:
|
||||
chunk = ser.read(256)
|
||||
if chunk:
|
||||
buf += chunk
|
||||
match = START_RE.search(buf)
|
||||
if match:
|
||||
break
|
||||
if not match:
|
||||
print("Timed out waiting for '===PCAP-DUMP-START:...===' marker.\n"
|
||||
"Check that: the firmware is built with the Memory pcap destination, a capture was\n"
|
||||
"actually taken ('sniffer -P' then 'sniffer --stop'), and no other program (like\n"
|
||||
"idf.py monitor) is holding the serial port open.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
length = int(match.group(1))
|
||||
print(f"Dump starting, {length} bytes expected.")
|
||||
|
||||
# Anything after the marker in our buffer is already part of the payload.
|
||||
payload = buf[match.end():]
|
||||
remaining = length - len(payload)
|
||||
while remaining > 0:
|
||||
chunk = ser.read(min(remaining, 4096))
|
||||
if not chunk:
|
||||
print(f"Serial read timed out with {remaining} bytes still missing.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
payload += chunk
|
||||
remaining -= len(chunk)
|
||||
|
||||
# Drain (and sanity-check) the trailing end marker, but don't fail hard if it's not exact.
|
||||
tail = ser.read(len(END_MARKER))
|
||||
if tail != END_MARKER:
|
||||
print("Warning: end marker didn't match exactly - payload may still be fine.", file=sys.stderr)
|
||||
|
||||
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
outpath = os.path.join(args.outdir, f"capture_{timestamp}.pcap")
|
||||
with open(outpath, "wb") as f:
|
||||
f.write(payload)
|
||||
|
||||
print(f"Saved {len(payload)} bytes to {outpath}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,226 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Continuously listens on the ITS-G5 receiver's serial console and writes every captured packet into a
|
||||
live-growing .pcap file, with no console commands needed on the device side.
|
||||
|
||||
The firmware streams every packet it captures out over the same serial connection the console runs on,
|
||||
automatically, as soon as the sniffer is running (which happens on boot by default). Each packet is framed
|
||||
with plain-text markers so this script can pull the binary pcap bytes out of the stream even though
|
||||
regular log lines are interleaved with it:
|
||||
|
||||
===PCAP-LIVE-HEADER:<len>===\\n<24 raw bytes>\\n (sent once, the pcap global header)
|
||||
===PCAP-LIVE-PKT:<len>===\\n<raw bytes>\\n (sent once per captured packet)
|
||||
|
||||
Usage:
|
||||
python live_capture.py COM5
|
||||
|
||||
Runs until you press Ctrl+C. Writes to recordings/capture_<timestamp>.pcap, flushing after every packet
|
||||
so you can open the file in Wireshark while it's still being written (use "File > Open" again, or
|
||||
Wireshark's own "Follow" won't auto-refresh but re-opening will show the latest packets).
|
||||
|
||||
Install dependency once: pip install pyserial
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
|
||||
try:
|
||||
import serial
|
||||
except ImportError:
|
||||
print("Missing dependency. Install it with: pip install pyserial", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# \r? because the ESP console emits CRLF: on Windows the markers arrive as
|
||||
# "===PCAP-LIVE-PKT:310===\r\n", which never matched a bare \n and left the capture silently
|
||||
# empty while the device was streaming perfectly well.
|
||||
HEADER_RE = re.compile(rb"===PCAP-LIVE-HEADER:(\d+)===\r?\n")
|
||||
PKT_RE = re.compile(rb"===PCAP-LIVE-PKT:(\d+)===\r?\n")
|
||||
|
||||
LINKTYPE_ETHERNET = 1
|
||||
LINKTYPE_IEEE802_11_RADIOTAP = 127
|
||||
|
||||
|
||||
def mac_str(b):
|
||||
return ":".join(f"{x:02x}" for x in b)
|
||||
|
||||
|
||||
def build_default_pcap_header(link_type):
|
||||
"""Synthesizes the same 24-byte global pcap header the firmware would have sent, for when we
|
||||
connect after the device's one-time header already went out (see the race note in main())."""
|
||||
header = bytearray(24)
|
||||
header[0:4] = bytes([0xD4, 0xC3, 0xB2, 0xA1]) # magic (LE bytes of 0xA1B2C3D4)
|
||||
header[4:6] = (2).to_bytes(2, "little") # major version
|
||||
header[6:8] = (4).to_bytes(2, "little") # minor version
|
||||
header[16:20] = (0x40000).to_bytes(4, "little") # snaplen
|
||||
header[20:24] = link_type.to_bytes(4, "little")
|
||||
return bytes(header)
|
||||
|
||||
|
||||
def summarize_packet(link_type, record_bytes, index):
|
||||
"""Best-effort human-readable one-line summary of a captured packet, for live feedback.
|
||||
record_bytes is the raw 16-byte pcap record header followed by the captured frame."""
|
||||
seconds = int.from_bytes(record_bytes[0:4], "little")
|
||||
microseconds = int.from_bytes(record_bytes[4:8], "little")
|
||||
cap_len = int.from_bytes(record_bytes[8:12], "little")
|
||||
frame = record_bytes[16:]
|
||||
ts = f"{seconds}.{microseconds:06d}"
|
||||
|
||||
if link_type == LINKTYPE_IEEE802_11_RADIOTAP and len(frame) >= 24:
|
||||
radiotap_len = int.from_bytes(frame[2:4], "little")
|
||||
rssi = frame[8] - 256 if frame[8] >= 128 else frame[8]
|
||||
station_id = int.from_bytes(frame[16:24], "little")
|
||||
mac_frame = frame[radiotap_len:]
|
||||
if len(mac_frame) >= 16:
|
||||
dst = mac_str(mac_frame[4:10])
|
||||
src = mac_str(mac_frame[10:16])
|
||||
else:
|
||||
dst = src = "?"
|
||||
station = f"{station_id:012x}" if station_id else "unknown"
|
||||
return (f"#{index:<5} [{ts}] len={cap_len:<5} rssi={rssi:>4}dBm "
|
||||
f"station={station} {src} -> {dst}")
|
||||
|
||||
if link_type == LINKTYPE_ETHERNET and len(frame) >= 14:
|
||||
dst = mac_str(frame[0:6])
|
||||
src = mac_str(frame[6:12])
|
||||
ethertype = int.from_bytes(frame[12:14], "big")
|
||||
return f"#{index:<5} [{ts}] len={cap_len:<5} eth {src} -> {dst} type=0x{ethertype:04x}"
|
||||
|
||||
return f"#{index:<5} [{ts}] len={cap_len:<5} (unrecognized frame format)"
|
||||
|
||||
|
||||
def undo_crlf(chunk, state):
|
||||
"""Undo the CR the device console inserts before every LF.
|
||||
|
||||
ESP-IDF's newlib console converts LF to CRLF on its way out, and that happens to every 0x0A
|
||||
byte of the binary pcap stream too, not only to log text. Each inserted CR shifts everything
|
||||
after it, so pcap record headers and captured frames alike come out corrupt. This is the
|
||||
"byte inserted mid-frame" seen in older recordings; with DENM traffic on air it wrecks most
|
||||
of a capture (measured 2026-09-14: a 787 KB file parsed cleanly for only 82 records).
|
||||
|
||||
Dropping one CR immediately before each LF undoes it exactly, provided it is done on the raw
|
||||
stream before any framing and a trailing CR is carried across read boundaries. CR and LF are
|
||||
written as byte values here so the transformation cannot be confused with an escape.
|
||||
"""
|
||||
CR, LF = bytes([13]), bytes([10])
|
||||
if state["pending_cr"]:
|
||||
chunk = CR + chunk
|
||||
state["pending_cr"] = False
|
||||
if chunk.endswith(CR):
|
||||
chunk = chunk[:-1]
|
||||
state["pending_cr"] = True
|
||||
return chunk.replace(CR + LF, LF)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("port", help="Serial port the device is on, e.g. COM5")
|
||||
parser.add_argument("-b", "--baud", type=int, default=115200, help="Baud rate (default: 115200)")
|
||||
parser.add_argument("-o", "--outdir", default="recordings", help="Output directory (default: ./recordings)")
|
||||
args = parser.parse_args()
|
||||
|
||||
os.makedirs(args.outdir, exist_ok=True)
|
||||
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
outpath = os.path.join(args.outdir, f"capture_{timestamp}.pcap")
|
||||
|
||||
print(f"Opening {args.port} @ {args.baud}...")
|
||||
print(f"Writing live capture to {outpath}")
|
||||
print("Press Ctrl+C to stop.")
|
||||
|
||||
header_written = False
|
||||
link_type = None
|
||||
packet_count = 0
|
||||
buf = b""
|
||||
total_bytes = 0
|
||||
last_status = time.monotonic()
|
||||
printed_raw_preview = False
|
||||
|
||||
crlf_state = {"pending_cr": False}
|
||||
|
||||
with serial.Serial(args.port, args.baud, timeout=1) as ser, open(outpath, "wb") as outfile:
|
||||
def read_bytes(n):
|
||||
return undo_crlf(ser.read(n), crlf_state)
|
||||
|
||||
try:
|
||||
while True:
|
||||
chunk = read_bytes(256)
|
||||
if chunk:
|
||||
buf += chunk
|
||||
total_bytes += len(chunk)
|
||||
|
||||
now = time.monotonic()
|
||||
if now - last_status >= 2:
|
||||
last_status = now
|
||||
print(f"[diagnostic] {total_bytes} raw bytes received so far, "
|
||||
f"{packet_count} packets recognized, header_written={header_written}")
|
||||
if total_bytes > 0 and not printed_raw_preview and not header_written and not PKT_RE.search(buf):
|
||||
# We're getting bytes but none of them look like our markers - show a preview
|
||||
# so we can tell whether this is plain log text (markers just haven't shown up
|
||||
# yet), garbage (baud/port mismatch), or something else entirely.
|
||||
preview = buf[:200]
|
||||
print(f"[diagnostic] no markers matched yet - raw preview: {preview!r}")
|
||||
printed_raw_preview = True
|
||||
elif total_bytes == 0:
|
||||
print("[diagnostic] zero bytes received from the port at all - this points at "
|
||||
"the wrong COM port, another program holding the port, or a port that "
|
||||
"isn't actually wired to the console/sniffer output.")
|
||||
|
||||
# The device only sends the global header once, right when the sniffer first starts
|
||||
# (typically within a second or two of boot). If this script connects even slightly
|
||||
# late - very likely right after a fresh flash, since esptool itself resets the board -
|
||||
# that header is already gone before we ever see it. Rather than blocking forever
|
||||
# waiting for a header that's never coming, look for whichever marker shows up first.
|
||||
header_match = None if header_written else HEADER_RE.search(buf)
|
||||
pkt_match = PKT_RE.search(buf)
|
||||
|
||||
if header_match and (not pkt_match or header_match.start() < pkt_match.start()):
|
||||
length = int(header_match.group(1))
|
||||
buf = buf[header_match.end():]
|
||||
while len(buf) < length:
|
||||
buf += read_bytes(length - len(buf))
|
||||
header_bytes = buf[:length]
|
||||
outfile.write(header_bytes)
|
||||
outfile.flush()
|
||||
buf = buf[length:]
|
||||
header_written = True
|
||||
if length >= 24:
|
||||
link_type = int.from_bytes(header_bytes[20:24], "little")
|
||||
print(f"Got pcap global header (link type {link_type}) - device is streaming.\n")
|
||||
continue
|
||||
|
||||
if not header_written and pkt_match:
|
||||
link_type = LINKTYPE_IEEE802_11_RADIOTAP
|
||||
outfile.write(build_default_pcap_header(link_type))
|
||||
outfile.flush()
|
||||
header_written = True
|
||||
print("Note: missed the device's one-time pcap header (it was likely sent before "
|
||||
"this script connected, e.g. right after a flash/reset) - assuming WLAN "
|
||||
"radiotap capture and writing a default header instead.\n")
|
||||
# fall through and process pkt_match below, don't discard this packet
|
||||
|
||||
if not pkt_match:
|
||||
# Keep the buffer from growing unbounded while waiting for a marker, but don't
|
||||
# discard anything - a marker could be split across reads.
|
||||
if len(buf) > 65536:
|
||||
buf = buf[-4096:]
|
||||
continue
|
||||
|
||||
length = int(pkt_match.group(1))
|
||||
buf = buf[pkt_match.end():]
|
||||
while len(buf) < length:
|
||||
buf += read_bytes(length - len(buf))
|
||||
record_bytes = buf[:length]
|
||||
outfile.write(record_bytes)
|
||||
outfile.flush()
|
||||
buf = buf[length:]
|
||||
packet_count += 1
|
||||
print(summarize_packet(link_type, record_bytes, packet_count))
|
||||
except KeyboardInterrupt:
|
||||
print(f"\nStopped. {packet_count} packets saved to {outpath}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -139,8 +139,8 @@ share no code. The requirement is satisfied twice, by different means.
|
||||
| 11.1 | Orientation-Independent Sensor Strategy | **Done** | `SensorRepository.kt` (magnitude-based) | — |
|
||||
| 11.2 | Running Standard Deviation Event Detector | **Done** | `EventDetector.kt`, `RunningStats.kt` | **18 unit tests, 0 failures** |
|
||||
| 11.3 | Trip Recording Architecture | **Done** | `TripRepository.kt`, `TripRecordingService.kt` *(cited)* | — |
|
||||
| 11.4 | Data Model | **Done** | `data/db/` Room entities *(cited)* | — |
|
||||
| 11.5 | New UI Elements for Phase A | **Done** | `TripHistoryScreen.kt`, `TripReviewScreen.kt` | — |
|
||||
| 11.4 | Data Model | **Partial — scope reduced** | `data/db/` Room entities *(cited)* | `detected_events` dropped in schema v5, see scope note |
|
||||
| 11.5 | New UI Elements for Phase A | **Partial — scope reduced** | `TripHistoryScreen.kt`, `TripReviewScreen.kt` | event pins/counters removed by decision, see note |
|
||||
| 11.6 | Phase A Success Criteria | **Partial** | — | needs a real ride; see Open Items |
|
||||
|
||||
**Correction note (11.2).** Four `EventDetectorTest` cases had been failing since the initial commit.
|
||||
@@ -149,6 +149,26 @@ described stimuli the detector cannot physically see, because they ignored the s
|
||||
rolling standard-deviation window. Tests corrected, assertions unchanged, detector untouched. This is
|
||||
worth reporting — it is a finding about test design, not a defect.
|
||||
|
||||
**Scope note (11.5).** The event-detection UI — the live per-type counters on the recording screen,
|
||||
the coloured event pins and detail sheet on the trip review map, and the event count on the trip
|
||||
history card — was removed deliberately. A count of the rider's own braking events is not a goal of
|
||||
this project. The detector itself still runs: it is the input to the CAM transmit-rate policy
|
||||
(§ 13), which raises the beacon rate from 1 Hz to the elevated rate for five seconds after a
|
||||
detected manoeuvre. That is now its only effect: the `detected_events` table was dropped in schema
|
||||
v5 and the per-event rows removed from the trip CSV, so a detected manoeuvre is consumed and
|
||||
discarded. `trips.eventCount` is kept as a single integer per ride, since dropping a SQLite column
|
||||
means recreating the table.
|
||||
|
||||
**Defect note (11.2).** Two defects found while documenting the detector were fixed on 2026-09-07.
|
||||
The nine threshold overrides in `TripRecordingService`'s constructor were promoted to
|
||||
`DetectionConfig`'s defaults and the override deleted, so there is one configuration and
|
||||
`EventDetectorTest` exercises the shipping thresholds rather than the superseded Phase A ones;
|
||||
detector sensitivity is unchanged, and the synthetic stimuli were re-derived because several no
|
||||
longer cleared the stricter real thresholds. `brakingHighConfidenceRate` was renamed
|
||||
`brakingHighConfidencePeakDrop`: it was documented as a rate but has always been compared against
|
||||
the peak cumulative speed drop. The name was corrected rather than the comparison, so detector
|
||||
output is unchanged and the confidence assertions remain valid evidence.
|
||||
|
||||
## 12. Future Architecture & Open Design Questions
|
||||
|
||||
| § | Title | Status | Notes |
|
||||
|
||||
@@ -0,0 +1,508 @@
|
||||
<mxfile host="Electron" agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) draw.io/22.1.2 Chrome/114.0.5735.289 Electron/25.9.4 Safari/537.36" modified="2026-09-10T12:53:19.876Z" etag="qSD0sUq7Dcw_rxL1Y7Bl" version="22.1.2" type="device">
|
||||
<diagram id="90a13364-a465-7bf4-72fc-28e22215d7a0" name="Seite-1">
|
||||
<mxGraphModel dx="1678" dy="1125" grid="1" gridSize="10" guides="1" tooltips="1" connect="0" arrows="1" fold="1" page="1" pageScale="1.5" pageWidth="1169" pageHeight="826" background="none" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="0" style=";html=1;" />
|
||||
<mxCell id="1" style=";html=1;" parent="0" />
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-92" value="" style="rounded=0;whiteSpace=wrap;html=1;dashed=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="950" y="960" width="70" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="3a17f1ce550125da-2" value="Mobile Phone" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="290" y="280" width="650" height="780" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="3a17f1ce550125da-10" value="ESP32-C5" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="1040" y="540" width="580" height="520" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-4" value="<font style="">ESPAR<br>Antenna</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;" parent="1" vertex="1">
|
||||
<mxGeometry x="1150" y="320" width="110" height="90" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-11" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.5;entryY=0;entryDx=0;entryDy=0;exitX=0.499;exitY=0.954;exitDx=0;exitDy=0;exitPerimeter=0;" parent="1" source="Iy5nDPde1Y9wQlhL-YeU-10" target="3a17f1ce550125da-2" edge="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-30" value="static map data" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontColor=#B3B3B3;" parent="Iy5nDPde1Y9wQlhL-YeU-11" connectable="0" vertex="1">
|
||||
<mxGeometry x="-0.2209" y="2" relative="1" as="geometry">
|
||||
<mxPoint x="-8" y="3" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-10" value="<font style="font-size: 18px;">GeoServer</font>" style="ellipse;shape=cloud;whiteSpace=wrap;html=1;align=center;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="540" y="130" width="150" height="100" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-12" value="<font style="">PoTi</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="780" y="670" width="150" height="110" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-13" value="<font style="">GNSS</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="793" y="700" width="60" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-14" value="<font style="">IMU</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="863" y="700" width="60" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-15" value="<font style="">Time Source</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="793" y="740" width="132" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-16" value="<font style="">HMI Support</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="440" y="670" width="171" height="110" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-17" value="<font style="">Display</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="450" y="700" width="70" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-18" value="Haptic" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="530" y="700" width="70" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-19" value="<font style="">Speaker</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="489.5" y="740" width="78" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-84" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="Iy5nDPde1Y9wQlhL-YeU-21" target="Iy5nDPde1Y9wQlhL-YeU-33" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="700" y="770" />
|
||||
<mxPoint x="650" y="770" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-85" value="encoded<br>VAM" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-84" vertex="1" connectable="0">
|
||||
<mxGeometry x="0.01" y="1" relative="1" as="geometry">
|
||||
<mxPoint y="-12" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-21" value="<font style="">VBS</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="440" y="500" width="410" height="160" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-22" value="<font style="">Encode</font><div><font style="">VAM</font></div>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="651.75" y="600" width="68.5" height="52" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-23" value="<font style="">Transmission</font><div><font style="">Management</font></div>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="730" y="600" width="111.5" height="52" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-24" value="GeoServer to<br>area-risk mapping" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="527" y="370" width="149" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-29" value="<font style="font-size: 20px;">VRU-Tx ITS-S Architecture of micrOBU</font>" style="text;html=1;whiteSpace=wrap;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;rounded=0;labelBorderColor=default;" parent="1" vertex="1">
|
||||
<mxGeometry x="850" y="154" width="223" height="52" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-31" value="VRU Basic Service Management" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="567.5" y="530" width="203" height="52" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-32" value="<font style="">GeoNetworking</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="685" y="947" width="150" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-33" value="<font style="">BTP-B</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="606.5" y="807" width="77" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-53" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.75;entryY=1;entryDx=0;entryDy=0;endArrow=classic;endFill=1;startArrow=classic;startFill=1;" parent="1" source="Iy5nDPde1Y9wQlhL-YeU-34" target="Iy5nDPde1Y9wQlhL-YeU-4" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="1270" y="480" />
|
||||
<mxPoint x="1232" y="480" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-147" value="RF Signal" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-53" vertex="1" connectable="0">
|
||||
<mxGeometry x="0.4593" y="1" relative="1" as="geometry">
|
||||
<mxPoint x="9" y="27" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-34" value="<font style="">ITS-G5 radio<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="1230" y="568" width="140" height="32" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-38" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.25;entryY=1;entryDx=0;entryDy=0;" parent="1" source="Iy5nDPde1Y9wQlhL-YeU-39" target="Iy5nDPde1Y9wQlhL-YeU-4" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="1100" y="480" />
|
||||
<mxPoint x="1178" y="480" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-41" value="Steering Signal" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-38" vertex="1" connectable="0">
|
||||
<mxGeometry x="0.2737" y="-1" relative="1" as="geometry">
|
||||
<mxPoint x="39" y="8" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-39" value="<font style="">Antenna<br>Steering<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="1060" y="746" width="80" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-40" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;innerLoopWaypoints=1;exitX=0.5;exitY=1;exitDx=0;exitDy=0;" parent="1" source="3a17f1ce550125da-10" target="3a17f1ce550125da-10" edge="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-1" value="" style="endArrow=none;html=1;rounded=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0;" parent="1" edge="1">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="430" y="790" as="sourcePoint" />
|
||||
<mxPoint x="940" y="790" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-3" value="<font style="">BLE</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="520" y="900" width="330" height="150" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-7" value="<font style="">ATT/ GATT</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="685" y="927" width="150" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-8" value="<font style="">L2CAP</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="685" y="968" width="150" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-9" value="<font style="">BLE Link Layer</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="685" y="1010" width="150" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-10" value="<strong data-end="7298" data-start="7253">≤512 B GATT attribute/application message</strong><br data-end="7301" data-start="7298"><br/><em data-end="7372" data-start="7303">L2CAP / Link Layer segmentation and reassembly handled by BLE stack</em>" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" parent="1" vertex="1">
|
||||
<mxGeometry x="530" y="930" width="140" height="110" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-34" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.5;entryY=1;entryDx=0;entryDy=0;" parent="1" source="ynnYWYYo9pkcd0MtkCud-15" target="ynnYWYYo9pkcd0MtkCud-32" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="1215" y="890" />
|
||||
<mxPoint x="1215" y="890" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-151" value="TX Message" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-34" vertex="1" connectable="0">
|
||||
<mxGeometry x="-0.2929" y="-2" relative="1" as="geometry">
|
||||
<mxPoint as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-116" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;fontColor=#B3B3B3;strokeColor=#B3B3B3;" parent="1" source="ynnYWYYo9pkcd0MtkCud-15" target="ynnYWYYo9pkcd0MtkCud-3" edge="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-118" value="BLE packet" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontColor=#B3B3B3;" parent="ynnYWYYo9pkcd0MtkCud-116" vertex="1" connectable="0">
|
||||
<mxGeometry x="0.0818" y="1" relative="1" as="geometry">
|
||||
<mxPoint x="6" y="1" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-15" value="<font style="">BLE</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="1130" y="900" width="180" height="150" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-16" value="<font style="">ATT/ GATT</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="1145" y="930" width="150" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-17" value="<font style="">L2CAP</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="1145" y="970" width="150" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-18" value="<font style="">BLE Link Layer</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="1145" y="1010" width="150" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-22" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" parent="1" source="ynnYWYYo9pkcd0MtkCud-9" target="ynnYWYYo9pkcd0MtkCud-18" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="1127" y="1025" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-42" value="segmented<br>BLE packets" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-22" vertex="1" connectable="0">
|
||||
<mxGeometry x="-0.5098" y="-1" relative="1" as="geometry">
|
||||
<mxPoint x="74" y="-1" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-30" value="<font style="">GeoNetworking SHB</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="531" y="846" width="177" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-90" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;endArrow=none;endFill=0;" parent="1" source="ynnYWYYo9pkcd0MtkCud-31" target="ynnYWYYo9pkcd0MtkCud-30" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="710" y="861" />
|
||||
<mxPoint x="710" y="861" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-31" value="<font style="">synced transmission dir.</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="720.25" y="846" width="210" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-144" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="ynnYWYYo9pkcd0MtkCud-32" target="ynnYWYYo9pkcd0MtkCud-141" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="1185" y="830" />
|
||||
<mxPoint x="1185" y="830" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-150" value="Transmission<br>Power" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-144" vertex="1" connectable="0">
|
||||
<mxGeometry x="-0.3161" relative="1" as="geometry">
|
||||
<mxPoint y="-3" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-145" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="ynnYWYYo9pkcd0MtkCud-32" target="Iy5nDPde1Y9wQlhL-YeU-39" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="1100" y="830" />
|
||||
<mxPoint x="1100" y="830" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-149" value="Transmission<br>Direction" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-145" vertex="1" connectable="0">
|
||||
<mxGeometry x="-0.3251" y="1" relative="1" as="geometry">
|
||||
<mxPoint x="1" y="-3" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-32" value="<font style="">Message Splitter<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="1060" y="837" width="310" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-48" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.5;entryY=1;entryDx=0;entryDy=0;" parent="1" source="ynnYWYYo9pkcd0MtkCud-44" target="ynnYWYYo9pkcd0MtkCud-45" edge="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-129" value="MPDU" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-48" vertex="1" connectable="0">
|
||||
<mxGeometry x="-0.4731" y="2" relative="1" as="geometry">
|
||||
<mxPoint x="2" y="-4" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-44" value="<font style="">MAC<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="1229" y="698" width="140" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-49" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;" parent="1" source="ynnYWYYo9pkcd0MtkCud-45" target="Iy5nDPde1Y9wQlhL-YeU-34" edge="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-130" value="PPDU" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-49" vertex="1" connectable="0">
|
||||
<mxGeometry x="-0.5724" y="-1" relative="1" as="geometry">
|
||||
<mxPoint x="-1" y="-7" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-127" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#B3B3B3;" parent="1" source="ynnYWYYo9pkcd0MtkCud-45" target="ynnYWYYo9pkcd0MtkCud-126" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="1439" y="640" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-45" value="<font style="">PHY<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="1229" y="630" width="140" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-50" value="" style="verticalLabelPosition=bottom;shadow=0;dashed=0;align=center;html=1;verticalAlign=top;shape=mxgraph.electrical.radio.aerial_-_antenna_1;" parent="1" vertex="1">
|
||||
<mxGeometry x="1187" y="280" width="30" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-56" value="802.11p @ 5.9GHz" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="1" vertex="1" connectable="0">
|
||||
<mxGeometry x="1250.0006451612903" y="310" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-57" value="" style="endArrow=none;html=1;rounded=0;entryX=1;entryY=0.75;entryDx=0;entryDy=0;" parent="1" source="3a17f1ce550125da-2" edge="1">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="590" y="790" as="sourcePoint" />
|
||||
<mxPoint x="940" y="790" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-68" value="" style="endArrow=none;html=1;rounded=0;exitX=0;exitY=0.5;exitDx=0;exitDy=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0;" parent="1" edge="1">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="431" y="490" as="sourcePoint" />
|
||||
<mxPoint x="941" y="490" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-70" value="if rx enabled" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="720" y="320" width="205" height="160" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-71" value="GLOSA" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="730" y="355" width="180" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-72" value="Collision Warning" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="730" y="396" width="180" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-73" value="Recorder/ Logger" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="730" y="435" width="180" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-79" value="Decode<br>VAM" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="451.5" y="600" width="68.5" height="52" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-80" value="Reception<br>Management" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="531" y="600" width="111.5" height="52" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-82" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.71;entryY=1.005;entryDx=0;entryDy=0;entryPerimeter=0;" parent="1" source="Iy5nDPde1Y9wQlhL-YeU-12" target="Iy5nDPde1Y9wQlhL-YeU-21" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="850" y="610" as="targetPoint" />
|
||||
<Array as="points">
|
||||
<mxPoint x="731" y="725" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-83" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.559;entryY=1.01;entryDx=0;entryDy=0;entryPerimeter=0;" parent="1" source="Iy5nDPde1Y9wQlhL-YeU-16" target="Iy5nDPde1Y9wQlhL-YeU-21" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="670" y="725" />
|
||||
<mxPoint x="670" y="680" />
|
||||
<mxPoint x="669" y="680" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-86" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="Iy5nDPde1Y9wQlhL-YeU-33" target="ynnYWYYo9pkcd0MtkCud-30" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="596" y="840" as="targetPoint" />
|
||||
<Array as="points">
|
||||
<mxPoint x="570" y="822" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-93" value="encrypted" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;" parent="1" vertex="1">
|
||||
<mxGeometry x="946" y="935" width="80" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-95" value="<font style="">Platform<br>Security<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="1500" y="580" width="110" height="470" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-99" value="<font style="">Secure<br>Boot<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="1510" y="837" width="90" height="48" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-102" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;fontColor=#B3B3B3;strokeColor=#B3B3B3;" parent="1" source="ynnYWYYo9pkcd0MtkCud-100" target="ynnYWYYo9pkcd0MtkCud-15" edge="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-153" value="BLE Credentials" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontColor=#B3B3B3;" parent="ynnYWYYo9pkcd0MtkCud-102" vertex="1" connectable="0">
|
||||
<mxGeometry x="0.5722" y="1" relative="1" as="geometry">
|
||||
<mxPoint x="52" y="9" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-100" value="<font style="">BLE<br>credentials<br>config<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="1510" y="965" width="90" height="73" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-101" value="<font style="">Debug<br>lockdown<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="1510" y="902" width="90" height="48" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-106" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="ynnYWYYo9pkcd0MtkCud-103" target="Iy5nDPde1Y9wQlhL-YeU-21" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="430" y="580" />
|
||||
<mxPoint x="430" y="580" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-103" value="<font style="">Platform<br>Security<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="300" y="300" width="120" height="740" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-105" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="ynnYWYYo9pkcd0MtkCud-104" target="ynnYWYYo9pkcd0MtkCud-3" edge="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-104" value="<font style="">BLE<br>credentials<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="310" y="982" width="100" height="48" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-108" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=1.008;entryY=0.559;entryDx=0;entryDy=0;entryPerimeter=0;" parent="1" source="Iy5nDPde1Y9wQlhL-YeU-12" target="Iy5nDPde1Y9wQlhL-YeU-33" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="850" y="824" />
|
||||
<mxPoint x="684" y="824" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-109" value="PCI" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-108" vertex="1" connectable="0">
|
||||
<mxGeometry x="-0.0782" y="2" relative="1" as="geometry">
|
||||
<mxPoint as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-110" value="<font style="">LLC/SNAP<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="1230" y="760" width="140" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-111" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="ynnYWYYo9pkcd0MtkCud-32" target="ynnYWYYo9pkcd0MtkCud-110" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="1300" y="810" as="targetPoint" />
|
||||
<Array as="points">
|
||||
<mxPoint x="1300" y="830" />
|
||||
<mxPoint x="1300" y="830" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-112" value="GNPDU" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-111" vertex="1" connectable="0">
|
||||
<mxGeometry x="-0.1526" relative="1" as="geometry">
|
||||
<mxPoint as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-113" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.502;entryY=1;entryDx=0;entryDy=0;entryPerimeter=0;" parent="1" source="ynnYWYYo9pkcd0MtkCud-110" target="ynnYWYYo9pkcd0MtkCud-44" edge="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-114" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.297;entryY=-0.004;entryDx=0;entryDy=0;entryPerimeter=0;" parent="1" source="ynnYWYYo9pkcd0MtkCud-30" target="ynnYWYYo9pkcd0MtkCud-3" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="618" y="890" />
|
||||
<mxPoint x="618" y="890" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-115" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="ynnYWYYo9pkcd0MtkCud-31" target="ynnYWYYo9pkcd0MtkCud-3" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="810" y="896" />
|
||||
<mxPoint x="810" y="896" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-119" value="<font style="">Certificate<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="310" y="720" width="100" height="33" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-122" value="<font style="">Private Key Handling<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="310" y="920" width="100" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-123" value="<font style="">Signer Selection<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="310" y="770" width="100" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-125" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="ynnYWYYo9pkcd0MtkCud-124" target="ynnYWYYo9pkcd0MtkCud-30" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="470" y="865" />
|
||||
<mxPoint x="470" y="865" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-124" value="<font style="">Secured<br>Message<br>Creation<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="310" y="837" width="100" height="70" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-126" value="<font style="">DCC-ACC Calculation<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="1390" y="655" width="97" height="55" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-128" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;jumpStyle=arc;fontColor=#B3B3B3;strokeColor=#B3B3B3;" parent="1" source="ynnYWYYo9pkcd0MtkCud-126" target="ynnYWYYo9pkcd0MtkCud-15" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="1129" y="930" as="targetPoint" />
|
||||
<Array as="points">
|
||||
<mxPoint x="1440" y="930" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-152" value="DCC Feedback" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontColor=#B3B3B3;" parent="ynnYWYYo9pkcd0MtkCud-128" vertex="1" connectable="0">
|
||||
<mxGeometry x="0.7303" relative="1" as="geometry">
|
||||
<mxPoint x="13" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-131" value="" style="endArrow=none;html=1;rounded=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0;" parent="1" edge="1">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="430" y="793" as="sourcePoint" />
|
||||
<mxPoint x="940" y="793" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-133" value="" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="847" y="787" width="6" height="9" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-136" value="" style="endArrow=none;html=1;rounded=0;exitX=0;exitY=0.5;exitDx=0;exitDy=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0;" parent="1" edge="1">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="431" y="883.78" as="sourcePoint" />
|
||||
<mxPoint x="941" y="883.78" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-137" value="" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="647" y="787" width="6" height="9" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-140" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="ynnYWYYo9pkcd0MtkCud-139" target="Iy5nDPde1Y9wQlhL-YeU-21" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="895" y="580" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-139" value="<font style="">DCC-<br>FAC<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="865" y="500" width="60" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-142" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="ynnYWYYo9pkcd0MtkCud-141" target="Iy5nDPde1Y9wQlhL-YeU-34" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="1185" y="584" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-148" value="TX power <br>config" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-142" vertex="1" connectable="0">
|
||||
<mxGeometry x="-0.4822" y="1" relative="1" as="geometry">
|
||||
<mxPoint as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-141" value="<font style="">Radio<br>Control<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="1150" y="746" width="70" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-155" value="" style="endArrow=none;html=1;rounded=0;exitX=0;exitY=0.5;exitDx=0;exitDy=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0;" parent="1" edge="1">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="431" y="887" as="sourcePoint" />
|
||||
<mxPoint x="941" y="887" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-154" value="" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="615" y="881" width="6" height="9" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-157" value="" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="807" y="881" width="6" height="9" as="geometry" />
|
||||
</mxCell>
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
</mxfile>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 237 KiB |
Binary file not shown.
Binary file not shown.
@@ -51,3 +51,49 @@ Known gaps, tracked as TODOs in the source: no real GNSS (lat/long hardcoded
|
||||
0), no real time source (detectionTime/referenceTime hardcoded 0, decodes as
|
||||
2004-01-01), fixed (non-rotating) pseudonym MAC, SHB instead of GeoBroadcast
|
||||
(no multi-hop forwarding), unsecured (no IEEE 1609.2 signing).
|
||||
|
||||
## Running it as a bench beacon
|
||||
|
||||
This firmware needs no phone: it beacons a CAM every second by itself
|
||||
(`TX_INTERVAL_MS`) from station `0x0BADC0DE` (195936478), stationType 5
|
||||
(passengerCar), at the hardcoded bench position, under the fixed MAC
|
||||
`02:00:00:00:00:01`, on 5900 MHz. That makes it the quickest way to put known,
|
||||
repeatable traffic on air, and it is how the 4-bit `yawRateConfidence` encoding
|
||||
was confirmed over the air on 2026-09-14.
|
||||
|
||||
A board with only one USB-C port is fine. This firmware's console is on UART0,
|
||||
so such a board shows no log output, but nothing here needs the console.
|
||||
|
||||
Flash it from the toolchain terminal (ESP-IDF 5.5.4, see the table above):
|
||||
|
||||
```powershell
|
||||
cd C:\Users\Ashin\AndroidStudioProjects\MicrOBU\obu-cam-transmistter
|
||||
idf.py -p COM10 -b 921600 flash
|
||||
```
|
||||
|
||||
Or flash the existing build without any toolchain terminal:
|
||||
|
||||
```powershell
|
||||
cd obu-cam-transmistter\build
|
||||
C:\Espressif\python_env\idf5.5_py3.11_env\Scripts\python.exe -m esptool --chip esp32c5 -p COM10 -b 921600 write_flash --flash_mode dio --flash_freq 80m --flash_size 2MB 0x2000 bootloader/bootloader.bin 0x8000 partition_table/partition-table.bin 0x10000 obu_firmware.bin
|
||||
```
|
||||
|
||||
It starts beaconing as soon as it boots, so there is nothing to start by hand,
|
||||
and unplugging it is how you stop it.
|
||||
|
||||
**It transmits under the same MAC as the phone's CAM pinger**, so on air the two
|
||||
are told apart by station ID (195936478 here, 999999 for the pinger), never by
|
||||
source address.
|
||||
|
||||
To see what it is sending, capture on the sniffer board and decode:
|
||||
|
||||
```powershell
|
||||
cd capture
|
||||
py -3.11 live_capture.py COM8
|
||||
py -3.11 ..\obu-firmware\test\pcap_gn_tally.py recordings\capture_<timestamp>.pcap
|
||||
```
|
||||
|
||||
The tally lists it as SHB / port 2001 / lifetime `0x05`. For the message itself,
|
||||
decode the payload with `asn1tools` against `asn1/cam_1_4_1.asn` +
|
||||
`asn1/cdd_1_3_1_1.asn`; re-encoding must return the identical bytes. On
|
||||
2026-09-14, 72 of 72 frames did.
|
||||
|
||||
@@ -117,9 +117,13 @@ int cam_encode(const cam_fields_t *f, uint8_t *buf, size_t buf_len)
|
||||
bw_put_bits(&bw, 0, 1); // extension bit: value is in the root list
|
||||
bw_put_bits(&bw, 2, 2); // unavailable(2)
|
||||
// YawRate: YawRateValue(-32766..32767)->16 (offset from -32766),
|
||||
// YawRateConfidence ENUM 8 values -> 3 bits
|
||||
// YawRateConfidence ENUM with NINE values, degSec-000-01(0) .. unavailable(8)
|
||||
// (cdd_1_3_1_1.asn) -> 4 bits. This wrote 3 bits with value 7, one bit short and the
|
||||
// wrong symbol (7 is outOfRange), so every field after it shifted for any
|
||||
// standards-compliant receiver. The app's CamUperCodec.kt fixed the same line on
|
||||
// 2026-08-20; this copy was missed until 2026-09-11.
|
||||
bw_put_bits(&bw, 32767 - (uint32_t)(-32766), 16); // yawRateValue: unavailable(32767)
|
||||
bw_put_bits(&bw, 7, 3); // yawRateConfidence: unavailable(7)
|
||||
bw_put_bits(&bw, 8, 4); // yawRateConfidence: unavailable(8)
|
||||
|
||||
// ---- LowFrequencyContainer ---- CHOICE { basicVehicleContainerLowFrequency,
|
||||
// ... } - EXTENSIBLE, 1 root alternative (index needs 0 bits).
|
||||
|
||||
@@ -19,7 +19,10 @@ int geonet_wrap_shb(const uint8_t *its_payload, int its_len,
|
||||
// ---- GN Basic Header (4 bytes) ---- (EN 302 636-4-1 clause 9.6)
|
||||
*p++ = (uint8_t)((1 << 4) | 1); // version=1, NextHeader=1 (Common Header, unsecured)
|
||||
*p++ = 0x00; // reserved
|
||||
*p++ = 0x83; // lifetime (~60s in the base/multiplier encoding) - tune if needed
|
||||
// Lifetime: multiplier in the upper 6 bits, base in the lower 2 (0 = 50 ms, 1 = 1 s, 2 = 10 s,
|
||||
// 3 = 100 s). 0x05 = 1 x 1 s, what real stations send their CAMs with. Was 0x83, commented as
|
||||
// ~60 s but decoding to 32 x 100 s = 3200 s. See obu-firmware's geonet.c.
|
||||
*p++ = 0x05;
|
||||
*p++ = 1; // remaining hop limit = 1 (SHB single-hop; matches CAM in the Rust reference)
|
||||
|
||||
// ---- GN Common Header (8 bytes) ---- (clause 9.7)
|
||||
|
||||
@@ -122,9 +122,13 @@ int cam_encode(const cam_fields_t *f, uint8_t *buf, size_t buf_len)
|
||||
bw_put_bits(&bw, 0, 1); // extension bit: value is in the root list
|
||||
bw_put_bits(&bw, 2, 2); // unavailable(2)
|
||||
// YawRate: YawRateValue(-32766..32767)->16 (offset from -32766),
|
||||
// YawRateConfidence ENUM 8 values -> 3 bits
|
||||
// YawRateConfidence ENUM with NINE values, degSec-000-01(0) .. unavailable(8)
|
||||
// (cdd_1_3_1_1.asn) -> 4 bits. This wrote 3 bits with value 7, one bit short and the
|
||||
// wrong symbol (7 is outOfRange), so every field after it shifted for any
|
||||
// standards-compliant receiver. The app's CamUperCodec.kt fixed the same line on
|
||||
// 2026-08-20; this reference copy was missed until 2026-09-11.
|
||||
bw_put_bits(&bw, 32767 - (uint32_t)(-32766), 16); // yawRateValue: unavailable(32767)
|
||||
bw_put_bits(&bw, 7, 3); // yawRateConfidence: unavailable(7)
|
||||
bw_put_bits(&bw, 8, 4); // yawRateConfidence: unavailable(8)
|
||||
|
||||
// ---- LowFrequencyContainer ---- CHOICE { basicVehicleContainerLowFrequency,
|
||||
// ... } - EXTENSIBLE, 1 root alternative (index needs 0 bits).
|
||||
|
||||
+39
-23
@@ -1,9 +1,23 @@
|
||||
#include "geonet.h"
|
||||
#include <string.h>
|
||||
|
||||
// GeoNetworking is big-endian throughout, unlike this project's serial framing.
|
||||
static void put_be16(uint8_t **p, uint16_t v)
|
||||
{
|
||||
*(*p)++ = (uint8_t)(v >> 8);
|
||||
*(*p)++ = (uint8_t)(v);
|
||||
}
|
||||
|
||||
static void put_be32(uint8_t **p, uint32_t v)
|
||||
{
|
||||
*(*p)++ = (uint8_t)(v >> 24);
|
||||
*(*p)++ = (uint8_t)(v >> 16);
|
||||
*(*p)++ = (uint8_t)(v >> 8);
|
||||
*(*p)++ = (uint8_t)(v);
|
||||
}
|
||||
|
||||
int geonet_wrap_shb(const uint8_t *its_payload, int its_len,
|
||||
const uint8_t mac[6], uint8_t station_type,
|
||||
int32_t latitude_tenmicrodeg, int32_t longitude_tenmicrodeg,
|
||||
const gn_lpv_t *lpv,
|
||||
uint16_t btp_dest_port,
|
||||
uint8_t *out, size_t out_len)
|
||||
{
|
||||
@@ -19,7 +33,12 @@ int geonet_wrap_shb(const uint8_t *its_payload, int its_len,
|
||||
// ---- GN Basic Header (4 bytes) ---- (EN 302 636-4-1 clause 9.6)
|
||||
*p++ = (uint8_t)((1 << 4) | 1); // version=1, NextHeader=1 (Common Header, unsecured)
|
||||
*p++ = 0x00; // reserved
|
||||
*p++ = 0x83; // lifetime (~60s in the base/multiplier encoding) - tune if needed
|
||||
// Lifetime: multiplier in the upper 6 bits, base in the lower 2 (0 = 50 ms, 1 = 1 s, 2 = 10 s,
|
||||
// 3 = 100 s). 0x05 = 1 x 1 s, which is what every other station in
|
||||
// its-g5-receiver-firmware/recordings sends its CAMs with. This was 0x83, commented as ~60 s but
|
||||
// decoding to 32 x 100 s = 3200 s, beyond the 600 s itsGnMaxPacketLifetime a sender may use at
|
||||
// all. A DENM would want a longer one (the captured GeoBroadcast DENMs use 0x79, 30 s).
|
||||
*p++ = 0x05;
|
||||
*p++ = 1; // remaining hop limit = 1 (SHB single-hop; matches CAM in the Rust reference)
|
||||
|
||||
// ---- GN Common Header (8 bytes) ---- (clause 9.7)
|
||||
@@ -50,27 +69,24 @@ int geonet_wrap_shb(const uint8_t *its_payload, int its_len,
|
||||
// defined to BE the link-layer (802.11) address - so this must match
|
||||
// the source address dot11p_build_frame uses, not just "look similar."
|
||||
uint8_t gn_addr[8];
|
||||
gn_addr[0] = (uint8_t)((0 << 7) | ((station_type & 0x1F) << 2)); // M=0, ST=station_type, top 2 reserved bits=0
|
||||
gn_addr[0] = (uint8_t)((0 << 7) | ((lpv->station_type & 0x1F) << 2)); // M=0, ST=station_type, top 2 reserved bits=0
|
||||
gn_addr[1] = 0x00; // remaining 8 reserved bits
|
||||
memcpy(&gn_addr[2], mac, 6); // MID = link-layer address
|
||||
memcpy(&gn_addr[2], lpv->mac, 6); // MID = link-layer address
|
||||
memcpy(p, gn_addr, 8); p += 8;
|
||||
// Timestamp (4 bytes, ms since 2004-01-01 mod 2^32) - placeholder 0,
|
||||
// same caveat as detectionTime in denm.c.
|
||||
memset(p, 0, 4); p += 4;
|
||||
// Latitude/Longitude (4+4 bytes, signed, big-endian, 1/10 microdegree) -
|
||||
// fixed-width binary fields, not UPER bit-packed.
|
||||
uint32_t lat_u = (uint32_t)latitude_tenmicrodeg;
|
||||
*p++ = (uint8_t)(lat_u >> 24); *p++ = (uint8_t)(lat_u >> 16);
|
||||
*p++ = (uint8_t)(lat_u >> 8); *p++ = (uint8_t)(lat_u);
|
||||
uint32_t lon_u = (uint32_t)longitude_tenmicrodeg;
|
||||
*p++ = (uint8_t)(lon_u >> 24); *p++ = (uint8_t)(lon_u >> 16);
|
||||
*p++ = (uint8_t)(lon_u >> 8); *p++ = (uint8_t)(lon_u);
|
||||
// PAI(1 bit) + Speed(15 bits), packed into 2 bytes: 0 = PAI false,
|
||||
// speed 0 - which is actually correct semantics for a STATIONARY
|
||||
// vehicle beacon, not just a placeholder.
|
||||
*p++ = 0x00; *p++ = 0x00;
|
||||
// Heading (16 bits, 0.1 degree units): 0 = due north / unavailable
|
||||
*p++ = 0x00; *p++ = 0x00;
|
||||
// TST (4 bytes): when the position below was acquired, ms, TimestampIts mod 2^32.
|
||||
put_be32(&p, lpv->tst_ms);
|
||||
// Latitude/Longitude (4+4 bytes, signed, 1/10 microdegree) - fixed-width binary fields, not
|
||||
// UPER bit-packed like the CAM payload's own position.
|
||||
put_be32(&p, (uint32_t)lpv->lat_tenmicrodeg);
|
||||
put_be32(&p, (uint32_t)lpv->lon_tenmicrodeg);
|
||||
// PAI (1 bit) + Speed (15 bits, signed, 0.01 m/s). Clamped, not masked: a 15-bit value that
|
||||
// overflows wraps its sign bit and reads as travelling backwards at speed.
|
||||
int32_t speed = lpv->speed_cms;
|
||||
if (speed > 16383) speed = 16383;
|
||||
if (speed < -16384) speed = -16384;
|
||||
put_be16(&p, (uint16_t)(((lpv->pai ? 1u : 0u) << 15) | ((uint16_t)speed & 0x7FFFu)));
|
||||
// Heading (16 bits, 0.1 degree from north, clockwise, 0..3599).
|
||||
put_be16(&p, (uint16_t)(lpv->heading_decideg % 3600u));
|
||||
// Reserved (4 bytes) - clause 9.8.4: the SHB extended header is the 24-byte Source Position
|
||||
// Vector FOLLOWED BY a 4-byte reserved field (media-dependent data), 28 bytes in total. These
|
||||
// four bytes were missing, which is why a standards-compliant receiver read our CAM payload's
|
||||
@@ -84,7 +100,7 @@ int geonet_wrap_shb(const uint8_t *its_payload, int its_len,
|
||||
*p++ = (uint8_t)(btp_dest_port & 0xFF);
|
||||
*p++ = 0x00; *p++ = 0x00; // destination port info, unused for BTP-B
|
||||
|
||||
// ---- ITS payload (DENM UPER bytes) ----
|
||||
// ---- ITS payload (CAM UPER bytes from the phone) ----
|
||||
memcpy(p, its_payload, its_len);
|
||||
p += its_len;
|
||||
|
||||
|
||||
+43
-29
@@ -1,43 +1,57 @@
|
||||
#ifndef GEONET_H
|
||||
#define GEONET_H
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
|
||||
// Wraps an ITS application payload (e.g. from denm_encode) with a minimal
|
||||
// GeoNetworking Basic Header + Common Header + Single-Hop-Broadcast
|
||||
// extended header (HeaderType=TSB(5), HeaderSubtype=SINGLE_HOP(0), per
|
||||
// ETSI EN 302 636-4-1 table 9), then prepends a BTP-B header addressed to
|
||||
// the DENM service port (2002).
|
||||
// The variable content of a GeoNetworking Long Position Vector (ETSI EN 302 636-4-1 clause
|
||||
// 9.5.2): who the sender is and where it was. This is the Source Position Vector every
|
||||
// GeoNetworking packet from this firmware carries.
|
||||
//
|
||||
// `mac` is the 6-byte pseudonym/link-layer address - pass the SAME address
|
||||
// you hand to dot11p_build_frame's src address, since GN_ADDR's MID field
|
||||
// (the last 6 bytes of the 8-byte GN_ADDR) is defined to BE that
|
||||
// link-layer address (EN 302 636-4-1 clause 9.5.1). `station_type` is the
|
||||
// 5-bit ITS-S type from the same clause (5 = passengerCar) and gets packed
|
||||
// into GN_ADDR alongside the address.
|
||||
// Every field here used to be a compile-time constant: the bench coordinates, speed 0, heading 0,
|
||||
// timestamp 0, passengerCar, and one fixed MAC. The phone never told the firmware where it was,
|
||||
// so the GN layer described a stationary car parked at the bench while the CAM inside it
|
||||
// described a moving cyclist somewhere else. The phone now supplies these values with each frame
|
||||
// (SERIAL_MSG_CAM_TX_PV in serial_link.h) and this firmware only lays them out on the wire.
|
||||
typedef struct {
|
||||
// Pseudonym. Written into GN_ADDR's MID field here AND, by dot11p_build_frame, into the
|
||||
// 802.11 source address. Clause 9.5.1 defines the MID as the link-layer address, so the two
|
||||
// must be the same six bytes; taking both from this one field is what keeps them identical
|
||||
// when the pseudonym rotates.
|
||||
uint8_t mac[6];
|
||||
// ITS-S type, TS 102 894-2 StationType (2 = cyclist). Only the low 5 bits fit in GN_ADDR.
|
||||
uint8_t station_type;
|
||||
// Position Accuracy Indicator.
|
||||
bool pai;
|
||||
// TST: the moment lat/lon were acquired, in ms, as TimestampIts modulo 2^32.
|
||||
uint32_t tst_ms;
|
||||
// 1/10 microdegree, signed.
|
||||
int32_t lat_tenmicrodeg;
|
||||
int32_t lon_tenmicrodeg;
|
||||
// 0.01 m/s. The wire field is 15-bit signed, so this is clamped to -16384..16383 on encode.
|
||||
int16_t speed_cms;
|
||||
// 0.1 degree from north, clockwise. Wrapped into 0..3599 on encode.
|
||||
uint16_t heading_decideg;
|
||||
} gn_lpv_t;
|
||||
|
||||
// Wraps an ITS application payload (the CAM UPER bytes the phone built) in a GeoNetworking Basic
|
||||
// Header + Common Header + Single-Hop-Broadcast extended header (HeaderType=TSB(5),
|
||||
// HeaderSubtype=SINGLE_HOP(0), EN 302 636-4-1 table 9), then a BTP-B header addressed to
|
||||
// `btp_dest_port`.
|
||||
//
|
||||
// `latitude_tenmicrodeg`/`longitude_tenmicrodeg` go into the Source Long
|
||||
// Position Vector (clause 9.5.2) as plain 32-bit signed big-endian fields -
|
||||
// NOT UPER bit-packed like the DENM payload's position fields, this is a
|
||||
// fixed-width binary protocol. Pass the SAME values you gave denm_encode's
|
||||
// eventPosition, so the GN-layer position and the DENM's own claimed
|
||||
// position agree.
|
||||
// Single-hop broadcast is the correct packet type for CAM, which ETSI defines as never forwarded,
|
||||
// so it has no destination area and no sequence number. A future DENM transmit path would need
|
||||
// GeoBroadcast (HeaderType=4) instead, which this function does not build.
|
||||
//
|
||||
// Deliberate simplification: real DENM dissemination normally uses
|
||||
// GeoBroadcast (GBC, HeaderType=4) so RSUs/OBUs can forward it across an
|
||||
// area - that needs a sequence number + geo-area fields this skeleton
|
||||
// doesn't build yet. Single-hop broadcast is simpler and is the
|
||||
// best-tested decode path in the receiver firmware you already have
|
||||
// working (same extended header shape as CAM). Fine for a single-vehicle
|
||||
// beacon; revisit if you need real multi-hop forwarding later.
|
||||
// `lpv` supplies the Source Position Vector. Hand the SAME lpv->mac to dot11p_build_frame as its
|
||||
// source address, or the GN and 802.11 layers will name two different senders.
|
||||
//
|
||||
// `btp_dest_port` is the BTP-B destination port for the service being carried
|
||||
// (ETSI TS 103 248): 2001 = CAM, 2002 = DENM, 2003 = MAPEM, 2004 = SPATEM, ...
|
||||
// `btp_dest_port` is the BTP-B destination port (ETSI TS 103 248): 2001 = CAM, 2002 = DENM,
|
||||
// 2003 = MAPEM, 2004 = SPATEM.
|
||||
//
|
||||
// Returns bytes written, or -1 if out buffer too small.
|
||||
// Returns bytes written, or -1 if the out buffer is too small.
|
||||
int geonet_wrap_shb(const uint8_t *its_payload, int its_len,
|
||||
const uint8_t mac[6], uint8_t station_type,
|
||||
int32_t latitude_tenmicrodeg, int32_t longitude_tenmicrodeg,
|
||||
const gn_lpv_t *lpv,
|
||||
uint16_t btp_dest_port,
|
||||
uint8_t *out, size_t out_len);
|
||||
|
||||
|
||||
+131
-15
@@ -28,9 +28,20 @@
|
||||
#define GN_HEADER_TYPE_TSB (5) // Topologically-Scoped Broadcast
|
||||
#define GN_HEADER_SUBTYPE_SINGLE_HOP (0)
|
||||
|
||||
#define GN_NEXT_HEADER_COMMON (1) // unsecured; 2 would be a secured packet
|
||||
#define GN_NEXT_HEADER_COMMON (1) // unsecured: the Common Header follows
|
||||
#define GN_NEXT_HEADER_SECURED (2) // a TS 103 097 envelope follows, Common Header inside it
|
||||
#define GN_COMMON_NEXT_HEADER_BTP_B (2)
|
||||
|
||||
// Common Header field (clause 9.7): length of everything after the GeoNetworking headers, i.e.
|
||||
// the BTP-B header plus the ITS payload.
|
||||
#define GN_COMMON_PAYLOAD_LEN_OFFSET (4)
|
||||
|
||||
// IEEE 1609.2 / TS 103 097 envelope, COER encoded - see unwrap_secured().
|
||||
#define IEEE1609DOT2_VERSION (3)
|
||||
#define CONTENT_TAG_UNSECURED_DATA (0x80) // Ieee1609Dot2Content CHOICE, context tag 0
|
||||
#define CONTENT_TAG_SIGNED_DATA (0x81) // context tag 1
|
||||
#define SIGNED_PAYLOAD_HAS_DATA (0x40) // SignedDataPayload preamble: `data` present
|
||||
|
||||
#define BTP_DEST_PORT_CAM (2001) // ETSI TS 103 248
|
||||
#define BTP_DEST_PORT_DENM (2002)
|
||||
// NOTE the crossover: SPATEM is BTP port 2004 but ItsPduHeader messageID 4, while MAPEM is port
|
||||
@@ -51,6 +62,90 @@ static uint16_t be16(const uint8_t *p)
|
||||
return (uint16_t)(((uint16_t)p[0] << 8) | (uint16_t)p[1]);
|
||||
}
|
||||
|
||||
// COER length determinant (ITU-T X.696): a first byte below 0x80 is the length itself; otherwise
|
||||
// its low 7 bits count the big-endian length bytes that follow. Two of them cover anything this
|
||||
// radio can deliver. Returns how many bytes the determinant occupies, or 0 if it does not fit in
|
||||
// `avail` or uses a form this does not read.
|
||||
static int coer_length(const uint8_t *p, int avail, int *len)
|
||||
{
|
||||
if (avail < 1) {
|
||||
return 0;
|
||||
}
|
||||
if (p[0] < 0x80) {
|
||||
*len = p[0];
|
||||
return 1;
|
||||
}
|
||||
const int n = p[0] & 0x7F;
|
||||
if (n < 1 || n > 2 || avail < 1 + n) {
|
||||
return 0;
|
||||
}
|
||||
int v = 0;
|
||||
for (int i = 1; i <= n; i++) {
|
||||
v = (v << 8) | p[i];
|
||||
}
|
||||
*len = v;
|
||||
return 1 + n;
|
||||
}
|
||||
|
||||
// Locates the GeoNetworking packet inside a secured one. `offset` points just past the Basic
|
||||
// Header. Returns the offset of the inner Common Header and sets *inner_end to where the envelope
|
||||
// says the inner packet ends - which lies beyond frame_len if the capture was cut short - or
|
||||
// returns -1 for anything this does not unwrap.
|
||||
//
|
||||
// The envelope is an Ieee1609Dot2Data (IEEE 1609.2, profiled by TS 103 097 v1.3.1 and later),
|
||||
// COER encoded. A signed message starts:
|
||||
//
|
||||
// 03 protocolVersion 3
|
||||
// 81 content = signedData
|
||||
// 00 hashId (sha256; any one-byte value is accepted - the hash is not checked)
|
||||
// 40 tbsData.payload preamble: `data` present (bit 6)
|
||||
// 03 80 <len> payload.data: an Ieee1609Dot2Data holding unsecuredData of <len> bytes, which
|
||||
// are the Common Header, extended header, BTP-B header and ITS payload
|
||||
// ... headerInfo, signer, signature: not read
|
||||
//
|
||||
// The inner packet comes first inside tbsData, so it is found without parsing the certificate
|
||||
// or the signature, and its explicit length is what separates it from them. The shape is
|
||||
// measured, not only read from the standard: all 157 signed frames in
|
||||
// capture_20260817_171055.pcap have it (150 CAM, 7 GeoBroadcast DENM; <len> in all three COER
|
||||
// forms), and asn1tools decodes every one of them to the same unsecuredData. A top-level
|
||||
// unsecuredData (03 80 <len>, no signature at all) is accepted too.
|
||||
static int unwrap_secured(const uint8_t *frame, int offset, int frame_len,
|
||||
int *inner_end, bool *is_signed)
|
||||
{
|
||||
const uint8_t *p = frame + offset;
|
||||
const int avail = frame_len - offset;
|
||||
int i;
|
||||
|
||||
if (avail < 2 || p[0] != IEEE1609DOT2_VERSION) {
|
||||
return -1; // includes the legacy TS 103 097 v1.2.1 envelope, protocolVersion 2
|
||||
}
|
||||
if (p[1] == CONTENT_TAG_SIGNED_DATA) {
|
||||
if (avail < 6 ||
|
||||
p[2] >= 0x80 || // hashId: a one-byte enumerated value
|
||||
!(p[3] & SIGNED_PAYLOAD_HAS_DATA) || // signs only a hash of data sent elsewhere
|
||||
p[4] != IEEE1609DOT2_VERSION ||
|
||||
p[5] != CONTENT_TAG_UNSECURED_DATA) { // nested signing or encryption
|
||||
return -1;
|
||||
}
|
||||
i = 6;
|
||||
*is_signed = true;
|
||||
} else if (p[1] == CONTENT_TAG_UNSECURED_DATA) {
|
||||
i = 2;
|
||||
*is_signed = false;
|
||||
} else {
|
||||
return -1; // encryptedData, certificate requests
|
||||
}
|
||||
|
||||
int len;
|
||||
const int used = coer_length(p + i, avail - i, &len);
|
||||
if (used == 0) {
|
||||
return -1;
|
||||
}
|
||||
i += used;
|
||||
*inner_end = offset + i + len;
|
||||
return offset + i;
|
||||
}
|
||||
|
||||
bool gn_unwrap_its(const uint8_t *frame, int frame_len, gn_rx_t *out)
|
||||
{
|
||||
if (!frame || !out || frame_len < IEEE80211_HEADER_LEN) {
|
||||
@@ -91,22 +186,33 @@ bool gn_unwrap_its(const uint8_t *frame, int frame_len, gn_rx_t *out)
|
||||
if (frame_len < offset + GN_BASIC_HEADER_LEN) {
|
||||
return false;
|
||||
}
|
||||
// NextHeader distinguishes an unsecured packet (1 = Common Header follows) from a secured one
|
||||
// (2 = a TS 103 097 SecuredMessage follows, with the Common Header buried inside it at a
|
||||
// variable offset). Checking this rather than blindly skipping means a secured packet is
|
||||
// rejected cleanly instead of having its security envelope misread as a Common Header.
|
||||
if ((frame[offset] & 0x0F) != GN_NEXT_HEADER_COMMON) {
|
||||
return false;
|
||||
}
|
||||
const uint8_t basic_next_header = frame[offset] & 0x0F;
|
||||
offset += GN_BASIC_HEADER_LEN;
|
||||
|
||||
// The headers from here on must end before `limit`: the end of the frame, or for a secured
|
||||
// packet the end of the envelope's inner packet if that comes first.
|
||||
int limit = frame_len;
|
||||
int envelope_end = -1;
|
||||
if (basic_next_header == GN_NEXT_HEADER_SECURED) {
|
||||
offset = unwrap_secured(frame, offset, frame_len, &envelope_end, &out->signed_unverified);
|
||||
if (offset < 0) {
|
||||
return false;
|
||||
}
|
||||
if (envelope_end < limit) {
|
||||
limit = envelope_end;
|
||||
}
|
||||
} else if (basic_next_header != GN_NEXT_HEADER_COMMON) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---- GN Common Header (8 bytes) ----
|
||||
if (frame_len < offset + GN_COMMON_HEADER_LEN) {
|
||||
if (limit < offset + GN_COMMON_HEADER_LEN) {
|
||||
return false;
|
||||
}
|
||||
uint8_t next_header = (frame[offset + 0] >> 4) & 0x0F;
|
||||
uint8_t header_type = (frame[offset + 1] >> 4) & 0x0F;
|
||||
uint8_t header_subtype = frame[offset + 1] & 0x0F;
|
||||
const int gn_payload_len = be16(frame + offset + GN_COMMON_PAYLOAD_LEN_OFFSET);
|
||||
if (next_header != GN_COMMON_NEXT_HEADER_BTP_B) {
|
||||
return false;
|
||||
}
|
||||
@@ -126,7 +232,7 @@ bool gn_unwrap_its(const uint8_t *frame, int frame_len, gn_rx_t *out)
|
||||
} else {
|
||||
return false; // Beacon / GeoUnicast / GeoAnycast / multi-hop TSB - see header comment
|
||||
}
|
||||
if (frame_len < offset + ext_len) {
|
||||
if (limit < offset + ext_len) {
|
||||
return false;
|
||||
}
|
||||
if (is_gbc) {
|
||||
@@ -138,7 +244,7 @@ bool gn_unwrap_its(const uint8_t *frame, int frame_len, gn_rx_t *out)
|
||||
offset += ext_len;
|
||||
|
||||
// ---- BTP-B header (4 bytes) ----
|
||||
if (frame_len < offset + BTP_B_HEADER_LEN) {
|
||||
if (limit < offset + BTP_B_HEADER_LEN) {
|
||||
return false;
|
||||
}
|
||||
uint16_t dest_port = be16(frame + offset);
|
||||
@@ -146,16 +252,26 @@ bool gn_unwrap_its(const uint8_t *frame, int frame_len, gn_rx_t *out)
|
||||
dest_port != BTP_DEST_PORT_SPATEM) {
|
||||
return false;
|
||||
}
|
||||
offset += BTP_B_HEADER_LEN;
|
||||
|
||||
// ---- Whatever's left is the ITS UPER payload ----
|
||||
int payload_len = frame_len - offset;
|
||||
// ---- ITS payload: exactly as long as the Common Header declares ----
|
||||
// Not "whatever is left of the frame": see "Payload bounds" in gn_unwrap.h for the 8 trailing
|
||||
// bytes every received frame carries and the signature that follows a secured packet.
|
||||
if (gn_payload_len <= BTP_B_HEADER_LEN) {
|
||||
return false; // no ITS payload at all
|
||||
}
|
||||
const int payload_start = offset + BTP_B_HEADER_LEN;
|
||||
const int payload_end = offset + gn_payload_len;
|
||||
if (envelope_end >= 0 && payload_end > envelope_end) {
|
||||
return false; // the inner packet claims more than its envelope holds
|
||||
}
|
||||
out->truncated = payload_end > frame_len;
|
||||
const int payload_len = (out->truncated ? frame_len : payload_end) - payload_start;
|
||||
if (payload_len <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
out->btp_dest_port = dest_port;
|
||||
out->payload = frame + offset;
|
||||
out->payload = frame + payload_start;
|
||||
out->payload_len = payload_len;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
|
||||
// Inverse of geonet_wrap_shb() + dot11p_build_frame(): takes a raw 802.11 frame as delivered by
|
||||
// the WiFi driver's promiscuous RX callback and strips 802.11 header -> LLC/SNAP ->
|
||||
// GeoNetworking Basic/Common/extended header -> BTP-B header, leaving the ITS payload (a UPER
|
||||
// message) plus the metadata the phone needs to know what it received.
|
||||
// GeoNetworking Basic Header -> [security envelope] -> Common/extended header -> BTP-B header,
|
||||
// leaving the ITS payload (a UPER message) plus the metadata the phone needs to know what it
|
||||
// received.
|
||||
//
|
||||
// ---- Supported GeoNetworking header types --------------------------------------------------
|
||||
// Two shapes, chosen by the Common Header's HeaderType, with DIFFERENT extended-header lengths:
|
||||
@@ -28,6 +29,23 @@
|
||||
// Beacon, GeoUnicast, GeoAnycast and multi-hop TSB are still rejected - nothing this project
|
||||
// talks to sends them, and each has its own extended-header length that would need measuring.
|
||||
//
|
||||
// ---- Secured packets -----------------------------------------------------------------------
|
||||
// A Basic Header NextHeader of 2 means an ETSI TS 103 097 (IEEE 1609.2) envelope follows, with
|
||||
// the Common Header onward inside it. Signed messages are unwrapped WITHOUT verifying the
|
||||
// signature or the certificate - this firmware has no trust store - and are reported with
|
||||
// signed_unverified set so the phone can tell. Most real traffic is signed: the 2026-08-17
|
||||
// capture held 157 signed frames from 15 source MACs. Encrypted payloads, nested signing and the
|
||||
// legacy v1.2.1 envelope are rejected. The layout is documented at unwrap_secured() in
|
||||
// gn_unwrap.c. Before 2026-09-11 every secured packet was rejected.
|
||||
//
|
||||
// ---- Payload bounds ------------------------------------------------------------------------
|
||||
// The payload is exactly as long as the Common Header's payload-length field says, minus the
|
||||
// BTP-B header - not "the rest of the frame". After the message comes, in a signed packet, the
|
||||
// signature; and every frame recorded through this chip's promiscuous RX API (~15 000 of them)
|
||||
// ends in 8 more bytes that are not part of the 802.11 frame and not a valid FCS. Until
|
||||
// 2026-09-11 those 8 bytes were forwarded to the phone as the tail of every message. UPER
|
||||
// decoders stop where the message ends, which is why nothing visibly broke.
|
||||
//
|
||||
// ---- Accepted BTP-B ports (ETSI TS 103 248) ------------------------------------------------
|
||||
// 2001 (CAM), 2002 (DENM) and 2004 (SPATEM). MAPEM (2003) and the rest are deliberately not
|
||||
// accepted yet: the phone has no decoder for them, so forwarding would just burn serial
|
||||
@@ -38,17 +56,11 @@
|
||||
// counted as an oversize drop rather than forwarded. The bench RSU trigger emits ~58-byte SPATEMs
|
||||
// and is unaffected, but real road RSUs measured 555 bytes median and 1243 max (2026-03-18 drive,
|
||||
// 79k messages), i.e. roughly 70% would be dropped. Raising the cap is deliberately deferred: it
|
||||
// also requires enlarging RX_FRAME_MAX_LEN and moving rx_item_t off the WiFi callback stack,
|
||||
// which at that size would overflow it.
|
||||
// also requires enlarging main.c's RX_FRAME_MAX_LEN.
|
||||
//
|
||||
// ---- What is NOT handled -------------------------------------------------------------------
|
||||
// Secured packets (GN Basic Header NextHeader=2, i.e. ETSI TS 103 097 signed messages). The
|
||||
// units on this bench run with ItsGnSecurity=0 so everything observed is unsecured; a secured
|
||||
// packet is rejected rather than mis-parsed.
|
||||
//
|
||||
// No FCS/CRC check: the WiFi driver has already validated and stripped it.
|
||||
// No FCS/CRC check here: the WiFi driver has already validated the frame.
|
||||
typedef struct {
|
||||
// BTP-B destination port, identifying the service: 2001 = CAM, 2002 = DENM.
|
||||
// BTP-B destination port, identifying the service: 2001 = CAM, 2002 = DENM, 2004 = SPATEM.
|
||||
uint16_t btp_dest_port;
|
||||
|
||||
// ITS payload (UPER message bytes). Points INTO the caller's `frame` buffer - NOT a copy, so
|
||||
@@ -64,11 +76,20 @@ typedef struct {
|
||||
int32_t geo_area_lat_tenmicrodeg;
|
||||
int32_t geo_area_lon_tenmicrodeg;
|
||||
uint16_t geo_area_distance_a_m;
|
||||
|
||||
// The packet arrived inside a TS 103 097 signed envelope. The signature was NOT checked.
|
||||
bool signed_unverified;
|
||||
|
||||
// The frame ended before the payload its headers declare. On the board only main.c's
|
||||
// RX_FRAME_MAX_LEN capture limit causes this (the driver drops frames that fail their FCS).
|
||||
// payload/payload_len then cover just the part that arrived, so it must not be forwarded.
|
||||
bool truncated;
|
||||
} gn_rx_t;
|
||||
|
||||
// Returns true and fills *out if this was a well-formed, supported ITS frame. Returns false
|
||||
// otherwise (wrong ethertype, secured, unsupported header type, unaccepted BTP port, truncated,
|
||||
// or promiscuous-capture garbage) - all common and expected on an open-air capture, so the caller
|
||||
// Returns true and fills *out if this was a well-formed, supported ITS frame - check `truncated`
|
||||
// before using the payload. Returns false otherwise (wrong ethertype, encrypted or unsupported
|
||||
// envelope, unsupported header type, unaccepted BTP port, headers cut short, or
|
||||
// promiscuous-capture garbage) - all common and expected on an open-air capture, so the caller
|
||||
// should treat false as "not for us", not as an error worth logging per frame.
|
||||
bool gn_unwrap_its(const uint8_t *frame, int frame_len, gn_rx_t *out);
|
||||
|
||||
|
||||
+85
-21
@@ -21,7 +21,9 @@
|
||||
static const char *TAG = "obu-tx";
|
||||
|
||||
// Phase 03: CAM is no longer built on this chip. The phone fuses its own GNSS+IMU, UPER-encodes
|
||||
// CAM itself, and hands the finished bytes down over serial_link (SERIAL_MSG_CAM_TX) - this
|
||||
// CAM itself, and hands the finished bytes down over serial_link (SERIAL_MSG_CAM_TX_PV, together
|
||||
// with the GeoNetworking position vector to send them under; plain SERIAL_MSG_CAM_TX from an app
|
||||
// that predates it) - this
|
||||
// firmware's job on transmit shrinks to "GeoNetworking/BTP-wrap + 802.11-wrap + key the PA the
|
||||
// instant a CAM arrives." There is no on-chip transmit timer anymore; the phone's send cadence
|
||||
// (1 Hz baseline, faster near intersections/events - all decided app-side) IS the air cadence.
|
||||
@@ -41,26 +43,25 @@ static const char *TAG = "obu-tx";
|
||||
#define TX_FREQ_MHZ 5900
|
||||
|
||||
// ---- CAM beacon profile (used for the GeoNetworking layer only now - see below) ----
|
||||
#define STATION_TYPE 5 // passengerCar (TS 102 894-2 StationType) - matches gn_addr's ST field
|
||||
#define STATION_TYPE 2 // cyclist (TS 102 894-2 StationType), legacy CAM_TX path only - see legacy_lpv()
|
||||
#define BTP_PORT_CAM 2001 // BTP-B destination port for CAM (ETSI TS 103 248)
|
||||
|
||||
// Bench location, hardcoded since there's no GNSS module wired in yet and the unit is genuinely
|
||||
// stationary here: 53°33'16.8"N 10°01'20.6"E, in 1/10-microdegree units. Used ONLY for the
|
||||
// GeoNetworking Source Long Position Vector now (geonet_wrap_shb's own claimed position) - the
|
||||
// CAM payload's own referencePosition comes from the phone's real GNSS and can legitimately
|
||||
// differ from this bench placeholder until the GN layer is also given a real position source.
|
||||
// TODO: feed this from the phone too (e.g. a lightweight position update piggybacked on
|
||||
// SERIAL_MSG_CAM_TX, or a new small message type) instead of a fixed bench location.
|
||||
// Bench location, 53°33'16.8"N 10°01'20.6"E, in 1/10-microdegree units. Used only by the legacy
|
||||
// SERIAL_MSG_CAM_TX path (see legacy_lpv), which carries no position of its own. A current app
|
||||
// sends SERIAL_MSG_CAM_TX_PV instead, and the GN Source Position Vector then comes from the
|
||||
// phone's real fix, the same one the CAM payload's own referencePosition is built from.
|
||||
#define BENCH_LATITUDE_TENMICRODEG 535546667
|
||||
#define BENCH_LONGITUDE_TENMICRODEG 100223889
|
||||
|
||||
// Single source of truth for the pseudonym/link-layer address: used both as
|
||||
// the 802.11 source MAC (Addr2) and as GN_ADDR's MID field, since the GN
|
||||
// spec defines those as being the same address. Locally-administered bit
|
||||
// set (0x02) per normal MAC convention. Fixed/non-rotating for now - real
|
||||
// stacks rotate this every 5-15 min for privacy. Owned entirely by this firmware (not the
|
||||
// phone) per the Phase 03 design decision - simplest given the phone never needs to know it.
|
||||
static const uint8_t pseudonym_mac[6] = {0x02, 0x00, 0x00, 0x00, 0x00, 0x01};
|
||||
// Link-layer address for the legacy SERIAL_MSG_CAM_TX path only. Locally-administered bit set
|
||||
// (0x02), per normal MAC convention.
|
||||
//
|
||||
// This reverses the Phase 03 decision that the pseudonym is owned entirely by this firmware. That
|
||||
// was simplest while the address never changed, but a pseudonym only protects anyone if the
|
||||
// 802.11 address, the GN_ADDR MID and the CAM's stationID all change together, and the phone owns
|
||||
// the stationID. One identity needs one owner, so with CAM_TX_PV the phone sends the address with
|
||||
// every frame and rotates it, and this constant is only what the legacy path falls back to.
|
||||
static const uint8_t LEGACY_MAC[6] = {0x02, 0x00, 0x00, 0x00, 0x00, 0x01};
|
||||
|
||||
// Undocumented libphy.a calls that push the radio into 802.11p OCB mode on
|
||||
// the 5.9 GHz ITS-G5 band. See docs/04-transmit-setup.md for source + what
|
||||
@@ -77,6 +78,7 @@ extern void phy_change_channel(int freq_mhz, int bw_mode, int sec_chan_offset, i
|
||||
typedef struct {
|
||||
uint8_t data[SERIAL_LINK_MAX_PAYLOAD];
|
||||
int len;
|
||||
gn_lpv_t lpv; // the Source Position Vector this CAM goes out under
|
||||
} cam_tx_item_t;
|
||||
|
||||
static QueueHandle_t s_tx_queue;
|
||||
@@ -87,6 +89,23 @@ static QueueHandle_t s_tx_queue;
|
||||
// tx_radio_task below, off the UART parsing path entirely. xQueueSend with 0 timeout: if the
|
||||
// radio task is somehow behind, drop this CAM rather than stall UART frame parsing - the next
|
||||
// one is only ~1s (or less, at elevated rate) away regardless.
|
||||
// Source Position Vector for the legacy SERIAL_MSG_CAM_TX path, which carries no position of its
|
||||
// own. Everything here describes the bench, not the rider: a fixed point, standing still, at an
|
||||
// unknown time, under a fixed address. That is exactly why the phone now sends CAM_TX_PV. Kept so
|
||||
// an app that predates it still transmits what it always did, except that the station type now
|
||||
// says cyclist to agree with the CAM inside.
|
||||
static void legacy_lpv(gn_lpv_t *lpv)
|
||||
{
|
||||
memcpy(lpv->mac, LEGACY_MAC, sizeof(lpv->mac));
|
||||
lpv->station_type = STATION_TYPE;
|
||||
lpv->pai = false;
|
||||
lpv->tst_ms = 0;
|
||||
lpv->lat_tenmicrodeg = BENCH_LATITUDE_TENMICRODEG;
|
||||
lpv->lon_tenmicrodeg = BENCH_LONGITUDE_TENMICRODEG;
|
||||
lpv->speed_cms = 0;
|
||||
lpv->heading_decideg = 0;
|
||||
}
|
||||
|
||||
static void on_cam_tx_from_phone(const uint8_t *cam_uper, int cam_len)
|
||||
{
|
||||
if (cam_len <= 0 || cam_len > SERIAL_LINK_MAX_PAYLOAD) {
|
||||
@@ -96,6 +115,42 @@ static void on_cam_tx_from_phone(const uint8_t *cam_uper, int cam_len)
|
||||
cam_tx_item_t item;
|
||||
item.len = cam_len;
|
||||
memcpy(item.data, cam_uper, (size_t)cam_len);
|
||||
legacy_lpv(&item.lpv);
|
||||
if (xQueueSend(s_tx_queue, &item, 0) != pdTRUE) {
|
||||
ESP_LOGW(TAG, "tx queue full, dropping CAM from phone");
|
||||
}
|
||||
}
|
||||
|
||||
static uint16_t le16(const uint8_t *p)
|
||||
{
|
||||
return (uint16_t)(p[0] | (p[1] << 8));
|
||||
}
|
||||
|
||||
static uint32_t le32(const uint8_t *p)
|
||||
{
|
||||
return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24);
|
||||
}
|
||||
|
||||
// SERIAL_MSG_CAM_TX_PV: the phone's CAM plus the position vector to send it under. The prefix
|
||||
// layout is documented at SERIAL_MSG_CAM_TX_PV in serial_link.h. Same speed constraint as
|
||||
// on_cam_tx_from_phone: decode, queue, return.
|
||||
static void on_cam_tx_pv_from_phone(const uint8_t *prefix, const uint8_t *cam_uper, int cam_len)
|
||||
{
|
||||
if (cam_len <= 0 || cam_len > SERIAL_LINK_MAX_PAYLOAD) {
|
||||
ESP_LOGW(TAG, "on_cam_tx_pv_from_phone: bad length %d", cam_len);
|
||||
return;
|
||||
}
|
||||
cam_tx_item_t item;
|
||||
item.len = cam_len;
|
||||
memcpy(item.data, cam_uper, (size_t)cam_len);
|
||||
memcpy(item.lpv.mac, prefix, sizeof(item.lpv.mac));
|
||||
item.lpv.station_type = prefix[6];
|
||||
item.lpv.pai = (prefix[7] & 0x01) != 0;
|
||||
item.lpv.tst_ms = le32(prefix + 8);
|
||||
item.lpv.lat_tenmicrodeg = (int32_t)le32(prefix + 12);
|
||||
item.lpv.lon_tenmicrodeg = (int32_t)le32(prefix + 16);
|
||||
item.lpv.speed_cms = (int16_t)le16(prefix + 20);
|
||||
item.lpv.heading_decideg = le16(prefix + 22);
|
||||
if (xQueueSend(s_tx_queue, &item, 0) != pdTRUE) {
|
||||
ESP_LOGW(TAG, "tx queue full, dropping CAM from phone");
|
||||
}
|
||||
@@ -116,8 +171,7 @@ static void tx_radio_task(void *arg)
|
||||
// singleton, created once in app_main. Both wrap functions bounds-check against the size
|
||||
// passed in and return <= 0 on overflow, so an oversized CAM is rejected, not written past.
|
||||
static uint8_t gn_payload[SERIAL_LINK_MAX_PAYLOAD + 64];
|
||||
int gn_len = geonet_wrap_shb(item.data, item.len, pseudonym_mac, STATION_TYPE,
|
||||
BENCH_LATITUDE_TENMICRODEG, BENCH_LONGITUDE_TENMICRODEG,
|
||||
int gn_len = geonet_wrap_shb(item.data, item.len, &item.lpv,
|
||||
BTP_PORT_CAM, gn_payload, sizeof(gn_payload));
|
||||
if (gn_len <= 0) {
|
||||
ESP_LOGW(TAG, "geonet_wrap_shb failed (cam_len=%d)", item.len);
|
||||
@@ -125,7 +179,9 @@ static void tx_radio_task(void *arg)
|
||||
}
|
||||
|
||||
static uint8_t frame[SERIAL_LINK_MAX_PAYLOAD + 192];
|
||||
int frame_len = dot11p_build_frame(gn_payload, gn_len, pseudonym_mac, frame,
|
||||
// Source address from the same lpv the GN header was built from, so the 802.11 and
|
||||
// GeoNetworking layers always name the same sender, including across a pseudonym change.
|
||||
int frame_len = dot11p_build_frame(gn_payload, gn_len, item.lpv.mac, frame,
|
||||
sizeof(frame), false);
|
||||
if (frame_len <= 0) {
|
||||
ESP_LOGW(TAG, "dot11p_build_frame failed (gn_len=%d)", gn_len);
|
||||
@@ -224,8 +280,16 @@ static void rx_forward_task(void *arg)
|
||||
// returning false here is the common case, not an error, so it isn't logged per frame.
|
||||
gn_rx_t rx;
|
||||
if (gn_unwrap_its(item.data, item.len, &rx)) {
|
||||
if (rx.truncated) {
|
||||
// Longer than the RX_FRAME_MAX_LEN bytes captured above, so it cannot be forwarded
|
||||
// whole - and at that size it could not cross the serial link either. Counted as
|
||||
// an oversize drop, as it was when the cut-off frame still reached
|
||||
// serial_link_send_v2x_rx() and failed the size check there.
|
||||
serial_link_note_oversize_drop(rx.btp_dest_port);
|
||||
continue;
|
||||
}
|
||||
serial_link_send_v2x_rx(rx.btp_dest_port, item.rssi,
|
||||
rx.has_geo_area,
|
||||
rx.has_geo_area, rx.signed_unverified,
|
||||
rx.geo_area_lat_tenmicrodeg,
|
||||
rx.geo_area_lon_tenmicrodeg,
|
||||
rx.geo_area_distance_a_m,
|
||||
@@ -339,7 +403,7 @@ void app_main(void)
|
||||
|
||||
xTaskCreate(tx_radio_task, "tx_radio", 4096, NULL, 6, NULL);
|
||||
xTaskCreate(rx_forward_task, "rx_forward", 4096, NULL, 5, NULL);
|
||||
serial_link_init(on_cam_tx_from_phone);
|
||||
serial_link_init(on_cam_tx_from_phone, on_cam_tx_pv_from_phone);
|
||||
|
||||
ESP_LOGW(TAG, "OCB @ %d MHz - TX/RX armed, driven by serial_link (no on-chip TX timer)",
|
||||
TX_FREQ_MHZ);
|
||||
|
||||
@@ -13,6 +13,7 @@ static const char *TAG = "serial_link";
|
||||
#define SYNC1 0x55
|
||||
|
||||
static serial_link_cam_tx_cb_t s_on_cam_tx;
|
||||
static serial_link_cam_tx_pv_cb_t s_on_cam_tx_pv;
|
||||
|
||||
// ---- Counters reported to the phone in every heartbeat (see SERIAL_MSG_STATUS in the header).
|
||||
// Saturating rather than wrapping: "65535 drops" reads as "lots and still going", whereas a wrap
|
||||
@@ -37,6 +38,13 @@ void serial_link_note_tx_failure(void)
|
||||
bump(&s_tx_failures);
|
||||
}
|
||||
|
||||
void serial_link_note_oversize_drop(uint16_t btp_dest_port)
|
||||
{
|
||||
bump(&s_oversize_drops);
|
||||
ESP_LOGW(TAG, "port %u message larger than the RX capture buffer, total oversize drops %u",
|
||||
btp_dest_port, s_oversize_drops);
|
||||
}
|
||||
|
||||
// ---- CRC-16/CCITT-FALSE (poly 0x1021, init 0xFFFF, no reflect, no xorout) ----
|
||||
// Bytewise (no table) - frames here are at most SERIAL_LINK_MAX_PAYLOAD + 3 bytes, so table
|
||||
// lookup isn't worth the flash/RAM tradeoff. MUST match the Kotlin-side implementation exactly
|
||||
@@ -113,7 +121,7 @@ static bool send_frame(uint8_t type, const uint8_t *payload, int len)
|
||||
}
|
||||
|
||||
bool serial_link_send_v2x_rx(uint16_t btp_dest_port, int8_t rssi,
|
||||
bool has_geo_area,
|
||||
bool has_geo_area, bool signed_unverified,
|
||||
int32_t geo_area_lat_tenmicrodeg,
|
||||
int32_t geo_area_lon_tenmicrodeg,
|
||||
uint16_t geo_area_distance_a_m,
|
||||
@@ -137,7 +145,7 @@ bool serial_link_send_v2x_rx(uint16_t btp_dest_port, int8_t rssi,
|
||||
s_v2x_payload[0] = (uint8_t)(btp_dest_port & 0xFF);
|
||||
s_v2x_payload[1] = (uint8_t)((btp_dest_port >> 8) & 0xFF);
|
||||
s_v2x_payload[2] = (uint8_t)rssi;
|
||||
s_v2x_payload[3] = has_geo_area ? 0x01 : 0x00;
|
||||
s_v2x_payload[3] = (uint8_t)((has_geo_area ? 0x01 : 0x00) | (signed_unverified ? 0x02 : 0x00));
|
||||
uint32_t lat = (uint32_t)geo_area_lat_tenmicrodeg;
|
||||
uint32_t lon = (uint32_t)geo_area_lon_tenmicrodeg;
|
||||
s_v2x_payload[4] = (uint8_t)(lat & 0xFF);
|
||||
@@ -157,9 +165,9 @@ bool serial_link_send_v2x_rx(uint16_t btp_dest_port, int8_t rssi,
|
||||
|
||||
bool serial_link_send_status(uint8_t status)
|
||||
{
|
||||
// [status:1][oversize_drops:2 LE][tx_failures:2 LE][rx_crc_errors:2 LE] - keep in lockstep
|
||||
// with EspLinkStatus.parse() in the app's SerialFrame.kt.
|
||||
uint8_t payload[7];
|
||||
// [status:1][oversize_drops:2 LE][tx_failures:2 LE][rx_crc_errors:2 LE][capabilities:1] -
|
||||
// keep in lockstep with EspLinkStatus.parse() in the app's SerialFrame.kt.
|
||||
uint8_t payload[8];
|
||||
payload[0] = status;
|
||||
payload[1] = (uint8_t)(s_oversize_drops & 0xFF);
|
||||
payload[2] = (uint8_t)((s_oversize_drops >> 8) & 0xFF);
|
||||
@@ -167,6 +175,9 @@ bool serial_link_send_status(uint8_t status)
|
||||
payload[4] = (uint8_t)((s_tx_failures >> 8) & 0xFF);
|
||||
payload[5] = (uint8_t)(s_rx_crc_errors & 0xFF);
|
||||
payload[6] = (uint8_t)((s_rx_crc_errors >> 8) & 0xFF);
|
||||
// What this firmware accepts. The app reads it to decide whether it may send CAM_TX_PV, which
|
||||
// is what lets a new app keep working against firmware that predates that message.
|
||||
payload[7] = SERIAL_CAP_CAM_TX_PV;
|
||||
return send_frame(SERIAL_MSG_STATUS, payload, sizeof(payload));
|
||||
}
|
||||
|
||||
@@ -262,9 +273,19 @@ static void rx_task(void *arg)
|
||||
uint16_t crc_calc = crc16_ccitt_false(crc_buf, (size_t)(3 + len));
|
||||
|
||||
if (crc_calc == crc_recv) {
|
||||
if (type == SERIAL_MSG_CAM_TX && s_on_cam_tx) {
|
||||
s_on_cam_tx(payload, len);
|
||||
} else if (type != SERIAL_MSG_CAM_TX) {
|
||||
if (type == SERIAL_MSG_CAM_TX) {
|
||||
if (s_on_cam_tx) s_on_cam_tx(payload, len);
|
||||
} else if (type == SERIAL_MSG_CAM_TX_PV) {
|
||||
// A frame that is all prefix has nothing to transmit.
|
||||
if (len > SERIAL_CAM_TX_PV_PREFIX_LEN) {
|
||||
if (s_on_cam_tx_pv) {
|
||||
s_on_cam_tx_pv(payload, payload + SERIAL_CAM_TX_PV_PREFIX_LEN,
|
||||
len - SERIAL_CAM_TX_PV_PREFIX_LEN);
|
||||
}
|
||||
} else {
|
||||
ESP_LOGW(TAG, "rx: CAM_TX_PV of %u bytes carries no CAM, ignoring", len);
|
||||
}
|
||||
} else {
|
||||
ESP_LOGW(TAG, "rx: unexpected frame type 0x%02x from phone, ignoring", type);
|
||||
}
|
||||
} else {
|
||||
@@ -279,9 +300,11 @@ static void rx_task(void *arg)
|
||||
}
|
||||
}
|
||||
|
||||
void serial_link_init(serial_link_cam_tx_cb_t on_cam_tx)
|
||||
void serial_link_init(serial_link_cam_tx_cb_t on_cam_tx,
|
||||
serial_link_cam_tx_pv_cb_t on_cam_tx_pv)
|
||||
{
|
||||
s_on_cam_tx = on_cam_tx;
|
||||
s_on_cam_tx_pv = on_cam_tx_pv;
|
||||
|
||||
s_tx_mutex = xSemaphoreCreateMutex();
|
||||
if (!s_tx_mutex) {
|
||||
|
||||
@@ -37,10 +37,13 @@
|
||||
// [0..1] btp_dest_port uint16 LE 2001 = CAM, 2002 = DENM (ETSI TS 103 248)
|
||||
// [2] rssi int8 dBm, from the promiscuous RX metadata
|
||||
// [3] flags uint8 bit0: geo area fields below are valid
|
||||
// bit1: arrived signed (TS 103 097), signature NOT
|
||||
// verified. An app that tests only bit0 ignores it.
|
||||
// [4..7] geo_area_lat int32 LE 1/10 microdegree, GeoBroadcast destination area
|
||||
// [8..11] geo_area_lon int32 LE 1/10 microdegree
|
||||
// [12..13] geo_area_dist uint16 LE Distance A, metres (relevance radius for a circle)
|
||||
// [14..] UPER message bytes
|
||||
// [14..] UPER message bytes - exactly the message. Before 2026-09-11 they were followed by
|
||||
// the 8 bytes the chip's promiscuous RX appends (gn_unwrap.h, "Payload bounds").
|
||||
//
|
||||
// All prefix fields are LITTLE-endian, matching this framing's own length field - note the
|
||||
// GeoNetworking wire format they came from is big-endian, so gn_unwrap.c converts.
|
||||
@@ -49,20 +52,52 @@
|
||||
// message's own ItsPduHeader.stationID is the meaningful identifier.
|
||||
// SERIAL_MSG_STATUS (0x03), ESP32 -> phone: heartbeat + counters, sent at 1 Hz so the phone can
|
||||
// distinguish "link idle" from "link dead" independent of CAM traffic (the app's watchdog in
|
||||
// UsbSerialTransport.kt declares the link dead after 3 missed beats). Payload is 7 bytes:
|
||||
// [status:1][oversize_drops:2 LE][tx_failures:2 LE][rx_crc_errors:2 LE]
|
||||
// UsbSerialTransport.kt declares the link dead after 3 missed beats). Payload is 8 bytes:
|
||||
// [status:1][oversize_drops:2 LE][tx_failures:2 LE][rx_crc_errors:2 LE][capabilities:1]
|
||||
// status 0 = ok. The counters are free-running totals since boot, saturating at 0xFFFF.
|
||||
// capabilities is a bitmask of the SERIAL_CAP_* flags below. It was appended as byte 7 rather
|
||||
// than inserted, so an app that predates it, and reads only the first 7 bytes, is unaffected.
|
||||
// They exist because the alternative - ESP_LOGW on the flashing port - is invisible to the
|
||||
// phone, which is the only thing watching during a bench session. Mirrored by EspLinkStatus
|
||||
// in the app's SerialFrame.kt.
|
||||
#define SERIAL_MSG_CAM_TX 0x01
|
||||
#define SERIAL_MSG_CAM_RX 0x02
|
||||
#define SERIAL_MSG_STATUS 0x03
|
||||
#define SERIAL_MSG_V2X_RX 0x04
|
||||
#define SERIAL_MSG_CAM_TX 0x01
|
||||
#define SERIAL_MSG_CAM_RX 0x02
|
||||
#define SERIAL_MSG_STATUS 0x03
|
||||
#define SERIAL_MSG_V2X_RX 0x04
|
||||
#define SERIAL_MSG_CAM_TX_PV 0x05
|
||||
|
||||
// Size of the V2X_RX prefix documented above. Must match the app's SerialFrame.kt.
|
||||
#define SERIAL_V2X_RX_PREFIX_LEN 14
|
||||
|
||||
// SERIAL_MSG_CAM_TX_PV (0x05), phone -> ESP32: a CAM together with the GeoNetworking Source
|
||||
// Position Vector to transmit it under. Payload is a fixed 24-byte prefix, then the CAM UPER:
|
||||
//
|
||||
// [0..5] mac 6 bytes pseudonym: the 802.11 source address AND the GN_ADDR MID
|
||||
// [6] station_type uint8 TS 102 894-2 StationType (2 = cyclist)
|
||||
// [7] flags uint8 bit0: PAI, position accuracy indicator
|
||||
// [8..11] tst uint32 LE ms at which lat/lon were acquired, TimestampIts mod 2^32
|
||||
// [12..15] lat int32 LE 1/10 microdegree
|
||||
// [16..19] lon int32 LE 1/10 microdegree
|
||||
// [20..21] speed int16 LE 0.01 m/s
|
||||
// [22..23] heading uint16 LE 0.1 degree from north, clockwise, 0..3599
|
||||
// [24..] CAM UPER bytes
|
||||
//
|
||||
// Little-endian like the rest of this framing; geonet.c converts to GeoNetworking's big-endian.
|
||||
// Every prefix field is something the phone already has when it builds the CAM, and none of it
|
||||
// can be known on this chip, which has no GNSS and no clock source on the OCB channel. Before
|
||||
// this message existed the GN header carried fixed placeholders instead (see main.c).
|
||||
//
|
||||
// A new type rather than a redefined CAM_TX, so app and firmware can be updated independently:
|
||||
// - old app, new firmware: the app sends CAM_TX, which is handled exactly as before.
|
||||
// - new app, old firmware: the app sends CAM_TX_PV only once the heartbeat advertises
|
||||
// SERIAL_CAP_CAM_TX_PV, and an old heartbeat carries no such bit, so it stays on CAM_TX.
|
||||
// Redefining CAM_TX would instead have double-wrapped every frame in one of those combinations
|
||||
// and sent one with no GN header in the other, silently, since neither side checks versions.
|
||||
#define SERIAL_CAM_TX_PV_PREFIX_LEN 24
|
||||
|
||||
// Capability bits, carried in byte 7 of the SERIAL_MSG_STATUS payload.
|
||||
#define SERIAL_CAP_CAM_TX_PV 0x01
|
||||
|
||||
// USB Serial/JTAG has no baud rate or GPIO pins to configure - it's a fixed on-chip USB device
|
||||
// controller wired directly to the native USB-C port's D+/D- lines in silicon. RX/TX buffer
|
||||
// sizes for usb_serial_jtag_driver_install() (see serial_link.c) are sized generously relative
|
||||
@@ -83,24 +118,34 @@
|
||||
// Raised from 160 to 512: 160 was reasoned from cam.c's 96-byte encode buffer, which only ever
|
||||
// described OUR OWN minimal CAM. A third-party CAM off the air carrying a path-history or
|
||||
// special-vehicle container comfortably exceeds it, and those stations would then never reach the
|
||||
// phone at all. 512 clears any realistic CAM; the real upstream ceiling on the RX path is
|
||||
// rx_item_t.data (400 bytes) in main.c, so nothing larger can get here anyway.
|
||||
// phone at all. 512 clears any realistic CAM. Our own CAM is 43 bytes of UPER.
|
||||
//
|
||||
// This, not the radio side, is the ceiling on the RX path. main.c captures up to RX_FRAME_MAX_LEN
|
||||
// (800) bytes per frame, sized for the CiT One's 528-byte DENM, so a larger ITS payload
|
||||
// does arrive here. serial_link_send_v2x_rx() then drops anything above this minus its 14-byte
|
||||
// prefix and counts it in the heartbeat's oversize-drop counter.
|
||||
#define SERIAL_LINK_MAX_PAYLOAD 512
|
||||
|
||||
// Initializes the USB Serial/JTAG driver and its background RX-framing and 1 Hz heartbeat tasks.
|
||||
// Call once from app_main, after nvs/event loop init. `on_cam_tx` is invoked (from the RX task's
|
||||
// context - keep it fast, it blocks the next frame's parsing) whenever a complete, checksummed
|
||||
// SERIAL_MSG_CAM_TX frame arrives from the phone.
|
||||
// Call once from app_main, after nvs/event loop init. Both callbacks run in the RX task's context,
|
||||
// so keep them fast: they block the next frame's parsing.
|
||||
// on_cam_tx a complete, checksummed SERIAL_MSG_CAM_TX frame: bare CAM UPER.
|
||||
// on_cam_tx_pv a complete, checksummed SERIAL_MSG_CAM_TX_PV frame, already checked to carry at
|
||||
// least one CAM byte after its prefix: the 24-byte prefix, then the CAM UPER.
|
||||
typedef void (*serial_link_cam_tx_cb_t)(const uint8_t *cam_uper, int cam_len);
|
||||
void serial_link_init(serial_link_cam_tx_cb_t on_cam_tx);
|
||||
typedef void (*serial_link_cam_tx_pv_cb_t)(const uint8_t *prefix,
|
||||
const uint8_t *cam_uper, int cam_len);
|
||||
void serial_link_init(serial_link_cam_tx_cb_t on_cam_tx,
|
||||
serial_link_cam_tx_pv_cb_t on_cam_tx_pv);
|
||||
|
||||
// Sends a SERIAL_MSG_V2X_RX frame: the metadata prefix plus the UPER bytes gn_unwrap.c extracted
|
||||
// from an over-the-air frame. Pass has_geo_area=false and zeroes for the area fields when the
|
||||
// source frame carried no destination area (i.e. it was single-hop broadcast, not GeoBroadcast).
|
||||
// signed_unverified is gn_rx_t's flag of the same name; it sets bit1 of the prefix flags.
|
||||
// Returns true if the frame was written to the USB endpoint - not an end-to-end ack, the phone
|
||||
// may still drop it.
|
||||
bool serial_link_send_v2x_rx(uint16_t btp_dest_port, int8_t rssi,
|
||||
bool has_geo_area,
|
||||
bool has_geo_area, bool signed_unverified,
|
||||
int32_t geo_area_lat_tenmicrodeg,
|
||||
int32_t geo_area_lon_tenmicrodeg,
|
||||
uint16_t geo_area_distance_a_m,
|
||||
@@ -115,4 +160,9 @@ bool serial_link_send_status(uint8_t status);
|
||||
// otherwise indistinguishable, from the phone's side, from one that transmitted fine.
|
||||
void serial_link_note_tx_failure(void);
|
||||
|
||||
// Counts an ITS message that cannot be forwarded because it is too large, in the same heartbeat
|
||||
// counter serial_link_send_v2x_rx() uses for its own size check. For main.c's rx_forward_task,
|
||||
// whose capture buffer is smaller than the largest frames on air.
|
||||
void serial_link_note_oversize_drop(uint16_t btp_dest_port);
|
||||
|
||||
#endif
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# Host-side tests for obu-firmware. See README.md: needs gcc and make on PATH (MSYS2 UCRT64), and
|
||||
# asn1tools under `py -3.11` for the replay check.
|
||||
#
|
||||
# make build and run everything: chain, replay, fuzz
|
||||
# make chain | replay | fuzz one of them
|
||||
# make fuzz FUZZ_ITER=50000000 FUZZ_SEED=7 a longer or different fuzz run
|
||||
# make clean remove build/
|
||||
#
|
||||
# The firmware sources are compiled straight from ../../main, never copied.
|
||||
|
||||
CC = gcc
|
||||
PYTHON = python
|
||||
PYTHON_ASN1 = py -3.11
|
||||
FW = ../../main
|
||||
BUILD = build
|
||||
EXE = $(if $(filter Windows_NT,$(OS)),.exe,)
|
||||
# Captures live in capture/recordings/ since 2026-09-14; older ones are still in the
|
||||
# receiver checkout beside this repo.
|
||||
RECORDINGS = $(wildcard ../../../capture/recordings/*.pcap ../../../its-g5-receiver-firmware/recordings/*.pcap)
|
||||
FUZZ_ITER = 2000000
|
||||
FUZZ_SEED = 1
|
||||
|
||||
# -Werror: these sources must stay warning-free on the host compiler too.
|
||||
# UBSan in trap mode needs no runtime library, so it works on MinGW; a trap shows up as a crash.
|
||||
CFLAGS = -std=c11 -O2 -g -Wall -Wextra -Wpedantic -Werror -I$(FW) \
|
||||
-fsanitize=undefined -fsanitize-undefined-trap-on-error
|
||||
|
||||
FW_SRCS = $(FW)/geonet.c $(FW)/dot11p.c $(FW)/gn_unwrap.c
|
||||
DEPS = test_util.c test_util.h $(FW_SRCS) $(wildcard $(FW)/*.h)
|
||||
|
||||
.PHONY: all check chain replay fuzz clean
|
||||
|
||||
all: check
|
||||
|
||||
check: chain replay fuzz
|
||||
|
||||
$(BUILD):
|
||||
mkdir -p $@
|
||||
|
||||
$(BUILD)/%$(EXE): %.c $(DEPS) | $(BUILD)
|
||||
$(CC) $(CFLAGS) -o $@ $< test_util.c $(FW_SRCS)
|
||||
|
||||
# The pcap goes through pcap_gn_tally.py as a second, independent parser of the same frames.
|
||||
chain: $(BUILD)/test_chain$(EXE)
|
||||
$(BUILD)/test_chain$(EXE) $(BUILD)/test_chain.pcap
|
||||
$(PYTHON) ../pcap_gn_tally.py $(BUILD)/test_chain.pcap
|
||||
|
||||
replay: $(BUILD)/test_replay$(EXE)
|
||||
ifeq ($(RECORDINGS),)
|
||||
@echo "replay: no recordings in ../../../its-g5-receiver-firmware/recordings, skipped"
|
||||
else
|
||||
$(BUILD)/test_replay$(EXE) $(BUILD)/replay.tsv $(RECORDINGS)
|
||||
$(PYTHON_ASN1) check_replay.py $(BUILD)/replay.tsv $(RECORDINGS)
|
||||
endif
|
||||
|
||||
fuzz: $(BUILD)/fuzz_gn_unwrap$(EXE)
|
||||
$(BUILD)/fuzz_gn_unwrap$(EXE) $(FUZZ_ITER) $(FUZZ_SEED) $(RECORDINGS)
|
||||
|
||||
clean:
|
||||
rm -rf $(BUILD)
|
||||
@@ -0,0 +1,151 @@
|
||||
# Host-side tests for obu-firmware
|
||||
|
||||
**Status (2026-09-11):** the chain test, the capture replay and the fuzzer are written and pass;
|
||||
results under "Running". Toolchain: MSYS2 UCRT64 gcc, Option B below (chosen and
|
||||
installed 2026-09-11).
|
||||
|
||||
`geonet.c`, `dot11p.c` and `gn_unwrap.c` include nothing but standard C headers, so they compile
|
||||
unmodified on a PC. That makes three things possible without a board: a TX -> RX round trip
|
||||
through our own code, replaying real captures through the RX parser, and fuzzing the parser that
|
||||
reads untrusted radio bytes. ESP-IDF only builds `main/` (and `components/`), so nothing under
|
||||
`test/` ever affects the firmware image.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
obu-firmware/
|
||||
├── main/ firmware sources; the tests compile these directly, never copies
|
||||
└── test/
|
||||
├── pcap_gn_tally.py GN header fields per station over .pcap captures (Python only)
|
||||
└── host/
|
||||
├── README.md this file
|
||||
├── Makefile `make`: builds ../../main/{geonet,dot11p,gn_unwrap}.c + tests, runs them
|
||||
├── test_chain.c geonet_wrap_shb -> dot11p_build_frame -> gn_unwrap_its, byte-checked
|
||||
├── test_util.c/.h shared: guard page, crash report, pcap read/write
|
||||
├── test_replay.c every recorded frame through gn_unwrap_its, results to a TSV
|
||||
├── check_replay.py re-derives each result independently, then asn1tools on the messages
|
||||
├── fuzz_gn_unwrap.c mutation fuzzer; each input ends against a no-access guard page, so
|
||||
│ an over-read faults (no ASan on MinGW)
|
||||
└── build/ compiler output; already ignored by the repo's `build/` rule
|
||||
```
|
||||
|
||||
The repo's `vanetza/` folder is a gitignored reading copy only; it is not built. `check_replay.py`
|
||||
reads the IEEE 1609.2 ASN.1 modules from it (they carry no licence header, so they are not copied
|
||||
into `asn1/`). Without it, signed frames are still replayed but not checked independently.
|
||||
|
||||
## Running
|
||||
|
||||
From PowerShell, with MSYS2 on PATH for the session (Option B step 3):
|
||||
|
||||
```powershell
|
||||
$env:PATH = "C:\msys64\ucrt64\bin;C:\msys64\usr\bin;$env:PATH"
|
||||
cd C:\Users\Ashin\AndroidStudioProjects\MicrOBU\obu-firmware\test\host
|
||||
make
|
||||
```
|
||||
|
||||
`make` runs three things (`make chain`, `make replay`, `make fuzz` run one). A non-zero exit, or a
|
||||
`CRASH ... during: <what>` line (from the fuzzer, followed by the input as hex), is a failure.
|
||||
|
||||
- **chain** (`test_chain.c`): frames built by the firmware's own TX code, checked byte by byte
|
||||
against EN 302 636-4-1 and parsed back. Covers the CAM layout (non-QoS and QoS), Source Position
|
||||
Vector edges, output-buffer bounds, a 512-byte payload through `main.c`'s buffer sizes,
|
||||
hand-built GeoBroadcast frames in all three shapes, signed frames in all three COER length
|
||||
forms plus a top-level unsecuredData, one-byte mutations that must be rejected or accepted, the
|
||||
Common Header's payload length as the message boundary, the 8-byte RX trailer, and every
|
||||
truncation length of each frame against the guard page. `pcap_gn_tally.py` then reads the
|
||||
frames back as a second parser; `build/test_chain.pcap` opens in Wireshark too.
|
||||
2026-09-11: 1731 checks, 0 failed.
|
||||
- **replay** (`test_replay.c` + `check_replay.py`): every record in
|
||||
`its-g5-receiver-firmware/recordings/*.pcap` through `gn_unwrap_its`, each cut to `main.c`'s
|
||||
800-byte capture buffer as on the board. `check_replay.py` re-derives each result on its own
|
||||
(its own GN/BTP parse; the security envelope decoded by asn1tools from the IEEE 1609.2 modules),
|
||||
compares record by record, then decodes and re-encodes every distinct message with asn1tools -
|
||||
a byte-identical re-encode is only possible when the message was cut at exactly the right byte.
|
||||
Needs `py -3.11` with asn1tools. 2026-09-11: 15 145 records, 15 131 accepted (10 831 CAM,
|
||||
4 300 DENM; 157 of them signed); C and Python agree on every record; 11 043 of the 11 106
|
||||
distinct messages re-encode byte-identically. The other 63 fail the same way with the old 8
|
||||
trailing bytes put back, so the boundary is not the cause: 5 are our own CAMs from before the
|
||||
2026-08-20 yawRateConfidence fix, 56 come from the CiT One and 1 from another station (see
|
||||
`TODO.md`), and 1 uses an extension asn1tools cannot re-encode.
|
||||
- **fuzz** (`fuzz_gn_unwrap.c`): random edits of every recorded frame, each run against the guard
|
||||
page; an over-read crashes, an accepted payload outside its input fails. Default 2 000 000
|
||||
iterations (about 2 s); `make fuzz FUZZ_ITER=50000000 FUZZ_SEED=7` for a longer run.
|
||||
2026-09-11: 50 000 000 iterations, no crash.
|
||||
|
||||
Not covered: `main.c` (serial prefix parsing, queues) and `serial_link.c`, which need ESP-IDF;
|
||||
and the phone's encoder, whose bytes are opaque here (asn1tools and the app's golden test cover
|
||||
it).
|
||||
|
||||
## Option A (not used): WSL2 + Ubuntu 24.04
|
||||
|
||||
Kept as the fallback if AddressSanitizer or libFuzzer are ever needed; Option B has neither.
|
||||
|
||||
1. In **PowerShell as Administrator**:
|
||||
|
||||
```powershell
|
||||
wsl --install -d Ubuntu-24.04
|
||||
```
|
||||
|
||||
Reboot if it asks. Ubuntu then opens and asks for a Linux username and password (separate from
|
||||
the Windows account). Checked 2026-09-11: Hyper-V is already running on this PC, so no BIOS
|
||||
change should be needed. If the install says virtualization is disabled, enable Intel VT-x /
|
||||
AMD SVM in the BIOS.
|
||||
|
||||
2. Confirm it is WSL **2**: `wsl -l -v` should list `Ubuntu-24.04` with VERSION `2`.
|
||||
|
||||
3. Inside Ubuntu, the compilers:
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install -y build-essential clang cmake ninja-build git pkg-config python3
|
||||
```
|
||||
|
||||
4. Smoke test: the firmware sources compile on the host (expect no output):
|
||||
|
||||
```bash
|
||||
cd /mnt/c/Users/Ashin/AndroidStudioProjects/MicrOBU/obu-firmware/test/host
|
||||
cc -std=c11 -Wall -Wextra -fsyntax-only ../../main/geonet.c ../../main/dot11p.c ../../main/gn_unwrap.c
|
||||
```
|
||||
|
||||
## Option B (chosen): native Windows gcc via MSYS2
|
||||
|
||||
Enough for the round-trip test, the capture replay and the guard-page fuzzer. No libFuzzer and no
|
||||
AddressSanitizer with MinGW gcc, which is why the fuzzer uses a guard page instead.
|
||||
|
||||
1. In PowerShell: `winget install -e --id MSYS2.MSYS2` (installs to `C:\msys64`).
|
||||
2. Open **MSYS2 UCRT64** from the Start menu and run `pacman -Syu`. If the window closes, reopen
|
||||
it and run `pacman -Syu` again. Then:
|
||||
|
||||
```bash
|
||||
pacman -S --needed mingw-w64-ucrt-x86_64-gcc make
|
||||
```
|
||||
|
||||
3. **`C:\msys64\ucrt64\bin` must be on PATH.** Calling `C:\msys64\ucrt64\bin\gcc.exe` by its full
|
||||
path alone exits 1 with no message, because gcc's compiler stages load their DLLs from that
|
||||
folder. `make` lives on the MSYS side, in `C:\msys64\usr\bin`. Either work inside the
|
||||
**MSYS2 UCRT64** shell, which has both, or put them on PATH for the current session:
|
||||
|
||||
```powershell
|
||||
$env:PATH = "C:\msys64\ucrt64\bin;C:\msys64\usr\bin;$env:PATH" # PowerShell
|
||||
```
|
||||
|
||||
```bash
|
||||
export PATH=/c/msys64/ucrt64/bin:/c/msys64/usr/bin:$PATH # Git Bash
|
||||
```
|
||||
|
||||
Adding them to the user PATH permanently also works; it was deliberately not done by setup.
|
||||
4. Smoke test (expect no output):
|
||||
|
||||
```powershell
|
||||
cd C:\Users\Ashin\AndroidStudioProjects\MicrOBU\obu-firmware\test\host
|
||||
gcc -std=c11 -Wall -Wextra -fsyntax-only ../../main/geonet.c ../../main/dot11p.c ../../main/gn_unwrap.c
|
||||
```
|
||||
|
||||
Installed on this PC 2026-09-11: MSYS2 20260611, gcc 16.2.0 (UCRT64), GNU Make 4.4.1. All three
|
||||
firmware sources compile with `-std=c11 -O2 -Wall -Wextra -Wpedantic` and no warnings.
|
||||
|
||||
## Line endings
|
||||
|
||||
The repo runs with `core.autocrlf=true`, so Windows checkouts have CRLF line endings. C compilers
|
||||
don't care; shell scripts run from WSL do (`bash: $'\r': command not found`). When the first `.sh`
|
||||
file lands here, add `*.sh text eol=lf` to a root `.gitattributes` (none exists yet).
|
||||
@@ -0,0 +1,253 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Independent check of test_replay's output. See README.md.
|
||||
|
||||
For every recorded frame this works out on its own what gn_unwrap_its should have produced -
|
||||
GeoNetworking and BTP parsed here from EN 302 636-4-1, the TS 103 097 security envelope decoded by
|
||||
asn1tools from the IEEE 1609.2 ASN.1 modules rather than by hand - and compares that with what the
|
||||
C code did, record by record. Then it decodes every distinct extracted message with asn1tools and
|
||||
re-encodes it. Only a message cut at exactly the right byte re-encodes to the same bytes, so this
|
||||
is what proves that no trailer or signature bytes came along with it.
|
||||
|
||||
py -3.11 check_replay.py replay.tsv capture.pcap [capture.pcap ...]
|
||||
|
||||
Needs asn1tools (installed for Python 3.11 on this PC). Message modules come from the repo's
|
||||
asn1/; the IEEE 1609.2 ones from asn1/ or, failing that, the gitignored vanetza/ checkout.
|
||||
Exit status 1 if the C code and this disagree about any record.
|
||||
"""
|
||||
import collections
|
||||
import pathlib
|
||||
import struct
|
||||
import sys
|
||||
|
||||
import asn1tools
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[3]
|
||||
PORT_NAMES = {2001: "CAM", 2002: "DENM", 2004: "SPATEM"}
|
||||
# main.c's RX_FRAME_MAX_LEN: the most of any frame the board hands to gn_unwrap_its. test_replay
|
||||
# cuts frames to it, so this does too.
|
||||
RX_FRAME_MAX_LEN = 800
|
||||
|
||||
|
||||
def compile_specs():
|
||||
a = ROOT / "asn1"
|
||||
uper = {
|
||||
2001: asn1tools.compile_files([str(a / "cam_1_4_1.asn"), str(a / "cdd_1_3_1_1.asn")], "uper"),
|
||||
2002: asn1tools.compile_files([str(a / "denm_1_3_1.asn"), str(a / "cdd_1_3_1_1.asn")], "uper"),
|
||||
2004: asn1tools.compile_files(
|
||||
[str(a / n) for n in ("spatem_2_2_1.asn", "mapem_2_2_1.asn", "dsrc_2_2_1.asn", "cdd_2_2_1.asn")],
|
||||
"uper"),
|
||||
}
|
||||
names = ("IEEE1609dot2BaseTypes.asn", "IEEE1609dot2.asn")
|
||||
for d in (a, ROOT / "vanetza" / "asn1"):
|
||||
if all((d / n).exists() for n in names):
|
||||
return uper, asn1tools.compile_files([str(d / n) for n in names], "oer"), d
|
||||
return uper, None, None
|
||||
|
||||
|
||||
def frames(path):
|
||||
"""(record index, 802.11 frame) for every record, numbered the way test_util.c numbers them."""
|
||||
d = pathlib.Path(path).read_bytes()
|
||||
if len(d) < 24:
|
||||
return
|
||||
magic = struct.unpack("<I", d[:4])[0]
|
||||
e = "<" if magic in (0xA1B2C3D4, 0xA1B23C4D) else ">"
|
||||
link = struct.unpack(e + "I", d[20:24])[0]
|
||||
if link not in (105, 127):
|
||||
return
|
||||
off, index = 24, 0
|
||||
while off + 16 <= len(d):
|
||||
incl = struct.unpack(e + "I", d[off + 8:off + 12])[0]
|
||||
if incl > len(d) - off - 16:
|
||||
break
|
||||
pkt = d[off + 16:off + 16 + incl]
|
||||
off += 16 + incl
|
||||
if link == 127:
|
||||
rl = pkt[2] | pkt[3] << 8 if len(pkt) >= 4 else len(pkt) + 1
|
||||
if rl > len(pkt):
|
||||
index += 1
|
||||
continue
|
||||
pkt = pkt[rl:]
|
||||
yield index, pkt
|
||||
index += 1
|
||||
|
||||
|
||||
def open_envelope(sec, env):
|
||||
"""(inner GeoNetworking packet, signed) of a TS 103 097 envelope per asn1tools, or None."""
|
||||
try:
|
||||
m = sec.decode("Ieee1609Dot2Data", env)
|
||||
except Exception:
|
||||
return None
|
||||
if m["protocolVersion"] != 3:
|
||||
return None
|
||||
kind, content = m["content"]
|
||||
if kind == "unsecuredData":
|
||||
return content, False
|
||||
if kind == "signedData":
|
||||
data = content["tbsData"]["payload"].get("data")
|
||||
if data and data["protocolVersion"] == 3 and data["content"][0] == "unsecuredData":
|
||||
return data["content"][1], True
|
||||
return None
|
||||
|
||||
|
||||
def u16(b):
|
||||
return int.from_bytes(b, "big")
|
||||
|
||||
|
||||
def s32(b):
|
||||
return int.from_bytes(b, "big", signed=True)
|
||||
|
||||
|
||||
def expect(f, sec):
|
||||
"""What gn_unwrap_its should report for frame f: None, or a dict matching test_replay's row.
|
||||
Second value: how many bytes after the message the pre-2026-09-11 code would have forwarded."""
|
||||
if len(f) < 24 or (f[0] >> 2) & 3 != 2 or f[1] & 3 == 3:
|
||||
return None, None
|
||||
o = 24 + (2 if f[0] & 0x80 else 0)
|
||||
if f[o:o + 8] != b"\xaa\xaa\x03\x00\x00\x00\x89\x47" or len(f) < o + 12:
|
||||
return None, None
|
||||
nh = f[o + 8] & 0x0F
|
||||
o += 12
|
||||
if nh == 2:
|
||||
if sec is None:
|
||||
return "unchecked", None
|
||||
opened = open_envelope(sec, f[o:])
|
||||
if opened is None:
|
||||
return None, None
|
||||
region, signed = opened
|
||||
elif nh == 1:
|
||||
region, signed = f[o:], False
|
||||
else:
|
||||
return None, None
|
||||
|
||||
if len(region) < 8 or region[0] >> 4 != 2:
|
||||
return None, None
|
||||
ht, hst, pl = region[1] >> 4, region[1] & 0x0F, u16(region[4:6])
|
||||
if ht == 5 and hst == 0:
|
||||
ext, area = 28, None
|
||||
elif ht == 4:
|
||||
ext = 44
|
||||
else:
|
||||
return None, None
|
||||
if len(region) < 8 + ext + 4:
|
||||
return None, None
|
||||
if ht == 4:
|
||||
a = 8 + 28
|
||||
area = (s32(region[a:a + 4]), s32(region[a + 4:a + 8]), u16(region[a + 8:a + 10]))
|
||||
port = u16(region[8 + ext:8 + ext + 2])
|
||||
if port not in PORT_NAMES or pl <= 4:
|
||||
return None, None
|
||||
start, end = 8 + ext + 4, 8 + ext + pl
|
||||
if signed is not None and nh == 2 and end > len(region):
|
||||
return None, None # the inner packet claims more than its envelope holds
|
||||
payload = region[start:min(end, len(region))]
|
||||
if not payload:
|
||||
return None, None
|
||||
old_extra = len(region) - end if nh == 1 else None
|
||||
return dict(port=port, signed=signed, truncated=end > len(region), area=area, payload=payload), old_extra
|
||||
|
||||
|
||||
def read_tsv(path):
|
||||
rows = {}
|
||||
with open(path, encoding="ascii") as fh:
|
||||
next(fh)
|
||||
for line in fh:
|
||||
c = line.rstrip("\n").split("\t")
|
||||
key = (c[0], int(c[1]))
|
||||
if c[2] == "0":
|
||||
rows[key] = None
|
||||
else:
|
||||
rows[key] = dict(port=int(c[3]), signed=c[4] == "1", truncated=c[5] == "1",
|
||||
area=(int(c[7]), int(c[8]), int(c[9])) if c[6] == "1" else None,
|
||||
payload=bytes.fromhex(c[10]))
|
||||
return rows
|
||||
|
||||
|
||||
def describe(r):
|
||||
if r is None:
|
||||
return "rejected"
|
||||
return "port %d signed %s truncated %s area %s %d bytes" % (
|
||||
r["port"], r["signed"], r["truncated"], r["area"], len(r["payload"]))
|
||||
|
||||
|
||||
def main(argv):
|
||||
if len(argv) < 2:
|
||||
sys.exit(__doc__)
|
||||
got = read_tsv(argv[0])
|
||||
uper, sec, sec_dir = compile_specs()
|
||||
if sec is None:
|
||||
print("WARNING: IEEE 1609.2 modules not found; secured frames are not checked independently")
|
||||
|
||||
stats, extra, disagreements, messages = collections.Counter(), collections.Counter(), [], {}
|
||||
for path in argv[1:]:
|
||||
for index, f in frames(path):
|
||||
key = (path, index)
|
||||
stats["records"] += 1
|
||||
stats["capped"] += len(f) > RX_FRAME_MAX_LEN
|
||||
want, old_extra = expect(f[:RX_FRAME_MAX_LEN], sec)
|
||||
if key not in got:
|
||||
disagreements.append((key, "no row from test_replay"))
|
||||
continue
|
||||
have = got.pop(key)
|
||||
if want == "unchecked":
|
||||
stats["secured, unchecked"] += 1
|
||||
continue
|
||||
if want != have:
|
||||
disagreements.append((key, "C: %s | independent: %s" % (describe(have), describe(want))))
|
||||
continue
|
||||
if want:
|
||||
stats["accepted"] += 1
|
||||
stats[PORT_NAMES[want["port"]]] += 1
|
||||
stats["signed"] += want["signed"]
|
||||
stats["truncated"] += want["truncated"]
|
||||
if old_extra is not None:
|
||||
extra[old_extra] += 1
|
||||
messages.setdefault((want["port"], want["payload"]), key)
|
||||
for key in got:
|
||||
disagreements.append((key, "row from test_replay for a record this did not see"))
|
||||
|
||||
print("check_replay: %d records (%d cut to %d bytes), %d accepted (CAM %d, DENM %d, SPATEM %d; "
|
||||
"%d signed, %d truncated)"
|
||||
% (stats["records"], stats["capped"], RX_FRAME_MAX_LEN, stats["accepted"], stats["CAM"],
|
||||
stats["DENM"], stats["SPATEM"], stats["signed"], stats["truncated"]))
|
||||
if sec_dir:
|
||||
print(" envelopes decoded with asn1tools using %s" % sec_dir.relative_to(ROOT))
|
||||
print(" C vs independent parse: %s" % ("agree on every record" if not disagreements
|
||||
else "%d DISAGREEMENTS" % len(disagreements)))
|
||||
for key, why in disagreements[:15]:
|
||||
print(" %s record %d: %s" % (pathlib.Path(key[0]).name, key[1], why))
|
||||
|
||||
outcome, failures = collections.Counter(), []
|
||||
for (port, payload), key in messages.items():
|
||||
name, spec = PORT_NAMES[port], uper[port]
|
||||
try:
|
||||
decoded = spec.decode(name, payload)
|
||||
except Exception as e:
|
||||
outcome[(name, "does not decode")] += 1
|
||||
failures.append((key, name, len(payload), str(e)[:110]))
|
||||
continue
|
||||
try:
|
||||
encoded = spec.encode(name, decoded)
|
||||
except Exception as e:
|
||||
# asn1tools decodes an alternative from a later module version (a CHOICE extension)
|
||||
# as (None, None) and then cannot encode it back. Says nothing about where the message
|
||||
# was cut, so it is reported apart from real mismatches.
|
||||
outcome[(name, "decodes; uses a newer extension")] += 1
|
||||
failures.append((key, name, len(payload), "cannot re-encode: " + str(e)[:90]))
|
||||
continue
|
||||
if encoded == payload:
|
||||
outcome[(name, "re-encodes byte-identically")] += 1
|
||||
else:
|
||||
outcome[(name, "re-encodes differently")] += 1
|
||||
failures.append((key, name, len(payload), "re-encoded to different bytes"))
|
||||
print(" asn1tools on the %d distinct extracted messages:" % len(messages))
|
||||
for (name, what), n in sorted(outcome.items()):
|
||||
print(" %-6s %-28s %d" % (name, what, n))
|
||||
for key, name, n, why in failures[:15]:
|
||||
print(" %s record %d, %s %d bytes: %s" % (pathlib.Path(key[0]).name, key[1], name, n, why))
|
||||
print(" bytes the pre-2026-09-11 code forwarded after each unsecured message: %s"
|
||||
% dict(sorted(extra.items())))
|
||||
return 1 if disagreements else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
@@ -0,0 +1,218 @@
|
||||
// Mutation fuzzer for gn_unwrap_its, the one function in this firmware that parses bytes from the
|
||||
// air. See README.md.
|
||||
//
|
||||
// Seeds are every recorded frame in the pcaps given, plus frames built by the firmware's own TX
|
||||
// code. Each iteration takes a seed, applies 1-4 random edits - bit flips, random bytes, boundary
|
||||
// bytes such as COER length markers, 16-bit length fields, truncation, insertion, deletion,
|
||||
// appended bytes - mostly within the first 128 bytes where the headers are, and runs gn_unwrap_its
|
||||
// on the result placed against the guard page. A read past the end crashes and prints the input;
|
||||
// an accepted frame whose payload is not inside the input is reported the same way. MinGW has
|
||||
// neither libFuzzer nor AddressSanitizer, hence this rather than coverage guidance. The same seed
|
||||
// gives the same run.
|
||||
//
|
||||
// Usage: fuzz_gn_unwrap iterations seed [capture.pcap ...]
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "dot11p.h"
|
||||
#include "geonet.h"
|
||||
#include "gn_unwrap.h"
|
||||
#include "test_util.h"
|
||||
|
||||
#define MAX_FRAME 2048 // within the guard page's one page, and above any 802.11 frame
|
||||
|
||||
static uint8_t **s_seeds;
|
||||
static int *s_seed_len;
|
||||
static int s_nseeds;
|
||||
static int s_seed_cap;
|
||||
|
||||
static void add_seed(const uint8_t *f, int len)
|
||||
{
|
||||
if (len <= 0 || len > MAX_FRAME) {
|
||||
return;
|
||||
}
|
||||
if (s_nseeds == s_seed_cap) {
|
||||
s_seed_cap = s_seed_cap ? 2 * s_seed_cap : 1024;
|
||||
s_seeds = realloc(s_seeds, (size_t)s_seed_cap * sizeof *s_seeds);
|
||||
s_seed_len = realloc(s_seed_len, (size_t)s_seed_cap * sizeof *s_seed_len);
|
||||
if (!s_seeds || !s_seed_len) {
|
||||
fprintf(stderr, "out of memory\n");
|
||||
exit(2);
|
||||
}
|
||||
}
|
||||
s_seeds[s_nseeds] = malloc((size_t)len);
|
||||
if (!s_seeds[s_nseeds]) {
|
||||
fprintf(stderr, "out of memory\n");
|
||||
exit(2);
|
||||
}
|
||||
memcpy(s_seeds[s_nseeds], f, (size_t)len);
|
||||
s_seed_len[s_nseeds++] = len;
|
||||
}
|
||||
|
||||
static void on_frame(const uint8_t *f, int len, int index, void *ctx)
|
||||
{
|
||||
(void)index;
|
||||
(void)ctx;
|
||||
add_seed(f, len);
|
||||
}
|
||||
|
||||
static void add_own_seeds(void)
|
||||
{
|
||||
static const uint8_t cam[] = {0x02, 0x02, 0x00, 0x0f, 0x42, 0x3f, 0x37, 0x00, 0x40, 0x2a, 0xb2};
|
||||
gn_lpv_t lpv;
|
||||
memset(&lpv, 0, sizeof lpv);
|
||||
lpv.mac[0] = 0x02;
|
||||
lpv.station_type = 2;
|
||||
uint8_t gn[256];
|
||||
uint8_t f[512];
|
||||
const int gn_len = geonet_wrap_shb(cam, (int)sizeof cam, &lpv, 2001, gn, sizeof gn);
|
||||
for (int qos = 0; qos <= 1; qos++) {
|
||||
add_seed(f, dot11p_build_frame(gn, gn_len, lpv.mac, f, sizeof f, qos));
|
||||
}
|
||||
}
|
||||
|
||||
static uint64_t s_rng;
|
||||
|
||||
static uint64_t rnd(void)
|
||||
{
|
||||
s_rng ^= s_rng << 13;
|
||||
s_rng ^= s_rng >> 7;
|
||||
s_rng ^= s_rng << 17;
|
||||
return s_rng;
|
||||
}
|
||||
|
||||
static int below(int n)
|
||||
{
|
||||
return n <= 0 ? 0 : (int)(rnd() % (uint64_t)n);
|
||||
}
|
||||
|
||||
// Three times in four inside the headers, otherwise anywhere.
|
||||
static int pick_pos(int len)
|
||||
{
|
||||
return below(4) ? below(len < 128 ? len : 128) : below(len);
|
||||
}
|
||||
|
||||
static const uint8_t k_bytes[] = {0x00, 0x01, 0x02, 0x03, 0x05, 0x10, 0x12, 0x20,
|
||||
0x40, 0x50, 0x7F, 0x80, 0x81, 0x82, 0x83, 0xFF};
|
||||
static const uint16_t k_words[] = {0x0000, 0x0001, 0x0003, 0x0004, 0x0005, 0x007F,
|
||||
0x0080, 0x00FF, 0x0100, 0x7FFF, 0x8000, 0xFFFF};
|
||||
|
||||
static void mutate(uint8_t *b, int *len)
|
||||
{
|
||||
const int edits = 1 + below(4);
|
||||
for (int e = 0; e < edits; e++) {
|
||||
const int n = *len;
|
||||
switch (below(8)) {
|
||||
case 0: // flip a bit
|
||||
if (n) {
|
||||
b[pick_pos(n)] ^= (uint8_t)(1u << below(8));
|
||||
}
|
||||
break;
|
||||
case 1: // random byte
|
||||
if (n) {
|
||||
b[pick_pos(n)] = (uint8_t)rnd();
|
||||
}
|
||||
break;
|
||||
case 2: // boundary byte
|
||||
if (n) {
|
||||
b[pick_pos(n)] = k_bytes[below((int)sizeof k_bytes)];
|
||||
}
|
||||
break;
|
||||
case 3: // truncate
|
||||
*len = below(n + 1);
|
||||
break;
|
||||
case 4: { // append
|
||||
const int add = 1 + below(16);
|
||||
if (n + add <= MAX_FRAME) {
|
||||
for (int i = 0; i < add; i++) {
|
||||
b[n + i] = (uint8_t)rnd();
|
||||
}
|
||||
*len = n + add;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 5: // insert a byte
|
||||
if (n < MAX_FRAME) {
|
||||
const int p = below(n + 1);
|
||||
memmove(b + p + 1, b + p, (size_t)(n - p));
|
||||
b[p] = (uint8_t)rnd();
|
||||
*len = n + 1;
|
||||
}
|
||||
break;
|
||||
case 6: // delete a byte
|
||||
if (n) {
|
||||
const int p = below(n);
|
||||
memmove(b + p, b + p + 1, (size_t)(n - p - 1));
|
||||
*len = n - 1;
|
||||
}
|
||||
break;
|
||||
default: // boundary 16-bit big-endian value, e.g. a length field
|
||||
if (n >= 2) {
|
||||
const int p = pick_pos(n - 1);
|
||||
const uint16_t v = k_words[below((int)(sizeof k_words / sizeof k_words[0]))];
|
||||
b[p] = (uint8_t)(v >> 8);
|
||||
b[p + 1] = (uint8_t)v;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
if (argc < 3) {
|
||||
fprintf(stderr, "usage: fuzz_gn_unwrap iterations seed [capture.pcap ...]\n");
|
||||
return 2;
|
||||
}
|
||||
const unsigned long long iterations = strtoull(argv[1], NULL, 10);
|
||||
s_rng = (strtoull(argv[2], NULL, 10) * 0x9E3779B97F4A7C15ull) | 1;
|
||||
|
||||
tu_install_crash_handler();
|
||||
tu_guard_init();
|
||||
for (int i = 3; i < argc; i++) {
|
||||
if (tu_pcap_foreach(argv[i], on_frame, NULL) < 0) {
|
||||
fprintf(stderr, "cannot read %s as a pcap\n", argv[i]);
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
add_own_seeds();
|
||||
|
||||
static uint8_t buf[MAX_FRAME];
|
||||
int len = 0;
|
||||
tu_set_crash_input(buf, &len);
|
||||
unsigned long long accepted = 0, signed_frames = 0, truncated = 0;
|
||||
for (unsigned long long it = 0; it < iterations; it++) {
|
||||
const int s = below(s_nseeds);
|
||||
len = s_seed_len[s];
|
||||
memcpy(buf, s_seeds[s], (size_t)len);
|
||||
mutate(buf, &len);
|
||||
tu_set_context("fuzz iteration %llu (seed %s), mutated from seed frame %d", it, argv[2], s);
|
||||
|
||||
const uint8_t *g = tu_guarded(buf, len);
|
||||
gn_rx_t rx;
|
||||
if (gn_unwrap_its(g, len, &rx)) {
|
||||
accepted++;
|
||||
signed_frames += rx.signed_unverified;
|
||||
truncated += rx.truncated;
|
||||
const uintptr_t lo = (uintptr_t)g;
|
||||
const uintptr_t p = (uintptr_t)rx.payload;
|
||||
if (p < lo || rx.payload_len <= 0 || p + (uintptr_t)rx.payload_len > lo + (uintptr_t)len) {
|
||||
fprintf(stderr, "FAIL during %s: accepted payload lies outside the input\ninput (%d bytes): ",
|
||||
tu_context(), len);
|
||||
for (int i = 0; i < len; i++) {
|
||||
fprintf(stderr, "%02x", buf[i]);
|
||||
}
|
||||
fputc('\n', stderr);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
printf("fuzz_gn_unwrap: %llu iterations from %d seed frames, %llu accepted (%llu signed, "
|
||||
"%llu truncated), no crash\n",
|
||||
iterations, s_nseeds, accepted, signed_frames, truncated);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,730 @@
|
||||
// Host-side chain test for obu-firmware. See README.md in this folder.
|
||||
//
|
||||
// Builds frames with the firmware's own TX code (geonet_wrap_shb -> dot11p_build_frame) and parses
|
||||
// them back with its own RX code (gn_unwrap_its), all compiled from ../../main unmodified.
|
||||
//
|
||||
// Two kinds of check, on purpose. The round trip proves TX and RX agree with each other. The byte
|
||||
// checks at fixed offsets prove they agree with EN 302 636-4-1, and only those catch a mistake made
|
||||
// the same way on both sides: the missing 4-byte SHB field (fixed 2026-08-13) round-tripped fine
|
||||
// between two ESP32s and was wrong against every other station. So expected values here come from
|
||||
// the standard, vanetza's serializers and real captures - never from reading geonet.c.
|
||||
//
|
||||
// Every frame handed to gn_unwrap_its is first copied so that its last byte sits right before a
|
||||
// no-access page (test_util.c): reading even one byte past a frame crashes the test instead of
|
||||
// passing quietly. MinGW has no AddressSanitizer; this covers what matters for a radio parser.
|
||||
//
|
||||
// Usage: test_chain [out.pcap]
|
||||
// With a path, also writes every test frame to a pcap (linktype 105, bare 802.11) so a second,
|
||||
// independent parser can read them: ../pcap_gn_tally.py, or Wireshark. The GeoBroadcast and
|
||||
// signed frames carry stub payloads/signatures; their headers are real.
|
||||
|
||||
#include <stdarg.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "dot11p.h"
|
||||
#include "geonet.h"
|
||||
#include "gn_unwrap.h"
|
||||
#include "serial_link.h" // SERIAL_LINK_MAX_PAYLOAD only; serial_link.c itself needs ESP-IDF
|
||||
#include "test_util.h"
|
||||
|
||||
// Same buffer sizes as tx_radio_task in main.c. Keep them in step with it.
|
||||
#define GN_BUF_LEN (SERIAL_LINK_MAX_PAYLOAD + 64)
|
||||
#define FRAME_BUF_LEN (SERIAL_LINK_MAX_PAYLOAD + 192)
|
||||
|
||||
// ---- Checks ---------------------------------------------------------------------------------
|
||||
|
||||
static int s_checks;
|
||||
static int s_failures;
|
||||
|
||||
static void check(bool ok, int line, const char *fmt, ...)
|
||||
{
|
||||
s_checks++;
|
||||
if (ok) {
|
||||
return;
|
||||
}
|
||||
s_failures++;
|
||||
va_list ap;
|
||||
va_start(ap, fmt);
|
||||
fprintf(stderr, "FAIL line %d [%s]: ", line, tu_context());
|
||||
vfprintf(stderr, fmt, ap);
|
||||
fputc('\n', stderr);
|
||||
va_end(ap);
|
||||
}
|
||||
#define CHECK(cond, ...) check((cond), __LINE__, __VA_ARGS__)
|
||||
|
||||
// ---- Test data ------------------------------------------------------------------------------
|
||||
|
||||
static uint16_t rd16(const uint8_t *p)
|
||||
{
|
||||
return (uint16_t)((p[0] << 8) | p[1]);
|
||||
}
|
||||
|
||||
static uint32_t rd32(const uint8_t *p)
|
||||
{
|
||||
return ((uint32_t)p[0] << 24) | ((uint32_t)p[1] << 16) | ((uint32_t)p[2] << 8) | p[3];
|
||||
}
|
||||
|
||||
// The app's reference CAM: the expected bytes of CamEncodeGoldenTest.kt (stationID 999999), which
|
||||
// asn1tools decodes and re-encodes byte-identically. The chain treats it as opaque bytes. Not taken
|
||||
// from the old captures on purpose: our own CAMs there predate the 2026-08-20 yawRateConfidence fix
|
||||
// and do not decode.
|
||||
static const uint8_t k_cam[] = {
|
||||
0x02, 0x02, 0x00, 0x0f, 0x42, 0x3f, 0x37, 0x00, 0x40, 0x2a, 0xb2, 0x15, 0xaf, 0x6e, 0x28, 0x64,
|
||||
0x77, 0xdf, 0xff, 0xff, 0xfc, 0x23, 0xb7, 0x74, 0x3e, 0x00, 0x27, 0xff, 0xc0, 0xd0, 0xfe, 0x01,
|
||||
0x18, 0x32, 0x93, 0x37, 0xfe, 0xeb, 0xff, 0xf6, 0x00, 0x00, 0x00,
|
||||
};
|
||||
#define CAM_LEN ((int)sizeof k_cam)
|
||||
|
||||
static const uint8_t k_mac[6] = {0x02, 0x11, 0x22, 0x33, 0x44, 0x55};
|
||||
static const uint8_t k_bcast[6] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};
|
||||
static const uint8_t k_llc_snap_gn[8] = {0xAA, 0xAA, 0x03, 0x00, 0x00, 0x00, 0x89, 0x47};
|
||||
|
||||
// The 8 bytes that follow the 802.11 frame in every frame recorded through the ESP32-C5's
|
||||
// promiscuous RX (these from capture_20260817_171055.pcap). See gn_unwrap.h, "Payload bounds".
|
||||
static const uint8_t k_rx_trailer[8] = {0xc8, 0x01, 0x00, 0x00, 0x88, 0x00, 0x00, 0x00};
|
||||
|
||||
// Header lengths from EN 302 636-4-1 / IEEE 802.11, used to compute every offset below.
|
||||
enum {
|
||||
MAC_HDR = 24, QOS_CTRL = 2, LLC_SNAP = 8, GN_BASIC = 4, GN_COMMON = 8,
|
||||
SHB_EXT = 28, // Source Position Vector (24) + DCC-MCO / reserved (4)
|
||||
GBC_EXT = 44, // SN (2) + reserved (2) + SO PV (24) + area (12) + reserved (4)
|
||||
BTP_B = 4,
|
||||
GN_COMMON_PAYLOAD_LEN_FIELD = 4, // offset of the payload-length field in the Common Header
|
||||
};
|
||||
// Where the ITS payload starts in a non-QoS SHB frame.
|
||||
#define SHB_PAYLOAD_OFFSET (MAC_HDR + LLC_SNAP + GN_BASIC + GN_COMMON + SHB_EXT + BTP_B)
|
||||
|
||||
static gn_lpv_t test_lpv(void)
|
||||
{
|
||||
gn_lpv_t lpv;
|
||||
memset(&lpv, 0, sizeof lpv);
|
||||
memcpy(lpv.mac, k_mac, sizeof lpv.mac);
|
||||
lpv.station_type = 2; // cyclist
|
||||
lpv.pai = true;
|
||||
lpv.tst_ms = 0x89ABCDEFu;
|
||||
lpv.lat_tenmicrodeg = 535546667; // the bench, 53.5546667 N
|
||||
lpv.lon_tenmicrodeg = -10022389; // negative on purpose: the sign has to survive
|
||||
lpv.speed_cms = 1234;
|
||||
lpv.heading_decideg = 2700;
|
||||
return lpv;
|
||||
}
|
||||
|
||||
// The TX path exactly as tx_radio_task runs it. Returns the frame length, or <= 0 on failure.
|
||||
static int build(const uint8_t *payload, int len, const gn_lpv_t *lpv, uint16_t port, bool qos,
|
||||
uint8_t *frame, size_t frame_size)
|
||||
{
|
||||
static uint8_t gn[GN_BUF_LEN];
|
||||
int gn_len = geonet_wrap_shb(payload, len, lpv, port, gn, sizeof gn);
|
||||
if (gn_len <= 0) {
|
||||
return gn_len;
|
||||
}
|
||||
return dot11p_build_frame(gn, gn_len, lpv->mac, frame, frame_size, qos);
|
||||
}
|
||||
|
||||
// Every prefix of a frame, each ending against the guard page. Up to the end of the BTP-B header
|
||||
// nothing may be accepted; a frame cut inside the payload is accepted as truncated with what
|
||||
// arrived; from `payload_end` on, the payload is exactly the declared one whatever follows it.
|
||||
static void sweep(const char *name, const uint8_t *f, int len, int payload_start, int payload_end)
|
||||
{
|
||||
for (int n = 0; n <= len; n++) {
|
||||
tu_set_context("truncation sweep, %s, %d of %d bytes", name, n, len);
|
||||
const uint8_t *g = tu_guarded(f, n);
|
||||
gn_rx_t rx;
|
||||
const bool ok = gn_unwrap_its(g, n, &rx);
|
||||
if (n <= payload_start) {
|
||||
CHECK(!ok, "%s cut to %d bytes was accepted", name, n);
|
||||
} else if (n < payload_end) {
|
||||
CHECK(ok && rx.truncated && rx.payload == g + payload_start &&
|
||||
rx.payload_len == n - payload_start,
|
||||
"%s cut to %d bytes: ok=%d truncated=%d payload_len=%d", name, n, ok,
|
||||
ok && rx.truncated, ok ? rx.payload_len : -1);
|
||||
} else {
|
||||
CHECK(ok && !rx.truncated && rx.payload == g + payload_start &&
|
||||
rx.payload_len == payload_end - payload_start,
|
||||
"%s at %d bytes: ok=%d truncated=%d payload_len=%d", name, n, ok,
|
||||
ok && rx.truncated, ok ? rx.payload_len : -1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- SHB: layout, position vector, bounds ---------------------------------------------------
|
||||
|
||||
// Every byte of a CAM frame against the standard, then back through the RX path.
|
||||
static void test_shb_layout(bool qos)
|
||||
{
|
||||
tu_set_context("SHB layout, qos=%d", qos);
|
||||
const gn_lpv_t lpv = test_lpv();
|
||||
uint8_t f[FRAME_BUF_LEN];
|
||||
const int len = build(k_cam, CAM_LEN, &lpv, 2001, qos, f, sizeof f);
|
||||
|
||||
const int llc = MAC_HDR + (qos ? QOS_CTRL : 0);
|
||||
const int basic = llc + LLC_SNAP;
|
||||
const int common = basic + GN_BASIC;
|
||||
const int shb = common + GN_COMMON;
|
||||
const int btp = shb + SHB_EXT;
|
||||
const int pay = btp + BTP_B;
|
||||
CHECK(len == pay + CAM_LEN, "frame length %d, expected %d", len, pay + CAM_LEN);
|
||||
if (len != pay + CAM_LEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 802.11 MAC header
|
||||
CHECK(f[0] == (qos ? 0x88 : 0x08) && f[1] == 0x00, "frame control %02x %02x", f[0], f[1]);
|
||||
CHECK(memcmp(f + 4, k_bcast, 6) == 0, "addr1 must be broadcast");
|
||||
CHECK(memcmp(f + 10, k_mac, 6) == 0, "addr2 must be the pseudonym");
|
||||
CHECK(memcmp(f + 16, k_bcast, 6) == 0, "addr3 (BSSID) must be the OCB wildcard");
|
||||
if (qos) {
|
||||
CHECK(f[24] == 0 && f[25] == 0, "QoS control %02x %02x", f[24], f[25]);
|
||||
}
|
||||
CHECK(memcmp(f + llc, k_llc_snap_gn, 8) == 0, "LLC/SNAP with EtherType 0x8947");
|
||||
|
||||
// GN Basic Header
|
||||
CHECK(f[basic + 0] == 0x11, "basic: version 1 + NextHeader 1 (Common, unsecured), got %02x",
|
||||
f[basic + 0]);
|
||||
CHECK(f[basic + 1] == 0x00, "basic: reserved, got %02x", f[basic + 1]);
|
||||
// Multiplier 1 in the upper 6 bits, base 1 (= 1 s) in the lower 2: 1 s, what every real station
|
||||
// in the recordings sends its CAMs with. Was 0x83 = 3200 s until 2026-09-11.
|
||||
CHECK(f[basic + 2] == 0x05, "basic: lifetime 0x05 (1 s), got 0x%02x", f[basic + 2]);
|
||||
CHECK(f[basic + 3] == 1, "basic: remaining hop limit 1, got %d", f[basic + 3]);
|
||||
|
||||
// GN Common Header
|
||||
CHECK(f[common + 0] == 0x20, "common: NextHeader 2 (BTP-B), got %02x", f[common + 0]);
|
||||
CHECK(f[common + 1] == 0x50, "common: HeaderType 5 (TSB) / subtype 0 (single hop), got %02x",
|
||||
f[common + 1]);
|
||||
CHECK(f[common + 2] == 0x02, "common: traffic class TC-ID 2 (DP2, CAM), got %02x", f[common + 2]);
|
||||
CHECK(f[common + 3] == 0x80, "common: flags = mobile, got %02x", f[common + 3]);
|
||||
CHECK(rd16(f + common + 4) == BTP_B + CAM_LEN,
|
||||
"common: payload length must count BTP-B + payload only, got %d", rd16(f + common + 4));
|
||||
CHECK(f[common + 6] == 1, "common: maximum hop limit 1, got %d", f[common + 6]);
|
||||
CHECK(f[common + 7] == 0, "common: reserved, got %02x", f[common + 7]);
|
||||
|
||||
// SHB extended header: Source Position Vector, then 4 bytes DCC-MCO / reserved
|
||||
// GN_ADDR: M flag bit 15, station type bits 14..10 (vanetza geonet/address.cpp), MID.
|
||||
CHECK(rd16(f + shb) == (2u << 10), "GN_ADDR: M=0, station type 2, got %04x", rd16(f + shb));
|
||||
CHECK(memcmp(f + shb + 2, k_mac, 6) == 0, "GN_ADDR MID must equal the 802.11 source address");
|
||||
CHECK(rd32(f + shb + 8) == 0x89ABCDEFu, "TST, got %08lx", (unsigned long)rd32(f + shb + 8));
|
||||
CHECK((int32_t)rd32(f + shb + 12) == 535546667, "latitude, got %ld",
|
||||
(long)(int32_t)rd32(f + shb + 12));
|
||||
CHECK((int32_t)rd32(f + shb + 16) == -10022389, "longitude, got %ld",
|
||||
(long)(int32_t)rd32(f + shb + 16));
|
||||
CHECK(rd16(f + shb + 20) == (0x8000 | 1234), "PAI bit 15 + speed 1234, got %04x",
|
||||
rd16(f + shb + 20));
|
||||
CHECK(rd16(f + shb + 22) == 2700, "heading, got %d", rd16(f + shb + 22));
|
||||
CHECK(rd32(f + shb + 24) == 0, "DCC-MCO / reserved: present and zero, got %08lx",
|
||||
(unsigned long)rd32(f + shb + 24));
|
||||
|
||||
// BTP-B, payload
|
||||
CHECK(rd16(f + btp) == 2001, "BTP-B destination port, got %d", rd16(f + btp));
|
||||
CHECK(rd16(f + btp + 2) == 0, "BTP-B destination port info, got %d", rd16(f + btp + 2));
|
||||
CHECK(memcmp(f + pay, k_cam, (size_t)CAM_LEN) == 0, "payload bytes");
|
||||
|
||||
// ...and back through the RX path
|
||||
const uint8_t *g = tu_guarded(f, len);
|
||||
gn_rx_t rx;
|
||||
const bool ok = gn_unwrap_its(g, len, &rx);
|
||||
CHECK(ok, "gn_unwrap_its rejected our own frame");
|
||||
if (ok) {
|
||||
CHECK(rx.btp_dest_port == 2001, "RX port %d", rx.btp_dest_port);
|
||||
CHECK(!rx.has_geo_area, "RX reports a geo area for an SHB frame");
|
||||
CHECK(!rx.signed_unverified, "RX reports an unsecured frame as signed");
|
||||
CHECK(!rx.truncated, "RX reports a whole frame as truncated");
|
||||
CHECK(rx.payload == g + pay, "RX payload offset %d, expected %d", (int)(rx.payload - g), pay);
|
||||
CHECK(rx.payload_len == CAM_LEN, "RX payload length %d, expected %d", rx.payload_len, CAM_LEN);
|
||||
CHECK(rx.payload_len == CAM_LEN && memcmp(rx.payload, k_cam, (size_t)CAM_LEN) == 0,
|
||||
"RX payload bytes differ from what was sent");
|
||||
}
|
||||
tu_pcap_add(f, len);
|
||||
}
|
||||
|
||||
// The Source Position Vector's packed fields at their edges.
|
||||
static void test_pv_encoding(void)
|
||||
{
|
||||
// Offsets inside the GeoNetworking packet (no 802.11 header): SO PV starts after Basic + Common.
|
||||
const int pv = GN_BASIC + GN_COMMON;
|
||||
uint8_t gn[GN_BUF_LEN];
|
||||
|
||||
// Speed is 15-bit signed (geonet.h), clamped rather than wrapped; PAI is bit 15.
|
||||
static const struct { int16_t speed; bool pai; uint16_t expect; } speeds[] = {
|
||||
{0, false, 0x0000}, {1234, true, 0x84D2}, {16383, false, 0x3FFF},
|
||||
{16384, false, 0x3FFF}, {32767, false, 0x3FFF}, {-100, false, 0x7F9C},
|
||||
{-16384, false, 0x4000}, {-32768, true, 0xC000},
|
||||
};
|
||||
for (size_t i = 0; i < sizeof speeds / sizeof speeds[0]; i++) {
|
||||
tu_set_context("PV speed %d pai %d", speeds[i].speed, speeds[i].pai);
|
||||
gn_lpv_t lpv = test_lpv();
|
||||
lpv.speed_cms = speeds[i].speed;
|
||||
lpv.pai = speeds[i].pai;
|
||||
const int n = geonet_wrap_shb(k_cam, CAM_LEN, &lpv, 2001, gn, sizeof gn);
|
||||
CHECK(n > 0 && rd16(gn + pv + 20) == speeds[i].expect, "speed %d pai %d: got %04x, expected %04x",
|
||||
speeds[i].speed, speeds[i].pai, rd16(gn + pv + 20), speeds[i].expect);
|
||||
}
|
||||
|
||||
// Heading is 0..3599 tenths of a degree, wrapped.
|
||||
static const struct { uint16_t in, expect; } headings[] = {
|
||||
{0, 0}, {2700, 2700}, {3599, 3599}, {3600, 0}, {3601, 1}, {65535, 735},
|
||||
};
|
||||
for (size_t i = 0; i < sizeof headings / sizeof headings[0]; i++) {
|
||||
tu_set_context("PV heading %d", headings[i].in);
|
||||
gn_lpv_t lpv = test_lpv();
|
||||
lpv.heading_decideg = headings[i].in;
|
||||
const int n = geonet_wrap_shb(k_cam, CAM_LEN, &lpv, 2001, gn, sizeof gn);
|
||||
CHECK(n > 0 && rd16(gn + pv + 22) == headings[i].expect, "heading %d: got %d, expected %d",
|
||||
headings[i].in, rd16(gn + pv + 22), headings[i].expect);
|
||||
}
|
||||
|
||||
// Station type has 5 bits; anything larger must not spill into the M flag.
|
||||
static const struct { uint8_t in; uint16_t expect; } types[] = {
|
||||
{2, 0x0800}, {15, 0x3C00}, {0xFF, 0x7C00},
|
||||
};
|
||||
for (size_t i = 0; i < sizeof types / sizeof types[0]; i++) {
|
||||
tu_set_context("PV station type %d", types[i].in);
|
||||
gn_lpv_t lpv = test_lpv();
|
||||
lpv.station_type = types[i].in;
|
||||
const int n = geonet_wrap_shb(k_cam, CAM_LEN, &lpv, 2001, gn, sizeof gn);
|
||||
CHECK(n > 0 && rd16(gn + pv) == types[i].expect, "station type %d: GN_ADDR %04x, expected %04x",
|
||||
types[i].in, rd16(gn + pv), types[i].expect);
|
||||
}
|
||||
}
|
||||
|
||||
// Output buffers: an exact fit works and writes nothing past its end; one byte less is refused.
|
||||
static void test_bounds(void)
|
||||
{
|
||||
const gn_lpv_t lpv = test_lpv();
|
||||
const int gn_len = GN_BASIC + GN_COMMON + SHB_EXT + BTP_B + CAM_LEN;
|
||||
|
||||
tu_set_context("geonet_wrap_shb bounds");
|
||||
CHECK(geonet_wrap_shb(k_cam, CAM_LEN, &lpv, 2001, tu_guard_buf(gn_len), (size_t)gn_len) == gn_len,
|
||||
"geonet_wrap_shb: exact-size buffer must fit");
|
||||
CHECK(geonet_wrap_shb(k_cam, CAM_LEN, &lpv, 2001, tu_guard_buf(gn_len - 1), (size_t)gn_len - 1) == -1,
|
||||
"geonet_wrap_shb: one byte short must be refused");
|
||||
|
||||
uint8_t gn[GN_BUF_LEN];
|
||||
geonet_wrap_shb(k_cam, CAM_LEN, &lpv, 2001, gn, sizeof gn);
|
||||
for (int qos = 0; qos <= 1; qos++) {
|
||||
tu_set_context("dot11p_build_frame bounds, qos=%d", qos);
|
||||
const int frame_len = MAC_HDR + (qos ? QOS_CTRL : 0) + LLC_SNAP + gn_len;
|
||||
CHECK(dot11p_build_frame(gn, gn_len, k_mac, tu_guard_buf(frame_len), (size_t)frame_len, qos) ==
|
||||
frame_len,
|
||||
"dot11p_build_frame qos=%d: exact-size buffer must fit", qos);
|
||||
CHECK(dot11p_build_frame(gn, gn_len, k_mac, tu_guard_buf(frame_len - 1), (size_t)frame_len - 1,
|
||||
qos) == -1,
|
||||
"dot11p_build_frame qos=%d: one byte short must be refused", qos);
|
||||
}
|
||||
}
|
||||
|
||||
// The largest CAM the serial link can carry must survive the whole chain in main.c's buffers.
|
||||
static void test_max_payload(void)
|
||||
{
|
||||
static uint8_t big[SERIAL_LINK_MAX_PAYLOAD];
|
||||
for (int i = 0; i < SERIAL_LINK_MAX_PAYLOAD; i++) {
|
||||
big[i] = (uint8_t)(i * 7 + 3);
|
||||
}
|
||||
for (int qos = 0; qos <= 1; qos++) {
|
||||
tu_set_context("max payload %d, qos=%d", SERIAL_LINK_MAX_PAYLOAD, qos);
|
||||
const gn_lpv_t lpv = test_lpv();
|
||||
uint8_t f[FRAME_BUF_LEN];
|
||||
const int hdr = MAC_HDR + (qos ? QOS_CTRL : 0) + LLC_SNAP + GN_BASIC + GN_COMMON + SHB_EXT + BTP_B;
|
||||
const int len = build(big, SERIAL_LINK_MAX_PAYLOAD, &lpv, 2001, qos, f, sizeof f);
|
||||
CHECK(len == hdr + SERIAL_LINK_MAX_PAYLOAD, "qos=%d: frame length %d, expected %d", qos, len,
|
||||
hdr + SERIAL_LINK_MAX_PAYLOAD);
|
||||
if (len <= 0) {
|
||||
continue;
|
||||
}
|
||||
gn_rx_t rx;
|
||||
const uint8_t *g = tu_guarded(f, len);
|
||||
const bool ok = gn_unwrap_its(g, len, &rx);
|
||||
CHECK(ok && !rx.truncated && rx.payload_len == SERIAL_LINK_MAX_PAYLOAD &&
|
||||
memcmp(rx.payload, big, SERIAL_LINK_MAX_PAYLOAD) == 0,
|
||||
"qos=%d: max-size payload did not round-trip", qos);
|
||||
tu_pcap_add(f, len);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- GeoBroadcast ---------------------------------------------------------------------------
|
||||
|
||||
// A GeoBroadcast DENM frame laid out by hand from EN 302 636-4-1 clause 9.8.5, since the firmware
|
||||
// has no GBC builder and real RSUs send DENM this way. QoS Data, lifetime 0x79 (30 s) and the
|
||||
// 44-byte extended header all as seen from the CiT One in the recordings.
|
||||
static const uint8_t k_rsu_mac[6] = {0x02, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE};
|
||||
static const uint8_t k_denm_stub[] = {0x02, 0x01, 0x00, 0x00, 0x30, 0x39, 0xDE, 0xAD, 0xBE,
|
||||
0xEF, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08};
|
||||
#define DENM_STUB_LEN ((int)sizeof k_denm_stub)
|
||||
#define GBC_PAYLOAD_OFFSET (MAC_HDR + QOS_CTRL + LLC_SNAP + GN_BASIC + GN_COMMON + GBC_EXT + BTP_B)
|
||||
|
||||
static uint8_t *put(uint8_t *p, const void *src, int n)
|
||||
{
|
||||
memcpy(p, src, (size_t)n);
|
||||
return p + n;
|
||||
}
|
||||
|
||||
static uint8_t *put16(uint8_t *p, uint16_t v)
|
||||
{
|
||||
*p++ = (uint8_t)(v >> 8);
|
||||
*p++ = (uint8_t)v;
|
||||
return p;
|
||||
}
|
||||
|
||||
static uint8_t *put32(uint8_t *p, uint32_t v)
|
||||
{
|
||||
p = put16(p, (uint16_t)(v >> 16));
|
||||
return put16(p, (uint16_t)v);
|
||||
}
|
||||
|
||||
static int build_gbc(uint8_t subtype, int32_t area_lat, int32_t area_lon, uint16_t dist_a, uint8_t *f)
|
||||
{
|
||||
uint8_t *p = f;
|
||||
// 802.11 QoS Data: FC, duration, addr1..3, sequence control, QoS control
|
||||
p = put16(p, 0x8800);
|
||||
p = put16(p, 0);
|
||||
p = put(p, k_bcast, 6);
|
||||
p = put(p, k_rsu_mac, 6);
|
||||
p = put(p, k_bcast, 6);
|
||||
p = put16(p, 0x1000);
|
||||
p = put16(p, 0);
|
||||
p = put(p, k_llc_snap_gn, 8);
|
||||
// Basic: version 1 / NH common, reserved, lifetime 30 s, RHL 10
|
||||
const uint8_t basic[4] = {0x11, 0x00, 0x79, 10};
|
||||
p = put(p, basic, 4);
|
||||
// Common: NH BTP-B, HT 4 (GBC) / subtype = area shape, TC 1, flags 0 (stationary), PL, MHL, reserved
|
||||
const uint8_t common[4] = {0x20, (uint8_t)(0x40 | subtype), 0x01, 0x00};
|
||||
p = put(p, common, 4);
|
||||
p = put16(p, (uint16_t)(BTP_B + DENM_STUB_LEN));
|
||||
*p++ = 10;
|
||||
*p++ = 0;
|
||||
// GBC extended header: SN, reserved, SO PV (GN_ADDR with station type 15 = RSU, MID, TST, lat,
|
||||
// lon, PAI/speed, heading), area centre lat/lon, DistanceA, DistanceB, angle, reserved
|
||||
p = put16(p, 0x1234);
|
||||
p = put16(p, 0);
|
||||
p = put16(p, 15u << 10);
|
||||
p = put(p, k_rsu_mac, 6);
|
||||
p = put32(p, 1000);
|
||||
p = put32(p, 535540100);
|
||||
p = put32(p, 100220100);
|
||||
p = put16(p, 0);
|
||||
p = put16(p, 0);
|
||||
p = put32(p, (uint32_t)area_lat);
|
||||
p = put32(p, (uint32_t)area_lon);
|
||||
p = put16(p, dist_a);
|
||||
p = put16(p, subtype ? 40 : 0);
|
||||
p = put16(p, subtype ? 900 : 0);
|
||||
p = put16(p, 0);
|
||||
// BTP-B: DENM
|
||||
p = put16(p, 2002);
|
||||
p = put16(p, 0);
|
||||
p = put(p, k_denm_stub, DENM_STUB_LEN);
|
||||
return (int)(p - f);
|
||||
}
|
||||
|
||||
static void test_gbc_rx(void)
|
||||
{
|
||||
static const struct { uint8_t subtype; int32_t lat, lon; uint16_t dist_a; } areas[] = {
|
||||
{0, 535540000, 100220000, 250}, // circle around the bench
|
||||
{1, -338600000, 1512100000, 1000}, // rectangle; southern and far-eastern: signs and range
|
||||
{2, 535540000, -100220000, 65535}, // ellipse; western, largest DistanceA
|
||||
};
|
||||
for (size_t i = 0; i < sizeof areas / sizeof areas[0]; i++) {
|
||||
tu_set_context("GBC subtype %d", areas[i].subtype);
|
||||
uint8_t f[256];
|
||||
const int len = build_gbc(areas[i].subtype, areas[i].lat, areas[i].lon, areas[i].dist_a, f);
|
||||
CHECK(len == GBC_PAYLOAD_OFFSET + DENM_STUB_LEN, "GBC frame length %d", len);
|
||||
gn_rx_t rx;
|
||||
const uint8_t *g = tu_guarded(f, len);
|
||||
const bool ok = gn_unwrap_its(g, len, &rx);
|
||||
CHECK(ok, "GBC subtype %d rejected", areas[i].subtype);
|
||||
if (ok) {
|
||||
CHECK(rx.btp_dest_port == 2002, "GBC port %d", rx.btp_dest_port);
|
||||
CHECK(rx.has_geo_area, "GBC area missing");
|
||||
CHECK(!rx.signed_unverified && !rx.truncated, "GBC flags signed=%d truncated=%d",
|
||||
rx.signed_unverified, rx.truncated);
|
||||
CHECK(rx.geo_area_lat_tenmicrodeg == areas[i].lat, "area lat %ld, expected %ld",
|
||||
(long)rx.geo_area_lat_tenmicrodeg, (long)areas[i].lat);
|
||||
CHECK(rx.geo_area_lon_tenmicrodeg == areas[i].lon, "area lon %ld, expected %ld",
|
||||
(long)rx.geo_area_lon_tenmicrodeg, (long)areas[i].lon);
|
||||
CHECK(rx.geo_area_distance_a_m == areas[i].dist_a, "DistanceA %d, expected %d",
|
||||
rx.geo_area_distance_a_m, areas[i].dist_a);
|
||||
CHECK(rx.payload == g + GBC_PAYLOAD_OFFSET && rx.payload_len == DENM_STUB_LEN &&
|
||||
memcmp(rx.payload, k_denm_stub, (size_t)DENM_STUB_LEN) == 0,
|
||||
"GBC payload offset %d length %d", (int)(rx.payload - g), rx.payload_len);
|
||||
}
|
||||
tu_pcap_add(f, len);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- What gets rejected, and where the payload ends -----------------------------------------
|
||||
|
||||
// One-byte changes to a good CAM frame: what must be dropped, and what must still pass.
|
||||
static void test_rejections(void)
|
||||
{
|
||||
const gn_lpv_t lpv = test_lpv();
|
||||
uint8_t good[FRAME_BUF_LEN];
|
||||
const int len = build(k_cam, CAM_LEN, &lpv, 2001, false, good, sizeof good);
|
||||
|
||||
// Offsets into that non-QoS frame: 802.11 0, LLC/SNAP 24, Basic 32, Common 36, SHB 44, BTP-B 72.
|
||||
static const struct {
|
||||
const char *what;
|
||||
int offset;
|
||||
uint8_t value;
|
||||
bool accept;
|
||||
uint16_t port;
|
||||
} cases[] = {
|
||||
{"management frame (beacon)", 0, 0x80, false, 0},
|
||||
{"WDS (ToDS and FromDS)", 1, 0x03, false, 0},
|
||||
{"LLC DSAP not 0xAA", 24, 0xAB, false, 0},
|
||||
{"EtherType not 0x8947", 30, 0x08, false, 0},
|
||||
{"Basic NextHeader 2 with no security envelope", 32, 0x12, false, 0},
|
||||
{"Basic NextHeader 0 (any)", 32, 0x10, false, 0},
|
||||
{"Common NextHeader 1 (BTP-A)", 36, 0x10, false, 0},
|
||||
{"Beacon (HeaderType 1)", 37, 0x10, false, 0},
|
||||
{"GeoUnicast (HeaderType 2)", 37, 0x20, false, 0},
|
||||
{"multi-hop TSB (HeaderType 5, subtype 1)", 37, 0x51, false, 0},
|
||||
{"BTP port 2003 (MAPEM, not accepted yet)", 73, 0xD3, false, 0},
|
||||
{"BTP port 2018 (VAM, not accepted yet)", 73, 0xE2, false, 0},
|
||||
{"BTP port 2002 (DENM)", 73, 0xD2, true, 2002},
|
||||
{"BTP port 2004 (SPATEM)", 73, 0xD4, true, 2004},
|
||||
};
|
||||
for (size_t i = 0; i < sizeof cases / sizeof cases[0]; i++) {
|
||||
tu_set_context("mutation: %s", cases[i].what);
|
||||
uint8_t f[FRAME_BUF_LEN];
|
||||
memcpy(f, good, (size_t)len);
|
||||
f[cases[i].offset] = cases[i].value;
|
||||
gn_rx_t rx;
|
||||
const bool ok = gn_unwrap_its(tu_guarded(f, len), len, &rx);
|
||||
CHECK(ok == cases[i].accept, "%s: %s", cases[i].what, ok ? "accepted" : "rejected");
|
||||
if (ok && cases[i].accept) {
|
||||
CHECK(rx.btp_dest_port == cases[i].port, "%s: port %d", cases[i].what, rx.btp_dest_port);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The Common Header's payload length, not the end of the frame, decides where the message stops.
|
||||
static void test_payload_length(void)
|
||||
{
|
||||
const gn_lpv_t lpv = test_lpv();
|
||||
uint8_t good[FRAME_BUF_LEN];
|
||||
const int len = build(k_cam, CAM_LEN, &lpv, 2001, false, good, sizeof good);
|
||||
const int pl = MAC_HDR + LLC_SNAP + GN_BASIC + GN_COMMON_PAYLOAD_LEN_FIELD;
|
||||
|
||||
static const struct { const char *what; uint16_t value; bool accept; bool truncated; int payload_len; }
|
||||
cases[] = {
|
||||
{"one byte less than sent", BTP_B + CAM_LEN - 1, true, false, CAM_LEN - 1},
|
||||
{"BTP-B header only, no payload", BTP_B, false, false, 0},
|
||||
{"shorter than the BTP-B header", BTP_B - 1, false, false, 0},
|
||||
{"zero", 0, false, false, 0},
|
||||
{"far more than arrived", 0xFFFF, true, true, CAM_LEN},
|
||||
};
|
||||
for (size_t i = 0; i < sizeof cases / sizeof cases[0]; i++) {
|
||||
tu_set_context("Common payload length: %s", cases[i].what);
|
||||
uint8_t f[FRAME_BUF_LEN];
|
||||
memcpy(f, good, (size_t)len);
|
||||
f[pl] = (uint8_t)(cases[i].value >> 8);
|
||||
f[pl + 1] = (uint8_t)cases[i].value;
|
||||
gn_rx_t rx;
|
||||
const uint8_t *g = tu_guarded(f, len);
|
||||
const bool ok = gn_unwrap_its(g, len, &rx);
|
||||
CHECK(ok == cases[i].accept, "%s: %s", cases[i].what, ok ? "accepted" : "rejected");
|
||||
if (ok && cases[i].accept) {
|
||||
CHECK(rx.truncated == cases[i].truncated && rx.payload == g + SHB_PAYLOAD_OFFSET &&
|
||||
rx.payload_len == cases[i].payload_len,
|
||||
"%s: truncated=%d payload_len=%d", cases[i].what, rx.truncated, rx.payload_len);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The 8 bytes the chip's promiscuous RX appends to every frame must not reach the phone.
|
||||
static void test_trailer(void)
|
||||
{
|
||||
uint8_t f[FRAME_BUF_LEN];
|
||||
for (int qos = 0; qos <= 1; qos++) {
|
||||
tu_set_context("RX trailer after an SHB frame, qos=%d", qos);
|
||||
const gn_lpv_t lpv = test_lpv();
|
||||
const int len = build(k_cam, CAM_LEN, &lpv, 2001, qos, f, sizeof f);
|
||||
memcpy(f + len, k_rx_trailer, sizeof k_rx_trailer);
|
||||
gn_rx_t rx;
|
||||
const bool ok = gn_unwrap_its(tu_guarded(f, len + 8), len + 8, &rx);
|
||||
CHECK(ok && !rx.truncated && rx.payload_len == CAM_LEN &&
|
||||
memcmp(rx.payload, k_cam, (size_t)CAM_LEN) == 0,
|
||||
"qos=%d: payload_len %d with the trailer, expected %d", qos, ok ? rx.payload_len : -1,
|
||||
CAM_LEN);
|
||||
}
|
||||
|
||||
tu_set_context("RX trailer after a GBC frame");
|
||||
const int len = build_gbc(0, 535540000, 100220000, 250, f);
|
||||
memcpy(f + len, k_rx_trailer, sizeof k_rx_trailer);
|
||||
gn_rx_t rx;
|
||||
const bool ok = gn_unwrap_its(tu_guarded(f, len + 8), len + 8, &rx);
|
||||
CHECK(ok && !rx.truncated && rx.payload_len == DENM_STUB_LEN,
|
||||
"GBC: payload_len %d with the trailer, expected %d", ok ? rx.payload_len : -1, DENM_STUB_LEN);
|
||||
}
|
||||
|
||||
// ---- Secured (TS 103 097) -------------------------------------------------------------------
|
||||
|
||||
// Stand-in for what follows the inner packet in a real signed frame: headerInfo, signer and
|
||||
// signature, 94 bytes with a digest signer. Its content is never read.
|
||||
#define SIG_STUB_LEN 94
|
||||
|
||||
static int put_coer_length(int len, uint8_t *out)
|
||||
{
|
||||
if (len < 0x80) {
|
||||
out[0] = (uint8_t)len;
|
||||
return 1;
|
||||
}
|
||||
if (len <= 0xFF) {
|
||||
out[0] = 0x81;
|
||||
out[1] = (uint8_t)len;
|
||||
return 2;
|
||||
}
|
||||
out[0] = 0x82;
|
||||
out[1] = (uint8_t)(len >> 8);
|
||||
out[2] = (uint8_t)len;
|
||||
return 3;
|
||||
}
|
||||
|
||||
// A secured CAM frame shaped like the recorded ones: our own SHB packet with the Basic Header's
|
||||
// NextHeader set to 2 and everything after the Basic Header wrapped as `prefix` + COER length +
|
||||
// inner packet, then the signature stand-in. Sets where the ITS payload starts and ends.
|
||||
static int build_secured(const uint8_t *prefix, int prefix_len, const uint8_t *payload,
|
||||
int payload_len, uint8_t *f, int *payload_start, int *payload_end)
|
||||
{
|
||||
const gn_lpv_t lpv = test_lpv();
|
||||
uint8_t plain[FRAME_BUF_LEN];
|
||||
const int plain_len = build(payload, payload_len, &lpv, 2001, false, plain, sizeof plain);
|
||||
const int basic = MAC_HDR + LLC_SNAP;
|
||||
const int inner = basic + GN_BASIC; // the Common Header onward
|
||||
const int inner_len = plain_len - inner;
|
||||
|
||||
int n = inner;
|
||||
memcpy(f, plain, (size_t)inner);
|
||||
f[basic] = (uint8_t)((f[basic] & 0xF0) | 2); // Basic Header NextHeader: secured
|
||||
memcpy(f + n, prefix, (size_t)prefix_len);
|
||||
n += prefix_len;
|
||||
n += put_coer_length(inner_len, f + n);
|
||||
memcpy(f + n, plain + inner, (size_t)inner_len);
|
||||
*payload_start = n + GN_COMMON + SHB_EXT + BTP_B;
|
||||
n += inner_len;
|
||||
*payload_end = n;
|
||||
for (int i = 0; i < SIG_STUB_LEN; i++) {
|
||||
f[n++] = (uint8_t)(0xA5 ^ i);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
static const uint8_t k_signed_prefix[] = {0x03, 0x81, 0x00, 0x40, 0x03, 0x80};
|
||||
static const uint8_t k_unsecured_prefix[] = {0x03, 0x80};
|
||||
|
||||
static void test_secured(void)
|
||||
{
|
||||
static uint8_t big[300];
|
||||
for (int i = 0; i < (int)sizeof big; i++) {
|
||||
big[i] = (uint8_t)(i * 13 + 1);
|
||||
}
|
||||
static const struct { const char *what; bool is_signed; int payload_len; } cases[] = {
|
||||
{"signed, short length form", true, CAM_LEN}, // inner packet 83 bytes
|
||||
{"signed, 1-byte long length form", true, 100}, // 140
|
||||
{"signed, 2-byte long length form", true, 300}, // 340
|
||||
{"top-level unsecuredData", false, CAM_LEN},
|
||||
};
|
||||
for (size_t c = 0; c < sizeof cases / sizeof cases[0]; c++) {
|
||||
tu_set_context("secured: %s", cases[c].what);
|
||||
const uint8_t *payload = cases[c].payload_len == CAM_LEN ? k_cam : big;
|
||||
uint8_t f[FRAME_BUF_LEN];
|
||||
int start, end;
|
||||
const int len = cases[c].is_signed
|
||||
? build_secured(k_signed_prefix, (int)sizeof k_signed_prefix, payload,
|
||||
cases[c].payload_len, f, &start, &end)
|
||||
: build_secured(k_unsecured_prefix, (int)sizeof k_unsecured_prefix, payload,
|
||||
cases[c].payload_len, f, &start, &end);
|
||||
gn_rx_t rx;
|
||||
const uint8_t *g = tu_guarded(f, len);
|
||||
const bool ok = gn_unwrap_its(g, len, &rx);
|
||||
CHECK(ok, "%s: rejected", cases[c].what);
|
||||
if (ok) {
|
||||
CHECK(rx.signed_unverified == cases[c].is_signed, "%s: signed_unverified %d",
|
||||
cases[c].what, rx.signed_unverified);
|
||||
CHECK(!rx.truncated && rx.btp_dest_port == 2001, "%s: truncated %d port %d",
|
||||
cases[c].what, rx.truncated, rx.btp_dest_port);
|
||||
CHECK(rx.payload == g + start && rx.payload_len == cases[c].payload_len &&
|
||||
memcmp(rx.payload, payload, (size_t)cases[c].payload_len) == 0,
|
||||
"%s: payload offset %d length %d, expected %d / %d", cases[c].what,
|
||||
(int)(rx.payload - g), rx.payload_len, start, cases[c].payload_len);
|
||||
}
|
||||
tu_pcap_add(f, len);
|
||||
sweep(cases[c].what, f, len, start, end);
|
||||
}
|
||||
|
||||
// One-byte changes to the signed, short-form frame. `env` is where the envelope starts.
|
||||
uint8_t good[FRAME_BUF_LEN];
|
||||
int start, end;
|
||||
const int len = build_secured(k_signed_prefix, (int)sizeof k_signed_prefix, k_cam, CAM_LEN,
|
||||
good, &start, &end);
|
||||
const int env = MAC_HDR + LLC_SNAP + GN_BASIC;
|
||||
const int inner = env + (int)sizeof k_signed_prefix + 1; // + one length byte
|
||||
static const struct { const char *what; int offset; uint8_t value; } bad[] = {
|
||||
{"legacy envelope, protocolVersion 2", 0, 0x02},
|
||||
{"encryptedData", 1, 0x82},
|
||||
{"hashId not a one-byte value", 2, 0x80},
|
||||
{"no data, only extDataHash (preamble 0x20)", 3, 0x20},
|
||||
{"inner protocolVersion 2", 4, 0x02},
|
||||
{"nested signedData", 5, 0x81},
|
||||
{"length form with no length bytes (0x80)", 6, 0x80},
|
||||
{"length form with 3 length bytes (0x83)", 6, 0x83},
|
||||
{"envelope too short for the Common Header", 6, 0x05},
|
||||
};
|
||||
for (size_t i = 0; i < sizeof bad / sizeof bad[0]; i++) {
|
||||
tu_set_context("secured mutation: %s", bad[i].what);
|
||||
uint8_t f[FRAME_BUF_LEN];
|
||||
memcpy(f, good, (size_t)len);
|
||||
f[env + bad[i].offset] = bad[i].value;
|
||||
gn_rx_t rx;
|
||||
CHECK(!gn_unwrap_its(tu_guarded(f, len), len, &rx), "%s: accepted", bad[i].what);
|
||||
}
|
||||
|
||||
tu_set_context("secured mutation: inner packet claims more than its envelope holds");
|
||||
uint8_t f[FRAME_BUF_LEN];
|
||||
memcpy(f, good, (size_t)len);
|
||||
f[inner + GN_COMMON_PAYLOAD_LEN_FIELD + 1]++; // Common Header payload length, low byte
|
||||
gn_rx_t rx;
|
||||
CHECK(!gn_unwrap_its(tu_guarded(f, len), len, &rx), "payload overrunning its envelope: accepted");
|
||||
}
|
||||
|
||||
static void test_truncation(void)
|
||||
{
|
||||
const gn_lpv_t lpv = test_lpv();
|
||||
uint8_t f[FRAME_BUF_LEN];
|
||||
for (int qos = 0; qos <= 1; qos++) {
|
||||
const int len = build(k_cam, CAM_LEN, &lpv, 2001, qos, f, sizeof f);
|
||||
const int start = MAC_HDR + (qos ? QOS_CTRL : 0) + LLC_SNAP + GN_BASIC + GN_COMMON + SHB_EXT + BTP_B;
|
||||
sweep(qos ? "SHB QoS" : "SHB", f, len, start, len);
|
||||
}
|
||||
const int len = build_gbc(0, 535540000, 100220000, 250, f);
|
||||
sweep("GBC", f, len, GBC_PAYLOAD_OFFSET, len);
|
||||
}
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
tu_install_crash_handler();
|
||||
tu_guard_init();
|
||||
if (argc > 1 && !tu_pcap_open(argv[1])) {
|
||||
fprintf(stderr, "cannot write %s\n", argv[1]);
|
||||
return 2;
|
||||
}
|
||||
|
||||
test_shb_layout(false);
|
||||
test_shb_layout(true);
|
||||
test_pv_encoding();
|
||||
test_bounds();
|
||||
test_max_payload();
|
||||
test_gbc_rx();
|
||||
test_rejections();
|
||||
test_payload_length();
|
||||
test_trailer();
|
||||
test_secured();
|
||||
test_truncation();
|
||||
|
||||
tu_pcap_close();
|
||||
printf("test_chain: %d checks, %d failed\n", s_checks, s_failures);
|
||||
return s_failures ? 1 : 0;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// Replays recorded air traffic through gn_unwrap_its, one pcap record at a time and exactly as the
|
||||
// promiscuous RX callback hands each frame over (the recordings come through the same API), and
|
||||
// writes what came out to a TSV that check_replay.py verifies independently. See README.md.
|
||||
//
|
||||
// Usage: test_replay out.tsv capture.pcap [capture.pcap ...]
|
||||
//
|
||||
// One row per pcap record:
|
||||
// file, record, accepted, port, signed, truncated, area, area_lat, area_lon, area_dist, payload
|
||||
// with everything after `accepted` empty for rejected frames and the payload in hex. Each frame
|
||||
// is placed against the guard page, and an accepted payload that is not inside its frame is a
|
||||
// failure here already.
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
#include "gn_unwrap.h"
|
||||
#include "test_util.h"
|
||||
|
||||
// main.c's RX_FRAME_MAX_LEN: the promiscuous callback copies at most this many bytes of a frame,
|
||||
// so it is the most gn_unwrap_its ever sees on the board. Keep in step with main.c.
|
||||
#define RX_FRAME_MAX_LEN 800
|
||||
|
||||
typedef struct {
|
||||
FILE *out;
|
||||
const char *path;
|
||||
long records, capped, accepted, cam, denm, spatem, signed_frames, truncated, bad;
|
||||
} replay_t;
|
||||
|
||||
static void on_frame(const uint8_t *frame, int len, int index, void *ctx)
|
||||
{
|
||||
replay_t *r = ctx;
|
||||
r->records++;
|
||||
if (len > RX_FRAME_MAX_LEN) {
|
||||
len = RX_FRAME_MAX_LEN;
|
||||
r->capped++;
|
||||
}
|
||||
tu_set_context("replay %s record %d (%d bytes)", r->path, index, len);
|
||||
const uint8_t *g = tu_guarded(frame, len);
|
||||
gn_rx_t rx;
|
||||
const bool ok = gn_unwrap_its(g, len, &rx);
|
||||
fprintf(r->out, "%s\t%d\t%d", r->path, index, ok);
|
||||
if (ok) {
|
||||
const uintptr_t lo = (uintptr_t)g;
|
||||
const uintptr_t p = (uintptr_t)rx.payload;
|
||||
if (p < lo || rx.payload_len <= 0 || p + (uintptr_t)rx.payload_len > lo + (uintptr_t)len) {
|
||||
fprintf(stderr, "FAIL %s: payload outside the frame\n", tu_context());
|
||||
r->bad++;
|
||||
} else {
|
||||
fprintf(r->out, "\t%u\t%d\t%d\t%d\t%ld\t%ld\t%u\t", rx.btp_dest_port, rx.signed_unverified,
|
||||
rx.truncated, rx.has_geo_area, (long)rx.geo_area_lat_tenmicrodeg,
|
||||
(long)rx.geo_area_lon_tenmicrodeg, rx.geo_area_distance_a_m);
|
||||
for (int i = 0; i < rx.payload_len; i++) {
|
||||
fprintf(r->out, "%02x", rx.payload[i]);
|
||||
}
|
||||
}
|
||||
r->accepted++;
|
||||
r->cam += rx.btp_dest_port == 2001;
|
||||
r->denm += rx.btp_dest_port == 2002;
|
||||
r->spatem += rx.btp_dest_port == 2004;
|
||||
r->signed_frames += rx.signed_unverified;
|
||||
r->truncated += rx.truncated;
|
||||
}
|
||||
fputc('\n', r->out);
|
||||
}
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
if (argc < 3) {
|
||||
fprintf(stderr, "usage: test_replay out.tsv capture.pcap [capture.pcap ...]\n");
|
||||
return 2;
|
||||
}
|
||||
tu_install_crash_handler();
|
||||
tu_guard_init();
|
||||
|
||||
replay_t r = {0};
|
||||
r.out = fopen(argv[1], "w");
|
||||
if (!r.out) {
|
||||
fprintf(stderr, "cannot write %s\n", argv[1]);
|
||||
return 2;
|
||||
}
|
||||
fprintf(r.out, "file\trecord\taccepted\tport\tsigned\ttruncated\tarea\tarea_lat\tarea_lon\tarea_dist\tpayload\n");
|
||||
|
||||
int unreadable = 0;
|
||||
for (int i = 2; i < argc; i++) {
|
||||
r.path = argv[i];
|
||||
if (tu_pcap_foreach(argv[i], on_frame, &r) < 0) {
|
||||
fprintf(stderr, "cannot read %s as a pcap\n", argv[i]);
|
||||
unreadable++;
|
||||
}
|
||||
}
|
||||
fclose(r.out);
|
||||
|
||||
printf("test_replay: %ld records (%ld cut to %d bytes as main.c does), %ld accepted (CAM %ld, "
|
||||
"DENM %ld, SPATEM %ld; %ld signed, %ld truncated), %ld bad\n",
|
||||
r.records, r.capped, RX_FRAME_MAX_LEN, r.accepted, r.cam, r.denm, r.spatem,
|
||||
r.signed_frames, r.truncated, r.bad);
|
||||
return (r.bad || unreadable) ? 1 : 0;
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
#ifndef _WIN32
|
||||
#define _DEFAULT_SOURCE // MAP_ANONYMOUS under -std=c11
|
||||
#endif
|
||||
|
||||
#include "test_util.h"
|
||||
|
||||
#include <stdarg.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <windows.h>
|
||||
#else
|
||||
#include <signal.h>
|
||||
#include <sys/mman.h>
|
||||
#include <unistd.h>
|
||||
#endif
|
||||
|
||||
// ---- Crash reporting ------------------------------------------------------------------------
|
||||
|
||||
static char s_context[200] = "startup";
|
||||
static const uint8_t *s_crash_bytes;
|
||||
static const int *s_crash_len;
|
||||
|
||||
void tu_set_context(const char *fmt, ...)
|
||||
{
|
||||
va_list ap;
|
||||
va_start(ap, fmt);
|
||||
vsnprintf(s_context, sizeof s_context, fmt, ap);
|
||||
va_end(ap);
|
||||
}
|
||||
|
||||
const char *tu_context(void)
|
||||
{
|
||||
return s_context;
|
||||
}
|
||||
|
||||
void tu_set_crash_input(const uint8_t *bytes, const int *len)
|
||||
{
|
||||
s_crash_bytes = bytes;
|
||||
s_crash_len = len;
|
||||
}
|
||||
|
||||
static void report_crash(const char *what)
|
||||
{
|
||||
fprintf(stderr, "CRASH (%s) during: %s\n", what, s_context);
|
||||
if (s_crash_bytes && s_crash_len) {
|
||||
fprintf(stderr, "input (%d bytes): ", *s_crash_len);
|
||||
for (int i = 0; i < *s_crash_len; i++) {
|
||||
fprintf(stderr, "%02x", s_crash_bytes[i]);
|
||||
}
|
||||
fputc('\n', stderr);
|
||||
}
|
||||
fflush(stderr);
|
||||
}
|
||||
|
||||
#ifdef _WIN32
|
||||
static LONG WINAPI on_crash(EXCEPTION_POINTERS *info)
|
||||
{
|
||||
char what[40];
|
||||
snprintf(what, sizeof what, "exception 0x%08lx",
|
||||
(unsigned long)info->ExceptionRecord->ExceptionCode);
|
||||
report_crash(what);
|
||||
return EXCEPTION_EXECUTE_HANDLER; // terminate, with the exception code as the exit status
|
||||
}
|
||||
|
||||
void tu_install_crash_handler(void)
|
||||
{
|
||||
SetUnhandledExceptionFilter(on_crash);
|
||||
}
|
||||
#else
|
||||
static void on_crash(int sig)
|
||||
{
|
||||
char what[40];
|
||||
snprintf(what, sizeof what, "signal %d", sig);
|
||||
report_crash(what); // not async-signal-safe, but the process is ending anyway
|
||||
_exit(2);
|
||||
}
|
||||
|
||||
void tu_install_crash_handler(void)
|
||||
{
|
||||
signal(SIGSEGV, on_crash);
|
||||
signal(SIGBUS, on_crash);
|
||||
signal(SIGILL, on_crash); // -fsanitize-undefined-trap-on-error traps with an illegal instruction
|
||||
}
|
||||
#endif
|
||||
|
||||
// ---- Guard page -----------------------------------------------------------------------------
|
||||
|
||||
static uint8_t *s_guard_end; // first byte of the no-access page
|
||||
static size_t s_page_size;
|
||||
|
||||
void tu_guard_init(void)
|
||||
{
|
||||
#ifdef _WIN32
|
||||
SYSTEM_INFO si;
|
||||
GetSystemInfo(&si);
|
||||
s_page_size = si.dwPageSize;
|
||||
uint8_t *base = VirtualAlloc(NULL, 2 * s_page_size, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);
|
||||
DWORD old;
|
||||
if (!base || !VirtualProtect(base + s_page_size, s_page_size, PAGE_NOACCESS, &old)) {
|
||||
fprintf(stderr, "guard page setup failed\n");
|
||||
exit(2);
|
||||
}
|
||||
#else
|
||||
s_page_size = (size_t)sysconf(_SC_PAGESIZE);
|
||||
uint8_t *base = mmap(NULL, 2 * s_page_size, PROT_READ | PROT_WRITE,
|
||||
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
|
||||
if (base == MAP_FAILED || mprotect(base + s_page_size, s_page_size, PROT_NONE) != 0) {
|
||||
fprintf(stderr, "guard page setup failed\n");
|
||||
exit(2);
|
||||
}
|
||||
#endif
|
||||
s_guard_end = base + s_page_size;
|
||||
}
|
||||
|
||||
uint8_t *tu_guard_buf(int len)
|
||||
{
|
||||
if (len < 0 || (size_t)len > s_page_size) {
|
||||
fprintf(stderr, "tu_guard_buf(%d) exceeds one page\n", len);
|
||||
exit(2);
|
||||
}
|
||||
return s_guard_end - len;
|
||||
}
|
||||
|
||||
const uint8_t *tu_guarded(const uint8_t *frame, int len)
|
||||
{
|
||||
uint8_t *dst = tu_guard_buf(len);
|
||||
if (len > 0) {
|
||||
memcpy(dst, frame, (size_t)len);
|
||||
}
|
||||
return dst;
|
||||
}
|
||||
|
||||
// ---- pcap -----------------------------------------------------------------------------------
|
||||
|
||||
static uint32_t rd_le32(const uint8_t *p)
|
||||
{
|
||||
return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24);
|
||||
}
|
||||
|
||||
static uint32_t rd_be32(const uint8_t *p)
|
||||
{
|
||||
return ((uint32_t)p[0] << 24) | ((uint32_t)p[1] << 16) | ((uint32_t)p[2] << 8) | (uint32_t)p[3];
|
||||
}
|
||||
|
||||
int tu_pcap_foreach(const char *path, tu_frame_fn fn, void *ctx)
|
||||
{
|
||||
FILE *f = fopen(path, "rb");
|
||||
if (!f) {
|
||||
return -1;
|
||||
}
|
||||
fseek(f, 0, SEEK_END);
|
||||
const long size = ftell(f);
|
||||
fseek(f, 0, SEEK_SET);
|
||||
if (size < 24) {
|
||||
fclose(f);
|
||||
return size == 0 ? 0 : -1; // the recordings include empty files
|
||||
}
|
||||
uint8_t *d = malloc((size_t)size);
|
||||
if (!d || fread(d, 1, (size_t)size, f) != (size_t)size) {
|
||||
fclose(f);
|
||||
free(d);
|
||||
return -1;
|
||||
}
|
||||
fclose(f);
|
||||
|
||||
const uint32_t magic = rd_le32(d);
|
||||
const bool swapped = magic == 0xD4C3B2A1u || magic == 0x4D3CB2A1u;
|
||||
if (!swapped && magic != 0xA1B2C3D4u && magic != 0xA1B23C4Du) {
|
||||
free(d);
|
||||
return -1;
|
||||
}
|
||||
uint32_t (*u32)(const uint8_t *) = swapped ? rd_be32 : rd_le32;
|
||||
const uint32_t linktype = u32(d + 20);
|
||||
if (linktype != 127 && linktype != 105) {
|
||||
free(d);
|
||||
return -1;
|
||||
}
|
||||
|
||||
long off = 24;
|
||||
int index = 0;
|
||||
while (off + 16 <= size) {
|
||||
const uint32_t incl = u32(d + off + 8);
|
||||
if (incl > (uint32_t)(size - off - 16)) {
|
||||
break; // last record cut off
|
||||
}
|
||||
const uint8_t *pkt = d + off + 16;
|
||||
int len = (int)incl;
|
||||
off += 16 + (long)incl;
|
||||
if (linktype == 127) {
|
||||
const int rt_len = len >= 4 ? (pkt[2] | (pkt[3] << 8)) : len + 1; // always little-endian
|
||||
if (rt_len > len) {
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
pkt += rt_len;
|
||||
len -= rt_len;
|
||||
}
|
||||
fn(pkt, len, index++, ctx);
|
||||
}
|
||||
free(d);
|
||||
return index;
|
||||
}
|
||||
|
||||
static FILE *s_pcap_out;
|
||||
|
||||
static void put_le32(uint8_t *p, uint32_t v)
|
||||
{
|
||||
p[0] = (uint8_t)v;
|
||||
p[1] = (uint8_t)(v >> 8);
|
||||
p[2] = (uint8_t)(v >> 16);
|
||||
p[3] = (uint8_t)(v >> 24);
|
||||
}
|
||||
|
||||
bool tu_pcap_open(const char *path)
|
||||
{
|
||||
// Global header: magic, version 2.4, zone 0, sigfigs 0, snaplen 65535, linktype 105.
|
||||
static const uint8_t hdr[24] = {
|
||||
0xD4, 0xC3, 0xB2, 0xA1, 0x02, 0x00, 0x04, 0x00, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0xFF, 0xFF, 0x00, 0x00, 105, 0, 0, 0,
|
||||
};
|
||||
s_pcap_out = fopen(path, "wb");
|
||||
return s_pcap_out && fwrite(hdr, 1, sizeof hdr, s_pcap_out) == sizeof hdr;
|
||||
}
|
||||
|
||||
void tu_pcap_add(const uint8_t *frame, int len)
|
||||
{
|
||||
static uint32_t seq;
|
||||
if (!s_pcap_out || len <= 0) {
|
||||
return;
|
||||
}
|
||||
uint8_t rec[16];
|
||||
put_le32(rec + 0, seq++); // timestamp seconds: just the frame's sequence number
|
||||
put_le32(rec + 4, 0);
|
||||
put_le32(rec + 8, (uint32_t)len);
|
||||
put_le32(rec + 12, (uint32_t)len);
|
||||
fwrite(rec, 1, sizeof rec, s_pcap_out);
|
||||
fwrite(frame, 1, (size_t)len, s_pcap_out);
|
||||
}
|
||||
|
||||
void tu_pcap_close(void)
|
||||
{
|
||||
if (s_pcap_out) {
|
||||
fclose(s_pcap_out);
|
||||
s_pcap_out = NULL;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
#ifndef TEST_UTIL_H
|
||||
#define TEST_UTIL_H
|
||||
// Shared by the host tests: crash reporting, the guard page, and pcap reading and writing.
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
|
||||
// What the test is doing right now, printed if it crashes. printf-style.
|
||||
void tu_set_context(const char *fmt, ...);
|
||||
const char *tu_context(void);
|
||||
// Also print these bytes as hex if it crashes (the fuzzer's current input). NULL to clear.
|
||||
void tu_set_crash_input(const uint8_t *bytes, const int *len);
|
||||
void tu_install_crash_handler(void);
|
||||
|
||||
// One read-write page followed by one no-access page.
|
||||
void tu_guard_init(void);
|
||||
// A buffer of exactly `len` bytes (at most one page) whose end touches the no-access page.
|
||||
uint8_t *tu_guard_buf(int len);
|
||||
// Copy of `frame` whose last byte is the last readable one: reading past it crashes.
|
||||
const uint8_t *tu_guarded(const uint8_t *frame, int len);
|
||||
|
||||
// Calls `fn` for every record of a pcap file, with its 802.11 frame. Linktype 127 (radiotap,
|
||||
// what its-g5-receiver-firmware records) has the radiotap header removed; linktype 105 is passed
|
||||
// as is. The frame is otherwise exactly as captured, including the 8 bytes the ESP32-C5's
|
||||
// promiscuous RX appends - which is what obu-firmware's callback receives through the same API.
|
||||
// `index` counts every record, including ones skipped for a malformed radiotap header. Returns
|
||||
// the number of records (0 for an empty file), or -1 if the file can't be read or isn't a pcap
|
||||
// of those linktypes.
|
||||
typedef void (*tu_frame_fn)(const uint8_t *frame, int len, int index, void *ctx);
|
||||
int tu_pcap_foreach(const char *path, tu_frame_fn fn, void *ctx);
|
||||
|
||||
// Writes frames to a pcap, linktype 105 (bare 802.11).
|
||||
bool tu_pcap_open(const char *path);
|
||||
void tu_pcap_add(const uint8_t *frame, int len);
|
||||
void tu_pcap_close(void);
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tally GeoNetworking header fields per sending station across .pcap captures.
|
||||
|
||||
Written to check the GN lifetime byte on air (see TODO.md), and it answers the general question
|
||||
"what do real stations put in this header" too: one row per source MAC, packet type, BTP port and
|
||||
lifetime byte, with a frame count.
|
||||
|
||||
python obu-firmware/test/pcap_gn_tally.py its-g5-receiver-firmware/recordings/*.pcap
|
||||
|
||||
Handles linktype 127 (radiotap, what its-g5-receiver-firmware records) and 105 (bare 802.11).
|
||||
Standard library only. Pseudonym MACs rotate, so one vehicle can appear as several rows. The
|
||||
pcap-over-serial dump path corrupts roughly 0.3% of frames, so a stray odd row is tooling noise.
|
||||
"""
|
||||
import collections
|
||||
import glob
|
||||
import struct
|
||||
import sys
|
||||
|
||||
LLC_SNAP_GN = b"\xaa\xaa\x03\x00\x00\x00\x89\x47"
|
||||
|
||||
# (HeaderType, HeaderSubtype) from the GN Common Header -> name, EN 302 636-4-1 table 9.
|
||||
HEADER_TYPES = {
|
||||
(1, 0): "beacon",
|
||||
(4, 0): "GBC-circle",
|
||||
(4, 1): "GBC-rect",
|
||||
(4, 2): "GBC-ellipse",
|
||||
(5, 0): "SHB",
|
||||
}
|
||||
# Extended header length, i.e. the distance from the end of the Common Header to BTP-B.
|
||||
EXT_LEN = {"SHB": 28, "GBC-circle": 44, "GBC-rect": 44, "GBC-ellipse": 44}
|
||||
|
||||
|
||||
def lifetime_seconds(raw):
|
||||
# Multiplier in the upper 6 bits, base in the lower 2: 50 ms, 1 s, 10 s, 100 s.
|
||||
return (raw >> 2) * (0.05, 1, 10, 100)[raw & 3]
|
||||
|
||||
|
||||
def frames(path):
|
||||
with open(path, "rb") as f:
|
||||
data = f.read()
|
||||
if len(data) < 24:
|
||||
return
|
||||
magic = struct.unpack("<I", data[:4])[0]
|
||||
endian = "<" if magic in (0xA1B2C3D4, 0xA1B23C4D) else ">"
|
||||
linktype = struct.unpack(endian + "I", data[20:24])[0]
|
||||
off = 24
|
||||
while off + 16 <= len(data):
|
||||
incl = struct.unpack(endian + "I", data[off + 8:off + 12])[0]
|
||||
pkt = data[off + 16:off + 16 + incl]
|
||||
off += 16 + incl
|
||||
if linktype == 127:
|
||||
if len(pkt) < 4:
|
||||
continue
|
||||
pkt = pkt[struct.unpack("<H", pkt[2:4])[0]:]
|
||||
elif linktype != 105:
|
||||
continue
|
||||
yield pkt
|
||||
|
||||
|
||||
def gn_fields(f):
|
||||
if len(f) < 24 or (f[0] >> 2) & 3 != 2:
|
||||
return None # Data frames only
|
||||
o = 24 + (2 if f[0] & 0x80 else 0) # QoS Data carries a 2-byte QoS Control field
|
||||
if f[o:o + 8] != LLC_SNAP_GN or len(f) < o + 12:
|
||||
return None
|
||||
o += 8
|
||||
src = f[10:16].hex(":")
|
||||
version, next_header, lifetime = f[o] >> 4, f[o] & 0x0F, f[o + 2]
|
||||
port = "-"
|
||||
if next_header == 2:
|
||||
kind = "secured" # Common Header is inside the security envelope
|
||||
elif next_header == 1 and len(f) >= o + 12:
|
||||
c = o + 4
|
||||
ht = (f[c + 1] >> 4, f[c + 1] & 0x0F)
|
||||
kind = HEADER_TYPES.get(ht, "type %d/%d" % ht)
|
||||
ext = EXT_LEN.get(kind)
|
||||
btp = c + 8 + (ext or 0)
|
||||
if ext and len(f) >= btp + 2:
|
||||
port = str(struct.unpack(">H", f[btp:btp + 2])[0])
|
||||
else:
|
||||
kind = "nh=%d" % next_header
|
||||
return src, version, kind, port, lifetime
|
||||
|
||||
|
||||
def main(argv):
|
||||
# PowerShell does not expand wildcards itself, so do it here.
|
||||
paths = [p for arg in argv for p in (glob.glob(arg) or [arg])]
|
||||
if not paths:
|
||||
sys.exit(__doc__)
|
||||
tally = collections.Counter()
|
||||
for path in paths:
|
||||
for frame in frames(path):
|
||||
fields = gn_fields(frame)
|
||||
if fields:
|
||||
tally[fields] += 1
|
||||
print("%-17s %3s %-11s %5s %8s %8s %7s"
|
||||
% ("source", "ver", "packet", "port", "lifetime", "seconds", "frames"))
|
||||
for (src, ver, kind, port, lt), n in sorted(tally.items()):
|
||||
print("%-17s %3d %-11s %5s %8s %8g %7d"
|
||||
% (src, ver, kind, port, "0x%02x" % lt, lifetime_seconds(lt), n))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(sys.argv[1:])
|
||||
Reference in New Issue
Block a user