4 Commits
Author SHA1 Message Date
Ashin Walpola c13c3891c9 Add a V2X data logger to Settings > Developer
Records every received V2X message (CAM, DENM, SPATEM, MAPEM, VAM) into one
SQLite file per run, with a timestamp, the phone's GNSS fix, the OBU's GNSS fix
where there is one (CiT One), and RSSI where the hardware reports it (ESP32-C5).
Works on both hardware paths: V2X_RX frames from the ESP32 link, and the raw
v2x/rx plus processed v2x-uca topics over MQTT. The UPER bytes are stored
untouched; CAM, DENM and SPATEM also get a few decoded columns.

A foreground service keeps it recording with the screen locked. Subscribes to
v2x/rx/mapem so MAPEM is received on the CiT One.

The current ESP32-C5 firmware forwards only BTP ports 2001, 2002 and 2004, so
MAPEM and VAM appear in an ESP32 log only once it accepts 2003 and 2018.

Checked on a Pixel 9 Pro against a simulated car and an RSU: 10 minutes with the
screen locked and forced deep Doze, no gaps, files pass integrity_check.
2026-10-02 16:16:02 +02:00
Ashin Walpola d3fc9bf66c Add a block diagram of the signed-ITS architecture
docs/Architecture-signed-its.drawio (with a PNG export beside it, like
Architecture2): PKI (today's demo chain, and dashed the future EU C-ITS path
with the lab's registered root), the phone app's transmit and receive path,
the USB-C and BLE links with their heartbeats, the ESP32-C5 firmware (link
endpoints, session, signing through vanetza-idf with the key in NVS, the
unsigned geonet.c path, radio and raw receive), the ITS-G5 air and the bench
receivers (V2X2MAP with signature check, CiT One, RSU, sim car), and a legend
of the station-link messages and recovery behaviour.
2026-09-24 10:56:16 +02:00
Ashin Walpola 6e4d293c3a Flashing notes: first flash of the signed firmware, and the way back
FLASHING.md still described flashing as for the previous firmware. The port
changed the partition table (NVS 24 KB -> 80 KB, app 0x10000 -> 0x20000), so a
board coming from the previous firmware needs its NVS range erased once and a
full flash, not app-flash; without the erase the BLE bond cannot be stored and
the phone pairs on every connection. Documented that, what the boot log and
the app show afterwards (credentials provisioned on first Connect, stale phone
pairings to forget), both ways back to the previous firmware (the backup image,
or commit 7285fa1 built with IDF 6.1), the production board's port (COM3,
UART bridge), the station-link names in the phone and bring-up sections, and
that the build is self-contained with obu-firmware/external/vanetza-idf.
2026-09-24 10:56:16 +02:00
Ashin Walpola d107534eb2 Keep vanetza-idf in obu-firmware, so a plain clone builds the firmware
obu-firmware builds against the vanetza-idf C-ITS library, which until now
came from the colleague's microbu-esp32c5 tree beside the repository and was
not tracked here, so a clone of this repository could not build the firmware
it ships. The library alone is now part of obu-firmware, as
obu-firmware/external/vanetza-idf: their external/vanetza-idf at commit
cf4b99f, unchanged (9775 files; see its PROVENANCE.md). CMake takes it from
there by default; -DVANETZA_IDF_DIR still points the build elsewhere.

The rest of the colleague's tree (their own VAM firmware, PKI tooling,
station-link Python tools, the V2X2MAP bridge) stays out of this repository
and gitignored; nothing is pushed to their repository. NOTES.md, docs/06,
TODO.md and the pcap verifier's usage line point at the new location.
2026-09-24 10:56:05 +02:00
9901 changed files with 1373 additions and 22083 deletions
+9
View File
@@ -54,3 +54,12 @@ sdkconfig.old
# ESP-IDF component manager downloads (espressif/esp-boost for obu-firmware's vanetza-idf), ~125 MB. # ESP-IDF component manager downloads (espressif/esp-boost for obu-firmware's vanetza-idf), ~125 MB.
# dependencies.lock beside the project pins them and is committed; this is its cache. # dependencies.lock beside the project pins them and is committed; this is its cache.
managed_components/ managed_components/
# The colleague's standalone ESP32-C5 VRU station (its own repository, HAW GitLab
# urban-mobility-lab/microbu/microbu-esp32c5). Kept beside the project on the lab laptop, for
# reference and because the V2X2MAP bridge runs from its tools/, but not part of this repository:
# obu-firmware only needs its vanetza-idf, which is copied to obu-firmware/external/vanetza-idf.
/microbu-esp32c5/
# draw.io keeps a backup beside an open diagram.
*.drawio.bkp
+20 -1
View File
@@ -5,6 +5,25 @@ Engineering to-do list. The reviewer-facing open items live in
## Waiting on hardware ## Waiting on hardware
### On-air check of the V2X data logger (added 2026-10-02)
Needs the phone, a second station sending (the simulated car works) and, for the CiT One part, the OBU.
Settings > Developer > V2X data logger. 110 unit tests pass. ESP32-C5 path checked on the Pixel 9 Pro on
2026-10-02 (see the ticked item); the rest has not run on a device.
- [x] **ESP32-C5 (done 2026-10-02, simulated car + RSU on air):** 153 CAMs and 60 SPATEMs in 30 s, RSSI -56..-36 dBm,
phone GNSS on every row, `obu_*` NULL, file integrity ok. MAPEM 0 as expected (firmware drops port 2003). Original steps: start a log, let the simulated car's CAMs arrive, stop. Pull the `.db` from
`files/v2x_logs/` and check `messages`: `msg_type='CAM'`, `source='esp32'`, `rssi_dbm` filled,
`phone_lat/lon` filled, `obu_*` NULL, `payload` decodes with `asn1tools`.
- [ ] **CiT One:** same, `source='cit_one'`, `rssi_dbm` NULL, `obu_*` filled from `v2x/rx/obu_gnss`.
Expect both `channel='raw'` and `channel='processed'` rows for the same CAM.
- [ ] **Screen off for a few minutes** with the log running: rows keep arriving (foreground service).
- [ ] **MAPEM and VAM on the ESP32-C5 need a firmware change.** `gn_unwrap.c` accepts only BTP ports
2001, 2002, 2004; add 2003 (MAPEM) and 2018 (VAM) and reflash. Real MAPEMs are mostly over the
512-byte link limit, so most will still be counted as oversize drops. The app side already logs
any port. On the CiT One, MAPEM arrives via `v2x/rx/mapem` (subscribed now) or the processed
`v2x-uca/output/json/map` topic; the API lists no VAM topic, so no VAM rows are expected there.
### Signed-TX firmware (vanetza-idf port), VAM and BLE: first on-air checks (added 2026-09-23) ### Signed-TX firmware (vanetza-idf port), VAM and BLE: first on-air checks (added 2026-09-23)
obu-firmware is now a port of the colleague's `microbu-esp32c5` station (vanetza-idf, TS 103 097 obu-firmware is now a port of the colleague's `microbu-esp32c5` station (vanetza-idf, TS 103 097
@@ -75,7 +94,7 @@ resets the board, the phone is on the other port.
Signed CAMs from the pinger already show "signature verified" live. Switch to VAM with signing Signed CAMs from the pinger already show "signature verified" live. Switch to VAM with signing
on: the VAM must be decoded (cyclist, position) and show "signature verified" too. on: the VAM must be decoded (cyclist, position) and show "signature verified" too.
Confirmed by the user 2026-09-23: signed VAMs decode and verify. Confirmed by the user 2026-09-23: signed VAMs decode and verify.
- [x] **VAM.** Transmit VAM: BTP port 2018, psid 638; with `microbu-esp32c5/tools/wireshark/psid-vru.lua` - [x] **VAM.** Transmit VAM: BTP port 2018, psid 638; with `tools/wireshark/psid-vru.lua` from the colleague's microbu-esp32c5 repository
Wireshark decodes the VAM (stationType cyclist, bicyclist profile in every ~2 s VAM). Rate: Wireshark decodes the VAM (stationType cyclist, bicyclist profile in every ~2 s VAM). Rate:
≥1 per 5 s standing still, about one per GNSS fix while riding. ≥1 per 5 s standing still, about one per GNSS fix while riding.
Covered by the V2X2MAP check above (decoded VAM, psid 638); Wireshark not needed. Covered by the V2X2MAP check above (decoded VAM, psid 638); Wireshark not needed.
+6
View File
@@ -72,6 +72,12 @@
android:foregroundServiceType="location" android:foregroundServiceType="location"
android:exported="false" /> android:exported="false" />
<!-- Developer V2X data logger: keeps the process and GNSS alive while a log records. -->
<service
android:name=".service.V2xLogService"
android:foregroundServiceType="location"
android:exported="false" />
<provider <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider" android:authorities="${applicationId}.fileprovider"
@@ -56,10 +56,12 @@ import com.hawhamburg.micr0bu.ui.screens.SensorsSettingsScreen
import com.hawhamburg.micr0bu.ui.screens.SettingsScreen import com.hawhamburg.micr0bu.ui.screens.SettingsScreen
import com.hawhamburg.micr0bu.ui.screens.UnitsSettingsScreen import com.hawhamburg.micr0bu.ui.screens.UnitsSettingsScreen
import com.hawhamburg.micr0bu.ui.screens.UseCaseAlertsSettingsScreen import com.hawhamburg.micr0bu.ui.screens.UseCaseAlertsSettingsScreen
import com.hawhamburg.micr0bu.ui.screens.V2xLogSettingsScreen
import com.hawhamburg.micr0bu.ui.theme.MicrOBUTheme import com.hawhamburg.micr0bu.ui.theme.MicrOBUTheme
import com.hawhamburg.micr0bu.viewmodel.MqttViewModel import com.hawhamburg.micr0bu.viewmodel.MqttViewModel
import com.hawhamburg.micr0bu.viewmodel.SensorViewModel import com.hawhamburg.micr0bu.viewmodel.SensorViewModel
import com.hawhamburg.micr0bu.viewmodel.TripRecordingViewModel import com.hawhamburg.micr0bu.viewmodel.TripRecordingViewModel
import com.hawhamburg.micr0bu.viewmodel.V2xLogViewModel
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint @AndroidEntryPoint
@@ -68,6 +70,7 @@ class MainActivity : AppCompatActivity() {
private val viewModel: SensorViewModel by viewModels() private val viewModel: SensorViewModel by viewModels()
private val mqttViewModel: MqttViewModel by viewModels() private val mqttViewModel: MqttViewModel by viewModels()
private val tripViewModel: TripRecordingViewModel by viewModels() private val tripViewModel: TripRecordingViewModel by viewModels()
private val logViewModel: V2xLogViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen() installSplashScreen()
@@ -99,6 +102,8 @@ class MainActivity : AppCompatActivity() {
val denmEvents by mqttViewModel.denmEvents.collectAsState() val denmEvents by mqttViewModel.denmEvents.collectAsState()
val spatIntersections by mqttViewModel.spatIntersections.collectAsState() val spatIntersections by mqttViewModel.spatIntersections.collectAsState()
val ownCamPosition by mqttViewModel.ownCamPosition.collectAsState() val ownCamPosition by mqttViewModel.ownCamPosition.collectAsState()
val v2xLogState by logViewModel.state.collectAsState()
val v2xLogFiles by logViewModel.files.collectAsState()
MicrOBUTheme(darkTheme = state.darkTheme) { MicrOBUTheme(darkTheme = state.darkTheme) {
val view = LocalView.current val view = LocalView.current
@@ -365,6 +370,18 @@ class MainActivity : AppCompatActivity() {
state = state, state = state,
onDeveloperMode = viewModel::setDeveloperMode, onDeveloperMode = viewModel::setDeveloperMode,
onOpenSensorMonitor = { navController.navigate(Screen.Sensors.route) }, onOpenSensorMonitor = { navController.navigate(Screen.Sensors.route) },
onOpenV2xLogger = { navController.navigate(Screen.SettingsV2xLog.route) },
onBack = { navController.popBackStack() },
)
}
composable(Screen.SettingsV2xLog.route) {
V2xLogSettingsScreen(
state = v2xLogState,
files = v2xLogFiles,
obuHardware = obuHardware,
onStart = logViewModel::start,
onStop = logViewModel::stop,
onDelete = logViewModel::delete,
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack() },
) )
} }
@@ -9,6 +9,7 @@ import com.hawhamburg.micr0bu.data.mqtt.MqttRepository
import com.hawhamburg.micr0bu.data.mqtt.ObuHardwarePreferences import com.hawhamburg.micr0bu.data.mqtt.ObuHardwarePreferences
import com.hawhamburg.micr0bu.data.mqtt.RAW_CAM_TOPIC import com.hawhamburg.micr0bu.data.mqtt.RAW_CAM_TOPIC
import com.hawhamburg.micr0bu.data.mqtt.RAW_DENM_TOPIC import com.hawhamburg.micr0bu.data.mqtt.RAW_DENM_TOPIC
import com.hawhamburg.micr0bu.data.mqtt.RAW_MAPEM_TOPIC
import com.hawhamburg.micr0bu.data.mqtt.RAW_SPATEM_TOPIC import com.hawhamburg.micr0bu.data.mqtt.RAW_SPATEM_TOPIC
import com.hawhamburg.micr0bu.data.mqtt.RecvV2xMessage import com.hawhamburg.micr0bu.data.mqtt.RecvV2xMessage
import com.hawhamburg.micr0bu.data.mqtt.UseCaseAlertPreferences import com.hawhamburg.micr0bu.data.mqtt.UseCaseAlertPreferences
@@ -241,6 +242,8 @@ class CamUseCaseRepository @Inject constructor(
source = "mqtt", source = "mqtt",
) )
RAW_SPATEM_TOPIC -> handleSpatUper(envelope.payload, rssiDbm = null, source = "mqtt") RAW_SPATEM_TOPIC -> handleSpatUper(envelope.payload, rssiDbm = null, source = "mqtt")
// No MAPEM decoder yet; it is received for the V2X data logger only.
RAW_MAPEM_TOPIC -> Unit
else -> Log.w(TAG, "rawV2x: unexpected topic ${raw.topic}") else -> Log.w(TAG, "rawV2x: unexpected topic ${raw.topic}")
} }
} }
@@ -0,0 +1,235 @@
package com.hawhamburg.micr0bu.data.log
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteStatement
import com.hawhamburg.micr0bu.domain.log.ItsMessageKind
import java.io.File
/** One GNSS fix, as stored next to a logged message. Phone and OBU fixes share this shape. */
data class LogGnss(
val latitude: Double,
val longitude: Double,
val altitudeM: Double? = null,
val speedMps: Double? = null,
val headingDeg: Double? = null,
val accuracyM: Double? = null,
/** Epoch ms the fix was taken, so a stale fix shows as one instead of passing for current. */
val fixMs: Long,
)
/** One received V2X message, ready to be written. */
class LogMessage(
val tsMs: Long,
/** `esp32` or `cit_one`: which link delivered it. */
val source: String,
/** `raw` (UPER bytes in [payload]) or `processed` (the Use Case app's JSON in [payloadText]). */
val channel: String,
val kind: ItsMessageKind,
val messageId: Int?,
val btpPort: Int?,
val stationId: Long?,
val isOwn: Boolean?,
val rssiDbm: Int?,
val geoRadiusM: Int?,
val senderLat: Double?,
val senderLon: Double?,
val senderSpeedMps: Double?,
val senderHeadingDeg: Double?,
val summary: String?,
val payload: ByteArray?,
val payloadText: String?,
val phone: LogGnss?,
val obu: LogGnss?,
)
/** One GNSS fix for the `gnss` table: the receiver's own track, independent of any message. */
class LogGnssSample(val tsMs: Long, val source: String, val fix: LogGnss)
/**
* A V2X log: one self-contained SQLite file. Not Room, on purpose: a log is a file the developer
* pulls off the phone and opens in any SQLite tool or pandas, so it carries its own schema and
* needs no migration path or app-side entity classes. The app never reads one back.
*
* Not thread-safe; [V2xLogger] drives it from one writer coroutine. Each [writeBatch] is one
* transaction on the default rollback journal, so a killed process leaves a file that is
* complete up to the last batch, and a single file with no `-wal` sidecar to forget when sharing.
*
* Columns that do not apply to a row are NULL rather than invented: `rssi_dbm` is NULL for the
* CiT One, `obu_*` is NULL when no OBU position is available (always on the ESP32-C5, which has no
* GNSS), and the `sender_*` fields are NULL for messages the app has no decoder for.
*/
class V2xLogWriter(val file: File, meta: Map<String, String>) {
private val db: SQLiteDatabase = SQLiteDatabase.openOrCreateDatabase(file, null)
private val insertMessage: SQLiteStatement
private val insertGnss: SQLiteStatement
init {
db.execSQL(
"""
CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT)
""".trimIndent()
)
db.execSQL(
"""
CREATE TABLE messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts_ms INTEGER NOT NULL,
source TEXT NOT NULL,
channel TEXT NOT NULL,
msg_type TEXT NOT NULL,
message_id INTEGER,
btp_port INTEGER,
station_id INTEGER,
is_own INTEGER,
rssi_dbm INTEGER,
geo_radius_m INTEGER,
sender_lat REAL,
sender_lon REAL,
sender_speed_mps REAL,
sender_heading_deg REAL,
summary TEXT,
payload BLOB,
payload_text TEXT,
phone_lat REAL,
phone_lon REAL,
phone_alt_m REAL,
phone_speed_mps REAL,
phone_heading_deg REAL,
phone_accuracy_m REAL,
phone_fix_ms INTEGER,
obu_lat REAL,
obu_lon REAL,
obu_alt_m REAL,
obu_speed_mps REAL,
obu_heading_deg REAL,
obu_fix_ms INTEGER
)
""".trimIndent()
)
db.execSQL("CREATE INDEX idx_messages_ts ON messages(ts_ms)")
db.execSQL("CREATE INDEX idx_messages_type ON messages(msg_type)")
db.execSQL(
"""
CREATE TABLE gnss (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts_ms INTEGER NOT NULL,
source TEXT NOT NULL,
lat REAL NOT NULL,
lon REAL NOT NULL,
alt_m REAL,
speed_mps REAL,
heading_deg REAL,
accuracy_m REAL,
fix_ms INTEGER NOT NULL
)
""".trimIndent()
)
db.execSQL("CREATE INDEX idx_gnss_ts ON gnss(ts_ms)")
// Same rows, with the timestamp also as readable UTC, for anyone browsing in a SQLite GUI.
db.execSQL(
"CREATE VIEW messages_v AS SELECT *, " +
"strftime('%Y-%m-%d %H:%M:%f', ts_ms / 1000.0, 'unixepoch') AS ts_utc FROM messages"
)
db.execSQL("PRAGMA user_version = $SCHEMA_VERSION")
for ((k, v) in meta) setMeta(k, v)
setMeta("schema_version", SCHEMA_VERSION.toString())
insertMessage = db.compileStatement(
"INSERT INTO messages (ts_ms, source, channel, msg_type, message_id, btp_port, station_id, " +
"is_own, rssi_dbm, geo_radius_m, sender_lat, sender_lon, sender_speed_mps, " +
"sender_heading_deg, summary, payload, payload_text, " +
"phone_lat, phone_lon, phone_alt_m, phone_speed_mps, phone_heading_deg, " +
"phone_accuracy_m, phone_fix_ms, obu_lat, obu_lon, obu_alt_m, obu_speed_mps, " +
"obu_heading_deg, obu_fix_ms) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)"
)
insertGnss = db.compileStatement(
"INSERT INTO gnss (ts_ms, source, lat, lon, alt_m, speed_mps, heading_deg, accuracy_m, fix_ms) " +
"VALUES (?,?,?,?,?,?,?,?,?)"
)
}
fun setMeta(key: String, value: String) {
db.execSQL("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)", arrayOf(key, value))
}
/** Writes [messages] and [gnss] in one transaction. */
fun writeBatch(messages: List<LogMessage>, gnss: List<LogGnssSample>) {
if (messages.isEmpty() && gnss.isEmpty()) return
db.beginTransaction()
try {
for (m in messages) bindAndRun(m)
for (g in gnss) bindAndRun(g)
db.setTransactionSuccessful()
} finally {
db.endTransaction()
}
}
fun close() {
runCatching { insertMessage.close() }
runCatching { insertGnss.close() }
runCatching { db.close() }
}
private fun bindAndRun(m: LogMessage) {
val s = insertMessage
s.clearBindings()
s.bind(1, m.tsMs)
s.bind(2, m.source)
s.bind(3, m.channel)
s.bind(4, m.kind.label)
s.bind(5, m.messageId?.toLong())
s.bind(6, m.btpPort?.toLong())
s.bind(7, m.stationId)
s.bind(8, m.isOwn?.let { if (it) 1L else 0L })
s.bind(9, m.rssiDbm?.toLong())
s.bind(10, m.geoRadiusM?.toLong())
s.bind(11, m.senderLat)
s.bind(12, m.senderLon)
s.bind(13, m.senderSpeedMps)
s.bind(14, m.senderHeadingDeg)
s.bind(15, m.summary)
s.bind(16, m.payload)
s.bind(17, m.payloadText)
s.bind(18, m.phone?.latitude)
s.bind(19, m.phone?.longitude)
s.bind(20, m.phone?.altitudeM)
s.bind(21, m.phone?.speedMps)
s.bind(22, m.phone?.headingDeg)
s.bind(23, m.phone?.accuracyM)
s.bind(24, m.phone?.fixMs)
s.bind(25, m.obu?.latitude)
s.bind(26, m.obu?.longitude)
s.bind(27, m.obu?.altitudeM)
s.bind(28, m.obu?.speedMps)
s.bind(29, m.obu?.headingDeg)
s.bind(30, m.obu?.fixMs)
s.executeInsert()
}
private fun bindAndRun(g: LogGnssSample) {
val s = insertGnss
s.clearBindings()
s.bind(1, g.tsMs)
s.bind(2, g.source)
s.bind(3, g.fix.latitude)
s.bind(4, g.fix.longitude)
s.bind(5, g.fix.altitudeM)
s.bind(6, g.fix.speedMps)
s.bind(7, g.fix.headingDeg)
s.bind(8, g.fix.accuracyM)
s.bind(9, g.fix.fixMs)
s.executeInsert()
}
// clearBindings() leaves every parameter NULL, so a null value is simply not bound.
private fun SQLiteStatement.bind(i: Int, v: Long?) { if (v != null) bindLong(i, v) }
private fun SQLiteStatement.bind(i: Int, v: Double?) { if (v != null) bindDouble(i, v) }
private fun SQLiteStatement.bind(i: Int, v: String?) { if (v != null) bindString(i, v) }
private fun SQLiteStatement.bind(i: Int, v: ByteArray?) { if (v != null) bindBlob(i, v) }
companion object {
const val SCHEMA_VERSION = 1
}
}
@@ -0,0 +1,413 @@
package com.hawhamburg.micr0bu.data.log
import android.content.Context
import android.os.Build
import android.util.Log
import com.hawhamburg.micr0bu.data.GnssReading
import com.hawhamburg.micr0bu.data.SensorRepository
import com.hawhamburg.micr0bu.data.cam.CamUseCaseRepository
import com.hawhamburg.micr0bu.data.mqtt.MessageDirection
import com.hawhamburg.micr0bu.data.mqtt.MqttRepository
import com.hawhamburg.micr0bu.data.mqtt.RAW_CAM_TOPIC
import com.hawhamburg.micr0bu.data.mqtt.RAW_DENM_TOPIC
import com.hawhamburg.micr0bu.data.mqtt.RAW_MAPEM_TOPIC
import com.hawhamburg.micr0bu.data.mqtt.RAW_SPATEM_TOPIC
import com.hawhamburg.micr0bu.data.mqtt.RecvV2xMessage
import com.hawhamburg.micr0bu.data.transport.Esp32Link
import com.hawhamburg.micr0bu.data.transport.Esp32LinkState
import com.hawhamburg.micr0bu.data.transport.ObuHardware
import com.hawhamburg.micr0bu.data.transport.SerialFrameType
import com.hawhamburg.micr0bu.data.transport.V2xRxFrame
import com.hawhamburg.micr0bu.domain.asn1.DenmUperCodec
import com.hawhamburg.micr0bu.domain.asn1.RealAsn1UperCodec
import com.hawhamburg.micr0bu.domain.asn1.SpatemUperCodec
import com.hawhamburg.micr0bu.domain.cam.ObuGnssParser
import com.hawhamburg.micr0bu.domain.log.ItsMessageKind
import com.hawhamburg.micr0bu.domain.log.classifyItsPdu
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton
private const val TAG = "V2xLogger"
private const val LOG_DIR = "v2x_logs"
private const val PROCESSED_PREFIX = "v2x-uca/output/json/"
private const val OBU_GNSS_TOPIC = "v2x/rx/obu_gnss"
private const val SOURCE_ESP32 = "esp32"
private const val SOURCE_CIT_ONE = "cit_one"
private const val SOURCE_PHONE = "phone"
private const val SOURCE_OBU = "obu"
/** How long the writer collects rows before it commits them. */
private const val FLUSH_INTERVAL_MS = 250L
/** What the logging screen shows about the log in progress. */
data class V2xLogState(
val active: Boolean = false,
val fileName: String? = null,
val startedMs: Long = 0L,
/** Rows written so far, by kind. Kinds never heard are absent. */
val counts: Map<ItsMessageKind, Int> = emptyMap(),
val total: Int = 0,
val fileBytes: Long = 0L,
/** Age of the newest phone / OBU fix at the time of the last update; null if none yet. */
val phoneFixAgeMs: Long? = null,
val obuFixAgeMs: Long? = null,
/** Set when the file could not be created or written. */
val error: String? = null,
)
/** A log file on the device. */
data class V2xLogFile(val file: File, val sizeBytes: Long, val modifiedMs: Long, val active: Boolean) {
val name: String get() = file.name
}
/**
* Developer-tool logger: records every V2X message the phone receives, with a timestamp, the
* phone's GNSS fix, the OBU's GNSS fix where there is one, and RSSI where the hardware reports it,
* into one SQLite file per run (see [V2xLogWriter] for the schema).
*
* Works on both hardware paths and listens to whichever is selected:
* - **ESP32-C5**: the V2X_RX frames from [Esp32Link], with the firmware's RSSI. No OBU position,
* since that board has no GNSS.
* - **CiT One**: the raw `v2x/rx` protobuf topics over MQTT, and the Use Case app's processed
* `v2x-uca/output/json` topics as a second channel (the only source of MAPEM when the OBU does
* not publish `v2x/rx/mapem`). The OBU's position comes from `v2x/rx/obu_gnss`. The API carries
* no RSSI, so that column stays NULL.
*
* Every message is stored, including kinds the app cannot decode (MAPEM, VAM): the UPER bytes go
* in the file untouched, and where a decoder exists a few fields are decoded alongside for
* convenience. A message is never skipped because decoding failed.
*
* What it can hear is bounded by the hardware, not by this class: the current ESP32-C5 firmware
* forwards only BTP ports 2001, 2002 and 2004 (see `gn_unwrap.c`), so MAPEM and VAM appear in an
* ESP32 log only once the firmware accepts ports 2003 and 2018.
*
* A singleton, like the repositories it listens to. [com.hawhamburg.micr0bu.service.V2xLogService]
* keeps the process alive and the CPU awake while a log runs; this class does the work.
*/
@Singleton
class V2xLogger @Inject constructor(
@ApplicationContext private val context: Context,
private val mqttRepository: MqttRepository,
private val esp32Link: Esp32Link,
private val camUseCaseRepository: CamUseCaseRepository,
private val camCodec: RealAsn1UperCodec,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val _state = MutableStateFlow(V2xLogState())
val state: StateFlow<V2xLogState> = _state.asStateFlow()
private val _files = MutableStateFlow<List<V2xLogFile>>(emptyList())
/** Logs on the device, newest first. Call [refreshFiles] after anything that changes the folder. */
val files: StateFlow<List<V2xLogFile>> = _files.asStateFlow()
private val logDir: File get() = File(context.filesDir, LOG_DIR).apply { mkdirs() }
private var session: Job? = null
private var rows = Channel<Any>(Channel.UNLIMITED)
@Volatile private var phoneFix: LogGnss? = null
@Volatile private var obuFix: LogGnss? = null
fun start() {
if (session?.isActive == true) return
val startedMs = System.currentTimeMillis()
val hardware = mqttRepository.obuHardware.value
val file = File(logDir, fileNameFor(startedMs, hardware))
val writer = try {
V2xLogWriter(file, metaFor(startedMs, hardware))
} catch (e: Exception) {
Log.e(TAG, "cannot create ${file.name}", e)
file.delete()
_state.value = V2xLogState(error = e.message ?: e.javaClass.simpleName)
return
}
phoneFix = null
obuFix = null
rows = Channel(Channel.UNLIMITED)
_state.value = V2xLogState(active = true, fileName = file.name, startedMs = startedMs)
refreshFiles()
session = scope.launch {
val sources = listOf(
launch { collectEsp32() },
launch { collectCitOneRaw() },
launch { collectCitOneMessages() },
launch { collectPhoneGnss() },
)
try {
runWriter(writer, file)
} finally {
sources.forEach { it.cancel() }
runCatching { writer.setMeta("ended_ms", System.currentTimeMillis().toString()) }
writer.close()
}
}
}
/** Stops the log and waits for the last rows to reach the file. */
suspend fun stop() {
val running = session ?: return
// Closing the channel lets the writer drain everything already queued before it ends.
rows.close()
withTimeoutOrNull(5_000L) { running.join() } ?: running.cancelAndJoin()
session = null
_state.update { it.copy(active = false) }
refreshFiles()
}
fun delete(log: V2xLogFile): Boolean {
if (log.active) return false
val ok = log.file.delete()
refreshFiles()
return ok
}
fun refreshFiles() {
val activeName = _state.value.fileName.takeIf { _state.value.active }
_files.value = logDir.listFiles { f -> f.isFile && f.extension == "db" }
.orEmpty()
.map { V2xLogFile(it, it.length(), it.lastModified(), it.name == activeName) }
.sortedByDescending { it.modifiedMs }
}
// ── Sources ───────────────────────────────────────────────────────────────
private suspend fun collectEsp32() {
esp32Link.incomingFrames.collect { frame ->
if (frame.type != SerialFrameType.V2X_RX) return@collect
if (mqttRepository.obuHardware.value != ObuHardware.ESP32_C5) return@collect
if (esp32Link.state.value != Esp32LinkState.CONNECTED) return@collect
val v2x = V2xRxFrame.parse(frame.payload) ?: return@collect
enqueueRaw(
source = SOURCE_ESP32, uper = v2x.uper, btpPort = v2x.btpPort,
rssiDbm = v2x.rssiDbm, geoRadiusM = v2x.geoArea?.radiusMeters,
)
}
}
private suspend fun collectCitOneRaw() {
mqttRepository.rawV2x.collect { raw ->
if (mqttRepository.obuHardware.value != ObuHardware.CIT_ONE) return@collect
val envelope = RecvV2xMessage.parse(raw.bytes)
if (envelope == null) {
Log.w(TAG, "unparseable RecvV2XMessage on ${raw.topic}, ${raw.bytes.size} bytes")
return@collect
}
enqueueRaw(
source = SOURCE_CIT_ONE, uper = envelope.payload,
// The envelope's own port when it has one; the topic says the same thing otherwise.
btpPort = envelope.destinationPort ?: portOfRawTopic(raw.topic),
rssiDbm = null, geoRadiusM = envelope.destAreaRadiusM,
tsMs = raw.timestamp,
)
}
}
private suspend fun collectCitOneMessages() {
mqttRepository.messages.collect { msg ->
if (msg.direction != MessageDirection.RX) return@collect
if (mqttRepository.obuHardware.value != ObuHardware.CIT_ONE) return@collect
if (msg.topic == OBU_GNSS_TOPIC) {
val ego = ObuGnssParser.parseEgo(msg.payload, timestamp = msg.timestamp) ?: return@collect
val fix = LogGnss(
latitude = ego.latitude, longitude = ego.longitude,
speedMps = ego.speedMps, headingDeg = ego.headingDeg, fixMs = msg.timestamp,
)
obuFix = fix
rows.trySend(LogGnssSample(msg.timestamp, SOURCE_OBU, fix))
return@collect
}
if (!msg.topic.startsWith(PROCESSED_PREFIX)) return@collect
val kind = ItsMessageKind.fromProcessedTopicName(msg.topic.removePrefix(PROCESSED_PREFIX))
?: return@collect
rows.trySend(
LogMessage(
tsMs = msg.timestamp, source = SOURCE_CIT_ONE, channel = "processed", kind = kind,
messageId = null, btpPort = null, stationId = null, isOwn = null, rssiDbm = null,
geoRadiusM = null, senderLat = null, senderLon = null, senderSpeedMps = null,
senderHeadingDeg = null, summary = null, payload = null, payloadText = msg.payload,
phone = phoneFix, obu = obuFix,
)
)
}
}
/**
* Phone GNSS, re-subscribed in a loop for the same reason [CamUseCaseRepository] does: a log
* can be started before the location permission is granted, and a single attempt would then
* leave every row without a phone position for the whole run.
*/
private suspend fun collectPhoneGnss() {
val sensors = SensorRepository(context)
while (true) {
runCatching {
sensors.gnssFlow().collect { reading ->
val fix = reading.toLogGnss()
phoneFix = fix
rows.trySend(LogGnssSample(System.currentTimeMillis(), SOURCE_PHONE, fix))
}
}
delay(5_000L)
}
}
// ── Rows ──────────────────────────────────────────────────────────────────
private fun enqueueRaw(
source: String,
uper: ByteArray,
btpPort: Int?,
rssiDbm: Int?,
geoRadiusM: Int?,
tsMs: Long = System.currentTimeMillis(),
) {
val cls = classifyItsPdu(uper, btpPort)
val decoded = decode(cls.kind, uper, rssiDbm, geoRadiusM, tsMs)
rows.trySend(
LogMessage(
tsMs = tsMs, source = source, channel = "raw", kind = cls.kind,
messageId = cls.messageId, btpPort = btpPort, stationId = cls.stationId,
isOwn = cls.stationId?.let { camUseCaseRepository.isOwnStationId(it) },
rssiDbm = rssiDbm, geoRadiusM = geoRadiusM,
senderLat = decoded.lat, senderLon = decoded.lon,
senderSpeedMps = decoded.speedMps, senderHeadingDeg = decoded.headingDeg,
summary = decoded.summary, payload = uper, payloadText = null,
phone = phoneFix, obu = obuFix,
)
)
}
private class Decoded(
val lat: Double? = null, val lon: Double? = null,
val speedMps: Double? = null, val headingDeg: Double? = null,
val summary: String? = null,
)
/** Decoded convenience fields for the kinds the app can decode. A failure just leaves them NULL. */
private fun decode(kind: ItsMessageKind, uper: ByteArray, rssiDbm: Int?, geoRadiusM: Int?, tsMs: Long): Decoded =
runCatching {
when (kind) {
ItsMessageKind.CAM -> camCodec.decodeCam(uper, tsMs)?.let {
Decoded(it.latitude, it.longitude, it.speedMps, it.headingDeg, "stationType=${it.stationType}")
}
ItsMessageKind.DENM -> DenmUperCodec.decode(uper, tsMs, rssiDbm, geoRadiusM)?.let {
Decoded(
it.latitude, it.longitude,
summary = "seq=${it.sequenceNumber} cause=${it.causeCode}/${it.subCauseCode}" +
if (it.isTermination) " termination" else "",
)
}
ItsMessageKind.SPATEM -> SpatemUperCodec.decode(uper, tsMs, rssiDbm)?.let { s ->
Decoded(summary = "intersections=" + s.intersections.joinToString(",") { it.key } +
" movements=" + s.intersections.sumOf { it.movements.size })
}
else -> null
}
}.getOrNull() ?: Decoded()
// ── Writer ────────────────────────────────────────────────────────────────
/** Drains [rows] into [writer] in short batches, until the channel is closed and empty. */
private suspend fun runWriter(writer: V2xLogWriter, file: File) {
val counts = HashMap<ItsMessageKind, Int>()
var total = 0
var failure: String? = null
val messages = ArrayList<LogMessage>()
val gnss = ArrayList<LogGnssSample>()
while (true) {
// Block for the first row of a batch, give more a moment to arrive, then take
// whatever is queued. No timeout around receive: a cancelled receive can lose the
// element it was handing over, which a logger must not do.
val first = rows.receiveCatching().getOrNull() ?: break
add(first, messages, gnss)
delay(FLUSH_INTERVAL_MS)
while (true) {
val next = rows.tryReceive().getOrNull() ?: break
add(next, messages, gnss)
}
try {
writer.writeBatch(messages, gnss)
} catch (e: Exception) {
// Disk full or the file went away. Say so on screen rather than logging into a void.
Log.e(TAG, "write failed", e)
failure = e.message ?: e.javaClass.simpleName
rows.close()
break
}
for (m in messages) counts.merge(m.kind, 1, Int::plus)
total += messages.size
messages.clear(); gnss.clear()
val now = System.currentTimeMillis()
_state.update {
it.copy(
counts = counts.toMap(), total = total, fileBytes = file.length(),
phoneFixAgeMs = phoneFix?.let { f -> now - f.fixMs },
obuFixAgeMs = obuFix?.let { f -> now - f.fixMs },
)
}
}
_state.update { it.copy(fileBytes = file.length(), error = failure ?: it.error, active = it.active && failure == null) }
}
private fun add(row: Any, messages: MutableList<LogMessage>, gnss: MutableList<LogGnssSample>) {
when (row) {
is LogMessage -> messages += row
is LogGnssSample -> gnss += row
}
}
// ── Helpers ───────────────────────────────────────────────────────────────
private fun portOfRawTopic(topic: String): Int? = when (topic) {
RAW_CAM_TOPIC -> 2001
RAW_DENM_TOPIC -> 2002
RAW_MAPEM_TOPIC -> 2003
RAW_SPATEM_TOPIC -> 2004
else -> null
}
private fun GnssReading.toLogGnss() = LogGnss(
latitude = latitude, longitude = longitude, altitudeM = altitude,
speedMps = speedMs.toDouble(), headingDeg = bearingDeg.toDouble(),
accuracyM = accuracyM.toDouble(), fixMs = timestamp,
)
private fun fileNameFor(startedMs: Long, hardware: ObuHardware): String {
val stamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date(startedMs))
val mode = if (hardware == ObuHardware.ESP32_C5) "esp32" else "citone"
return "v2x_log_${stamp}_$mode.db"
}
private fun metaFor(startedMs: Long, hardware: ObuHardware) = mapOf(
"started_ms" to startedMs.toString(),
"hardware" to if (hardware == ObuHardware.ESP32_C5) "esp32_c5" else "cit_one",
"phone" to "${Build.MANUFACTURER} ${Build.MODEL}",
"android_sdk" to Build.VERSION.SDK_INT.toString(),
"app_version" to runCatching {
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "?"
}.getOrDefault("?"),
"note" to "ts_ms is phone wall-clock epoch ms. payload is the ITS PDU as UPER bytes. " +
"rssi_dbm is NULL on the CiT One; obu_* is NULL on the ESP32-C5.",
)
}
@@ -48,6 +48,9 @@ private val SUBSCRIBED_TOPICS = listOf(
RAW_CAM_TOPIC, RAW_CAM_TOPIC,
RAW_DENM_TOPIC, RAW_DENM_TOPIC,
RAW_SPATEM_TOPIC, RAW_SPATEM_TOPIC,
// Nothing decodes MAPEM yet; subscribed so the V2X data logger records it. An OBU product
// configuration that does not publish it simply never delivers anything here.
RAW_MAPEM_TOPIC,
// Kept subscribed as a fallback for an OBU whose product configuration does not publish the // Kept subscribed as a fallback for an OBU whose product configuration does not publish the
// raw topics, and because the Use Case app is still the only source of its own alert output. // raw topics, and because the Use Case app is still the only source of its own alert output.
"v2x-uca/output/json/cam", "v2x-uca/output/json/cam",
@@ -61,8 +64,9 @@ private val SUBSCRIBED_TOPICS = listOf(
const val RAW_CAM_TOPIC = "v2x/rx/cam" const val RAW_CAM_TOPIC = "v2x/rx/cam"
const val RAW_DENM_TOPIC = "v2x/rx/denm" const val RAW_DENM_TOPIC = "v2x/rx/denm"
const val RAW_SPATEM_TOPIC = "v2x/rx/spatem" const val RAW_SPATEM_TOPIC = "v2x/rx/spatem"
const val RAW_MAPEM_TOPIC = "v2x/rx/mapem"
private val RAW_V2X_TOPICS = setOf(RAW_CAM_TOPIC, RAW_DENM_TOPIC, RAW_SPATEM_TOPIC) private val RAW_V2X_TOPICS = setOf(RAW_CAM_TOPIC, RAW_DENM_TOPIC, RAW_SPATEM_TOPIC, RAW_MAPEM_TOPIC)
/** /**
* A message straight off a `v2x/rx` topic, before the protobuf envelope is opened. * A message straight off a `v2x/rx` topic, before the protobuf envelope is opened.
@@ -0,0 +1,79 @@
package com.hawhamburg.micr0bu.domain.log
/**
* Which ITS message a received PDU is, for the V2X data logger.
*
* The logger records every message it hears, including ones the app has no decoder for (MAPEM,
* VAM), so classification cannot depend on a codec. It reads the two things every ETSI ITS PDU
* has in common instead: the `ItsPduHeader` at the start of the UPER bytes
* (`protocolVersion` 8 bits, `messageID` 8 bits, `stationID` 32 bits, none of it extensible, so
* it sits byte-aligned at the front), and the BTP destination port the transport reports.
*
* messageID is the primary source. The port is the fallback for a payload too short to carry a
* header. Mind the crossover: SPATEM is messageID 4 on port 2004 but MAPEM is messageID 5 on port
* 2003; the two numbering schemes are unrelated.
*/
enum class ItsMessageKind(val label: String, val messageId: Int, val btpPort: Int?) {
DENM("DENM", 1, 2002),
CAM("CAM", 2, 2001),
SPATEM("SPATEM", 4, 2004),
MAPEM("MAPEM", 5, 2003),
CPM("CPM", 14, null),
VAM("VAM", 16, 2018),
OTHER("OTHER", -1, null);
companion object {
/** The five kinds the logger counts and shows on screen. */
val TRACKED = listOf(CAM, DENM, SPATEM, MAPEM, VAM)
fun fromMessageId(id: Int): ItsMessageKind? = entries.firstOrNull { it.messageId == id && it != OTHER }
fun fromBtpPort(port: Int): ItsMessageKind? = entries.firstOrNull { it.btpPort == port }
/** Matches the `v2x-uca/output/json/<name>` suffix, for the CiT One's processed topics. */
fun fromProcessedTopicName(name: String): ItsMessageKind? = when (name) {
"cam" -> CAM
"denm" -> DENM
"spat" -> SPATEM
"map" -> MAPEM
"cpm" -> CPM
else -> null
}
}
}
/** The fixed 6-byte header at the start of every ETSI ITS PDU. */
data class ItsPduHeader(val protocolVersion: Int, val messageId: Int, val stationId: Long) {
companion object {
const val SIZE = 6
/** Null when [uper] is too short to hold a header. */
fun parse(uper: ByteArray): ItsPduHeader? {
if (uper.size < SIZE) return null
var station = 0L
for (i in 2 until SIZE) station = (station shl 8) or (uper[i].toLong() and 0xFF)
return ItsPduHeader(
protocolVersion = uper[0].toInt() and 0xFF,
messageId = uper[1].toInt() and 0xFF,
stationId = station,
)
}
}
}
/** What [classifyItsPdu] worked out about one received PDU. */
data class ItsClassification(val kind: ItsMessageKind, val messageId: Int?, val stationId: Long?)
/**
* Classifies [uper], preferring its header's messageID over [btpPort].
*
* A message whose id and port disagree is classified by the id, since the id is what the sender
* actually put in the PDU; the port is only where it happened to arrive.
*/
fun classifyItsPdu(uper: ByteArray, btpPort: Int?): ItsClassification {
val header = ItsPduHeader.parse(uper)
val kind = header?.let { ItsMessageKind.fromMessageId(it.messageId) }
?: btpPort?.let { ItsMessageKind.fromBtpPort(it) }
?: ItsMessageKind.OTHER
return ItsClassification(kind, header?.messageId, header?.stationId)
}
@@ -0,0 +1,135 @@
package com.hawhamburg.micr0bu.service
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.IBinder
import android.os.PowerManager
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import com.hawhamburg.micr0bu.MainActivity
import com.hawhamburg.micr0bu.R
import com.hawhamburg.micr0bu.data.log.V2xLogger
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
/**
* Keeps the process alive, and the CPU awake, while [V2xLogger] records.
*
* The logger does the work; this exists only because a log is most useful on a drive, with the
* screen off, and a process with no foreground service is one the system may kill and whose GNSS
* callbacks it throttles. Same shape as [TripRecordingService], which does this for trips.
*
* Stops itself when the logger stops, including when the logger stops on a write failure.
*/
@AndroidEntryPoint
class V2xLogService : Service() {
@Inject lateinit var logger: V2xLogger
private val job = SupervisorJob()
private val scope = CoroutineScope(Dispatchers.Default + job)
private var watchJob: Job? = null
private var wakeLock: PowerManager.WakeLock? = null
override fun onCreate() {
super.onCreate()
(getSystemService(NOTIFICATION_SERVICE) as NotificationManager).createNotificationChannel(
NotificationChannel(CHANNEL_ID, "V2X logging", NotificationManager.IMPORTANCE_LOW).apply {
description = "Shown while the developer V2X data logger is recording"
}
)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_START -> begin()
ACTION_STOP -> end()
}
return START_NOT_STICKY
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onDestroy() {
wakeLock?.let { if (it.isHeld) it.release() }
job.cancel()
super.onDestroy()
}
private fun begin() {
// Foreground first: Android allows five seconds from startForegroundService.
startForeground(NOTIFICATION_ID, notification(0), ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION)
wakeLock = (getSystemService(POWER_SERVICE) as PowerManager)
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "micr0bu:V2xLog")
.also { it.acquire() }
logger.start()
if (!logger.state.value.active) { // the file could not be created; the screen shows why
end()
return
}
watchJob?.cancel()
watchJob = scope.launch {
logger.state.collect { state ->
if (!state.active) {
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
} else {
(getSystemService(NOTIFICATION_SERVICE) as NotificationManager)
.notify(NOTIFICATION_ID, notification(state.total))
}
}
}
}
private fun end() {
scope.launch {
logger.stop()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
}
private fun notification(messages: Int) =
NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("Logging V2X messages")
.setContentText("$messages messages recorded")
.setSmallIcon(R.mipmap.ic_launcher_foreground)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setContentIntent(
PendingIntent.getActivity(
this, 0,
Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
},
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
)
.build()
companion object {
const val ACTION_START = "com.hawhamburg.micr0bu.V2X_LOG_START"
const val ACTION_STOP = "com.hawhamburg.micr0bu.V2X_LOG_STOP"
private const val NOTIFICATION_ID = 9002
private const val CHANNEL_ID = "v2x_logging"
fun start(context: Context) = ContextCompat.startForegroundService(
context, Intent(context, V2xLogService::class.java).setAction(ACTION_START)
)
fun stop(context: Context) {
context.startService(Intent(context, V2xLogService::class.java).setAction(ACTION_STOP))
}
}
}
@@ -51,6 +51,7 @@ sealed class Screen(val route: String, val labelRes: Int) {
data object SettingsMqtt : Screen("settings/mqtt", R.string.settings_mqtt_section) data object SettingsMqtt : Screen("settings/mqtt", R.string.settings_mqtt_section)
data object SettingsUseCaseAlerts : Screen("settings/usecases", R.string.settings_usecase_alerts) data object SettingsUseCaseAlerts : Screen("settings/usecases", R.string.settings_usecase_alerts)
data object SettingsDeveloper : Screen("settings/developer", R.string.settings_developer) data object SettingsDeveloper : Screen("settings/developer", R.string.settings_developer)
data object SettingsV2xLog : Screen("settings/developer/v2x_log", R.string.v2xlog_title)
data object SettingsAbout : Screen("settings/about", R.string.settings_about) data object SettingsAbout : Screen("settings/about", R.string.settings_about)
} }
@@ -137,7 +137,7 @@ private fun MenuRow(label: String, onClick: () -> Unit) {
// ── Sub-screen shell ──────────────────────────────────────────────────────── // ── Sub-screen shell ────────────────────────────────────────────────────────
@Composable @Composable
private fun SubScreen(title: String, onBack: () -> Unit, content: @Composable () -> Unit) { internal fun SubScreen(title: String, onBack: () -> Unit, content: @Composable () -> Unit) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -448,6 +448,7 @@ fun DeveloperSettingsScreen(
state: SensorUiState, state: SensorUiState,
onDeveloperMode: (Boolean) -> Unit, onDeveloperMode: (Boolean) -> Unit,
onOpenSensorMonitor: () -> Unit, onOpenSensorMonitor: () -> Unit,
onOpenV2xLogger: () -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
) { ) {
SubScreen(stringResource(R.string.settings_developer), onBack) { SubScreen(stringResource(R.string.settings_developer), onBack) {
@@ -457,6 +458,9 @@ fun DeveloperSettingsScreen(
// Sensor Monitor lives here rather than in the bottom nav: a live phone-sensor feed is // Sensor Monitor lives here rather than in the bottom nav: a live phone-sensor feed is
// a bench-diagnosis tool, not something a rider needs mid-ride. // a bench-diagnosis tool, not something a rider needs mid-ride.
MenuRow(stringResource(R.string.sensor_monitor_title), onOpenSensorMonitor) MenuRow(stringResource(R.string.sensor_monitor_title), onOpenSensorMonitor)
Divider()
// Records every received V2X message to a database file, on either OBU hardware.
MenuRow(stringResource(R.string.v2xlog_title), onOpenV2xLogger)
if (state.developerMode) { if (state.developerMode) {
Divider() Divider()
DisabledRow(stringResource(R.string.settings_wifi), stringResource(R.string.settings_wifi_val)) DisabledRow(stringResource(R.string.settings_wifi), stringResource(R.string.settings_wifi_val))
@@ -481,7 +485,7 @@ fun AboutSettingsScreen(onBack: () -> Unit) {
// ── Internal components ───────────────────────────────────────────────────── // ── Internal components ─────────────────────────────────────────────────────
@Composable @Composable
private fun SectionCard(content: @Composable () -> Unit) { internal fun SectionCard(content: @Composable () -> Unit) {
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
@@ -699,7 +703,7 @@ private fun TwoWayChoice(
private fun RowDivider() = HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.4f)) private fun RowDivider() = HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.4f))
@Composable @Composable
private fun Divider() = HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.4f)) internal fun Divider() = HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.4f))
@Composable @Composable
private fun SettingToggleRow( private fun SettingToggleRow(
@@ -723,7 +727,7 @@ private fun SettingToggleRow(
} }
@Composable @Composable
private fun DisabledRow(label: String, value: String) { internal fun DisabledRow(label: String, value: String) {
Row( Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp), modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
@@ -0,0 +1,247 @@
package com.hawhamburg.micr0bu.ui.screens
import android.content.Context
import android.content.Intent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.FiberManualRecord
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.core.content.FileProvider
import com.hawhamburg.micr0bu.R
import com.hawhamburg.micr0bu.data.log.V2xLogFile
import com.hawhamburg.micr0bu.data.log.V2xLogState
import com.hawhamburg.micr0bu.data.transport.ObuHardware
import com.hawhamburg.micr0bu.domain.log.ItsMessageKind
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
private val modifiedFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
/**
* Settings > Developer > V2X data logger.
*
* Start and stop one log at a time, watch what it is catching, and share or delete the finished
* files. What is recorded, and from where, is described on [com.hawhamburg.micr0bu.data.log.V2xLogger].
*/
@Composable
fun V2xLogSettingsScreen(
state: V2xLogState,
files: List<V2xLogFile>,
obuHardware: ObuHardware,
onStart: () -> Unit,
onStop: () -> Unit,
onDelete: (V2xLogFile) -> Unit,
onBack: () -> Unit,
) {
val context = LocalContext.current
var pendingDelete by remember { mutableStateOf<V2xLogFile?>(null) }
val isEsp32 = obuHardware == ObuHardware.ESP32_C5
pendingDelete?.let { log ->
AlertDialog(
onDismissRequest = { pendingDelete = null },
title = { Text(stringResource(R.string.v2xlog_delete_title)) },
text = { Text(stringResource(R.string.v2xlog_delete_text, log.name)) },
confirmButton = {
TextButton(onClick = { onDelete(log); pendingDelete = null }) {
Text(stringResource(R.string.log_delete_confirm), color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { pendingDelete = null }) { Text(stringResource(R.string.log_cancel)) }
},
)
}
SubScreen(stringResource(R.string.v2xlog_title), onBack) {
Text(
stringResource(R.string.v2xlog_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
SectionCard {
Spacer(Modifier.height(8.dp))
Text(
stringResource(if (isEsp32) R.string.v2xlog_mode_esp32 else R.string.v2xlog_mode_cit),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// Said up front, not discovered later as two rows of zeros: the firmware decides this.
if (isEsp32) {
Spacer(Modifier.height(4.dp))
Text(
stringResource(R.string.v2xlog_esp32_firmware_note),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.height(12.dp))
Button(
onClick = if (state.active) onStop else onStart,
modifier = Modifier.fillMaxWidth(),
colors = if (state.active) ButtonDefaults.buttonColors(containerColor = Color(0xFFB3261E))
else ButtonDefaults.buttonColors(),
) {
Icon(
if (state.active) Icons.Default.Stop else Icons.Default.FiberManualRecord,
contentDescription = null,
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(stringResource(if (state.active) R.string.v2xlog_stop else R.string.v2xlog_start))
}
state.error?.let {
Spacer(Modifier.height(8.dp))
Text(
stringResource(R.string.v2xlog_error, it),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
Spacer(Modifier.height(12.dp))
}
if (state.active) {
SectionCard {
DisabledRow(stringResource(R.string.v2xlog_file), state.fileName.orEmpty())
Divider()
DisabledRow(
stringResource(R.string.v2xlog_total),
stringResource(R.string.v2xlog_total_val, state.total, formatBytes(state.fileBytes)),
)
ItsMessageKind.TRACKED.forEach { kind ->
Divider()
DisabledRow(kind.label, (state.counts[kind] ?: 0).toString())
}
Divider()
DisabledRow(stringResource(R.string.v2xlog_phone_gnss), fixAge(state.phoneFixAgeMs))
Divider()
DisabledRow(
stringResource(R.string.v2xlog_obu_gnss),
if (isEsp32) stringResource(R.string.v2xlog_not_available) else fixAge(state.obuFixAgeMs),
)
}
if (state.total == 0) {
Text(
stringResource(R.string.v2xlog_waiting),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Text(
stringResource(R.string.v2xlog_files_title, files.size),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (files.isEmpty()) {
Text(
stringResource(R.string.v2xlog_none),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
SectionCard {
files.forEachIndexed { i, log ->
if (i > 0) Divider()
LogFileRow(
log = log,
onShare = { shareLog(context, log) },
onDelete = { pendingDelete = log },
)
}
}
}
}
}
@Composable
private fun LogFileRow(log: V2xLogFile, onShare: () -> Unit, onDelete: () -> Unit) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Column(modifier = Modifier.weight(1f)) {
Text(
log.name,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.SemiBold,
)
Text(
"${modifiedFormat.format(Date(log.modifiedMs))} · ${formatBytes(log.sizeBytes)}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// The file being written is open and incomplete; sharing or deleting it would hand over a
// half-written database or pull it out from under the writer.
IconButton(onClick = onShare, enabled = !log.active) {
Icon(Icons.Default.Share, contentDescription = stringResource(R.string.v2xlog_share_cd))
}
IconButton(onClick = onDelete, enabled = !log.active) {
Icon(
Icons.Default.Delete,
contentDescription = stringResource(R.string.v2xlog_delete_cd),
tint = if (log.active) LocalContentColorDisabled() else MaterialTheme.colorScheme.error,
)
}
}
}
@Composable
private fun LocalContentColorDisabled(): Color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
@Composable
private fun fixAge(ageMs: Long?): String =
if (ageMs == null) stringResource(R.string.v2xlog_no_fix)
else stringResource(R.string.v2xlog_fix_age, (ageMs / 1000).toInt())
private fun formatBytes(bytes: Long): String = when {
bytes < 1024 -> "$bytes B"
bytes < 1024 * 1024 -> "%.1f kB".format(Locale.US, bytes / 1024.0)
else -> "%.1f MB".format(Locale.US, bytes / (1024.0 * 1024.0))
}
private fun shareLog(context: Context, log: V2xLogFile) {
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", log.file)
val intent = Intent(Intent.ACTION_SEND).apply {
type = "application/vnd.sqlite3"
putExtra(Intent.EXTRA_STREAM, uri)
putExtra(Intent.EXTRA_SUBJECT, "MicrOBU V2X log - ${log.name}")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(Intent.createChooser(intent, context.getString(R.string.v2xlog_share_chooser)))
}
@@ -0,0 +1,45 @@
package com.hawhamburg.micr0bu.viewmodel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.hawhamburg.micr0bu.data.log.V2xLogFile
import com.hawhamburg.micr0bu.data.log.V2xLogState
import com.hawhamburg.micr0bu.data.log.V2xLogger
import com.hawhamburg.micr0bu.service.V2xLogService
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
/** Backs Settings > Developer > V2X data logger. */
@HiltViewModel
class V2xLogViewModel @Inject constructor(
application: Application,
private val logger: V2xLogger,
) : AndroidViewModel(application) {
val state: StateFlow<V2xLogState> = logger.state
val files: StateFlow<List<V2xLogFile>> = logger.files
init {
logger.refreshFiles()
// The running file grows between state updates, so keep the list's sizes current while a
// log is on screen.
viewModelScope.launch {
while (true) {
delay(2_000L)
if (logger.state.value.active) logger.refreshFiles()
}
}
}
fun start() = V2xLogService.start(getApplication())
fun stop() = V2xLogService.stop(getApplication())
fun delete(log: V2xLogFile) {
logger.delete(log)
}
}
+24
View File
@@ -332,4 +332,28 @@
<string name="conn_esp32_signing">Signieren %1$s · Tickets %2$d · signiert %3$d · abgelehnt %4$d · gesendet %5$d</string> <string name="conn_esp32_signing">Signieren %1$s · Tickets %2$d · signiert %3$d · abgelehnt %4$d · gesendet %5$d</string>
<string name="conn_esp32_signing_on">an</string> <string name="conn_esp32_signing_on">an</string>
<string name="conn_esp32_signing_off">aus</string> <string name="conn_esp32_signing_off">aus</string>
<string name="v2xlog_title">V2X-Datenlogger</string>
<string name="v2xlog_desc">Zeichnet jede empfangene V2X-Nachricht (CAM, DENM, SPATEM, MAPEM, VAM) in einer Datenbankdatei auf, mit Zeitstempel, der GNSS-Position des Telefons und, wenn vorhanden, der GNSS-Position der OBU und der Signalstärke (RSSI).</string>
<string name="v2xlog_mode_esp32">ESP32-C5: protokolliert, was das Board über die Verbindung weiterleitet, mit RSSI. Das Board hat kein GNSS, daher gibt es keine OBU-Position.</string>
<string name="v2xlog_mode_cit">CiT One: protokolliert die MQTT-Topics. Die API liefert keine RSSI; die OBU-Position stammt aus v2x/rx/obu_gnss.</string>
<string name="v2xlog_esp32_firmware_note">Die aktuelle ESP32-C5-Firmware leitet nur CAM, DENM und SPATEM weiter; MAPEM und VAM erscheinen erst, wenn die Firmware sie annimmt.</string>
<string name="v2xlog_start">Aufzeichnung starten</string>
<string name="v2xlog_stop">Aufzeichnung beenden</string>
<string name="v2xlog_error">Aufzeichnung fehlgeschlagen: %1$s</string>
<string name="v2xlog_file">Datei</string>
<string name="v2xlog_total">Aufgezeichnet</string>
<string name="v2xlog_total_val">%1$d Nachrichten · %2$s</string>
<string name="v2xlog_phone_gnss">Telefon-GNSS</string>
<string name="v2xlog_obu_gnss">OBU-GNSS</string>
<string name="v2xlog_fix_age">Fix vor %1$d s</string>
<string name="v2xlog_no_fix">kein Fix</string>
<string name="v2xlog_not_available">nicht verfügbar</string>
<string name="v2xlog_waiting">Warte auf Nachrichten. Ist die OBU noch nicht verbunden, verbinde sie auf dem Verbindungsbildschirm.</string>
<string name="v2xlog_files_title">Logdateien (%1$d)</string>
<string name="v2xlog_none">Noch keine Logs</string>
<string name="v2xlog_share_cd">Log teilen</string>
<string name="v2xlog_delete_cd">Log löschen</string>
<string name="v2xlog_share_chooser">V2X-Log teilen</string>
<string name="v2xlog_delete_title">Log löschen?</string>
<string name="v2xlog_delete_text">%1$s wird dauerhaft vom Gerät entfernt.</string>
</resources> </resources>
+24
View File
@@ -339,4 +339,28 @@
<string name="conn_esp32_signing">Signing %1$s · tickets %2$d · signed %3$d · refused %4$d · on air %5$d</string> <string name="conn_esp32_signing">Signing %1$s · tickets %2$d · signed %3$d · refused %4$d · on air %5$d</string>
<string name="conn_esp32_signing_on">on</string> <string name="conn_esp32_signing_on">on</string>
<string name="conn_esp32_signing_off">off</string> <string name="conn_esp32_signing_off">off</string>
<string name="v2xlog_title">V2X data logger</string>
<string name="v2xlog_desc">Records every V2X message received (CAM, DENM, SPATEM, MAPEM, VAM) into a database file, with a timestamp, the phone GNSS fix and, where available, the OBU GNSS fix and the signal strength (RSSI).</string>
<string name="v2xlog_mode_esp32">ESP32-C5: logging what the board forwards over the link, with RSSI. The board has no GNSS, so there is no OBU position.</string>
<string name="v2xlog_mode_cit">CiT One: logging the MQTT topics. The API carries no RSSI; the OBU position comes from v2x/rx/obu_gnss.</string>
<string name="v2xlog_esp32_firmware_note">The current ESP32-C5 firmware forwards only CAM, DENM and SPATEM, so MAPEM and VAM appear only once the firmware accepts them.</string>
<string name="v2xlog_start">Start logging</string>
<string name="v2xlog_stop">Stop logging</string>
<string name="v2xlog_error">Logging failed: %1$s</string>
<string name="v2xlog_file">File</string>
<string name="v2xlog_total">Recorded</string>
<string name="v2xlog_total_val">%1$d messages · %2$s</string>
<string name="v2xlog_phone_gnss">Phone GNSS</string>
<string name="v2xlog_obu_gnss">OBU GNSS</string>
<string name="v2xlog_fix_age">fix %1$d s ago</string>
<string name="v2xlog_no_fix">no fix</string>
<string name="v2xlog_not_available">not available</string>
<string name="v2xlog_waiting">Waiting for messages. If the OBU is not connected yet, connect it on the Connection screen.</string>
<string name="v2xlog_files_title">Log files (%1$d)</string>
<string name="v2xlog_none">No logs yet</string>
<string name="v2xlog_share_cd">Share log</string>
<string name="v2xlog_delete_cd">Delete log</string>
<string name="v2xlog_share_chooser">Share V2X log</string>
<string name="v2xlog_delete_title">Delete log?</string>
<string name="v2xlog_delete_text">This permanently removes %1$s from the device.</string>
</resources> </resources>
+2
View File
@@ -2,4 +2,6 @@
<paths> <paths>
<!-- Covers context.cacheDir — used for CSV exports --> <!-- Covers context.cacheDir — used for CSV exports -->
<cache-path name="shared_csv" path="." /> <cache-path name="shared_csv" path="." />
<!-- V2X data logs (V2xLogger), shared from Settings > Developer -->
<files-path name="v2x_logs" path="v2x_logs/" />
</paths> </paths>
@@ -0,0 +1,73 @@
package com.hawhamburg.micr0bu
import com.hawhamburg.micr0bu.domain.log.ItsMessageKind
import com.hawhamburg.micr0bu.domain.log.ItsPduHeader
import com.hawhamburg.micr0bu.domain.log.classifyItsPdu
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/**
* Pins how the V2X data logger names a message it may have no decoder for. The CAM fixture is the
* golden UPER frame from [CamEncodeGoldenTest] (protocolVersion 2, messageID 2, stationID 999999);
* the others only need a valid ItsPduHeader, which is all the classifier reads.
*/
class ItsMessageKindTest {
private fun String.hexToBytes() = chunked(2).map { it.toInt(16).toByte() }.toByteArray()
private val goldenCam =
"0202000f423f3700402ab215af6e286477dffffffc23b7743e0027ffc0d0fe0118329337feebfff6000000".hexToBytes()
/** An ItsPduHeader with [messageId] and stationID 0x01020304, then a stand-in body. */
private fun pdu(messageId: Int) =
byteArrayOf(2, messageId.toByte(), 1, 2, 3, 4, 0x55, 0x66)
@Test fun `header of the golden CAM is read`() {
val h = ItsPduHeader.parse(goldenCam)!!
assertEquals(2, h.protocolVersion)
assertEquals(2, h.messageId)
assertEquals(999_999L, h.stationId)
}
@Test fun `station id above 2^31 is not sign-extended`() {
val h = ItsPduHeader.parse(byteArrayOf(2, 2, 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFE.toByte()))!!
assertEquals(0xFFFFFFFEL, h.stationId)
}
@Test fun `every tracked kind is recognised by messageID alone`() {
assertEquals(ItsMessageKind.DENM, classifyItsPdu(pdu(1), null).kind)
assertEquals(ItsMessageKind.CAM, classifyItsPdu(pdu(2), null).kind)
assertEquals(ItsMessageKind.SPATEM, classifyItsPdu(pdu(4), null).kind)
assertEquals(ItsMessageKind.MAPEM, classifyItsPdu(pdu(5), null).kind)
assertEquals(ItsMessageKind.VAM, classifyItsPdu(pdu(16), null).kind)
}
@Test fun `messageID wins over the BTP port, and the SPATEM-MAPEM numbers are not swapped`() {
// SPATEM is port 2004 but messageID 4; MAPEM is port 2003 but messageID 5.
assertEquals(ItsMessageKind.SPATEM, classifyItsPdu(pdu(4), 2004).kind)
assertEquals(ItsMessageKind.MAPEM, classifyItsPdu(pdu(5), 2003).kind)
// A PDU that disagrees with the port it arrived on is what its header says it is.
assertEquals(ItsMessageKind.MAPEM, classifyItsPdu(pdu(5), 2004).kind)
}
@Test fun `a PDU too short for a header falls back to the BTP port`() {
val c = classifyItsPdu(byteArrayOf(2, 5), 2018)
assertEquals(ItsMessageKind.VAM, c.kind)
assertNull(c.messageId)
assertNull(c.stationId)
}
@Test fun `an unknown messageID is kept as OTHER with its id`() {
val c = classifyItsPdu(pdu(99), null)
assertEquals(ItsMessageKind.OTHER, c.kind)
assertEquals(99, c.messageId)
assertEquals(0x01020304L, c.stationId)
}
@Test fun `processed topic names map to kinds`() {
assertEquals(ItsMessageKind.MAPEM, ItsMessageKind.fromProcessedTopicName("map"))
assertEquals(ItsMessageKind.SPATEM, ItsMessageKind.fromProcessedTopicName("spat"))
assertNull(ItsMessageKind.fromProcessedTopicName("unknown"))
}
}
+5 -5
View File
@@ -35,10 +35,10 @@ fit together, how it was verified, and what is still open. Hardware checks still
## Firmware (obu-firmware) ## Firmware (obu-firmware)
obu-firmware is now a port of `microbu-esp32c5/firmware`: the colleague's repository, of which obu-firmware is now a port of `microbu-esp32c5/firmware`, from the colleague's own repository
this repository keeps a copy in `microbu-esp32c5/` (their commit cf4b99f plus our V2X2MAP signature (not part of this one; nothing is pushed there). The C-ITS library it needs is copied into this
verification; nothing is pushed to their repository). vanetza-idf is taken from repository as `obu-firmware/external/vanetza-idf` (their commit cf4b99f, unchanged), so
`microbu-esp32c5/external/vanetza-idf`. It builds obu-firmware builds from a plain clone. It builds
with **ESP-IDF 6.0.2 only**: the raw-TX path uses private Wi-Fi driver structures that vanetza-idf with **ESP-IDF 6.0.2 only**: the raw-TX path uses private Wi-Fi driver structures that vanetza-idf
pins to that version. The previous C firmware (IDF 6.1) is backed up as a full flash image in pins to that version. The previous C firmware (IDF 6.1) is backed up as a full flash image in
`firmware-backups/` (gitignored, restore command in its README.txt); its sources stay on disk, `firmware-backups/` (gitignored, restore command in its README.txt); its sources stay on disk,
@@ -64,7 +64,7 @@ Main changes against the colleague's firmware:
## Link protocol ## Link protocol
Station-link v1 (`microbu-esp32c5/station-link/README.md`): `[opcode][flags][sequence LE][body]`, Station-link v1 (colleague's repository, `station-link/README.md`): `[opcode][flags][sequence LE][body]`,
little-endian, at most 512 octets. Over USB each message is one `0xAA55` frame of type `0x10` little-endian, at most 512 octets. Over USB each message is one `0xAA55` frame of type `0x10`
(the old framing and CRC). Over BLE each message is one GATT value on service (the old framing and CRC). Over BLE each message is one GATT value on service
`0000C175-BA5E-4C17-8000-00805F9B34FB` (the README describes a different, Nordic-UART layout; the `0000C175-BA5E-4C17-8000-00805F9B34FB` (the README describes a different, Nordic-UART layout; the
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 660 KiB

-43
View File
@@ -1,43 +0,0 @@
# --- ESP-IDF build output ---
firmware/build/
firmware/build-*/
firmware/managed_components/
firmware/.cache/
firmware/.vscode/
firmware/sdkconfig
firmware/sdkconfig.old
firmware/*.log
firmware/rf_characterization_output/
# --- Secrets: never commit real private key material ---
# (this repo only ever ships the disposable, non-registered demo chain)
*.vkey
*.ekey
private/
*_private_encrypted.pem
*_RCA_NEW_*/
# --- PKI reference-generator: regenerate locally, do not commit ---
# (vendored Rust crates for the offline/deterministic c-its build path; see
# pki/uml-l0-rca/reference-generator/README.md to rebuild with `cargo vendor`)
pki/uml-l0-rca/reference-generator/vendor/
pki/uml-l0-rca/reference-generator/_build_cits/
pki/uml-l0-rca/reference-generator/_cargo_vendor/
# --- Python ---
__pycache__/
*.pyc
*.egg-info/
.venv/
venv/
# --- v2x2map bridge local artifacts ---
tools/v2x2map-0.3.0/bridge/recordings/
tools/v2x2map-0.3.0/bridge/dist/
tools/v2x2map-0.3.0/bridge/build/
*.pcap
# --- OS / editor ---
.DS_Store
Thumbs.db
*.swp
-155
View File
@@ -1,155 +0,0 @@
# micrOBU ESP32-C5
A standalone ITS-G5 (802.11p) VRU (Vulnerable Road User) ITS-S station on the
ESP32-C5: firmware, the embedded [Vanetza](https://github.com/riebl/vanetza)
C-ITS protocol stack (`external/vanetza-idf/`, see
[PROVENANCE.md](external/vanetza-idf/PROVENANCE.md)), a phone-emulator
example for the station-internal link, a localhost PKI reference chain, a
Wireshark VAM dissector and the [V2X2MAP](https://github.com/711it/v2x2map)
receiver bridge.
A fresh clone can send a real, signed VAM (VRU Awareness Message) over the
air in a handful of commands — see [Send a signed VAM](#4-send-a-signed-vam-in-a-few-commands)
below. `station-link/python/demo-chain.vcr` is a disposable, **non-EU-registered**
test credential chain generated specifically for this purpose (see
[Security note on credentials](#security-note-on-credentials)); it carries no
real-world trust and is safe to ship.
## Quickstart
### 1. Build & flash the ESP32-C5 firmware
Requires [ESP-IDF](https://docs.espressif.com/projects/esp-idf/en/latest/esp32c5/get-started/) 6.0.2 with the `esp32c5` target.
```powershell
cd firmware
idf.py set-target esp32c5
idf.py build
idf.py -p COM<PORT> flash monitor
```
### 2. Install the Wireshark VAM dissector
Wireshark decodes IEEE 1609.2 / ETSI TS 103 097 secured packets only down to
`unsecuredData` unless the PSID is registered in its dissector table. **PSID
638** (VRU Awareness Service, ETSI TS 102 965) is not registered by default
in Wireshark 4.x, so signed VAM traffic stops decoding at the security
envelope without this plugin.
```powershell
# Windows
Copy-Item tools\wireshark\psid-vru.lua "$env:APPDATA\Wireshark\plugins\"
```
```bash
# Linux
mkdir -p ~/.local/lib/wireshark/plugins && cp tools/wireshark/psid-vru.lua ~/.local/lib/wireshark/plugins/
# macOS
mkdir -p ~/.config/wireshark/plugins && cp tools/wireshark/psid-vru.lua ~/.config/wireshark/plugins/
```
Verify under **Help → About Wireshark → Plugins**, or use it directly with `tshark`:
```bash
tshark -X lua_script:tools/wireshark/psid-vru.lua -r capture.pcap
```
See [tools/wireshark/README.md](tools/wireshark/README.md) for details.
### 3. Install Python dependencies
```bash
pip install -r station-link/python/requirements.txt
pip install -r tools/v2x2map-0.3.0/bridge/requirements.txt
```
### 4. Send a signed VAM in a few commands
With the firmware flashed (step 1) and the ESP32-C5 connected over USB
Serial/JTAG, the phone emulator provisions the disposable demo credential
chain and starts a small VRU basic service that assembles and transmits
VAMs:
```bash
python station-link/python/phone_emulator.py \
--port COM<PORT> --bundle station-link/python/demo-chain.vcr \
--radio txrx --duration 30
```
That's it — the micrOBU signs every VAM with the demo AT ticket (VRU ITS-AID
638, `psid 638 ssp 01`) and transmits it over ITS-G5. Capture it with a
second ITS-G5-capable radio (or the [V2X2MAP bridge](#5-run-the-v2x2map-receiver-bridge-optional)
below) and decode it with the [Wireshark dissector](#2-install-the-wireshark-vam-dissector)
from step 2.
To provision over BLE instead of USB, or to mirror packets to a `.pcap`
without radiating, see the header of
[`phone_emulator.py`](station-link/python/phone_emulator.py) for the
`--ble`, `--radio off --divert --pcap` and full `--pki-*` (real online TS
102 941 enrolment/authorization) variants, and
[station-link/README.md](station-link/README.md) for the link protocol
itself.
### 5. Run the V2X2MAP receiver bridge (optional)
A second ESP32-C5 flashed with the receiver firmware in
[`tools/v2x2map-0.3.0/bridge/firmware/`](tools/v2x2map-0.3.0/bridge/firmware/)
can feed a live web dashboard:
```bash
python tools/v2x2map-0.3.0/bridge/its_g5_bridge.py --port COM<PORT> --dashboard-port 8080 --open-browser
```
Open `http://localhost:8080` if it doesn't open automatically. This tool
decodes VAMs for display but does **not** verify signatures (see
[tools/v2x2map-0.3.0/README.md](tools/v2x2map-0.3.0/README.md)).
## Repository layout
| Path | Contents |
|---|---|
| `firmware/` | ESP-IDF firmware project for the ESP32-C5 VRU ITS-S |
| `external/vanetza-idf/` | Vanetza C-ITS stack + ESP-IDF port (upstream provenance in [PROVENANCE.md](external/vanetza-idf/PROVENANCE.md)) |
| `station-link/` | Station-internal link protocol, Python client library, phone emulator |
| `pki/` | Localhost PKI reference chain (root/AA/AT tooling); see [security note](#security-note-on-credentials) |
| `tools/wireshark/` | PSID 638 (VRU) Wireshark Lua dissector |
| `tools/v2x2map-0.3.0/` | Vendored [V2X2MAP](https://github.com/711it/v2x2map) receiver bridge and live dashboard |
## Security note on credentials
`pki/uml-l0-rca/` documents the tooling for a **real, EU CCMS L0 ECTL-registered**
root CA used elsewhere in the wider micrOBU project. Its private key material
is intentionally **not** in this repository — `.gitignore` also backstops
this (`*.vkey`, `*.ekey`, `private/`).
`station-link/python/demo-chain.vcr` is unrelated: a separate, throwaway,
**non-registered** root/AA/AT chain generated specifically for this repo's
quickstart with `pki/uml-l0-rca/bin/windows/vidf_issue.exe`. Its
`HashedId8` values do not match the registered root, it grants no real-world
trust, and regenerating it is safe:
```powershell
$pool = "<some scratch directory>"
$exe = "pki\uml-l0-rca\bin\windows\vidf_issue.exe"
openssl ecparam -name prime256v1 -genkey -noout -out "$pool\demo_root_key.pem"
& $exe root --key "$pool\demo_root_key.pem" --name "Demo Root (NOT REGISTERED)" --id DEMO_RCA --out $pool --years 10
& $exe authority --issuer "$pool\DEMO_RCA.oer" --issuer-key "$pool\demo_root_key.pem" --name "Demo AA" --id DEMO_AA --out $pool --years 5
& $exe ticket --issuer "$pool\DEMO_AA.oer" --issuer-key "$pool\DEMO_AA.vkey" --id DEMO_AT --out $pool --hours 8760 --root "$pool\DEMO_RCA.oer"
python external\vanetza-idf\ports\esp_idf\tools\credential_bundle.py build `
--pool $pool --root DEMO_RCA --aa DEMO_AA --at DEMO_AT --out station-link\python\demo-chain.vcr
```
`pki/uml-l0-rca/reference-generator/` cross-validates certificate generation
against an independent Rust implementation ([`TheEnbyperor/c-its`](https://github.com/TheEnbyperor/c-its),
pinned commit in [`reference-generator/README.md`](pki/uml-l0-rca/reference-generator/README.md)).
Its vendored crates (`vendor/`) are excluded from this repository by
`.gitignore` for size; regenerate with `cargo vendor` from that directory's
`Cargo.lock`, or use `vidf_issue` directly as shown above — the vendor tree
is only needed for that independent cross-check, not for ordinary use.
## License / provenance
- Vanetza and its ESP-IDF port: BSD-3-Clause, see
[external/vanetza-idf/LICENSE.md](external/vanetza-idf/LICENSE.md) and
[external/vanetza-idf/PROVENANCE.md](external/vanetza-idf/PROVENANCE.md).
- V2X2MAP bridge: MIT, see
[tools/v2x2map-0.3.0/LICENSE](tools/v2x2map-0.3.0/LICENSE).
-45
View File
@@ -1,45 +0,0 @@
cmake_minimum_required(VERSION 3.22)
# micrOBU firmware: the ESP32-C5 half of the VRU ITS-S on the external vanetza-idf component
# (tracked directly in this repository at external/vanetza-idf; see external/vanetza-idf/PROVENANCE.md).
# Provenance guard for main/otm_tx_custom.c (implementation/provenance/adapted-code.yaml, key
# otm-c5-radio-tx-custom): this checked-in copy -- not the vanetza-idf submodule's own
# radio_c5.cmake-generated one, which builds into vanetza_idf::C5Radio, a class this firmware
# never references -- is what microbu::C5Radio::request() and C5Radio::transmit_burst() actually
# link against for every over-the-air transmission. Fail configure if either the pinned upstream
# (when the nested OTM submodule is checked out) or this file drifts from the reviewed revision,
# so a silent edit to either can no longer pass unnoticed the way it previously could.
set(_otm_upstream "${CMAKE_CURRENT_LIST_DIR}/../external/vanetza-idf/ports/esp_idf/third_party/otm/main/tx_custom.c")
if(EXISTS "${_otm_upstream}")
file(READ "${_otm_upstream}" _otm_upstream_text)
string(REPLACE "\r\n" "\n" _otm_upstream_text "${_otm_upstream_text}")
string(SHA256 _otm_upstream_hash "${_otm_upstream_text}")
if(NOT _otm_upstream_hash STREQUAL "cb1dccfef96912ca59275e8a9102f41f56925b94a19d4a4629082aaeb779be1b")
message(FATAL_ERROR "OpenTrafficMap upstream tx_custom.c (nested submodule third_party/otm) differs from the reviewed source revision (674e3412) -- review before updating main/otm_tx_custom.c and this hash")
endif()
endif()
set(_otm_checked_in "${CMAKE_CURRENT_LIST_DIR}/main/otm_tx_custom.c")
file(READ "${_otm_checked_in}" _otm_checked_in_text)
string(REPLACE "\r\n" "\n" _otm_checked_in_text "${_otm_checked_in_text}")
string(SHA256 _otm_checked_in_hash "${_otm_checked_in_text}")
if(NOT _otm_checked_in_hash STREQUAL "114693af99ce3866cfc767066d484e45822eaf15335d94a03626b60ac5777277")
message(FATAL_ERROR "main/otm_tx_custom.c differs from the reviewed copy -- update implementation/provenance/adapted-code.yaml (key otm-c5-radio-tx-custom) and this hash after reviewing the change")
endif()
list(APPEND EXTRA_COMPONENT_DIRS "${CMAKE_CURRENT_LIST_DIR}/../external/vanetza-idf")
set(SDKCONFIG_DEFAULTS "${CMAKE_CURRENT_LIST_DIR}/sdkconfig.defaults")
set(COMPONENTS main)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(microbu_firmware)
add_compile_options(-Wno-error -Wno-cpp -Wno-error=implicit-function-declaration)
idf_component_get_property(vanetza_lib vanetza-idf COMPONENT_LIB)
if(vanetza_lib)
target_compile_options(${vanetza_lib} PRIVATE -Wno-error=implicit-function-declaration -Wno-error=cpp)
endif()
# GCC 15's stricter -Warray-bounds false-positives on NimBLE's fixed-size bond-store arrays
# (upstream Apache Mynewt code, not ours); demote to a warning so the component still builds.
idf_component_get_property(bt_lib bt COMPONENT_LIB)
if(bt_lib)
target_compile_options(${bt_lib} PRIVATE -Wno-error=array-bounds)
endif()
@@ -1,21 +0,0 @@
dependencies:
espressif/esp-boost:
component_hash: 45cfa63ade2ad7c489203ab2ddf3f33be50ec9cab752b6eb17a79f06aee8f8f0
dependencies:
- name: idf
require: private
version: '>=5.3'
source:
registry_url: https://components.espressif.com/
type: service
version: 0.4.1
idf:
source:
type: idf
version: 6.0.2
direct_dependencies:
- espressif/esp-boost
- idf
manifest_hash: 3fca1283d556ade5b08c63857e23cb3b08582f1d1c24bb7c1d5e374f438415d7
target: esp32c5
version: 3.0.0
@@ -1,5 +0,0 @@
idf_component_register(SRCS "app_main.cpp" "board_controls.cpp" "simple_ble.cpp" "c5_radio.cpp" "otm_tx_custom.c" "link_protocol.cpp" "serial_link.cpp" "station.cpp" "station_test.cpp" "link_service.cpp" "test_channel.cpp"
INCLUDE_DIRS "."
LDFRAGMENTS "linker.lf"
REQUIRES vanetza-idf esp_wifi esp_phy esp_event bt esp_driver_gpio esp_driver_usb_serial_jtag nvs_flash esp_timer)
target_compile_features(${COMPONENT_LIB} PRIVATE cxx_std_17)
@@ -1,28 +0,0 @@
menu "micrOBU firmware"
config MICROBU_TEST_CHANNEL
bool "Test channel on the serial link (software lower tester, ETSI campaign hooks)"
default y
help
Serves serial frame type 0x11: mirror or divert every AL_DATA.request the stack
makes to the tester, inject AL_DATA.indication, GN-DATA.request for the
Security ATS. Not part of the phone interface; disable for a production image.
config MICROBU_TX_LED_GPIO
int "Active-low transmit LED GPIO"
range 0 28
default 27
help
GPIO27 drives the on-board yellow user LED on the XIAO ESP32-C5.
config MICROBU_TX_LED_BLINK_MS
int "LED indication pulse (ms)"
range 10 2000
default 100
help
Duration in milliseconds for the LED visual indication (pulse on for TX, pulse off for RX).
config MICROBU_LED_INVERTED_RX
bool "Inverted LED mode for receiver/bridge (normally ON, blink OFF on packet)"
default n
help
When enabled, the LED stays constantly ON and blinks OFF briefly whenever a packet is
received (useful for bridge and receiver nodes). When disabled, the LED stays OFF and
blinks ON briefly whenever a packet is disseminated (sender mode).
endmenu
-126
View File
@@ -1,126 +0,0 @@
// micrOBU firmware: the ESP32-C5 half of the VRU ITS-S on vanetza-idf.
// One station task owns stack, security entity and radio; the serial reader task only
// enqueues frames. Link messages: implementation/station-link/README.md.
#include "link_protocol.hpp"
#include "link_service.hpp"
#include "board_controls.hpp"
#include "simple_ble.hpp"
#include "serial_link.hpp"
#include "station.hpp"
#if CONFIG_MICROBU_TEST_CHANNEL
#include "test_channel.hpp"
#endif
#include <esp_log.h>
#include <esp_task_wdt.h>
#include <nvs_flash.h>
#include <freertos/FreeRTOS.h>
#include <freertos/queue.h>
#include <freertos/task.h>
#include <cstdio>
#include <new>
namespace {
const char* TAG = "microbu";
/// @brief Represents one frame received from a transport, to be queued for the station task.
struct Incoming {
microbu::LinkTransport transport;
microbu::serial::Frame frame;
};
QueueHandle_t frames = nullptr; // FIFO of Incoming* (8 bytes each); frame bytes never enter the queue itself
/// @brief Attempts to heap-allocate an Incoming so it can be pushed onto the frames queue.
/// @param transport Transport the frame arrived on.
/// @param frame Decoded frame to take ownership of.
/// @return Owning pointer to push onto the queue, or nullptr if allocation failed.
Incoming* try_new_incoming(microbu::LinkTransport transport, microbu::serial::Frame frame) noexcept {
try {
// std::move here steals frame's payload buffer into the heap Incoming (no byte copy);
// it has nothing to do with the queue below, which only ever transports the resulting pointer.
return new Incoming {transport, std::move(frame)};
} catch (const std::bad_alloc&) {
return nullptr;
}
}
/// @brief Dispatches one queued frame to the link service (or the test channel); logs and drops it on failure.
void handle_incoming(microbu::Station& station, microbu::LinkService& link, const Incoming& incoming) {
try {
if (incoming.frame.type == microbu::serial::FrameType::LINK)
link.handle(incoming.frame.payload, incoming.transport);
#if CONFIG_MICROBU_TEST_CHANNEL
else if (incoming.transport == microbu::LinkTransport::serial &&
incoming.frame.type == microbu::serial::FrameType::TEST)
microbu::serial::write(microbu::serial::FrameType::TEST,
microbu::test_channel_execute(station, incoming.frame.payload));
#endif
} catch (const std::bad_alloc&) {
ESP_LOGE(TAG, "out of memory while handling a frame");
} catch (const std::exception& e) {
ESP_LOGE(TAG, "frame handling failed: %s", e.what());
}
}
/// @brief FreeRTOS task body: owns the station, link service and board controls. Drains the incoming queue.
void station_task(void*) {
microbu::Station station;
microbu::LinkService link(station);
microbu::BoardControls controls;
station.on_disseminated([&controls] { controls.blink(); });
station.on_received([&controls] { controls.blink(); });
ESP_LOGI(TAG, "station task ready; the phone/PC configures the station over BLE or USB Serial-JTAG");
for (;;) { /// main loop: drain the incoming queue, tick the station and link service, tick the controls
Incoming* incoming = nullptr;
if (xQueueReceive(frames, &incoming, pdMS_TO_TICKS(5)) == pdTRUE && incoming) {
handle_incoming(station, link, *incoming);
delete incoming;
}
station.tick();
link.tick();
controls.tick();
}
}
}
/// @brief Firmware entry point: initializes NVS, watchdog, serial/BLE transports, and starts the station task.
extern "C" void app_main() {
// NVS: the credential store (and the Wi-Fi driver's calibration data)
esp_err_t nvs = nvs_flash_init();
if (nvs == ESP_ERR_NVS_NO_FREE_PAGES || nvs == ESP_ERR_NVS_NEW_VERSION_FOUND) {
ESP_ERROR_CHECK(nvs_flash_erase());
nvs = nvs_flash_init();
}
ESP_ERROR_CHECK(nvs);
// Signing and verification take tens of milliseconds each on the C5. This keeps the idle
// watchdog from reporting a busy station task (5 s default) while a credential bundle applies.
esp_task_wdt_config_t watchdog = {};
watchdog.timeout_ms = 30000;
watchdog.idle_core_mask = (1 << portNUM_PROCESSORS) - 1;
watchdog.trigger_panic = false; // log, don't reboot, on timeout
esp_task_wdt_reconfigure(&watchdog);
frames = xQueueCreate(32, sizeof(Incoming*));
microbu::serial::start([](microbu::serial::Frame frame) {
auto* copy = try_new_incoming(microbu::LinkTransport::serial, std::move(frame));
if (!copy) return;
// xQueueSend copies the 8-byte pointer value into the queue, not *copy itself.
// station_task then pops pointers FIFO and owns/deletes whatever it receives.
if (xQueueSend(frames, &copy, 0) != pdTRUE) delete copy; // the phone retries on a missing RESULT
});
if (!microbu::ble::start([](microbu::link::Bytes message) {
microbu::serial::Frame frame {microbu::serial::FrameType::LINK, std::move(message)};
auto* copy = try_new_incoming(microbu::LinkTransport::ble, std::move(frame));
if (!copy) return;
if (xQueueSend(frames, &copy, 0) != pdTRUE) delete copy; // same hand-off as the serial callback above
})) {
ESP_LOGE(TAG, "BLE station link failed to start. USB remains available");
}
ESP_LOGI(TAG, "micrOBU firmware on vanetza-idf, link protocol version 1%s",
#if CONFIG_MICROBU_TEST_CHANNEL
", test channel enabled");
#else
"");
#endif
xTaskCreate(station_task, "station", 12288, nullptr, 10, nullptr);
}
@@ -1,59 +0,0 @@
#include "board_controls.hpp"
#include <driver/gpio.h>
#include <esp_log.h>
#include <esp_timer.h>
namespace microbu {
namespace {
const char* TAG = "board";
constexpr std::int64_t blink_us = CONFIG_MICROBU_TX_LED_BLINK_MS * 1000LL;
}
BoardControls::BoardControls() {
gpio_config_t led = {};
led.pin_bit_mask = 1ULL << CONFIG_MICROBU_TX_LED_GPIO;
led.mode = GPIO_MODE_OUTPUT;
led.pull_up_en = GPIO_PULLUP_DISABLE;
led.pull_down_en = GPIO_PULLDOWN_DISABLE;
led.intr_type = GPIO_INTR_DISABLE;
ESP_ERROR_CHECK(gpio_config(&led));
#if CONFIG_MICROBU_LED_INVERTED_RX
mode_ = LedMode::inverted_rx;
#else
mode_ = LedMode::normal_tx;
#endif
apply_led_state();
ESP_LOGI(TAG, "LED GPIO %d (mode: %s)", CONFIG_MICROBU_TX_LED_GPIO,
mode_ == LedMode::inverted_rx ? "inverted_rx (normally ON)" : "normal_tx (normally OFF)");
}
void BoardControls::apply_led_state() {
// The XIAO ESP32-C5 user LED is wired active-low (0 = ON, 1 = OFF).
// In normal_tx mode: default OFF (1), pulsing ON (0).
// In inverted_rx mode: default ON (0), pulsing OFF (1).
bool led_illuminated = false;
if (mode_ == LedMode::normal_tx) {
led_illuminated = pulsing_;
} else {
led_illuminated = !pulsing_;
}
gpio_set_level(static_cast<gpio_num_t>(CONFIG_MICROBU_TX_LED_GPIO), led_illuminated ? 0 : 1);
}
void BoardControls::blink() {
pulse_until_us_ = esp_timer_get_time() + blink_us;
if (!pulsing_) {
pulsing_ = true;
apply_led_state();
}
}
void BoardControls::tick() {
if (pulsing_ && esp_timer_get_time() >= pulse_until_us_) {
pulsing_ = false;
apply_led_state();
}
}
} // namespace microbu
@@ -1,31 +0,0 @@
#pragma once
#include <cstdint>
namespace microbu {
/// XIAO ESP32-C5 active-low user LED: a visual indicator for TX/RX activity.
class BoardControls {
public:
/// @brief Configures the LED GPIO and sets its idle state.
BoardControls();
enum class LedMode {
normal_tx, // Normally off, pulses on upon frame dissemination
inverted_rx // Normally on, pulses off upon frame reception
};
/// @brief Triggers visual indication (blink on in normal_tx mode, blink off in inverted_rx mode).
void blink();
/// @brief Updates LED state and checks indication timers; call periodically.
void tick();
private:
void apply_led_state();
LedMode mode_ = LedMode::normal_tx;
bool pulsing_ = false;
std::int64_t pulse_until_us_ = 0;
};
} // namespace microbu
-440
View File
@@ -1,440 +0,0 @@
#include "c5_radio.hpp"
#include "otm_tx_custom.h"
#include <vanetza_idf/its_g5_frame.hpp>
#include <esp_event.h>
#include <esp_log.h>
#include <esp_wifi.h>
#include <hal/modem_syscon_ll.h>
#include <esp_timer.h>
#include <freertos/FreeRTOS.h>
#include <freertos/queue.h>
#include <algorithm>
#include <atomic>
#include <cmath>
#include <cstring>
#include <iterator>
#include <limits>
#include <mutex>
extern "C" { //all of these arentt in the esp-idf public api
// phy_11p_set/phy_change_channel: undocumented esp_phy/lib/esp32c5/libphy.a entry points, not
// declared in any Espressif header.
// The call sites and argument values below (phy_11p_set(1, 0), phy_change_channel(freq, 1, 0, 0))
// are copied from OpenTrafficMap's its-g5-receiver-firmware_txenabled, main/cmd_sniffer.c
// (https://codeberg.org/opentrafficmap/its-g5-receiver-firmware_txenabled, community reverse
// engineering, no stated license).
void phy_11p_set(int enable, int arg2);
void phy_change_channel(int freq_mhz, int arg2_ignored, int arg3_ignored, int arg4);
// phy_get_cca/phy_set_cca: register 0x600a701c[7:0] holds the configured CCA energy
// detection threshold (defaults to 191 = 0xBF = -65 dBm in 8-bit two's complement).
// phy_get_cca() reads this configured threshold.
int phy_get_cca(void);
void phy_set_cca(int enable, int threshold);
// phy_get_cca_cnt/phy_set_cca_cnt: register 0x600a7c58 arms the 27-bit hardware CCA
// cycle counters (0x600a7c5c = total cycles, 0x600a7c60 = busy cycles; confirmed on
// hardware -- out[0] free-runs at ~40 MHz, out[1] stays near zero on a quiet channel).
// phy_get_cca_cnt returns bit 27 (busy/status bit) and writes both counters to out[2].
int phy_get_cca_cnt(std::int32_t out[2]);
void phy_set_cca_cnt(std::int32_t val, bool enable);
void phy_enable_cca(void);
void phy_disable_cca(void);
int phy_get_noise_floor(void);
}
namespace microbu {
namespace {
const char* TAG = "c5_radio";
}
class C5Radio::Impl {
public:
/// @brief Raw received frame metadata and bytes from the promiscuous RX callback.
struct Raw {
std::uint16_t length;
std::int8_t rssi;
std::uint32_t timestamp;
std::uint8_t bytes[2346]; // max 802.11 frame size
};
C5RadioConfig config;
QueueHandle_t queue = nullptr;
bool initialized = false, started = false, own_event_loop = false;
std::uint16_t sequence = 0;
std::atomic<std::uint32_t> dropped {0};
static Impl* active;
static std::mutex callback_mutex;
explicit Impl(C5RadioConfig c) : config(c) {}
static void receive(void* buffer, wifi_promiscuous_pkt_type_t type) {
if (!buffer || type != WIFI_PKT_DATA) return;
const auto* packet = static_cast<const wifi_promiscuous_pkt_t*>(buffer);
if (packet->rx_ctrl.rx_state != 0) return;
const auto length = packet->rx_ctrl.sig_len;
std::lock_guard<std::mutex> lock(callback_mutex);
if (!active || !active->queue) return;
if (length < 38 || length > sizeof(Raw::bytes)) { ++active->dropped; return; }
Raw raw {};
raw.length = length;
raw.rssi = packet->rx_ctrl.rssi;
raw.timestamp = packet->rx_ctrl.timestamp;
std::memcpy(raw.bytes, packet->payload, length);
if (xQueueSend(active->queue, &raw, 0) != pdTRUE) ++active->dropped;
}
};
C5Radio::Impl* C5Radio::Impl::active = nullptr;
std::mutex C5Radio::Impl::callback_mutex;
C5Radio::C5Radio(C5RadioConfig c) : impl_(std::make_unique<Impl>(c)) {}
C5Radio::~C5Radio() { stop(); }
esp_err_t C5Radio::start() {
auto& p = *impl_;
const auto& c = p.config;
if (p.initialized) return ESP_ERR_INVALID_STATE;
// Reject channel/power/queue config outside the supported ITS-G5 range
if (c.channel_number < 172 || c.channel_number > 184 || c.channel_number % 2 ||
!std::isfinite(c.transmit_power_dbm) || c.transmit_power_dbm < 2 || c.transmit_power_dbm > 23 ||
std::floor(c.transmit_power_dbm * 4) != c.transmit_power_dbm * 4 ||
c.receive_queue_length == 0 || c.receive_queue_length > 32) {
return ESP_ERR_INVALID_ARG;
}
{
std::lock_guard<std::mutex> lock(Impl::callback_mutex);
if (Impl::active) return ESP_ERR_INVALID_STATE;
p.queue = xQueueCreate(c.receive_queue_length, sizeof(Impl::Raw));
if (!p.queue) return ESP_ERR_NO_MEM;
Impl::active = &p;
}
auto result = esp_event_loop_create_default();
p.own_event_loop = (result == ESP_OK);
if (result != ESP_OK && result != ESP_ERR_INVALID_STATE) {
stop();
return result;
}
// Establish modem FE clock for 802.11p OFDM
modem_syscon_ll_enable_fe_40m_clock(&MODEM_SYSCON, true);
wifi_init_config_t wifi = WIFI_INIT_CONFIG_DEFAULT();
wifi.nvs_enable = 0;
result = esp_wifi_init(&wifi);
if (result != ESP_OK) {
stop();
return result;
}
p.initialized = true;
auto attempt = [&](esp_err_t r) { if (result == ESP_OK) result = r; };
attempt(esp_wifi_set_storage(WIFI_STORAGE_RAM));
attempt(esp_wifi_set_mode(WIFI_MODE_STA));
if (result == ESP_OK) {
result = esp_wifi_start();
p.started = (result == ESP_OK);
}
if (result != ESP_OK) {
stop();
return result;
}
attempt(esp_wifi_set_band_mode(WIFI_BAND_MODE_5G_ONLY));
attempt(esp_wifi_set_ps(WIFI_PS_NONE));
attempt(esp_wifi_set_max_tx_power(static_cast<std::int8_t>(c.transmit_power_dbm * 4)));
wifi_promiscuous_filter_t filter {};
filter.filter_mask = WIFI_PROMIS_FILTER_MASK_DATA;
attempt(esp_wifi_set_promiscuous_filter(&filter));
attempt(esp_wifi_set_promiscuous_rx_cb(Impl::receive));
attempt(esp_wifi_set_promiscuous(true));
if (result != ESP_OK) {
stop();
return result;
}
// 10 MHz channel bandwidth (ITS-G5 / 802.11p)
phy_11p_set(1, 0);
phy_change_channel(5000 + 5 * c.channel_number, 1, 0, 0); // = 5900 MHz
// Enable and arm hardware CCA counters (40 MHz baseband clock timebase) for DCC
phy_enable_cca();
phy_set_cca_cnt(0x07FFFFFF, true);
ESP_LOGI(TAG, "ITS-G5 802.11p radio started on channel %u (5900 MHz), %s",
unsigned(c.channel_number), c.laboratory_transmission ? "TX/RX" : "RX only");
return ESP_OK;
}
void C5Radio::stop() {
if (!impl_) return;
auto& p = *impl_;
if (p.started) esp_wifi_set_promiscuous(false);
{
std::lock_guard<std::mutex> lock(Impl::callback_mutex);
if (Impl::active == &p) Impl::active = nullptr;
if (p.queue) {
vQueueDelete(p.queue);
p.queue = nullptr;
}
}
if (p.started) esp_wifi_stop();
if (p.initialized) esp_wifi_deinit();
if (p.own_event_loop) esp_event_loop_delete_default();
p.started = p.initialized = p.own_event_loop = false;
}
vanetza_idf::Result C5Radio::request(vanetza_idf::AlDataRequest request) {
auto& p = *impl_;
if (!p.started) return vanetza_idf::Result::rejected;
if (!p.config.laboratory_transmission) return vanetza_idf::Result::unsupported;
if (request.bandwidth_mhz != 10 || request.channel_number != p.config.channel_number ||
request.transceiver_id != 0 || request.transceiver_mode || request.datastream_id ||
request.transmit_power_dbm != p.config.transmit_power_dbm) {
return vanetza_idf::Result::unsupported;
}
constexpr wifi_phy_rate_t rates[] = {
WIFI_PHY_RATE_6M, WIFI_PHY_RATE_9M, WIFI_PHY_RATE_12M,
WIFI_PHY_RATE_18M, WIFI_PHY_RATE_24M, WIFI_PHY_RATE_36M,
WIFI_PHY_RATE_48M, WIFI_PHY_RATE_54M
};
const auto index = static_cast<unsigned>(request.mcs);
if (index >= std::size(rates)) return vanetza_idf::Result::invalid_argument;
vanetza::ByteBuffer bytes;
const auto encoded = vanetza_idf::its_g5::encode_frame(request, p.sequence, bytes);
if (encoded != vanetza_idf::Result::accepted) {
ESP_LOGE(TAG, "encode_frame failed: %d", int(encoded));
return encoded;
}
p.sequence = (p.sequence + 1) & 4095;
wifi_tx_rate_config_t rate {};
rate.phymode = WIFI_PHY_MODE_11A;
rate.rate = rates[index];
// Transmit frame via 802.11p driver
const auto result = esp_wifi_80211_tx_custom(
WIFI_IF_STA, bytes.data(), bytes.size(), false,
&rate, WIFI_BAND_5G, WIFI_BW20);
if (result != ESP_OK) {
ESP_LOGW(TAG, "esp_wifi_80211_tx_custom failed: %s (0x%x)", esp_err_to_name(result), result);
}
return result == ESP_OK ? vanetza_idf::Result::accepted :
result == ESP_ERR_NO_MEM ? vanetza_idf::Result::resource_limit :
vanetza_idf::Result::rejected;
}
void C5Radio::poll(const Receive& receive, const Capture& capture) {
auto& p = *impl_;
if (!p.queue) return;
Impl::Raw raw {};
for (unsigned i = 0; i < p.config.receive_queue_length && xQueueReceive(p.queue, &raw, 0) == pdTRUE; ++i) {
if (capture) {
capture(vanetza::ByteBuffer(raw.bytes, raw.bytes + raw.length), raw.rssi, raw.timestamp);
}
vanetza_idf::AlDataIndication ind;
if (vanetza_idf::its_g5::decode_frame(raw.bytes, raw.length, true, ind) != vanetza_idf::Result::accepted) {
continue;
}
ind.channel_number = p.config.channel_number;
ind.received_power_dbm = raw.rssi;
if (receive) receive(std::move(ind));
}
}
std::uint32_t C5Radio::dropped_frames() const {
return impl_->dropped.load();
}
#if CONFIG_MICROBU_TEST_CHANNEL
esp_err_t C5Radio::transmit_burst(std::uint16_t channel, double power_dbm, unsigned mcs,
unsigned count, unsigned interval_ms, std::size_t payload_len) {
auto& p = *impl_;
if (!p.started) return ESP_ERR_INVALID_STATE;
if (channel < 172 || channel > 184 || channel % 2 != 0) return ESP_ERR_INVALID_ARG;
if (power_dbm < 2.0 || power_dbm > 20.0) return ESP_ERR_INVALID_ARG;
if (mcs > 7) return ESP_ERR_INVALID_ARG;
if (count == 0) return ESP_OK;
constexpr wifi_phy_rate_t rates[] = {
WIFI_PHY_RATE_6M, WIFI_PHY_RATE_9M, WIFI_PHY_RATE_12M,
WIFI_PHY_RATE_18M, WIFI_PHY_RATE_24M, WIFI_PHY_RATE_36M,
WIFI_PHY_RATE_48M, WIFI_PHY_RATE_54M
};
// Dynamically retune channel or adjust TX power if different from running config
if (channel != p.config.channel_number) {
phy_11p_set(1, 0);
phy_change_channel(5000 + 5 * channel, 1, 0, 0);
p.config.channel_number = channel;
}
const auto power_quarter_db = static_cast<std::int8_t>(std::round(power_dbm * 4.0)); // esp_wifi power is in 0.25 dBm units
esp_wifi_set_max_tx_power(power_quarter_db);
p.config.transmit_power_dbm = power_dbm;
wifi_tx_rate_config_t rate {};
rate.phymode = WIFI_PHY_MODE_11A;
rate.rate = rates[mcs];
// Assemble a standard IEEE 802.11 QoS data / LLC frame (EtherType 0x8947 GeoNetworking)
// Header: Frame Control (0x0088 QoS Data), Duration (0x0000), Addr1 (Broadcast FF..FF),
// Addr2 (Source 02:00:00:00:00:01), Addr3 (BSSID FF..FF), Sequence, QoS Control (0x0000),
// LLC/SNAP header (AA AA 03 00 00 00 89 47).
std::vector<std::uint8_t> frame;
const std::size_t actual_payload = std::clamp<std::size_t>(payload_len, 32, 1400);
frame.reserve(34 + actual_payload);
// MAC Header (26 bytes with QoS)
frame.push_back(0x88); frame.push_back(0x00); // Frame Control: QoS Data
frame.push_back(0x00); frame.push_back(0x00); // Duration
for (int i = 0; i < 6; ++i) frame.push_back(0xFF); // RA / Destination: Broadcast
frame.push_back(0x02); frame.push_back(0x00); frame.push_back(0x00);
frame.push_back(0x00); frame.push_back(0x00); frame.push_back(0x01); // TA / Source
for (int i = 0; i < 6; ++i) frame.push_back(0xFF); // BSSID: Broadcast
frame.push_back(0x00); frame.push_back(0x00); // Sequence (updated per frame)
frame.push_back(0x00); frame.push_back(0x00); // QoS Control
// LLC/SNAP header (8 bytes)
frame.push_back(0xAA); frame.push_back(0xAA); frame.push_back(0x03);
frame.push_back(0x00); frame.push_back(0x00); frame.push_back(0x00);
frame.push_back(0x89); frame.push_back(0x47); // EtherType 0x8947 (GeoNetworking)
// Test payload with identifiable sequence numbers
const std::size_t header_len = frame.size();
frame.resize(header_len + actual_payload, 0x5A);
esp_err_t last_err = ESP_OK;
for (unsigned i = 0; i < count; ++i) {
p.sequence = (p.sequence + 1) & 4095;
frame[22] = static_cast<std::uint8_t>((p.sequence << 4) & 0xF0);
frame[23] = static_cast<std::uint8_t>((p.sequence >> 4) & 0xFF);
// Put burst counter inside payload
frame[header_len + 0] = static_cast<std::uint8_t>(i & 0xFF);
frame[header_len + 1] = static_cast<std::uint8_t>((i >> 8) & 0xFF);
esp_err_t err = esp_wifi_80211_tx_custom(
WIFI_IF_STA, frame.data(), frame.size(), false,
&rate, WIFI_BAND_5G, WIFI_BW20);
if (err == ESP_ERR_NO_MEM) {
// Buffer briefly full: yield task to allow DMA descriptors to clear
vTaskDelay(pdMS_TO_TICKS(2));
err = esp_wifi_80211_tx_custom(
WIFI_IF_STA, frame.data(), frame.size(), false,
&rate, WIFI_BAND_5G, WIFI_BW20);
}
if (err != ESP_OK) {
last_err = err;
}
const auto delay_ms = std::max<unsigned>(interval_ms, 2);
if (i + 1 < count) {
vTaskDelay(pdMS_TO_TICKS(delay_ms));
}
}
return last_err;
}
CcaSampleResult C5Radio::sample_cca(unsigned duration_ms) {
CcaSampleResult result;
auto& p = *impl_;
if (!p.started) return result;
result.noise_floor_dbm = phy_get_noise_floor();
// Enable CCA hardware and arm the 27-bit cycle counters with full window (0x07FFFFFF).
phy_enable_cca();
phy_set_cca_cnt(0x07FFFFFF, true);
std::int32_t cca_cnt_before[2] = {};
phy_get_cca_cnt(cca_cnt_before);
const auto t_start = esp_timer_get_time();
const auto t_deadline = t_start + static_cast<std::int64_t>(duration_ms) * 1000;
std::int64_t last_t = t_start;
result.min_delta_us = std::numeric_limits<std::uint32_t>::max();
while (esp_timer_get_time() < t_deadline) {
std::int32_t cur_cnt[2] = {};
const auto status = phy_get_cca_cnt(cur_cnt);
const auto cca_threshold = phy_get_cca();
const auto now = esp_timer_get_time();
if (result.samples == 0) {
result.first_cca = cca_threshold;
} else {
const auto delta = static_cast<std::uint32_t>(now - last_t);
result.min_delta_us = std::min(result.min_delta_us, delta);
result.max_delta_us = std::max(result.max_delta_us, delta);
}
last_t = now;
result.last_cca = cca_threshold;
if (status) ++result.busy_count;
++result.samples;
// Cooperative yielding: prevent starving IDLE task, esp_timer, and bb_wdt
// on the single-core C5 during multi-millisecond polling windows.
if ((result.samples & 0x3F) == 0) {
taskYIELD();
}
}
result.duration_us = static_cast<std::uint32_t>(esp_timer_get_time() - t_start);
std::int32_t cca_cnt_after[2] = {};
result.cca_status = phy_get_cca_cnt(cca_cnt_after);
// Both words are 27-bit hardware counters (mask 0x07FFFFFF).
constexpr std::int32_t mask27 = 0x07FFFFFF;
auto delta27 = [](std::int32_t after, std::int32_t before) -> std::int32_t {
std::int32_t diff = (after & mask27) - (before & mask27);
if (diff < 0) diff += (mask27 + 1);
return diff;
};
result.cca_total_cycles_delta = delta27(cca_cnt_after[0], cca_cnt_before[0]);
result.cca_busy_cycles_delta = delta27(cca_cnt_after[1], cca_cnt_before[1]);
if (result.samples < 2) result.min_delta_us = 0;
return result;
}
#endif // CONFIG_MICROBU_TEST_CHANNEL
CcaCounters C5Radio::read_cca_counters() const {
CcaCounters c;
std::int32_t out[2] = {};
phy_get_cca_cnt(out);
constexpr std::int32_t mask27 = 0x07FFFFFF; // remomve the busy/status bit (bit 27) from the 27-bit hardware counters
c.total_cycles = static_cast<std::uint32_t>(out[0] & mask27);
c.busy_cycles = static_cast<std::uint32_t>(out[1] & mask27);
return c;
}
double C5Radio::calculate_cbr(const CcaCounters& current, const CcaCounters& previous) {
constexpr std::uint32_t counter_range = 1u << 27;
auto delta27 = [](std::uint32_t after, std::uint32_t before) -> std::uint32_t {
if (after >= before) {
return after - before;
}
// Counter wrapped from 2^27 - 1 back to zero.
return after + counter_range - before;
};
const std::uint32_t dt = delta27(current.total_cycles, previous.total_cycles);
const std::uint32_t db = delta27(current.busy_cycles, previous.busy_cycles);
if (dt == 0) return 0.0;
return static_cast<double>(db) / static_cast<double>(dt);
}
} // namespace microbu
-117
View File
@@ -1,117 +0,0 @@
#pragma once
#include <vanetza_idf/access.hpp>
#include <esp_err.h>
#include <functional>
#include <memory>
namespace microbu {
struct C5RadioConfig {
std::uint16_t channel_number = 180;
double transmit_power_dbm = 10.0;
unsigned receive_queue_length = 16;
bool laboratory_transmission = true;
};
#if CONFIG_MICROBU_TEST_CHANNEL
/// Result of polling the PHY's own (undocumented) CCA state as fast as possible for a
/// fixed window (feasibility probe for EN 303 797 clause 4.6.2)
/// to determine if the platform can observe channel-busy state at >=1 kHz at all.
struct CcaSampleResult {
std::uint32_t samples = 0;
std::uint32_t duration_us = 0;
std::uint32_t min_delta_us = 0;
std::uint32_t max_delta_us = 0;
std::uint32_t busy_count = 0;
std::int32_t first_cca = 0;
std::int32_t last_cca = 0;
std::int32_t noise_floor_dbm = 0;
std::int32_t cca_total_cycles_delta = 0; // out[0]/0x600a7c5c: 40 MHz free-running counter
std::int32_t cca_busy_cycles_delta = 0; // out[1]/0x600a7c60: increments while channel busy
std::int32_t cca_status = 0;
};
#endif
/// Snapshot of the ESP32-C5 27-bit hardware PHY counters for ETSI TS 102 687 / EN 303 797 DCC.
/// total_cycles: 40 MHz FE clock cycles (register 0x600a7c5c[26:0]).
/// busy_cycles: cycles during which received RF energy exceeded the CCA threshold (register 0x600a7c60[26:0]).
struct CcaCounters {
std::uint32_t total_cycles = 0;
std::uint32_t busy_cycles = 0;
};
/**
* ITS-G5 802.11p Radio Access Adapter for ESP32-C5 (EN 303 797 Annex B.2).
* Direct Wi-Fi promiscuous RX mode on 5.9 GHz (ITS-G5 Channel 180, 10 MHz BW)
* and 802.11p frame transmission via esp_wifi_80211_tx_custom.
*/
class C5Radio final : public vanetza_idf::Access {
public:
using Receive = std::function<void(vanetza_idf::AlDataIndication)>;
using Capture = std::function<void(const vanetza::ByteBuffer&, int, std::uint32_t)>;
/// @brief Constructs a radio adapter with the given configuration (not yet started).
/// @param config Radio configuration; defaults to channel 180, 10 dBm, 16-deep RX queue.
explicit C5Radio(C5RadioConfig config = {});
/// @brief Stops the radio and releases all resources.
~C5Radio() override;
C5Radio(const C5Radio&) = delete;
C5Radio& operator=(const C5Radio&) = delete;
/// @brief Initializes Wi-Fi in promiscuous 802.11p mode and starts the radio.
/// @return ESP_OK on success, otherwise an ESP-IDF error code.
esp_err_t start();
/// @brief Stops the radio and releases all resources.
void stop();
// vanetza_idf::Access
/// @brief Encodes and transmits an ITS-G5 frame requested by the access layer.
/// @param request Frame and transmit parameters from the access layer.
/// @return Result of the transmit attempt.
vanetza_idf::Result request(vanetza_idf::AlDataRequest request) override;
/// @brief Dispatches queued received frames to the owning task callback.
/// @param receive Invoked once per queued frame with its decoded indication.
/// @param capture Optional; invoked with the raw frame bytes, RSSI and timestamp for diagnostics.
void poll(const Receive& receive, const Capture& capture = {});
#if CONFIG_MICROBU_TEST_CHANNEL
/// @brief Sends a burst of raw 802.11p test frames for RF characterization.
/// @param channel ITS-G5 channel number to transmit on.
/// @param power_dbm Transmit power in dBm.
/// @param mcs 802.11p modulation and coding scheme index.
/// @param count Number of frames to send.
/// @param interval_ms Interval between frames in milliseconds.
/// @param payload_len Length of each frame's payload in bytes.
/// @return ESP_OK if all frames were sent, otherwise the last transmit error.
esp_err_t transmit_burst(std::uint16_t channel, double power_dbm, unsigned mcs,
unsigned count, unsigned interval_ms, std::size_t payload_len);
/// @brief Polls the CCA state as fast as possible for a fixed window (see CcaSampleResult); requires start().
/// @note This is a diagnostic tool/ feasibility probe for EN 303 797 clause 4.6.2 to determine if the platform can observe channel-busy state at >=1 kHz at all.
/// @param duration_ms Duration of the sampling window in milliseconds.
/// @return Sampling statistics and counter deltas over the window.
CcaSampleResult sample_cca(unsigned duration_ms);
#endif
/// @brief Non-blocking read of the hardware CCA counters for periodic DCC evaluation.
/// Continuous 40 MHz hardware integration satisfies EN 303 797 Profile-1 (>=1 kHz) with zero CPU busy-wait.
/// @return Snapshot of total and busy cycle counts.
CcaCounters read_cca_counters() const;
/// @brief Calculates Channel Busy Ratio (CBR) between two counter snapshots: delta(busy) / delta(total).
/// @param current More recent counter snapshot.
/// @param previous Earlier counter snapshot.
/// @return CBR in [0, 1].
static double calculate_cbr(const CcaCounters& current, const CcaCounters& previous);
/// @brief Number of frames dropped since start() due to queue overflow or invalid length.
/// @return Dropped frame count.
std::uint32_t dropped_frames() const;
private:
class Impl;
std::unique_ptr<Impl> impl_;
};
} // namespace microbu
@@ -1,186 +0,0 @@
#include "link_protocol.hpp"
namespace microbu::link {
bool encode(const Message& message, Bytes& out) {
if (header_size + message.body.size() > maximum_message) return false;
out.clear();
out.reserve(header_size + message.body.size());
out.push_back(static_cast<std::uint8_t>(message.header.opcode));
out.push_back(message.header.flags);
out.push_back(message.header.sequence & 0xFF);
out.push_back(message.header.sequence >> 8);
out.insert(out.end(), message.body.begin(), message.body.end());
return true;
}
bool decode(const Bytes& octets, Message& out) {
if (octets.size() < header_size || octets.size() > maximum_message) return false;
out.header.opcode = static_cast<Opcode>(octets[0]);
out.header.flags = octets[1];
out.header.sequence = octets[2] | (octets[3] << 8);
out.body.assign(octets.begin() + header_size, octets.end());
return true;
}
namespace {
void put_area(Writer& w, const DestinationArea& a) {
w.u8(a.shape); w.i32(a.latitude); w.i32(a.longitude); w.u16(a.distance_a); w.u16(a.distance_b); w.u16(a.angle);
}
DestinationArea get_area(Reader& r) {
DestinationArea a;
a.shape = r.u8(); a.latitude = r.i32(); a.longitude = r.i32(); a.distance_a = r.u16(); a.distance_b = r.u16(); a.angle = r.u16();
return a;
}
}
// STATION_CONFIGURE
Bytes encode(const StationConfigure& c) {
Writer w;
w.u8(c.station_type); w.u8(c.security); w.u8(c.address_configuration); w.bytes(c.mid, 6); w.u8(c.beaconing);
w.u16(c.channel_number); w.u8(c.transmit_power_dbm); w.u8(c.radio); w.u8(c.default_traffic_class); w.u8(c.default_lifetime);
return w.out;
}
bool decode(const Bytes& body, StationConfigure& c) {
Reader r(body);
c.station_type = r.u8(); c.security = r.u8(); c.address_configuration = r.u8(); r.bytes(c.mid, 6); c.beaconing = r.u8();
c.channel_number = r.u16(); c.transmit_power_dbm = r.u8(); c.radio = r.u8(); c.default_traffic_class = r.u8(); c.default_lifetime = r.u8();
return r.done() && c.security <= 1 && c.address_configuration <= 1 && c.beaconing <= 1 && c.radio <= 2;
}
// POTI_UPDATE
Bytes encode(const PotiUpdate& p) {
Writer w;
w.u64(p.timestamp_ms); w.i32(p.latitude); w.i32(p.longitude); w.u16(p.semi_major_cm); w.u16(p.semi_minor_cm);
w.u16(p.orientation_deci_degree); w.u8(p.flags); w.i32(p.altitude_cm); w.u16(p.speed_cm_s); w.u16(p.heading_deci_degree);
return w.out;
}
bool decode(const Bytes& body, PotiUpdate& p) {
Reader r(body);
p.timestamp_ms = r.u64(); p.latitude = r.i32(); p.longitude = r.i32(); p.semi_major_cm = r.u16(); p.semi_minor_cm = r.u16();
p.orientation_deci_degree = r.u16(); p.flags = r.u8(); p.altitude_cm = r.i32(); p.speed_cm_s = r.u16(); p.heading_deci_degree = r.u16();
return r.done();
}
// BTP_DATA_REQUEST
Bytes encode(const BtpDataRequest& q) {
Writer w;
w.u8(q.btp_type); w.u16(q.destination_port); w.u16(q.destination_port_info); w.u8(q.gn_packet_transport_type);
w.u8(q.gn_communication_profile); w.u8(q.gn_security_profile); w.u8(q.gn_traffic_class); w.u8(q.gn_maximum_packet_lifetime);
w.u8(q.gn_maximum_hop_limit); w.u16(q.gn_repetition_interval_ms); w.u16(q.gn_repetition_maximum_ms); w.u32(q.its_aid);
w.u8(static_cast<std::uint8_t>(q.permissions.size())); w.bytes(q.permissions);
w.u8(static_cast<std::uint8_t>(q.context.size())); w.bytes(q.context);
if (q.gn_packet_transport_type == 3) put_area(w, q.area.value_or(DestinationArea {}));
w.u16(static_cast<std::uint16_t>(q.fl_sdu.size())); w.bytes(q.fl_sdu);
return w.out;
}
bool decode(const Bytes& body, BtpDataRequest& q) {
Reader r(body);
q.btp_type = r.u8(); q.destination_port = r.u16(); q.destination_port_info = r.u16(); q.gn_packet_transport_type = r.u8();
q.gn_communication_profile = r.u8(); q.gn_security_profile = r.u8(); q.gn_traffic_class = r.u8(); q.gn_maximum_packet_lifetime = r.u8();
q.gn_maximum_hop_limit = r.u8(); q.gn_repetition_interval_ms = r.u16(); q.gn_repetition_maximum_ms = r.u16(); q.its_aid = r.u32();
q.permissions = r.bytes(r.u8());
q.context = r.bytes(r.u8());
q.area.reset();
if (q.gn_packet_transport_type == 3) q.area = get_area(r);
q.fl_sdu = r.bytes(r.u16());
return r.done() && q.btp_type <= 1 && q.permissions.size() <= 31;
}
// BTP_DATA_INDICATION
Bytes encode(const BtpDataIndication& i) {
Writer w;
w.u8(i.btp_type); w.u16(i.destination_port); w.u16(i.destination_port_info); w.u8(i.gn_packet_transport_type);
w.u8(i.gn_traffic_class); w.u8(i.gn_remaining_packet_lifetime); w.u8(i.gn_remaining_hop_limit);
w.bytes(i.source_gn_address, 8); w.u32(i.source_timestamp); w.i32(i.source_latitude); w.i32(i.source_longitude);
w.u8(i.security_report); w.u32(i.its_aid);
w.u8(static_cast<std::uint8_t>(i.permissions.size())); w.bytes(i.permissions);
w.u8(i.certificate_present ? 1 : 0); w.bytes(i.certificate_id, 8);
w.u8(i.area ? 1 : 0);
if (i.area) put_area(w, *i.area);
w.u16(static_cast<std::uint16_t>(i.received_fl_sdu.size())); w.bytes(i.received_fl_sdu);
return w.out;
}
bool decode(const Bytes& body, BtpDataIndication& i) {
Reader r(body);
i.btp_type = r.u8(); i.destination_port = r.u16(); i.destination_port_info = r.u16(); i.gn_packet_transport_type = r.u8();
i.gn_traffic_class = r.u8(); i.gn_remaining_packet_lifetime = r.u8(); i.gn_remaining_hop_limit = r.u8();
r.bytes(i.source_gn_address, 8); i.source_timestamp = r.u32(); i.source_latitude = r.i32(); i.source_longitude = r.i32();
i.security_report = r.u8(); i.its_aid = r.u32();
i.permissions = r.bytes(r.u8());
i.certificate_present = r.u8() != 0; r.bytes(i.certificate_id, 8);
i.area.reset();
if (r.u8()) i.area = get_area(r);
i.received_fl_sdu = r.bytes(r.u16());
return r.done();
}
// CREDENTIALS_PROVISION
Bytes encode(const CredentialsProvision& c) {
Writer w;
w.u16(c.total_length); w.u16(c.offset); w.u8(static_cast<std::uint8_t>(c.segment.size())); w.bytes(c.segment);
return w.out;
}
bool decode(const Bytes& body, CredentialsProvision& c) {
Reader r(body);
c.total_length = r.u16(); c.offset = r.u16(); c.segment = r.bytes(r.u8());
return r.done() && !c.segment.empty() && c.offset + c.segment.size() <= c.total_length;
}
// SF_IDCHANGE_EVENT
Bytes encode(const IdChangeEvent& e) {
Writer w;
w.u64(e.subscription); w.u8(e.command); w.bytes(e.id, 8);
w.u8(static_cast<std::uint8_t>(e.subscriber_data.size())); w.bytes(e.subscriber_data);
return w.out;
}
bool decode(const Bytes& body, IdChangeEvent& e) {
Reader r(body);
e.subscription = r.u64(); e.command = r.u8(); r.bytes(e.id, 8); e.subscriber_data = r.bytes(r.u8());
return r.done() && e.command <= 3;
}
Bytes encode(const IdChangeEventResponse& e) { Writer w; w.u64(e.subscription); w.u8(e.return_code); return w.out; }
bool decode(const Bytes& body, IdChangeEventResponse& e) {
Reader r(body);
e.subscription = r.u64(); e.return_code = r.u8();
return r.done() && e.return_code <= 1;
}
// STATUS
Bytes encode(const Status& s) {
Writer w;
w.u32(s.uptime_ms); w.u8(s.configured); w.bytes(s.gn_address, 8); w.bytes(s.identifier, 8); w.u8(s.change_pending); w.u8(s.tickets);
w.u32(s.signed_messages); w.u32(s.refused_no_ticket); w.u32(s.refused_change_pending); w.u32(s.refused_permission);
w.u32(s.sign_failed); w.u32(s.verified); w.u32(s.rejected);
w.u32(s.requests_accepted); w.u32(s.requests_refused); w.u32(s.indications);
w.u32(s.radio_submitted); w.u32(s.radio_failed); w.u32(s.radio_received); w.u32(s.radio_dropped);
w.u32(s.link_rx_frames); w.u32(s.link_crc_errors); w.u32(s.link_malformed); w.u32(s.poti_updates);
w.u64(s.its_time_ms);
return w.out;
}
bool decode(const Bytes& body, Status& s) {
Reader r(body);
s.uptime_ms = r.u32(); s.configured = r.u8(); r.bytes(s.gn_address, 8); r.bytes(s.identifier, 8); s.change_pending = r.u8(); s.tickets = r.u8();
s.signed_messages = r.u32(); s.refused_no_ticket = r.u32(); s.refused_change_pending = r.u32(); s.refused_permission = r.u32();
s.sign_failed = r.u32(); s.verified = r.u32(); s.rejected = r.u32();
s.requests_accepted = r.u32(); s.requests_refused = r.u32(); s.indications = r.u32();
s.radio_submitted = r.u32(); s.radio_failed = r.u32(); s.radio_received = r.u32(); s.radio_dropped = r.u32();
s.link_rx_frames = r.u32(); s.link_crc_errors = r.u32(); s.link_malformed = r.u32(); s.poti_updates = r.u32();
s.its_time_ms = r.u64();
return r.done();
}
// RESULT
Bytes encode(const Result& res) {
Writer w;
w.u8(static_cast<std::uint8_t>(res.code)); w.u8(static_cast<std::uint8_t>(res.detail.size())); w.bytes(res.detail);
return w.out;
}
bool decode(const Bytes& body, Result& res) {
Reader r(body);
res.code = static_cast<Code>(r.u8()); res.detail = r.bytes(r.u8());
return r.done();
}
} // namespace microbu::link
@@ -1,357 +0,0 @@
#pragma once
// micrOBU station-internal link, message layer version 1 (implementation/station-link/README.md).
// Transport independent: the same octets are one GATT attribute value later and one serial
// frame payload today. Little-endian integers; ETSI payloads inside stay opaque.
#include <cstddef>
#include <cstdint>
#include <optional>
#include <string>
#include <vector>
namespace microbu::link {
using Bytes = std::vector<std::uint8_t>;
constexpr std::size_t maximum_message = 512;
constexpr std::size_t header_size = 4;
/// @brief The message opcode, which determines the body type.
enum class Opcode : std::uint8_t {
// phone -> ESP32-C5
STATION_CONFIGURE = 0x01,
POTI_UPDATE = 0x02,
BTP_DATA_REQUEST = 0x03,
CREDENTIALS_PROVISION = 0x04,
CREDENTIALS_ERASE = 0x05,
SF_IDCHANGE_SUBSCRIBE = 0x06,
SF_IDCHANGE_UNSUBSCRIBE = 0x07,
SF_IDCHANGE_EVENT_RESPONSE = 0x08,
SF_IDCHANGE_TRIGGER = 0x09,
SF_ID_LOCK = 0x0A,
SF_ID_UNLOCK = 0x0B,
STATUS_REQUEST = 0x0C,
// ESP32-C5 -> phone
RESULT = 0x80,
BTP_DATA_INDICATION = 0x81,
SF_IDCHANGE_EVENT = 0x82,
STATUS = 0x84,
};
constexpr std::uint8_t flag_fragment_first = 0x01;
constexpr std::uint8_t flag_fragment_more = 0x02;
// RESULT codes: vanetza_idf::Result values first (same numbering), then link codes.
enum class Code : std::uint8_t {
accepted = 0, invalid_argument = 1, unsupported = 2, wrong_entry_point = 3, security_unavailable = 4,
resource_limit = 5, rejected = 6, time_regression = 7, identity_change_pending = 8,
unknown_opcode = 0x10, malformed = 0x11, not_configured = 0x12, busy = 0x13, no_credentials = 0x14,
};
/// @brief The header of a link message.
struct Header {
Opcode opcode;
std::uint8_t flags = 0;
std::uint16_t sequence = 0;
};
/// @brief A complete link message.
struct Message {
Header header;
Bytes body;
};
/// @brief Serializes header + body.
/// @param message Message to serialize.
/// @param out Receives the serialized bytes.
/// @return false when the result would exceed maximum_message.
bool encode(const Message& message, Bytes& out);
/// @brief Parses header + body.
/// @param octets Raw bytes to parse.
/// @param out Receives the decoded message.
/// @return false when shorter than the header or longer than maximum_message.
bool decode(const Bytes& octets, Message& out);
// ---- bodies ------------------------------------------------------------------------------
// security, address_configuration, default_traffic_class and default_lifetime are the GN protocol
// constants itsGnSecurity, itsGnLocalAddrConfMethod, itsGnDefaultTrafficClass and
// itsGnDefaultPacketLifetime of ETSI TS 103 836-4-1 (GeoNetworking) annex H; channel_number,
// transmit_power_dbm and radio are ITS-G5 access-layer parameters (ETSI EN 303 797).
struct StationConfigure {
std::uint8_t station_type = 2; // TS 102 894-2 StationType (2 = cyclist)
std::uint8_t security = 1; // itsGnSecurity
std::uint8_t address_configuration = 1; // 0 AUTO (mid below), 1 ANONYMOUS (ticket digest)
std::uint8_t mid[6] = {2, 0, 0, 0, 0, 1};
std::uint8_t beaconing = 1;
std::uint16_t channel_number = 180;
std::uint8_t transmit_power_dbm = 10;
std::uint8_t radio = 0; // 0 off, 1 receive only, 2 transmit and receive
std::uint8_t default_traffic_class = 2;
std::uint8_t default_lifetime = 0x05; // 1 s (base One_Second, multiplier 1)
};
/// @param body Raw message body bytes.
/// @param out Receives the decoded configuration.
/// @return false if malformed.
bool decode(const Bytes& body, StationConfigure& out);
/// @param value Configuration to encode.
/// @return Encoded body bytes.
Bytes encode(const StationConfigure& value);
// Position/confidence-ellipse/speed/heading fields are the ETSI TS 102 894-2 (Common Data
// Dictionary) ReferencePosition / PosConfidenceEllipse data types; pai() mirrors the Position
// Accuracy Indicator field of the GeoNetworking Long Position Vector (ETSI TS 103 836-4-1
// clause 9.5.2.2, table 2).
struct PotiUpdate {
std::uint64_t timestamp_ms = 0; // TimestampIts
std::int32_t latitude = 0; // 1/10 microdegree
std::int32_t longitude = 0;
std::uint16_t semi_major_cm = 0;
std::uint16_t semi_minor_cm = 0;
std::uint16_t orientation_deci_degree = 0;
std::uint8_t flags = 0; // bit0 altitude, bit1 speed, bit2 heading, bit3 PAI
std::int32_t altitude_cm = 0;
std::uint16_t speed_cm_s = 0;
std::uint16_t heading_deci_degree = 0;
bool has_altitude() const { return flags & 1; }
bool has_speed() const { return flags & 2; }
bool has_heading() const { return flags & 4; }
bool pai() const { return flags & 8; }
};
/// @param body Raw message body bytes.
/// @param out Receives the decoded fix.
/// @return false if malformed.
bool decode(const Bytes& body, PotiUpdate& out);
/// @param value Fix to encode.
/// @return Encoded body bytes.
Bytes encode(const PotiUpdate& value);
/// @brief The message body of a BTP_DATA_REQUEST (ETSI TS 103 836-4-1 annex J.2) request.
// GeoArea per ETSI TS 103 899 "Geographical Area Definition". shape follows the GEOBROADCAST_*/
// GEOANYCAST_* header sub-type encoding of ETSI TS 103 836-4-1 (GeoNetworking) clause 9.7.4 table 9
// (0 circle, 1 rectangle, 2 ellipse); position/distance_a/distance_b/angle are the GeoArea fields
// carried in the GBC/GAC extended header per clause 9.8.5 table 36.
struct DestinationArea {
std::uint8_t shape = 0; // 0 circle, 1 rectangle, 2 ellipse
std::int32_t latitude = 0;
std::int32_t longitude = 0;
std::uint16_t distance_a = 0;
std::uint16_t distance_b = 0;
std::uint16_t angle = 0;
};
// btp_type/destination_port(_info) are the BTP-A/BTP-B header fields of ETSI TS 103 836-5-1 (Basic
// Transport Protocol); the gn_* fields mirror the TRANSP_CORE.request service primitive parameters
// of ETSI TS 103 836-4-1 (GeoNetworking) annex J.2 (Packet transport type, Traffic class, Maximum
// hop limit, Repetition interval/maximum, Security profile); its_aid follows the ITS-AID registry
// of ETSI TS 102 965.
struct BtpDataRequest {
std::uint8_t btp_type = 1;
std::uint16_t destination_port = 0;
std::uint16_t destination_port_info = 0; // or source_port for BTP-A
std::uint8_t gn_packet_transport_type = 1; // 0 GUC, 1 SHB, 2 TSB, 3 GBC, 4 GAC (TS 103 836-4-1 annex J.2)
std::uint8_t gn_communication_profile = 1;
std::uint8_t gn_security_profile = 0; // 0 not given, 1 unsecured, 2 secured
std::uint8_t gn_traffic_class = 0xFF; // 0xFF = station default
std::uint8_t gn_maximum_packet_lifetime = 0xFF; // 0xFF = station default
std::uint8_t gn_maximum_hop_limit = 0; // 0 = station default
std::uint16_t gn_repetition_interval_ms = 0;
std::uint16_t gn_repetition_maximum_ms = 0;
std::uint32_t its_aid = 0;
Bytes permissions;
Bytes context;
std::optional<DestinationArea> area; // present for GBC
Bytes fl_sdu;
};
/// @param body Raw message body bytes.
/// @param out Receives the decoded request.
/// @return false if malformed.
bool decode(const Bytes& body, BtpDataRequest& out);
/// @param value Request to encode.
/// @return Encoded body bytes.
Bytes encode(const BtpDataRequest& value);
// Mirrors the TRANSP_CORE.indication service primitive parameters of ETSI TS 103 836-4-1
// (GeoNetworking) annex J.4: source_gn_address/source_timestamp/source_latitude/source_longitude
// are the sender's Long Position Vector (clause 9.5.2), security_report/certificate_* the Security
// report/Certificate id parameters, its_aid/permissions the ITS-AID/Security permissions
// parameters.
struct BtpDataIndication {
std::uint8_t btp_type = 1;
std::uint16_t destination_port = 0;
std::uint16_t destination_port_info = 0;
std::uint8_t gn_packet_transport_type = 1; // 0 GUC, 1 SHB, 2 TSB, 3 GBC, 4 GAC (TS 103 836-4-1 annex J.4)
std::uint8_t gn_traffic_class = 0;
std::uint8_t gn_remaining_packet_lifetime = 0xFF;
std::uint8_t gn_remaining_hop_limit = 0xFF;
std::uint8_t source_gn_address[8] = {};
std::uint32_t source_timestamp = 0;
std::int32_t source_latitude = 0;
std::int32_t source_longitude = 0;
std::uint8_t security_report = 0; // 0 unsecured, 1 + VerificationReport ordinal
std::uint32_t its_aid = 0;
Bytes permissions;
bool certificate_present = false;
std::uint8_t certificate_id[8] = {};
std::optional<DestinationArea> area;
Bytes received_fl_sdu;
};
/// @param body Raw message body bytes.
/// @param out Receives the decoded indication.
/// @return false if malformed.
bool decode(const Bytes& body, BtpDataIndication& out);
/// @param value Indication to encode.
/// @return Encoded body bytes.
Bytes encode(const BtpDataIndication& value);
// Segmented upload of an ETSI TS 102 941 (Trust and Privacy Management) credential bundle
// (root CA / enrolment or authorization authority certificates, authorization tickets), whose
// certificate encoding is ETSI TS 103 097.
struct CredentialsProvision {
std::uint16_t total_length = 0;
std::uint16_t offset = 0;
Bytes segment;
};
/// @param body Raw message body bytes.
/// @param out Receives the decoded segment.
/// @return false if malformed.
/// @note One CredentialsProvision carries a single segment of a larger bundle; total_length
/// and offset let the caller reassemble the full bundle across several messages.
bool decode(const Bytes& body, CredentialsProvision& out);
/// @param value Segment to encode.
/// @return Encoded body bytes.
Bytes encode(const CredentialsProvision& value);
// Counts of ETSI TS 102 941 credentials (root CA / EA-AA certificates / authorization tickets)
// applied from a CredentialsProvision bundle.
struct ApplyReport { std::uint8_t roots = 0, authorities = 0, tickets = 0; };
// Mirrors the security entity's pseudonym-change handshake that the GN Core subscribes to via the
// SN-IDCHANGE-SUBSCRIBE/-EVENT/-UNSUBSCRIBE primitives at the CORE_SEC interface (ETSI
// TS 103 836-4-1 clause 10.2.1.4); the pseudonym/Authorization Ticket change itself is governed by
// ETSI TS 102 941. command is the link's own PREPARE/COMMIT/ABORT/DEREG handshake state, not an
// ETSI-defined field.
struct IdChangeEvent {
std::uint64_t subscription = 0;
std::uint8_t command = 0; // 0 PREPARE, 1 COMMIT, 2 ABORT, 3 DEREG
std::uint8_t id[8] = {};
Bytes subscriber_data;
};
/// @param body Raw message body bytes.
/// @param out Receives the decoded event.
/// @return false if malformed.
bool decode(const Bytes& body, IdChangeEvent& out);
/// @param value Event to encode.
/// @return Encoded body bytes.
Bytes encode(const IdChangeEvent& value);
// The link's encoding of the SN-IDCHANGE-EVENT.response primitive (ETSI TS 103 836-4-1
// clause 10.2.1.4), acknowledging an IdChangeEvent.
struct IdChangeEventResponse { std::uint64_t subscription = 0; std::uint8_t return_code = 0; };
/// @param body Raw message body bytes.
/// @param out Receives the decoded response.
/// @return false if malformed.
bool decode(const Bytes& body, IdChangeEventResponse& out);
/// @param value Response to encode.
/// @return Encoded body bytes.
Bytes encode(const IdChangeEventResponse& value);
struct Status {
std::uint32_t uptime_ms = 0;
std::uint8_t configured = 0;
std::uint8_t gn_address[8] = {};
std::uint8_t identifier[8] = {};
std::uint8_t change_pending = 0;
std::uint8_t tickets = 0;
std::uint32_t signed_messages = 0, refused_no_ticket = 0, refused_change_pending = 0, refused_permission = 0,
sign_failed = 0, verified = 0, rejected = 0;
std::uint32_t requests_accepted = 0, requests_refused = 0, indications = 0;
std::uint32_t radio_submitted = 0, radio_failed = 0, radio_received = 0, radio_dropped = 0;
std::uint32_t link_rx_frames = 0, link_crc_errors = 0, link_malformed = 0, poti_updates = 0;
std::uint64_t its_time_ms = 0;
};
/// @param body Raw message body bytes.
/// @param out Receives the decoded status.
/// @return false if malformed.
bool decode(const Bytes& body, Status& out);
/// @param value Status to encode.
/// @return Encoded body bytes.
Bytes encode(const Status& value);
struct Result {
Code code = Code::accepted;
Bytes detail;
};
/// @param body Raw message body bytes.
/// @param out Receives the decoded result.
/// @return false if malformed.
bool decode(const Bytes& body, Result& out);
/// @param value Result to encode.
/// @return Encoded body bytes.
Bytes encode(const Result& value);
// ---- little-endian helpers shared with the test channel ----------------------------------
/// @note All multi-byte values are written little-endian.
class Writer {
public:
/// @brief Output buffer thats apppended to
Bytes out;
/// @brief Appends a single byte to the output.
void u8(std::uint8_t v) { out.push_back(v); }
/// @brief Appends a 16-bit unsigned integer to the output.
void u16(std::uint16_t v) { out.push_back(v & 0xFF); out.push_back(v >> 8); }
/// @brief Appends a 32-bit unsigned integer to the output.
void u32(std::uint32_t v) { for (int i = 0; i < 4; ++i) out.push_back((v >> (8 * i)) & 0xFF); }
/// @brief Appends a 64-bit unsigned integer to the output.
void u64(std::uint64_t v) { for (int i = 0; i < 8; ++i) out.push_back((v >> (8 * i)) & 0xFF); }
/// @brief Appends a 32-bit signed integer to the output.
void i32(std::int32_t v) { u32(static_cast<std::uint32_t>(v)); }
/// @brief Appends a sequence of bytes to the output.
void bytes(const std::uint8_t* p, std::size_t n) { out.insert(out.end(), p, p + n); }
/// @brief Appends a sequence of bytes to the output.
void bytes(const Bytes& b) { out.insert(out.end(), b.begin(), b.end()); }
};
/// @note All multi-byte values are read little-endian. Once a read runs past the end of the
/// buffer, ok() becomes false and all further reads return zero/empty instead of throwing.
class Reader {
public:
/// @param b Buffer to read from; must outlive the Reader.
/// @param at Starting offset into b.
Reader(const Bytes& b, std::size_t at = 0) : b_(b), at_(at) {}
bool ok() const { return ok_; }
bool done() const { return ok_ && at_ == b_.size(); }
std::size_t remaining() const { return b_.size() - at_; }
std::uint8_t u8() { return need(1) ? b_[at_++] : 0; }
std::uint16_t u16() { if (!need(2)) return 0; std::uint16_t v = b_[at_] | (b_[at_ + 1] << 8); at_ += 2; return v; }
std::uint32_t u32() { if (!need(4)) return 0; std::uint32_t v = 0; for (int i = 3; i >= 0; --i) v = (v << 8) | b_[at_ + i]; at_ += 4; return v; }
std::uint64_t u64() { if (!need(8)) return 0; std::uint64_t v = 0; for (int i = 7; i >= 0; --i) v = (v << 8) | b_[at_ + i]; at_ += 8; return v; }
std::int32_t i32() { return static_cast<std::int32_t>(u32()); }
bool bytes(std::uint8_t* p, std::size_t n) { if (!need(n)) return false; for (std::size_t i = 0; i < n; ++i) p[i] = b_[at_ + i]; at_ += n; return true; }
Bytes bytes(std::size_t n) { Bytes r; if (need(n)) { r.assign(b_.begin() + at_, b_.begin() + at_ + n); at_ += n; } return r; }
Bytes rest() { Bytes r(b_.begin() + at_, b_.end()); at_ = b_.size(); return r; }
private:
bool need(std::size_t n) { if (!ok_ || at_ + n > b_.size()) { ok_ = false; return false; } return true; }
const Bytes& b_;
std::size_t at_;
bool ok_ = true;
};
} // namespace microbu::link
@@ -1,155 +0,0 @@
#include "link_service.hpp"
#include "simple_ble.hpp"
#include "serial_link.hpp"
#include <esp_log.h>
#include <esp_timer.h>
namespace microbu {
namespace {
const char* TAG = "link";
}
LinkService::LinkService(Station& station) : station_(station) {
station_.on_indication([this](const link::BtpDataIndication& indication) {
broadcast(link::Opcode::BTP_DATA_INDICATION, ++own_sequence_, link::encode(indication));
});
station_.on_id_event([this](const link::IdChangeEvent& event) {
// PREPARE/COMMIT expect SF_IDCHANGE_EVENT_RESPONSE with this sequence
broadcast(link::Opcode::SF_IDCHANGE_EVENT, ++own_sequence_, link::encode(event));
});
}
void LinkService::send(LinkTransport transport, link::Opcode opcode, std::uint16_t sequence, const link::Bytes& body) {
link::Bytes octets;
link::Message message {{opcode, 0, sequence}, body};
if (!link::encode(message, octets)) { ESP_LOGE(TAG, "message 0x%02x too long (%u)", unsigned(opcode), unsigned(body.size())); return; }
if (transport == LinkTransport::serial) serial::write(serial::FrameType::LINK, octets);
else ble::write(octets);
}
void LinkService::broadcast(link::Opcode opcode, std::uint16_t sequence, const link::Bytes& body) {
send(LinkTransport::serial, opcode, sequence, body);
if (ble::connected()) send(LinkTransport::ble, opcode, sequence, body);
}
void LinkService::reply(LinkTransport transport, std::uint16_t sequence, link::Code code, const link::Bytes& detail) {
send(transport, link::Opcode::RESULT, sequence, link::encode(link::Result {code, detail}));
}
link::Status LinkService::status() {
auto value = station_.status();
const auto serial_counters = serial::counters();
const auto ble_counters = ble::counters();
value.link_rx_frames = serial_counters.frames + ble_counters.rx_messages;
value.link_crc_errors = serial_counters.crc_errors;
value.link_malformed += ble_counters.malformed;
return value;
}
void LinkService::handle(const link::Bytes& octets, LinkTransport origin) {
link::Message m;
if (!link::decode(octets, m)) return;
const auto seq = m.header.sequence;
using link::Opcode; using link::Code;
switch (m.header.opcode) {
case Opcode::STATION_CONFIGURE: {
link::StationConfigure c;
if (!link::decode(m.body, c)) return reply(origin, seq, Code::malformed);
link::Bytes detail;
const auto result = station_.configure(c, detail);
ESP_LOGI(TAG, "station configured: security %u, radio %u, result %d", unsigned(c.security), unsigned(c.radio), int(result));
return reply(origin, seq, result, detail);
}
case Opcode::POTI_UPDATE: {
link::PotiUpdate p;
if (!link::decode(m.body, p)) return reply(origin, seq, Code::malformed);
const auto result = station_.poti(p);
if (result != Code::accepted) reply(origin, seq, result); // accepted updates are silent
return;
}
case Opcode::BTP_DATA_REQUEST: {
link::BtpDataRequest q;
if (!link::decode(m.body, q)) return reply(origin, seq, Code::malformed);
return reply(origin, seq, station_.btp_request(q));
}
case Opcode::CREDENTIALS_PROVISION: {
link::CredentialsProvision c;
if (!link::decode(m.body, c)) return reply(origin, seq, Code::malformed);
if (c.offset == 0) { bundle_.clear(); bundle_total_ = c.total_length; }
if (c.total_length != bundle_total_ || c.offset != bundle_.size() || c.total_length > 8192) { bundle_.clear(); return reply(origin, seq, Code::malformed); }
bundle_.insert(bundle_.end(), c.segment.begin(), c.segment.end());
if (bundle_.size() < bundle_total_) return reply(origin, seq, Code::accepted);
link::ApplyReport report;
link::Code result = Code::accepted;
try {
result = station_.provision(bundle_, report);
} catch (const std::exception& e) {
ESP_LOGE(TAG, "provision exception: %s", e.what());
result = Code::invalid_argument;
}
bundle_.clear();
link::Bytes detail;
detail.push_back(static_cast<std::uint8_t>(report.roots));
detail.push_back(static_cast<std::uint8_t>(report.authorities));
detail.push_back(static_cast<std::uint8_t>(report.tickets));
return reply(origin, seq, result, detail);
}
case Opcode::CREDENTIALS_ERASE:
return reply(origin, seq, station_.erase_credentials());
case Opcode::SF_IDCHANGE_SUBSCRIBE: {
link::Reader r(m.body);
const auto data = r.bytes(r.u8());
if (!r.done()) {
ESP_LOGW(TAG, "SF_IDCHANGE_SUBSCRIBE malformed (len=%u)", (unsigned)m.body.size());
return reply(origin, seq, Code::malformed);
}
std::uint64_t handle = 0;
const auto result = station_.subscribe(data, handle);
ESP_LOGI(TAG, "SF_IDCHANGE_SUBSCRIBE handle=%llu result=%d", (unsigned long long)handle, int(result));
link::Writer w; w.u64(handle);
return reply(origin, seq, result, result == Code::accepted ? w.out : link::Bytes {});
}
case Opcode::SF_IDCHANGE_UNSUBSCRIBE: {
link::Reader r(m.body);
const auto handle = r.u64();
if (!r.done()) return reply(origin, seq, Code::malformed);
return reply(origin, seq, station_.unsubscribe(handle));
}
case Opcode::SF_IDCHANGE_EVENT_RESPONSE: {
link::IdChangeEventResponse e;
if (!link::decode(m.body, e)) return; // no reply defined for a response
station_.event_response(e.subscription, e.return_code != 0);
return;
}
case Opcode::SF_IDCHANGE_TRIGGER:
return reply(origin, seq, station_.trigger());
case Opcode::SF_ID_LOCK: {
link::Reader r(m.body);
const auto seconds = r.u8();
if (!r.done()) return reply(origin, seq, Code::malformed);
std::uint64_t handle = 0;
const auto result = station_.lock(seconds, handle);
link::Writer w; w.u64(handle);
return reply(origin, seq, result, result == Code::accepted ? w.out : link::Bytes {});
}
case Opcode::SF_ID_UNLOCK: {
link::Reader r(m.body);
const auto handle = r.u64();
if (!r.done()) return reply(origin, seq, Code::malformed);
return reply(origin, seq, station_.unlock(handle));
}
case Opcode::STATUS_REQUEST:
return send(origin, link::Opcode::STATUS, seq, link::encode(status()));
default:
return reply(origin, seq, Code::unknown_opcode);
}
}
void LinkService::tick() {
const auto now = esp_timer_get_time();
if (now - last_status_us_ < 1000000) return;
last_status_us_ = now;
broadcast(link::Opcode::STATUS, ++own_sequence_, link::encode(status()));
}
} // namespace microbu
@@ -1,51 +0,0 @@
#pragma once
// Dispatches station-link messages (phone -> micrOBU) to the station and sends the
// micrOBU -> phone messages (results, indications, identifier-change events, status).
#include "link_protocol.hpp"
#include "station.hpp"
namespace microbu {
/// @brief Represents the transport mechanism for link messages. Either BLE or Serial
enum class LinkTransport : std::uint8_t { serial, ble };
class LinkService {
public:
/// @brief Binds the service to a station and subscribes to its indication/id-event callbacks.
/// @param station Station to bind to; must outlive the LinkService.
explicit LinkService(Station& station);
/// @brief Decodes and dispatches one received message; call from the station task.
/// @param message Raw message bytes as received from the transport.
/// @param origin Transport the message arrived on; replies go back on the same transport.
void handle(const link::Bytes& message, LinkTransport origin);
/// @brief Periodic work: broadcasts STATUS once a second.
void tick();
private:
/// @brief Encodes and writes one message on the given transport.
/// @param transport Transport to write to.
/// @param opcode Message opcode.
/// @param sequence Sequence number to stamp on the header.
/// @param body Encoded message body.
void send(LinkTransport transport, link::Opcode opcode, std::uint16_t sequence, const link::Bytes& body);
/// @brief Sends a message on serial, and also on BLE when a phone is connected.
/// @param opcode Message opcode.
/// @param sequence Sequence number to stamp on the header.
/// @param body Encoded message body.
void broadcast(link::Opcode opcode, std::uint16_t sequence, const link::Bytes& body);
/// @brief Sends a RESULT reply to the message's origin transport.
/// @param origin Transport to reply on.
/// @param sequence Sequence number of the message being replied to.
/// @param code Result code to report.
/// @param detail Optional result detail bytes.
void reply(LinkTransport origin, std::uint16_t sequence, link::Code code, const link::Bytes& detail = {});
/// @brief Builds a STATUS snapshot from the station and transport counters.
/// @return Current status.
link::Status status();
Station& station_;
std::uint16_t own_sequence_ = 0;
link::Bytes bundle_; // credentials being provisioned
std::uint16_t bundle_total_ = 0;
std::int64_t last_status_us_ = 0;
};
} // namespace microbu
-4
View File
@@ -1,4 +0,0 @@
[mapping:newlib_memcmp]
archive: libnewlib.a
entries:
memcmp (noflash)
@@ -1,175 +0,0 @@
// Sourced from OpenTrafficMap's its-g5-receiver-firmware_txenabled, main/tx_custom.c
// (https://codeberg.org/opentrafficmap/its-g5-receiver-firmware_txenabled, pinned commit
// 674e34128279235ba34c9f8d778f43cf1d075397, no stated license). Reproduced here as generated by
// implementation/external/vanetza-idf/ports/esp_idf/radio_c5.cmake (which hashes the upstream file
// so this copy cannot silently drift) and checked in for readability. The struct layouts
// (x_eb_txdesc_t, x_middle_data_t, x_ebuf_t) and the bit-level manipulation of esp_wifi's private
// tx descriptor are OTM's own reverse engineering of the closed esp_wifi/libphy internals -- not
// documented or supported by Espressif, and not verified independently by this project. The one
// deliberate change from upstream is `result = ieee80211_post_hmac_tx(eb);` below: upstream
// discards that return value, but ESP_OK from this function means the driver accepted the frame
// for submission, not that it was independently observed on air (see
// experiments/evidence/c5-radio-characterization/phy-internals-investigation.md).
#include "esp_private/wifi_os_adapter.h"
#include "esp_wifi.h"
#include "otm_tx_custom.h"
#include <assert.h>
#include <stddef.h>
esp_err_t ieee80211_raw_frame_sanity_check(wifi_interface_t ifx, const void *buffer, int32_t len, bool en_sys_seq);
esp_err_t ieee80211_post_hmac_tx(void *ebuf);
void *ic_ebuf_alloc(const void *packet, uint32_t unknown, uint32_t len);
void *ic_get_default_sched(void);
extern wifi_osi_funcs_t *g_osi_funcs_p;
extern void *g_wifi_global_lock;
typedef struct x_eb_txdesc
{
uint32_t flags;
uint32_t field_4;
uint32_t field_8;
uint8_t rate;
uint8_t field_d;
uint8_t field_e;
uint8_t field_f;
uint32_t field_10;
uint32_t field_14;
uint32_t timestamp;
void* sched;
uint32_t field_20;
uint32_t field_24;
uint32_t field_28;
union {
uint32_t field_2c_32;
struct {
uint8_t field_2c;
uint8_t field_2d;
uint8_t field_2e;
uint8_t field_2f;
};
};
union {
uint32_t field_30_32;
struct {
uint8_t field_30;
uint8_t field_31;
uint8_t field_32;
uint8_t field_33;
};
};
uint32_t field_34;
uint32_t field_38;
uint32_t field_3c;
uint32_t field_40;
uint32_t field_44;
} x_eb_txdesc_t;
_Static_assert(sizeof(x_eb_txdesc_t) == 0x48, "eb_txdesc size");
typedef struct x_middle_data
{
uint32_t field_40;
uint8_t* buf;
uint32_t field_48;
uint32_t field_4c;
} x_middle_data_t;
_Static_assert(sizeof(x_middle_data_t) == 0x10, "middle_data size");
typedef struct x_ebuf
{
uint32_t field_0;
x_middle_data_t* ds_head;
x_middle_data_t* ds_tail;
uint16_t field_c;
uint16_t field_e;
uint32_t extra_data_start;
uint16_t header_length;
uint32_t data_length;
uint16_t field_1c;
uint8_t alloc_type;
uint8_t field_1f;
uint32_t field_20;
uint8_t field_24;
uint8_t field_25;
uint8_t field_26;
uint8_t field_27;
uint32_t field_28;
uint8_t field_2c;
uint32_t field_30;
uint32_t next_free;
x_eb_txdesc_t* txdesc;
uint16_t field_3c;
uint8_t field_3e;
uint8_t field_3f;
} x_ebuf_t;
_Static_assert(sizeof(x_ebuf_t) == 0x40, "ebuf size");
_Static_assert(offsetof(x_ebuf_t, txdesc) == 0x38, "ebuf txdesc offset");
_Static_assert(offsetof(x_eb_txdesc_t, rate) == 0x0c, "txdesc rate offset");
esp_err_t esp_wifi_80211_tx_custom(wifi_interface_t ifx, const void *buffer, int32_t len, bool en_sys_seq, wifi_tx_rate_config_t *tx_rate_config, wifi_band_t band, wifi_bandwidth_t bw)
{
esp_err_t result = 0;
if (!result)
{
g_osi_funcs_p->_mutex_lock(g_wifi_global_lock);
x_ebuf_t* eb = ic_ebuf_alloc(buffer, 1, len);
if (eb)
{
eb->data_length = 0;
x_eb_txdesc_t *txdesc_1 = eb->txdesc;
eb->header_length = len;
txdesc_1->flags |= 0x4000;
txdesc_1->sched = ic_get_default_sched();
wifi_phy_rate_t rate = tx_rate_config->rate;
x_eb_txdesc_t *txdesc = eb->txdesc;
if (rate)
txdesc->rate = (char)rate;
else if (band != WIFI_BAND_5G)
txdesc->rate = 0;
else
txdesc->rate = (char)WIFI_PHY_RATE_6M;
wifi_phy_mode_t phymode = tx_rate_config->phymode;
if (phymode == WIFI_PHY_MODE_HE20)
{
txdesc->flags |= 0x80000000;
txdesc->field_2f =
(char)((((uint32_t)tx_rate_config->ersu + 6) & 0xf) << 3)
| (txdesc->field_2f & 0x87);
if ((uint32_t)tx_rate_config->dcm)
txdesc->field_31 |= 0x80;
}
else if (phymode == WIFI_PHY_MODE_VHT20)
txdesc->flags |= 0x1000000;
// OTM's own comment on this line upstream: "No idea if this is correct, but this is
// what the original code does...". This project always passes WIFI_BW20 (see
// c5_radio.cpp), so bw_is_bw40 is always 0 here; see the investigation doc above for
// what is and isn't verified about ITS-G5's 10 MHz channel width on this path.
uint32_t bw_is_bw40 = bw == WIFI_BW40;
txdesc->field_8 = (bw_is_bw40 << 0xf) | (txdesc->field_8 & 0xffff7fff);
if (en_sys_seq)
txdesc->flags |= 1;
txdesc->field_10 =
(txdesc->field_10 & 0xfff3ffff) | ((ifx & WIFI_IF_MAX) << 0x12);
txdesc->field_14 = 0x100;
result = ieee80211_post_hmac_tx(eb);
g_osi_funcs_p->_mutex_unlock(g_wifi_global_lock);
}
else
{
result = ESP_ERR_NO_MEM;
g_osi_funcs_p->_mutex_unlock(g_wifi_global_lock);
}
}
return result;
}
@@ -1,23 +0,0 @@
#pragma once
// Sourced from OpenTrafficMap's its-g5-receiver-firmware_txenabled, main/tx_custom.h
// (https://codeberg.org/opentrafficmap/its-g5-receiver-firmware_txenabled, pinned commit
// 674e34128279235ba34c9f8d778f43cf1d075397, no stated license). That project reverse-engineered
// this ESP32-C5 ROM/libphy entry point -- it is not documented or supported by Espressif.
// See experiments/evidence/c5-radio-characterization/phy-internals-investigation.md for what
// this repository independently verified (by disassembling libphy.a) versus what is still an
// unverified upstream guess.
#include "esp_wifi.h"
#ifdef __cplusplus
extern "C" {
#endif
esp_err_t esp_wifi_80211_tx_custom(wifi_interface_t ifx, const void *buffer, int32_t len,
bool en_sys_seq, wifi_tx_rate_config_t *tx_rate_config,
wifi_band_t band, wifi_bandwidth_t bw);
#ifdef __cplusplus
}
#endif
@@ -1,139 +0,0 @@
#include "serial_link.hpp"
#include <driver/usb_serial_jtag.h>
#include <esp_log.h>
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <freertos/task.h>
#include <cstdarg>
#include <cstdio>
#include <cstring>
namespace microbu::serial {
namespace {
std::uint16_t crc16_ccitt_false_step(std::uint16_t crc, std::uint8_t octet) {
crc ^= static_cast<std::uint16_t>(octet) << 8;
for (int bit = 0; bit < 8; ++bit) crc = (crc & 0x8000) ? static_cast<std::uint16_t>((crc << 1) ^ 0x1021) : static_cast<std::uint16_t>(crc << 1);
return crc;
}
}
std::uint16_t crc16_ccitt_false(const std::uint8_t* data, std::size_t length) {
std::uint16_t crc = 0xFFFF;
for (std::size_t i = 0; i < length; ++i) crc = crc16_ccitt_false_step(crc, data[i]);
return crc;
}
Bytes encode_frame(FrameType type, const Bytes& payload) {
Bytes out;
out.reserve(payload.size() + 7);
out.push_back(0xAA); out.push_back(0x55);
out.push_back(static_cast<std::uint8_t>(type));
out.push_back(payload.size() & 0xFF); out.push_back(payload.size() >> 8);
out.insert(out.end(), payload.begin(), payload.end());
const auto crc = crc16_ccitt_false(out.data() + 2, out.size() - 2);
out.push_back(crc & 0xFF); out.push_back(crc >> 8);
return out;
}
void Decoder::feed(const std::uint8_t* data, std::size_t length, const Handler& handler) {
for (std::size_t i = 0; i < length; ++i) {
const std::uint8_t b = data[i];
switch (state_) {
case State::SYNC0: state_ = (b == 0xAA) ? State::SYNC1 : State::SYNC0; break;
case State::SYNC1: state_ = (b == 0x55) ? State::TYPE : (b == 0xAA ? State::SYNC1 : State::SYNC0); break;
case State::TYPE: type_ = b; state_ = State::LEN_LO; break;
case State::LEN_LO: length_ = b; state_ = State::LEN_HI; break;
case State::LEN_HI:
length_ |= static_cast<std::uint16_t>(b) << 8;
payload_.clear();
if (length_ > maximum_payload) state_ = State::SYNC0; // untrustworthy boundary: resync
else state_ = length_ == 0 ? State::CRC_LO : State::PAYLOAD;
break;
case State::PAYLOAD:
payload_.push_back(b);
if (payload_.size() >= length_) state_ = State::CRC_LO;
break;
case State::CRC_LO: crc_ = b; state_ = State::CRC_HI; break;
case State::CRC_HI: {
crc_ |= static_cast<std::uint16_t>(b) << 8;
// CRC over head then payload without concatenating them: same running value as encode_frame.
const std::uint8_t head[3] = {type_, static_cast<std::uint8_t>(length_ & 0xFF), static_cast<std::uint8_t>(length_ >> 8)};
std::uint16_t crc = 0xFFFF;
for (auto octet : head) crc = crc16_ccitt_false_step(crc, octet);
for (auto octet : payload_) crc = crc16_ccitt_false_step(crc, octet);
if (crc == crc_) { ++frames_; handler(Frame {static_cast<FrameType>(type_), payload_}); }
else ++crc_errors_;
state_ = State::SYNC0;
break;
}
}
}
}
namespace {
SemaphoreHandle_t writer_lock = nullptr;
Counters the_counters;
Decoder::Handler frame_handler;
vprintf_like_t previous_vprintf = nullptr;
bool write_all(const Bytes& frame) {
std::size_t sent = 0;
while (sent < frame.size()) {
const int count = usb_serial_jtag_write_bytes(frame.data() + sent, frame.size() - sent, pdMS_TO_TICKS(500));
if (count <= 0) return false; // the host detects an incomplete frame by CRC
sent += count;
}
return true;
}
// ESP_LOG sink: one LOG frame per call, never a raw write into the frame stream.
int log_to_frame(const char* format, va_list args) {
char line[256];
const int length = std::vsnprintf(line, sizeof line, format, args);
if (length <= 0) return 0;
std::size_t n = static_cast<std::size_t>(length) < sizeof line ? length : sizeof line - 1;
while (n > 0 && (line[n - 1] == '\n' || line[n - 1] == '\r')) --n;
if (n == 0) return length;
// esp_log colour escapes are stripped by the emulator; keep the line verbatim otherwise.
write(FrameType::LOG, Bytes(line, line + n));
return length;
}
void reader_task(void*) {
Decoder decoder;
std::uint8_t buffer[512];
for (;;) {
const int count = usb_serial_jtag_read_bytes(buffer, sizeof buffer, pdMS_TO_TICKS(20));
if (count > 0) {
decoder.feed(buffer, static_cast<std::size_t>(count), frame_handler);
the_counters.frames = decoder.frames();
the_counters.crc_errors = decoder.crc_errors();
}
}
}
}
void start(const Decoder::Handler& on_frame) {
frame_handler = on_frame;
writer_lock = xSemaphoreCreateMutex();
usb_serial_jtag_driver_config_t config {};
config.rx_buffer_size = 8192;
config.tx_buffer_size = 8192;
ESP_ERROR_CHECK(usb_serial_jtag_driver_install(&config));
previous_vprintf = esp_log_set_vprintf(log_to_frame);
xTaskCreate(reader_task, "link_rx", 6144, nullptr, 12, nullptr);
}
bool write(FrameType type, const Bytes& payload) {
if (payload.size() > maximum_payload || !writer_lock) return false;
const auto frame = encode_frame(type, payload);
if (xSemaphoreTake(writer_lock, pdMS_TO_TICKS(1000)) != pdTRUE) { ++the_counters.write_failures; return false; }
const bool ok = write_all(frame);
xSemaphoreGive(writer_lock);
if (!ok) ++the_counters.write_failures;
return ok;
}
Counters counters() { return the_counters; }
} // namespace microbu::serial
@@ -1,71 +0,0 @@
#pragma once
// Serial transport of the station-internal link over the ESP32-C5 native USB Serial/JTAG port.
// Framing is the app's Phase 03 one: [AA][55][type][len LE][payload][crc16 LE] with
// CRC-16/CCITT-FALSE over type+len+payload (implementation/station-link/README.md, "Serial").
// One writer serialises complete frames; ESP_LOG output travels as LOG frames so the byte
// stream never mixes text with a binary frame.
#include <cstddef>
#include <cstdint>
#include <functional>
#include <vector>
namespace microbu::serial {
/// @brief Byte buffer type for frame payloads.
using Bytes = std::vector<std::uint8_t>;
/// @brief The type of a frame. Either Link (link protocol), Test (test channel), or Log (ESP_LOG output).
enum class FrameType : std::uint8_t { LINK = 0x10, TEST = 0x11, LOG = 0x7F };
constexpr std::size_t maximum_payload = 1536;
/// @brief A complete frame with a type and a payload.
struct Frame { FrameType type; Bytes payload; };
/// @brief Computes the CRC-16/CCITT-FALSE checksum over a byte range.
/// @param data Pointer to the first byte to checksum.
/// @param length Number of bytes to checksum.
/// @return Checksum value.
std::uint16_t crc16_ccitt_false(const std::uint8_t* data, std::size_t length);
/// @brief Frames a payload as [AA][55][type][len LE][payload][crc16 LE].
/// @param type Frame type tag.
/// @param payload Payload bytes; must not exceed maximum_payload.
/// @return Encoded frame bytes.
Bytes encode_frame(FrameType type, const Bytes& payload);
/// Byte-at-a-time decoder, same state machine as the app's SerialFrameDecoder.
class Decoder {
public:
using Handler = std::function<void(Frame)>;
/// @brief Feeds raw bytes through the frame state machine, invoking the handler per complete frame.
/// @param data Pointer to the first byte to feed.
/// @param length Number of bytes to feed.
/// @param handler Invoked once per complete, CRC-valid frame; may be called zero or more times.
void feed(const std::uint8_t* data, std::size_t length, const Handler& handler);
/// @return Number of frames rejected for a CRC mismatch so far.
std::uint32_t crc_errors() const { return crc_errors_; }
/// @return Number of frames decoded successfully so far.
std::uint32_t frames() const { return frames_; }
private:
enum class State { SYNC0, SYNC1, TYPE, LEN_LO, LEN_HI, PAYLOAD, CRC_LO, CRC_HI } state_ = State::SYNC0;
std::uint8_t type_ = 0;
std::uint16_t length_ = 0, crc_ = 0;
Bytes payload_;
std::uint32_t crc_errors_ = 0, frames_ = 0;
};
struct Counters { std::uint32_t frames = 0, crc_errors = 0, write_failures = 0; };
/// @brief Installs the USB Serial/JTAG driver, starts the reader task and routes ESP_LOG into LOG frames.
/// @param on_frame Invoked once per complete, CRC-valid frame.
/// @note The handler runs on the reader task; it must only enqueue, never block.
void start(const Decoder::Handler& on_frame);
/// @brief Writes one complete frame (blocking, mutex-protected). Safe from any task.
/// @param type Frame type tag.
/// @param payload Payload bytes; must not exceed maximum_payload.
/// @return false on a full payload, missing driver, lock timeout, or write failure.
bool write(FrameType type, const Bytes& payload);
/// @return Current frame/error counters.
Counters counters();
} // namespace microbu::serial
@@ -1,441 +0,0 @@
#include "simple_ble.hpp"
#include "sdkconfig.h"
#include <esp_log.h>
#include <esp_mac.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <nimble/ble.h>
#include <host/ble_att.h>
#include <host/ble_gap.h>
#include <host/ble_gatt.h>
#include <host/ble_hs.h>
#include <host/ble_hs_mbuf.h>
#include <host/ble_sm.h>
#include <host/ble_uuid.h>
#include <host/util/util.h>
#include <nimble/nimble_port.h>
#include <nimble/nimble_port_freertos.h>
#include <services/gap/ble_svc_gap.h>
#include <services/gatt/ble_svc_gatt.h>
#include <cstdio>
#include <cstring>
extern "C" void ble_store_config_init(void);
namespace microbu::ble {
namespace {
const char* TAG = "cits_ble";
constexpr uint32_t FIXED_PASSKEY = 123456;
// Base UUID: 0000xxxx-ba5e-4c17-8000-00805f9b34fb
// Little-endian byte order for NimBLE (16 bytes, index 15 down to 0):
#define CITS_UUID_BASE(id_lo, id_hi) \
0xfb, 0x34, 0x9b, 0x5f, 0x80, 0x00, 0x00, 0x80, \
0x17, 0x4c, 0x5e, 0xba, (id_lo), (id_hi), 0x00, 0x00
static const ble_uuid128_t s_svc_uuid = BLE_UUID128_INIT(CITS_UUID_BASE(0x75, 0xc1));
static const ble_uuid128_t s_chr_btp_req_uuid = BLE_UUID128_INIT(CITS_UUID_BASE(0x76, 0xc1));
static const ble_uuid128_t s_chr_btp_ind_uuid = BLE_UUID128_INIT(CITS_UUID_BASE(0x77, 0xc1));
static const ble_uuid128_t s_chr_poti_uuid = BLE_UUID128_INIT(CITS_UUID_BASE(0x78, 0xc1));
static const ble_uuid128_t s_chr_status_uuid = BLE_UUID128_INIT(CITS_UUID_BASE(0x79, 0xc1));
static const ble_uuid128_t s_chr_id_uuid = BLE_UUID128_INIT(CITS_UUID_BASE(0x7a, 0xc1));
static const ble_uuid128_t s_chr_config_uuid = BLE_UUID128_INIT(CITS_UUID_BASE(0x7b, 0xc1));
static const ble_uuid128_t s_chr_result_uuid = BLE_UUID128_INIT(CITS_UUID_BASE(0x7c, 0xc1));
enum class ChrId : uintptr_t {
BtpRequest = 1,
BtpIndication = 2,
Poti = 3,
Status = 4,
IdChange = 5,
Configure = 6,
Result = 7,
};
Receiver s_receiver;
Counters statistics;
char s_dev_name[32] = "micrOBU";
uint16_t s_btp_ind_val_handle = 0;
uint16_t s_status_val_handle = 0;
uint16_t s_id_val_handle = 0;
uint16_t s_result_val_handle = 0;
volatile uint16_t s_conn_handle = BLE_HS_CONN_HANDLE_NONE;
int s_notify_count = 0; // number of characteristics with active CCCD subscription
bool s_subscribed = false;
uint8_t s_own_addr_type = 0;
// GATT characteristic read/write callback, shared by all characteristics (arg identifies which).
int chr_access(uint16_t conn_handle, uint16_t attr_handle,
struct ble_gatt_access_ctxt *ctxt, void *arg) {
uintptr_t id = reinterpret_cast<uintptr_t>(arg);
if (ctxt->op == BLE_GATT_ACCESS_OP_WRITE_CHR) {
uint16_t total = OS_MBUF_PKTLEN(ctxt->om);
if (total == 0 || total > link::maximum_message) {
ESP_LOGW(TAG, "rx invalid length: %u", (unsigned)total);
++statistics.malformed;
return BLE_ATT_ERR_INVALID_ATTR_VALUE_LEN;
}
link::Bytes buf(total);
uint16_t copied = 0;
int rc = ble_hs_mbuf_to_flat(ctxt->om, buf.data(), total, &copied);
if (rc != 0 || copied != total) {
++statistics.malformed;
return BLE_ATT_ERR_UNLIKELY;
}
++statistics.rx_messages;
if (s_receiver) {
// Require valid link header (opcode + flags + seq).
// Host->station opcodes are 0x01..0x0F; station->host are 0x80+.
if (total >= link::header_size && buf[0] >= 0x01 && buf[0] <= 0x0F) {
ESP_LOGD(TAG, "rx chr %u op=0x%02x len=%u", (unsigned)id, buf[0], (unsigned)total);
s_receiver(std::move(buf));
} else {
ESP_LOGW(TAG, "rx chr %u unframed or invalid op=0x%02x len=%u", (unsigned)id, buf[0], (unsigned)total);
++statistics.malformed;
return BLE_ATT_ERR_INVALID_ATTR_VALUE_LEN;
}
}
return 0;
} else if (ctxt->op == BLE_GATT_ACCESS_OP_READ_CHR) {
if (id == static_cast<uintptr_t>(ChrId::Status)) {
return 0; // empty read; status is pushed via notify
}
}
return BLE_ATT_ERR_READ_NOT_PERMITTED;
}
#define CHR_FLAG_RW_ENC (BLE_GATT_CHR_F_READ_ENC | BLE_GATT_CHR_F_READ_AUTHEN | \
BLE_GATT_CHR_F_WRITE_ENC | BLE_GATT_CHR_F_WRITE_AUTHEN)
#define CHR_FLAG_NOTIFY_ENC (BLE_GATT_CHR_F_NOTIFY_INDICATE_ENC | \
BLE_GATT_CHR_F_NOTIFY_INDICATE_AUTHEN)
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wmissing-field-initializers"
static struct ble_gatt_chr_def s_chr_defs[] = {
{
// NF-SAP: BTP-DATA.request (Central -> Station Write)
.uuid = &s_chr_btp_req_uuid.u,
.access_cb = chr_access,
.arg = reinterpret_cast<void*>(ChrId::BtpRequest),
.flags = BLE_GATT_CHR_F_WRITE | CHR_FLAG_RW_ENC,
},
{
// NF-SAP: BTP-DATA.indication (Station -> Central Notify)
.uuid = &s_chr_btp_ind_uuid.u,
.access_cb = chr_access,
.arg = reinterpret_cast<void*>(ChrId::BtpIndication),
.flags = BLE_GATT_CHR_F_NOTIFY | CHR_FLAG_NOTIFY_ENC,
.val_handle = &s_btp_ind_val_handle,
},
{
// PoTi: Position & Time Fix Update (Central -> Station Write)
.uuid = &s_chr_poti_uuid.u,
.access_cb = chr_access,
.arg = reinterpret_cast<void*>(ChrId::Poti),
.flags = BLE_GATT_CHR_F_WRITE | CHR_FLAG_RW_ENC,
},
{
// Station Status (Station -> Central Read/Notify)
.uuid = &s_chr_status_uuid.u,
.access_cb = chr_access,
.arg = reinterpret_cast<void*>(ChrId::Status),
.flags = BLE_GATT_CHR_F_READ | BLE_GATT_CHR_F_NOTIFY | CHR_FLAG_RW_ENC | CHR_FLAG_NOTIFY_ENC,
.val_handle = &s_status_val_handle,
},
{
// SF-SAP: Identity Change Event (Station -> Central Notify)
.uuid = &s_chr_id_uuid.u,
.access_cb = chr_access,
.arg = reinterpret_cast<void*>(ChrId::IdChange),
.flags = BLE_GATT_CHR_F_WRITE | BLE_GATT_CHR_F_NOTIFY | CHR_FLAG_RW_ENC | CHR_FLAG_NOTIFY_ENC,
.val_handle = &s_id_val_handle,
},
{
// Station Configure / Credentials (Central -> Station Write)
.uuid = &s_chr_config_uuid.u,
.access_cb = chr_access,
.arg = reinterpret_cast<void*>(ChrId::Configure),
.flags = BLE_GATT_CHR_F_WRITE | CHR_FLAG_RW_ENC,
},
{
// Result / Response (Station -> Central Notify)
.uuid = &s_chr_result_uuid.u,
.access_cb = chr_access,
.arg = reinterpret_cast<void*>(ChrId::Result),
.flags = BLE_GATT_CHR_F_NOTIFY | CHR_FLAG_NOTIFY_ENC,
.val_handle = &s_result_val_handle,
},
{0}
};
static struct ble_gatt_svc_def s_svc_defs[] = {
{
.type = BLE_GATT_SVC_TYPE_PRIMARY,
.uuid = &s_svc_uuid.u,
.characteristics = s_chr_defs,
},
{0}
};
#pragma GCC diagnostic pop
int start_advertising();
// NimBLE GAP callback: connect/disconnect, pairing (fixed-passkey), and notify subscriptions.
int gap_event(struct ble_gap_event *event, void *arg) {
struct ble_gap_conn_desc desc;
switch (event->type) {
case BLE_GAP_EVENT_CONNECT:
ESP_LOGI(TAG, "connect status=%d handle=%d", event->connect.status, event->connect.conn_handle);
if (event->connect.status == 0) {
s_conn_handle = event->connect.conn_handle;
s_subscribed = false;
struct ble_gap_upd_params params = {};
params.itvl_min = 12; // 15 ms
params.itvl_max = 16; // 20 ms
params.latency = 0;
params.supervision_timeout = 400; // 4 s
params.min_ce_len = 0;
params.max_ce_len = 0;
ble_gap_update_params(event->connect.conn_handle, &params);
ble_gap_security_initiate(event->connect.conn_handle);
} else {
start_advertising();
}
return 0;
case BLE_GAP_EVENT_DISCONNECT:
ESP_LOGI(TAG, "disconnect reason=%d", event->disconnect.reason);
s_conn_handle = BLE_HS_CONN_HANDLE_NONE;
s_subscribed = false;
s_notify_count = 0;
start_advertising();
return 0;
case BLE_GAP_EVENT_REPEAT_PAIRING:
if (ble_gap_conn_find(event->repeat_pairing.conn_handle, &desc) == 0) {
ble_store_util_delete_peer(&desc.peer_id_addr);
}
return BLE_GAP_REPEAT_PAIRING_RETRY;
case BLE_GAP_EVENT_PASSKEY_ACTION:
if (event->passkey.params.action == BLE_SM_IOACT_DISP) {
struct ble_sm_io pkey = {};
pkey.action = BLE_SM_IOACT_DISP;
pkey.passkey = FIXED_PASSKEY;
ESP_LOGW(TAG, "BLE PAIRING FIXED PASSKEY: %06lu", static_cast<unsigned long>(FIXED_PASSKEY));
int rc = ble_sm_inject_io(event->passkey.conn_handle, &pkey);
if (rc != 0) {
ESP_LOGW(TAG, "ble_sm_inject_io rc=%d", rc);
}
} else {
ESP_LOGW(TAG, "unhandled passkey action %d", event->passkey.params.action);
}
return 0;
case BLE_GAP_EVENT_SUBSCRIBE:
if (event->subscribe.attr_handle == s_btp_ind_val_handle ||
event->subscribe.attr_handle == s_status_val_handle ||
event->subscribe.attr_handle == s_id_val_handle ||
event->subscribe.attr_handle == s_result_val_handle) {
if (event->subscribe.cur_notify && !event->subscribe.prev_notify)
++s_notify_count;
else if (!event->subscribe.cur_notify && event->subscribe.prev_notify)
--s_notify_count;
if (s_notify_count < 0) s_notify_count = 0;
s_subscribed = (s_notify_count > 0);
ESP_LOGI(TAG, "subscription handle %u changed: notify=%d count=%d",
event->subscribe.attr_handle, (int)event->subscribe.cur_notify, s_notify_count);
}
return 0;
case BLE_GAP_EVENT_ADV_COMPLETE:
start_advertising();
return 0;
default:
return 0;
}
}
// (Re)starts undirected advertising with the device name and service UUID.
int start_advertising() {
const char* name = s_dev_name;
size_t name_len = strlen(name);
struct ble_hs_adv_fields adv = {};
adv.flags = BLE_HS_ADV_F_DISC_GEN | BLE_HS_ADV_F_BREDR_UNSUP;
adv.tx_pwr_lvl_is_present = 1;
adv.tx_pwr_lvl = BLE_HS_ADV_TX_PWR_LVL_AUTO;
adv.name = reinterpret_cast<const uint8_t*>(name);
adv.name_len = static_cast<uint8_t>(name_len);
adv.name_is_complete = 1;
int rc = ble_gap_adv_set_fields(&adv);
if (rc != 0) {
ESP_LOGE(TAG, "adv_set_fields rc=%d", rc);
return rc;
}
struct ble_hs_adv_fields rsp = {};
rsp.uuids128 = &s_svc_uuid;
rsp.num_uuids128 = 1;
rsp.uuids128_is_complete = 1;
rc = ble_gap_adv_rsp_set_fields(&rsp);
if (rc != 0) {
ESP_LOGE(TAG, "adv_rsp_set_fields rc=%d", rc);
return rc;
}
struct ble_gap_adv_params params = {};
params.conn_mode = BLE_GAP_CONN_MODE_UND;
params.disc_mode = BLE_GAP_DISC_MODE_GEN;
rc = ble_gap_adv_start(s_own_addr_type, nullptr, BLE_HS_FOREVER,
&params, gap_event, nullptr);
if (rc != 0) {
ESP_LOGE(TAG, "adv_start rc=%d", rc);
return rc;
}
ESP_LOGI(TAG, "BLE advertising started as '%s'", name);
return 0;
}
// NimBLE host reset callback.
void on_reset(int reason) {
ESP_LOGE(TAG, "NimBLE reset, reason=%d", reason);
}
// NimBLE host sync callback: resolves our address and starts advertising.
void on_sync() {
int rc = ble_hs_util_ensure_addr(0);
assert(rc == 0);
rc = ble_hs_id_infer_auto(0, &s_own_addr_type);
if (rc != 0) {
ESP_LOGE(TAG, "ble_hs_id_infer_auto rc=%d", rc);
return;
}
start_advertising();
}
void nimble_host_task(void* param) {
ESP_LOGI(TAG, "NimBLE host task running");
nimble_port_run();
nimble_port_freertos_deinit();
}
} // namespace
bool start(Receiver receiver) {
s_receiver = std::move(receiver);
uint8_t mac[6] = {};
if (esp_read_mac(mac, ESP_MAC_BT) == ESP_OK) {
std::snprintf(s_dev_name, sizeof(s_dev_name), "micrOBU-%02X%02X", mac[4], mac[5]);
}
esp_err_t err = nimble_port_init();
if (err != ESP_OK) {
ESP_LOGE(TAG, "nimble_port_init failed: %d", err);
return false;
}
ble_hs_cfg.reset_cb = on_reset;
ble_hs_cfg.sync_cb = on_sync;
ble_hs_cfg.store_status_cb = ble_store_util_status_rr;
// Fixed passkey LE Secure Connections + Bonding
ble_hs_cfg.sm_io_cap = BLE_HS_IO_DISPLAY_ONLY;
ble_hs_cfg.sm_sc = 1;
ble_hs_cfg.sm_bonding = 1;
ble_hs_cfg.sm_mitm = 1;
ble_hs_cfg.sm_our_key_dist = BLE_SM_PAIR_KEY_DIST_ENC | BLE_SM_PAIR_KEY_DIST_ID;
ble_hs_cfg.sm_their_key_dist = BLE_SM_PAIR_KEY_DIST_ENC | BLE_SM_PAIR_KEY_DIST_ID;
ble_svc_gap_init();
ble_svc_gatt_init();
ble_svc_gap_device_name_set(s_dev_name);
int rc = ble_gatts_count_cfg(s_svc_defs);
if (rc != 0) {
ESP_LOGE(TAG, "ble_gatts_count_cfg failed: %d", rc);
nimble_port_deinit();
return false;
}
rc = ble_gatts_add_svcs(s_svc_defs);
if (rc != 0) {
ESP_LOGE(TAG, "ble_gatts_add_svcs failed: %d", rc);
nimble_port_deinit();
return false;
}
ble_store_config_init();
nimble_port_freertos_init(nimble_host_task);
ESP_LOGI(TAG, "simple_ble initialized (fixed passkey %06lu)", static_cast<unsigned long>(FIXED_PASSKEY));
return true;
}
bool write(const link::Bytes& message) {
uint16_t conn_handle = s_conn_handle;
if (conn_handle == BLE_HS_CONN_HANDLE_NONE) {
++statistics.tx_failed;
return false;
}
if (message.empty() || message.size() > link::maximum_message) {
++statistics.tx_failed;
return false;
}
uint16_t val_handle = s_result_val_handle;
if (message.size() >= link::header_size) {
link::Opcode op = static_cast<link::Opcode>(message[0]);
switch (op) {
case link::Opcode::BTP_DATA_INDICATION:
val_handle = s_btp_ind_val_handle;
break;
case link::Opcode::SF_IDCHANGE_EVENT:
val_handle = s_id_val_handle;
break;
case link::Opcode::STATUS:
// Route unsolicited or requested STATUS over the subscribed result/reply characteristic
val_handle = s_result_val_handle;
break;
case link::Opcode::RESULT:
default:
val_handle = s_result_val_handle;
break;
}
}
struct os_mbuf *om = ble_hs_mbuf_from_flat(message.data(), message.size());
if (!om) {
++statistics.tx_failed;
return false;
}
int rc = ble_gatts_notify_custom(conn_handle, val_handle, om);
if (rc != 0) {
ESP_LOGW(TAG, "notify failed handle=%u op=0x%02x rc=%d", (unsigned)val_handle, (unsigned)(message.empty() ? 0 : message[0]), rc);
if (rc == BLE_HS_ENOTSUP) os_mbuf_free_chain(om);
++statistics.tx_failed;
return false;
}
++statistics.tx_messages;
return true;
}
bool connected() {
return s_conn_handle != BLE_HS_CONN_HANDLE_NONE && s_subscribed;
}
bool is_subscribed() {
return s_subscribed;
}
Counters counters() {
return statistics;
}
} // namespace microbu::ble
@@ -1,53 +0,0 @@
#pragma once
#include "link_protocol.hpp"
#include <cstdint>
#include <functional>
namespace microbu::ble {
using Receiver = std::function<void(link::Bytes)>;
/// @brief A set of counters for tracking BLE message traffic.
struct Counters {
std::uint32_t rx_messages = 0;
std::uint32_t tx_messages = 0;
std::uint32_t tx_failed = 0;
std::uint32_t malformed = 0;
};
/**
* @brief Starts a simple, bonded BLE GATT peripheral on the NimBLE host.
* Uses structured ETSI C-ITS Station Service & Characteristics:
* Service: 0000C175-BA5E-4C17-8000-00805F9B34FB
* BTP-DATA.request: 0000C176-BA5E-4C17-8000-00805F9B34FB (Write, Authenticated/Encrypted)
* BTP-DATA.indication: 0000C177-BA5E-4C17-8000-00805F9B34FB (Notify, Authenticated/Encrypted)
* PoTi Fix Update: 0000C178-BA5E-4C17-8000-00805F9B34FB (Write, Authenticated/Encrypted)
* Station Status: 0000C179-BA5E-4C17-8000-00805F9B34FB (Read / Notify, Authenticated/Encrypted)
* SF-SAP Identity: 0000C17A-BA5E-4C17-8000-00805F9B34FB (Notify, Authenticated/Encrypted)
* Station Configure: 0000C17B-BA5E-4C17-8000-00805F9B34FB (Write, Authenticated/Encrypted)
* Result: 0000C17C-BA5E-4C17-8000-00805F9B34FB (Notify, Authenticated/Encrypted)
*
* Fixed passkey bonding: 123456.
* Single ATT message transmission/reception up to 512 bytes without fragmentation chunk delays.
* @param receiver Invoked with each decoded station-link message written by the phone.
* @return false if the NimBLE host or GATT service failed to start.
*/
bool start(Receiver receiver);
/**
* @brief Sends one complete station-link message via GATT notification.
* Dispatches immediately without queuing delay.
* @param message Encoded station-link message to notify.
* @return false if not connected/subscribed or the notification could not be sent.
*/
bool write(const link::Bytes& message);
/// @return true if a phone is connected.
bool connected();
/// @return true if the phone has subscribed to notifications.
bool is_subscribed();
/// @return Current message/error counters.
Counters counters();
} // namespace microbu::ble
-579
View File
@@ -1,579 +0,0 @@
#include "station.hpp"
#include <vanetza_idf/nf_sap.hpp>
#include <vanetza_idf/sf_sap.hpp>
#include <vanetza_idf/nvs_credential_store.hpp>
#include <vanetza/geonet/serialization_buffer.hpp>
#include <vanetza/geonet/areas.hpp>
#include <vanetza/units/angle.hpp>
#include <vanetza/units/length.hpp>
#include <vanetza/units/velocity.hpp>
#include <esp_log.h>
#include <esp_timer.h>
#include "c5_radio.hpp"
#include <algorithm>
#include <chrono>
#include <cmath>
namespace microbu {
using namespace vanetza_idf;
using vanetza::ByteBuffer;
namespace gn = vanetza::geonet;
namespace {
const char* TAG = "station";
link::Code code(Result result) { return static_cast<link::Code>(result); }
// Numbering of the link's "Packet transport type" field matches the TRANSP_CORE.request/.indication
// service primitive parameter of ETSI TS 103 836-4-1 annex J.2/J.4 (0 GUC, 1 SHB, 2 TSB, 3 GBC,
// 4 GAC); it does not reuse the HT wire encoding of clause 9.7.4 table 9, which numbers these
// differently (GEOUNICAST=2, GEOANYCAST=3, GEOBROADCAST=4, TSB=5, no separate SHB value there).
std::uint8_t transport_number(gn::TransportType t) {
switch (t) {
case gn::TransportType::GUC: return 0;
case gn::TransportType::SHB: return 1;
case gn::TransportType::TSB: return 2;
case gn::TransportType::GBC: return 3;
case gn::TransportType::GAC: return 4;
}
return 0;
}
std::optional<gn::TransportType> transport_from(std::uint8_t n) {
switch (n) {
case 0: return gn::TransportType::GUC;
case 1: return gn::TransportType::SHB;
case 2: return gn::TransportType::TSB;
case 3: return gn::TransportType::GBC;
case 4: return gn::TransportType::GAC;
}
return std::nullopt;
}
// Wire DestinationArea -> vanetza gn::Area (GBC destination).
gn::Area area_from(const link::DestinationArea& a) {
gn::Area area;
switch (a.shape) {
case 0: { gn::Circle c; c.r = double(a.distance_a) * vanetza::units::si::meter; area.shape = c; break; }
case 1: { gn::Rectangle r; r.a = double(a.distance_a) * vanetza::units::si::meter; r.b = double(a.distance_b) * vanetza::units::si::meter; area.shape = r; break; }
default: { gn::Ellipse e; e.a = double(a.distance_a) * vanetza::units::si::meter; e.b = double(a.distance_b) * vanetza::units::si::meter; area.shape = e; break; }
}
area.position = gn::GeodeticPosition(a.latitude / 1.0e7 * vanetza::units::degree, a.longitude / 1.0e7 * vanetza::units::degree);
area.angle = vanetza::units::Angle(double(a.angle) * vanetza::units::degree);
return area;
}
// gn::Area -> wire DestinationArea (the inverse of area_from).
struct AreaToLink : boost::static_visitor<link::DestinationArea> {
const gn::Area& area;
explicit AreaToLink(const gn::Area& a) : area(a) {}
link::DestinationArea common() const {
link::DestinationArea out;
out.latitude = static_cast<std::int32_t>(area.position.latitude.value() * 1.0e7);
out.longitude = static_cast<std::int32_t>(area.position.longitude.value() * 1.0e7);
out.angle = static_cast<std::uint16_t>(area.angle.value());
return out;
}
link::DestinationArea operator()(const gn::Circle& c) const { auto o = common(); o.shape = 0; o.distance_a = c.r.value(); return o; }
link::DestinationArea operator()(const gn::Rectangle& r) const { auto o = common(); o.shape = 1; o.distance_a = r.a.value(); o.distance_b = r.b.value(); return o; }
link::DestinationArea operator()(const gn::Ellipse& e) const { auto o = common(); o.shape = 2; o.distance_a = e.a.value(); o.distance_b = e.b.value(); return o; }
};
// 0 unsecured (no security envelope), 1 + VerificationReport ordinal otherwise
struct ReportToLink : boost::static_visitor<std::uint8_t> {
ReportToLink() = default;
std::uint8_t operator()(boost::blank) const { return 0; }
std::uint8_t operator()(vanetza::security::VerificationReport r) const { return 1 + static_cast<std::uint8_t>(r); }
};
}
// Credentials, backend, trust configuration, ticket pool and the entity built on them.
struct Station::Security {
BackendMbedTls backend;
security::TrustConfiguration trust;
security::CertificatePool pool {backend};
std::unique_ptr<security::SecurityEntity> entity;
bool loaded = false;
security::ApplyReport report;
};
std::int64_t Station::Clock::now_us() const {
return base_its_us + (esp_timer_get_time() - base_esp_us);
}
Station::Station() = default;
Station::~Station() { teardown(); }
void Station::teardown() {
rebuilding_ = true; // the DEREG of TS 102 723-8 Figure 15 is ours, not the phone's
stack_.reset(); // router timers before the runtime
// AccessStack::Dcc's Limeric holds a reference to *runtime_ and calls into it from its
// destructor (Runtime::cancel): it must be destroyed before runtime_ is.
access_stack_.reset();
security_.reset();
runtime_.reset();
dcc_cca_last_.reset();
dcc_last_sample_its_us_ = 0;
pending_responders_.clear();
rebuilding_ = false;
}
// TS 102 894-2 station config -> the GeoNetworking MIB (TS 103 836-4-1).
void Station::apply_mib(StackConfig& config, const link::StationConfigure& c) const {
config.mib.itsGnLocalGnAddr.mid(vanetza::MacAddress {c.mid[0], c.mid[1], c.mid[2], c.mid[3], c.mid[4], c.mid[5]});
config.mib.itsGnLocalGnAddr.station_type(static_cast<gn::StationType>(c.station_type & 0x1F));
config.mib.itsGnLocalGnAddr.is_manually_configured(c.address_configuration == 0);
config.mib.itsGnLocalAddrConfMethod = c.address_configuration == 1 ? gn::AddrConfMethod::Anonymous : gn::AddrConfMethod::Auto;
config.mib.itsGnSecurity = c.security == 1;
config.mib.vanetzaDisableBeaconing = c.beaconing == 0;
config.mib.itsGnDefaultTrafficClass = gn::TrafficClass(c.default_traffic_class);
gn::Lifetime lifetime; lifetime.raw(c.default_lifetime);
config.mib.itsGnDefaultPacketLifetime = lifetime;
config.mib.itsGnMaxPacketLifetime = lifetime < config.mib.itsGnMaxPacketLifetime ? config.mib.itsGnMaxPacketLifetime : lifetime;
config.radio_parameters.channel_number = c.channel_number;
config.radio_parameters.transmit_power_dbm = c.transmit_power_dbm;
}
// Carry the phone's SF-SAP pseudonym-change subscriptions (TS 102 723-8 clause 6.3)
// over to the identity manager of a freshly rebuilt security entity.
void Station::resubscribe_id_change() {
std::map<std::uint64_t, std::uint64_t> renewed;
for (const auto& [handle, old_service] : link_subscriptions_) {
(void)old_service;
renewed[handle] = security_->entity->id_change().subscribe(
[this, handle](security::IdChangeCommand command, const security::Identifier& id, const ByteBuffer& data,
std::shared_ptr<security::IdChangeResponder> responder) {
link::IdChangeEvent event;
event.subscription = handle;
event.command = static_cast<std::uint8_t>(command);
std::copy(id.begin(), id.end(), event.id);
event.subscriber_data = data;
if (responder) pending_responders_[handle] = responder;
if (id_event_ && !rebuilding_) id_event_(event);
});
}
link_subscriptions_ = renewed;
}
// (Re)build stack and security entity from config_, the stored credentials and the current clock.
link::Code Station::build() {
teardown();
const auto& c = *config_;
runtime_ = std::make_unique<vanetza::ManualRuntime>(vanetza::Clock::time_point(std::chrono::microseconds(clock_.synchronised ? clock_.now_us() : 0)));
StackConfig config;
apply_mib(config, c);
radio_parameters_ = config.radio_parameters;
vanetza::security::SecurityEntity* entity = nullptr;
if (config.mib.itsGnSecurity) {
security_ = std::make_unique<Security>();
security::Credentials credentials;
security::NvsCredentialStore store;
const auto load = store.load(credentials);
if (load == Result::accepted) {
security_->report = security::apply(credentials, security_->trust, security_->pool);
security_->loaded = security_->report.result == Result::accepted && !security_->pool.empty();
ESP_LOGI(TAG, "credentials from NVS: %u roots, %u authorities, %u tickets (result %d)",
unsigned(security_->report.roots), unsigned(security_->report.authorities), unsigned(security_->report.tickets),
int(security_->report.result));
} else if (load == Result::rejected) {
ESP_LOGW(TAG, "no credentials in NVS: secured requests will be refused until provisioned");
} else {
ESP_LOGE(TAG, "stored credentials do not decode (result %d)", int(load));
}
security_->entity = std::make_unique<security::SecurityEntity>(*runtime_, *this, security_->backend, security_->pool, security_->trust);
entity = security_->entity.get();
resubscribe_id_change();
}
stack_ = std::make_unique<Stack>(config, *runtime_, *this, entity);
stack_->on_receive([this](BtpIndication indication) { deliver(std::move(indication)); });
#if CONFIG_MICROBU_TEST_CHANNEL
stack_->on_receive_gn([this](GnIndication indication) { record(3, std::move(indication.data)); });
#endif
stack_->on_access_result([](Result) { /* counted in Station::request, the adapter itself */ });
// DCC_ACC gate in front of whatever radio_ currently is (rebuilt here rather than in
// Station::configure() because it must reference the fresh runtime_, not a torn-down one --
// see the destruction-order comment in Station::teardown()).
if (radio_) {
access_stack_ = std::make_unique<AccessStack>(*radio_);
access_stack_->enable_dcc(*runtime_);
stack_->report_tx_power(static_cast<unsigned>(std::lround(radio_parameters_.transmit_power_dbm)));
}
dcc_cca_last_.reset();
dcc_last_sample_its_us_ = clock_.synchronised ? clock_.now_us() : 0;
if (have_fix_) apply_position();
return link::Code::accepted;
}
link::Code Station::configure(const link::StationConfigure& c, link::Bytes& detail) {
const bool radio_changed = !config_ || config_->radio != c.radio || config_->channel_number != c.channel_number ||
config_->transmit_power_dbm != c.transmit_power_dbm;
if (radio_changed) {
radio_.reset();
if (c.radio != 0) {
C5RadioConfig rc;
rc.channel_number = c.channel_number;
rc.transmit_power_dbm = c.transmit_power_dbm;
rc.laboratory_transmission = c.radio == 2;
radio_ = std::make_unique<C5Radio>(rc);
const auto error = radio_->start();
if (error != ESP_OK) {
ESP_LOGE(TAG, "radio start failed: %s", esp_err_to_name(error));
radio_.reset();
return link::Code::rejected;
}
ESP_LOGI(TAG, "radio on channel %u, %s", unsigned(c.channel_number), c.radio == 2 ? "transmit and receive" : "receive only");
}
}
config_ = c;
link_subscriptions_.clear(); // a configuration starts a phone session: earlier subscriptions are void
counters_ = link::Status {}; // and the session's counters start at zero
const auto result = build();
if (result != link::Code::accepted) return result;
link::Writer w;
ByteBuffer address;
gn::serialize_into_buffer(stack_->address(), address);
w.bytes(address);
security::Identifier identifier {};
if (security_ && security_->entity) identifier = security_->entity->id_change().current_identifier();
w.bytes(identifier.data(), 8);
w.u8(security_ && security_->loaded ? 1 : 0);
w.u8(security_ ? static_cast<std::uint8_t>(std::min<std::size_t>(security_->pool.size(), 255)) : 0);
detail = w.out;
counters_.configured = 1;
return link::Code::accepted;
}
void Station::apply_position() {
if (!stack_ || !have_fix_) return;
// never ahead of the station clock (Stack::update_position rejects that)
const auto now = runtime_->now();
if (fix_.timestamp > now) fix_.timestamp = now;
const auto result = stack_->update_position(fix_);
if (result != Result::accepted) ESP_LOGW(TAG, "position rejected: %d", int(result));
}
link::Code Station::poti(const link::PotiUpdate& p) {
if (p.latitude < -900000000 || p.latitude > 900000000 || p.longitude < -1800000000 || p.longitude > 1800000000)
return link::Code::invalid_argument;
// ITS clock: the phone's PoTi time is the reference; the local esp_timer runs between updates.
const std::int64_t its_us = static_cast<std::int64_t>(p.timestamp_ms) * 1000;
const std::int64_t esp_us = esp_timer_get_time();
link::Code result = link::Code::accepted;
if (!clock_.synchronised) {
clock_ = Clock {true, its_us, esp_us};
if (config_) build(); // the runtime started at 0: restart it at real time
} else {
const std::int64_t drift = its_us - clock_.now_us();
if (drift >= 0) {
clock_.base_its_us = its_us; clock_.base_esp_us = esp_us; // forward: step immediately
} else if (drift > -1000000) {
// small backward drift: hold the local clock, it catches up with the next updates
} else {
// the reference moved back by more than a second: restart the station at that time
ESP_LOGW(TAG, "ITS time moved back by %lld ms, restarting the station", static_cast<long long>(-drift / 1000));
clock_ = Clock {true, its_us, esp_us};
if (config_) build();
result = link::Code::time_regression;
}
}
fix_ = vanetza::PositionFix {};
fix_.timestamp = vanetza::Clock::time_point(std::chrono::microseconds(std::min(its_us, clock_.now_us())));
fix_.latitude = p.latitude / 1.0e7 * vanetza::units::degree;
fix_.longitude = p.longitude / 1.0e7 * vanetza::units::degree;
fix_.confidence.semi_major = p.semi_major_cm / 100.0 * vanetza::units::si::meter;
fix_.confidence.semi_minor = p.semi_minor_cm / 100.0 * vanetza::units::si::meter;
fix_.confidence.orientation = p.orientation_deci_degree / 10.0 * vanetza::units::true_north_degrees;
fix_.speed = (p.has_speed() ? p.speed_cm_s / 100.0 : 0.0) * vanetza::units::si::meters_per_second;
fix_.course = (p.has_heading() ? p.heading_deci_degree / 10.0 : 0.0) * vanetza::units::true_north_degrees;
if (p.has_altitude()) fix_.altitude = vanetza::ConfidentQuantity<vanetza::units::Length>(p.altitude_cm / 100.0 * vanetza::units::si::meter);
have_fix_ = true;
++counters_.poti_updates;
tick();
apply_position();
return result;
}
link::Code Station::btp_request(const link::BtpDataRequest& q) {
if (!stack_) return link::Code::not_configured;
if (!clock_.synchronised || !have_fix_) { ++counters_.requests_refused; return link::Code::rejected; }
NF_SAP::BTP_DATA_request request;
request.fl_sdu = q.fl_sdu;
request.length = q.fl_sdu.size();
request.btp_type = q.btp_type == 0 ? BtpType::a : BtpType::b;
request.destination_port = q.destination_port;
if (request.btp_type == BtpType::a) request.source_port = q.destination_port_info;
else request.destination_port_info = q.destination_port_info;
const auto transport = transport_from(q.gn_packet_transport_type);
if (!transport) { ++counters_.requests_refused; return link::Code::invalid_argument; }
request.gn_packet_transport_type = *transport;
switch (q.gn_communication_profile) {
case 0: request.gn_communication_profile = gn::CommunicationProfile::Unspecified; break;
case 1: request.gn_communication_profile = gn::CommunicationProfile::ITS_G5; break;
case 2: request.gn_communication_profile = gn::CommunicationProfile::LTE_V2X; break;
default: ++counters_.requests_refused; return link::Code::invalid_argument;
}
// Mandatory signing: all transmissions over the radio must be signed
if (q.gn_security_profile == 1) {
ESP_LOGW(TAG, "unsecured transmission rejected: all transmissions must be signed");
++counters_.requests_refused;
return link::Code::rejected;
}
if (q.gn_security_profile != 0 && q.gn_security_profile != 2) {
++counters_.requests_refused;
return link::Code::invalid_argument;
}
request.gn_security_profile = NF_SAP::SecurityProfile::SECURED;
request.gn_traffic_class = q.gn_traffic_class == 0xFF ? stack_->config().mib.itsGnDefaultTrafficClass : gn::TrafficClass(q.gn_traffic_class);
if (q.gn_maximum_packet_lifetime != 0xFF) { gn::Lifetime l; l.raw(q.gn_maximum_packet_lifetime); request.gn_maximum_packet_lifetime = l; }
if (q.gn_maximum_hop_limit) request.gn_maximum_hop_limit = q.gn_maximum_hop_limit;
if (q.gn_repetition_interval_ms) {
gn::DataRequest::Repetition repetition;
repetition.interval = (q.gn_repetition_interval_ms / 1000.0) * vanetza::units::si::seconds;
repetition.maximum = (q.gn_repetition_maximum_ms / 1000.0) * vanetza::units::si::seconds;
request.gn_repetition = repetition;
}
if (*transport == gn::TransportType::GBC) {
if (!q.area) { ++counters_.requests_refused; return link::Code::invalid_argument; }
request.gn_destination_address = area_from(*q.area);
}
request.its_aid = q.its_aid;
request.permissions = q.permissions;
request.context_information = q.context;
tick(); // the packet carries the current time and position
ESP_LOGI(TAG, "heap before sign: free=%lu min=%lu dma=%lu stack_hwm=%u",
(unsigned long)esp_get_free_heap_size(),
(unsigned long)esp_get_minimum_free_heap_size(),
(unsigned long)heap_caps_get_free_size(MALLOC_CAP_DMA),
(unsigned)uxTaskGetStackHighWaterMark(nullptr));
Result result = Result::rejected;
try {
result = NF_SAP::BTP_DATA_request_submit(*stack_, std::move(request));
if (result == Result::accepted) ++counters_.requests_accepted; else ++counters_.requests_refused;
} catch (const std::exception& e) {
ESP_LOGE(TAG, "BTP_DATA_request_submit exception: %s", e.what());
++counters_.requests_refused;
return link::Code::rejected;
}
return code(result);
}
void Station::deliver(BtpIndication received) {
auto indication = NF_SAP::BTP_DATA_indication_from(std::move(received));
++counters_.indications;
#if CONFIG_MICROBU_TEST_CHANNEL
// The test channel sees every BTP indication as well (kind 2, the HIL SUT layout).
if (mirror_ != Mirror::off) {
link::Writer w;
w.u8(indication.btp_type == BtpType::b ? 1 : 0);
const auto port = indication.destination_port;
const auto info = indication.source_port.value_or(indication.destination_port_info.value_or(0));
w.out.push_back(port >> 8); w.out.push_back(port & 0xFF); // big-endian like hil_sut.cpp
w.out.push_back(info >> 8); w.out.push_back(info & 0xFF);
w.bytes(indication.received_fl_sdu);
record(2, w.out);
}
#endif
if (!indication_) return;
link::BtpDataIndication out;
out.btp_type = indication.btp_type == BtpType::b ? 1 : 0;
out.destination_port = indication.destination_port;
out.destination_port_info = indication.source_port.value_or(indication.destination_port_info.value_or(0));
out.gn_packet_transport_type = transport_number(indication.gn.transport_type);
out.gn_traffic_class = indication.gn.traffic_class.raw();
out.gn_remaining_packet_lifetime = indication.gn.remaining_packet_lifetime ? indication.gn.remaining_packet_lifetime->raw() : 0xFF;
out.gn_remaining_hop_limit = indication.gn.remaining_hop_limit ? static_cast<std::uint8_t>(std::min(*indication.gn.remaining_hop_limit, 254u)) : 0xFF;
ByteBuffer address;
gn::serialize_into_buffer(indication.gn.source_position.gn_addr, address);
std::copy_n(address.begin(), std::min<std::size_t>(address.size(), 8), out.source_gn_address);
out.source_timestamp = indication.gn.source_position.timestamp.raw();
out.source_latitude = indication.gn.source_position.latitude.value();
out.source_longitude = indication.gn.source_position.longitude.value();
out.security_report = boost::apply_visitor(ReportToLink {}, indication.gn.security_report);
out.its_aid = indication.gn.its_aid.value_or(0);
if (indication.gn.permissions) out.permissions = *indication.gn.permissions;
if (indication.gn.certificate_id) { out.certificate_present = true; std::copy(indication.gn.certificate_id->begin(), indication.gn.certificate_id->end(), out.certificate_id); }
if (const auto* area = boost::get<gn::Area>(&indication.gn.destination)) out.area = boost::apply_visitor(AreaToLink {*area}, area->shape);
out.received_fl_sdu = std::move(indication.received_fl_sdu);
indication_(out);
}
link::Code Station::provision(const link::Bytes& bundle, link::ApplyReport& report) {
try {
security::Credentials credentials;
if (!security::decode(bundle, credentials) || credentials.roots.empty() || credentials.tickets.empty()) {
return link::Code::invalid_argument;
}
security::NvsCredentialStore store;
const auto saved = store.save(credentials);
if (saved != Result::accepted) return code(saved);
if (config_ && config_->security) {
const auto result = build();
if (result != link::Code::accepted) return result;
if (security_) {
report.roots = security_->report.roots;
report.authorities = security_->report.authorities;
report.tickets = security_->report.tickets;
ESP_LOGI(TAG, "credentials provisioned: %u roots, %u authorities, %u tickets",
unsigned(report.roots), unsigned(report.authorities), unsigned(report.tickets));
}
return link::Code::accepted;
}
report.roots = credentials.roots.size();
report.authorities = credentials.authorities.size();
report.tickets = credentials.tickets.size();
return link::Code::accepted;
} catch (const std::bad_alloc&) {
ESP_LOGE(TAG, "out of memory provisioning credentials");
return link::Code::resource_limit;
} catch (const std::exception& e) {
ESP_LOGE(TAG, "failed provisioning credentials: %s", e.what());
return link::Code::invalid_argument;
}
}
link::Code Station::erase_credentials() {
security::NvsCredentialStore store;
const auto erased = store.erase();
if (erased != Result::accepted && erased != Result::rejected) return code(erased);
if (config_ && config_->security) return build();
return link::Code::accepted;
}
link::Code Station::subscribe(const link::Bytes& subscriber_data, std::uint64_t& subscription) {
if (!security_ || !security_->entity) return link::Code::security_unavailable;
static std::uint64_t next_handle = 1;
const auto handle = next_handle++;
const auto service = security_->entity->id_change().subscribe(
[this, handle](security::IdChangeCommand command, const security::Identifier& id, const ByteBuffer& data,
std::shared_ptr<security::IdChangeResponder> responder) {
link::IdChangeEvent event;
event.subscription = handle;
event.command = static_cast<std::uint8_t>(command);
std::copy(id.begin(), id.end(), event.id);
event.subscriber_data = data;
if (responder) pending_responders_[handle] = responder;
if (id_event_ && !rebuilding_) id_event_(event);
}, subscriber_data);
link_subscriptions_[handle] = service;
subscription = handle;
return link::Code::accepted;
}
link::Code Station::unsubscribe(std::uint64_t subscription) {
const auto it = link_subscriptions_.find(subscription);
if (it == link_subscriptions_.end()) return link::Code::invalid_argument;
Result result = Result::accepted;
if (security_ && security_->entity) result = security_->entity->id_change().unsubscribe(it->second);
link_subscriptions_.erase(it);
pending_responders_.erase(subscription);
return code(result);
}
link::Code Station::event_response(std::uint64_t subscription, bool return_code) {
const auto it = pending_responders_.find(subscription);
if (it == pending_responders_.end()) return link::Code::invalid_argument;
it->second->respond(return_code);
pending_responders_.erase(it);
return link::Code::accepted;
}
link::Code Station::trigger() {
if (!security_ || !security_->entity) return link::Code::security_unavailable;
return code(security_->entity->id_change().trigger());
}
link::Code Station::lock(std::uint8_t seconds, std::uint64_t& handle) {
if (!security_ || !security_->entity) return link::Code::security_unavailable;
handle = security_->entity->id_change().lock(seconds);
return link::Code::accepted;
}
link::Code Station::unlock(std::uint64_t handle) {
if (!security_ || !security_->entity) return link::Code::security_unavailable;
return code(security_->entity->id_change().unlock(handle));
}
void Station::tick() {
if (!stack_ || !clock_.synchronised) return;
const auto now = vanetza::Clock::time_point(std::chrono::microseconds(clock_.now_us()));
if (now > runtime_->now()) stack_->advance(now);
if (radio_) {
radio_->poll([this](AlDataIndication indication) {
++counters_.radio_received;
if (received_) received_();
if (stack_) stack_->indicate(std::move(indication));
});
counters_.radio_dropped = radio_->dropped_frames();
}
sample_dcc_channel_load();
}
// Sample the hardware LCBR counters at the T_Cbr cadence (TS 102 687 clause 5.4 /
// TS 103 836-4-2 clause 5.2, both 100 ms) and feed both DCC entities. The first sample after
// (re)build only establishes the baseline counter snapshot -- a CBR delta needs two samples.
void Station::sample_dcc_channel_load() {
if (!radio_ || !access_stack_) return;
const auto its_us = clock_.now_us();
if (its_us - dcc_last_sample_its_us_ < 100000) return;
const auto counters = radio_->read_cca_counters();
if (dcc_cca_last_) {
const vanetza::dcc::ChannelLoad local_cbr(C5Radio::calculate_cbr(counters, *dcc_cca_last_));
stack_->report_local_channel_load(local_cbr);
const auto global_cbr = stack_->global_channel_busy_ratio();
// Consume Release-2 CBR_G for DCC_ACC when available, else LCBR.
access_stack_->report_channel_load(global_cbr ? *global_cbr : local_cbr);
}
dcc_cca_last_ = counters;
dcc_last_sample_its_us_ = its_us;
}
link::Status Station::status() {
link::Status s = counters_;
s.uptime_ms = static_cast<std::uint32_t>(esp_timer_get_time() / 1000);
s.configured = stack_ ? 1 : 0;
if (stack_) {
ByteBuffer address;
gn::serialize_into_buffer(stack_->address(), address);
std::copy_n(address.begin(), std::min<std::size_t>(address.size(), 8), s.gn_address);
s.change_pending = stack_->identity_change_pending() ? 1 : 0;
}
if (security_ && security_->entity) {
const auto id = security_->entity->id_change().current_identifier();
std::copy(id.begin(), id.end(), s.identifier);
s.tickets = static_cast<std::uint8_t>(std::min<std::size_t>(security_->pool.size(), 255));
const auto& st = security_->entity->statistics();
s.signed_messages = st.signed_messages; s.refused_no_ticket = st.refused_no_ticket;
s.refused_change_pending = st.refused_change_pending; s.refused_permission = st.refused_permission;
s.sign_failed = st.failed; s.verified = st.verified;
s.rejected = st.rejected_profile + st.rejected_signer + st.rejected_certificate + st.rejected_signature + st.rejected_time + st.replayed;
}
s.its_time_ms = clock_.synchronised ? static_cast<std::uint64_t>(clock_.now_us() / 1000) : 0;
return s;
}
// ---- access adapter: radio, mirrored or diverted to the software lower tester ----
Result Station::request(AlDataRequest request) {
#if CONFIG_MICROBU_TEST_CHANNEL
if (mirror_ != Mirror::off) record(1, request.data);
if (mirror_ == Mirror::divert) { ++counters_.radio_submitted; return overflow_ ? Result::resource_limit : Result::accepted; }
#endif
if (radio_) {
// Adaptive DCC_ACC gates here when access_stack_ has it enabled (build()); it falls
// through to radio_->request() unchanged otherwise.
const auto result = access_stack_ ? access_stack_->request(std::move(request)) : radio_->request(std::move(request));
if (result == Result::accepted) {
++counters_.radio_submitted;
if (disseminated_) disseminated_();
}
else {
++counters_.radio_failed;
static std::int64_t last_report = 0; // one line per second, the counter has the rest
const auto now = esp_timer_get_time();
if (now - last_report > 1000000) { last_report = now; ESP_LOGW(TAG, "radio refused a frame: result %d", int(result)); }
}
return result;
}
++counters_.radio_failed;
return Result::unsupported; // no radio configured and nothing diverting: the request has nowhere to go
}
} // namespace microbu
-242
View File
@@ -1,242 +0,0 @@
#pragma once
// The ESP32-C5 half of the VRU ITS-S: BTP-B, GeoNetworking, the SN-SAP
// security entity with the station's provisioned credentials, and the ITS-G5 access adapter.
// Everything here runs on one task (the station task).
#include "c5_radio.hpp"
#include "link_protocol.hpp"
#include <vanetza_idf/stack.hpp>
#include <vanetza_idf/security.hpp>
#include <vanetza_idf/credentials.hpp>
#include <vanetza_idf/backend_mbedtls.hpp>
#include <vanetza/common/manual_runtime.hpp>
#include <vanetza/common/position_provider.hpp>
#include <deque>
#include <functional>
#include <map>
#include <memory>
#include <optional>
namespace microbu {
#if CONFIG_MICROBU_TEST_CHANNEL
/// Records the software lower tester collects (test channel, test firmware only).
struct TestRecord { std::uint8_t kind; link::Bytes bytes; };
#endif
/// @note All public members except the on_*() callback setters must be called from the station task.
class Station final : public vanetza_idf::Access, public vanetza::PositionProvider {
public:
using Indication = std::function<void(const link::BtpDataIndication&)>;
using IdEvent = std::function<void(const link::IdChangeEvent&)>;
using Disseminated = std::function<void()>;
using Received = std::function<void()>;
/// @brief Constructs an unconfigured station (call configure() before use).
Station();
/// @brief Tears down the stack, security entity and radio in dependency order.
~Station() override;
// ---- link primitives (station task) ----
/// @brief (Re)builds the stack and security entity for a new station configuration.
/// @param config Requested configuration.
/// @param detail Receives the assigned GN address, identifier, and ticket count.
/// @return Result code.
link::Code configure(const link::StationConfigure& config, link::Bytes& detail);
/// @brief Applies a position/time fix from the phone and advances the ITS clock.
/// @param fix Position/time fix to apply.
/// @return Result code.
link::Code poti(const link::PotiUpdate& fix);
/// @brief Submits a BTP data request to the stack.
/// @param request Request to submit.
/// @return Result code.
link::Code btp_request(const link::BtpDataRequest& request);
/// @brief Decodes and stores a credential bundle, rebuilding the security entity if configured.
/// @param bundle Raw credential bundle bytes.
/// @param report Receives the applied root/authority/ticket counts.
/// @return Result code.
link::Code provision(const link::Bytes& bundle, link::ApplyReport& report);
/// @brief Erases stored credentials and rebuilds if security is configured.
/// @return Result code.
link::Code erase_credentials();
/// @brief Subscribes to pseudonym-change events.
/// @param subscriber_data Opaque data echoed back with events for this subscription.
/// @param subscription Receives the assigned subscription handle.
/// @return Result code.
link::Code subscribe(const link::Bytes& subscriber_data, std::uint64_t& subscription);
/// @brief Cancels a pseudonym-change subscription.
/// @param subscription Handle returned by subscribe().
/// @return Result code.
link::Code unsubscribe(std::uint64_t subscription);
/// @brief Resolves a pending PREPARE/COMMIT identity-change event.
/// @param subscription Handle the event was delivered for.
/// @param return_code Caller's response (accept/reject) to the pending event.
/// @return Result code.
link::Code event_response(std::uint64_t subscription, bool return_code);
/// @brief Triggers an immediate pseudonym change.
/// @return Result code.
link::Code trigger();
/// @brief Locks the current pseudonym for the given duration.
/// @param seconds Lock duration in seconds.
/// @param handle Receives the assigned lock handle.
/// @return Result code.
link::Code lock(std::uint8_t seconds, std::uint64_t& handle);
/// @brief Releases a pseudonym lock.
/// @param handle Handle returned by lock().
/// @return Result code.
link::Code unlock(std::uint64_t handle);
/// @return Current station status snapshot.
link::Status status();
/// @brief Advances the ITS clock, runs timers, polls the radio; call every few milliseconds.
void tick();
/// @brief Registers the callback invoked when a BTP data indication arrives.
/// @param f Callback to invoke; replaces any previously registered callback.
void on_indication(Indication f) { indication_ = std::move(f); }
/// @brief Registers the callback invoked when a pseudonym-change event fires.
/// @param f Callback to invoke; replaces any previously registered callback.
void on_id_event(IdEvent f) { id_event_ = std::move(f); }
/// @brief Registers the callback invoked after a frame is disseminated.
/// @param f Callback to invoke; replaces any previously registered callback.
void on_disseminated(Disseminated f) { disseminated_ = std::move(f); }
/// @brief Registers the callback invoked after a frame is received.
/// @param f Callback to invoke; replaces any previously registered callback.
void on_received(Received f) { received_ = std::move(f); }
#if CONFIG_MICROBU_TEST_CHANNEL
// ---- test channel (software lower tester; not part of the phone interface -- see test_channel.hpp) ----
enum class Mirror : std::uint8_t { off = 0, mirror = 1, divert = 2 };
/// @brief Sets whether requests/indications are mirrored to, or diverted through, the test channel.
/// @param mode Off, mirror (copy) or divert (intercept) mode.
void test_mirror(Mirror mode);
/// @brief Injects a raw GN PDU as if received over the air.
/// @param source Source MAC address to report for the injected frame.
/// @param destination Destination MAC address to report for the injected frame.
/// @param gnpdu Raw GeoNetworking PDU bytes.
/// @return Result code.
link::Code test_inject(const vanetza::MacAddress& source, const vanetza::MacAddress& destination, link::Bytes gnpdu);
/// @brief Submits a raw GeoNetworking request bypassing BTP.
/// @param traffic_class GeoNetworking traffic class to submit with.
/// @param payload Raw payload bytes.
/// @return Result code.
link::Code test_gn_request(std::uint8_t traffic_class, link::Bytes payload);
/// @brief Pops queued mirror/divert records fitting into budget octets (3 per record header); the rest stays queued.
/// @param overflow Receives true if records were dropped because the queue budget was exceeded.
/// @param budget Maximum number of octets of records to drain.
/// @return Drained records.
std::deque<TestRecord> test_drain(bool& overflow, std::size_t budget);
/// @brief Clears mirror mode and any queued records.
void test_reset();
/// @brief Starts the radio if needed and sends a burst of raw test frames.
/// @param channel ITS-G5 channel number to transmit on.
/// @param power_dbm Transmit power in dBm.
/// @param mcs 802.11p modulation and coding scheme index.
/// @param count Number of frames to send.
/// @param interval_ms Interval between frames in milliseconds.
/// @param payload_len Length of each frame's payload in bytes.
/// @return Result code.
link::Code test_radio_burst(std::uint16_t channel, double power_dbm, unsigned mcs,
unsigned count, unsigned interval_ms, std::size_t payload_len);
/// @brief Starts the radio if needed and samples the CCA state for duration_ms.
/// @param duration_ms Sampling window duration in milliseconds.
/// @param detail Receives the sampling statistics.
/// @return Result code.
link::Code test_cca_sample(unsigned duration_ms, link::Bytes& detail);
#endif
// vanetza_idf::Access
/// @brief Submits a frame to the radio (through DCC_ACC when enabled), mirroring/diverting for the test channel.
/// @param request Frame and transmit parameters from the access layer.
/// @return Result code.
vanetza_idf::Result request(vanetza_idf::AlDataRequest request) override;
// vanetza::PositionProvider
/// @return The most recently applied position fix.
const vanetza::PositionFix& position_fix() override { return fix_; }
private:
struct Security;
struct Clock {
bool synchronised = false;
std::int64_t base_its_us = 0; // ITS time at base_esp_us
std::int64_t base_esp_us = 0;
std::int64_t now_us() const;
};
/// @brief (Re)builds the stack and security entity from config_, stored credentials and the clock.
/// @return Result code.
link::Code build();
/// @brief Destroys the stack, security entity and runtime in dependency order.
void teardown();
/// @brief Maps a station configuration onto the GeoNetworking MIB.
/// @param mib MIB to populate.
/// @param config Station configuration to map from.
void apply_mib(vanetza_idf::StackConfig& mib, const link::StationConfigure& config) const;
/// @brief Re-subscribes existing pseudonym-change handles to a freshly built security entity.
void resubscribe_id_change();
/// @brief Pushes the current position fix into the stack, clamped to not precede the station clock.
void apply_position();
/// @brief Samples the hardware CCA counters at the DCC cadence and feeds both DCC entities.
void sample_dcc_channel_load();
#if CONFIG_MICROBU_TEST_CHANNEL
/// @brief Queues bytes for the test channel, subject to the mirror-buffer budget.
/// @param kind Record kind tag.
/// @param bytes Record payload bytes.
void record(std::uint8_t kind, link::Bytes bytes);
#endif
/// @brief Translates and forwards a stack BTP indication to the link service and test channel.
/// @param indication Indication received from the stack.
void deliver(vanetza_idf::BtpIndication indication);
link::Status counters_;
std::optional<link::StationConfigure> config_;
Clock clock_;
vanetza::PositionFix fix_;
bool have_fix_ = false;
std::unique_ptr<vanetza::ManualRuntime> runtime_;
std::unique_ptr<Security> security_;
std::unique_ptr<vanetza_idf::Stack> stack_;
std::unique_ptr<C5Radio> radio_;
std::unique_ptr<vanetza_idf::AccessStack> access_stack_; // DCC_ACC gate in front of radio_
vanetza_idf::AlDataRequest radio_parameters_;
std::optional<CcaCounters> dcc_cca_last_; // previous read_cca_counters() sample, for the 100 ms LCBR delta
std::int64_t dcc_last_sample_its_us_ = 0;
Indication indication_;
IdEvent id_event_;
Disseminated disseminated_;
Received received_;
std::map<std::uint64_t, std::shared_ptr<vanetza_idf::security::IdChangeResponder>> pending_responders_;
std::map<std::uint64_t, std::uint64_t> link_subscriptions_; // subscription -> service handle (identity)
bool rebuilding_ = false;
#if CONFIG_MICROBU_TEST_CHANNEL
Mirror mirror_ = Mirror::off;
std::deque<TestRecord> records_;
std::size_t record_bytes_ = 0;
bool overflow_ = false;
#endif
};
} // namespace microbu
@@ -1,130 +0,0 @@
// Station's test-channel API (software lower tester; not part of the phone interface, see
// test_channel.hpp). Kept in its own translation unit so the productive station.cpp stays free
// of test-only code; compiles to nothing when CONFIG_MICROBU_TEST_CHANNEL is off.
#include "station.hpp"
#if CONFIG_MICROBU_TEST_CHANNEL
#include <vanetza_idf/nf_sap.hpp>
#include <vanetza_idf/sf_sap.hpp>
#include <esp_log.h>
#include "c5_radio.hpp"
namespace microbu {
using namespace vanetza_idf;
namespace gn = vanetza::geonet;
namespace {
const char* TAG = "station";
link::Code code(Result result) { return static_cast<link::Code>(result); }
}
void Station::test_mirror(Mirror mode) { mirror_ = mode; }
link::Code Station::test_inject(const vanetza::MacAddress& source, const vanetza::MacAddress& destination, link::Bytes gnpdu) {
if (!stack_) return link::Code::not_configured;
tick();
AlDataIndication indication;
indication.source = source;
indication.destination = destination;
indication.channel_number = radio_parameters_.channel_number;
indication.data = std::move(gnpdu);
return code(stack_->indicate(std::move(indication)));
}
link::Code Station::test_gn_request(std::uint8_t traffic_class, link::Bytes payload) {
if (!stack_) return link::Code::not_configured;
if (!clock_.synchronised || !have_fix_) return link::Code::rejected;
tick();
GnRequest request;
request.traffic_class = gn::TrafficClass(traffic_class);
request.data = std::move(payload);
return code(stack_->request(std::move(request)));
}
std::deque<TestRecord> Station::test_drain(bool& overflow, std::size_t budget) {
std::deque<TestRecord> out;
std::size_t used = 0;
while (!records_.empty() && used + records_.front().bytes.size() + 3 <= budget) {
used += records_.front().bytes.size() + 3;
record_bytes_ -= records_.front().bytes.size();
out.push_back(std::move(records_.front()));
records_.pop_front();
}
overflow = overflow_ && records_.empty();
if (records_.empty()) overflow_ = false;
return out;
}
void Station::test_reset() {
mirror_ = Mirror::off;
records_.clear();
record_bytes_ = 0;
overflow_ = false;
}
link::Code Station::test_radio_burst(std::uint16_t channel, double power_dbm, unsigned mcs,
unsigned count, unsigned interval_ms, std::size_t payload_len) {
if (!radio_) {
// Automatically start the radio for testing if not yet started
C5RadioConfig rc;
rc.channel_number = channel;
rc.transmit_power_dbm = power_dbm;
rc.laboratory_transmission = true;
radio_ = std::make_unique<C5Radio>(rc);
const auto error = radio_->start();
if (error != ESP_OK) {
ESP_LOGE(TAG, "radio start failed for test burst: %s", esp_err_to_name(error));
radio_.reset();
return link::Code::rejected;
}
}
const auto err = radio_->transmit_burst(channel, power_dbm, mcs, count, interval_ms, payload_len);
if (err == ESP_OK) {
counters_.radio_submitted += count;
if (disseminated_) disseminated_();
return link::Code::accepted;
}
if (err == ESP_ERR_INVALID_ARG) return link::Code::invalid_argument;
if (err == ESP_ERR_NO_MEM) return link::Code::resource_limit;
return link::Code::rejected;
}
link::Code Station::test_cca_sample(unsigned duration_ms, link::Bytes& detail) {
// Capped: this busy-loops with interrupts otherwise free, so keep it short enough that
// the task watchdog and the WiFi/BLE stack's own housekeeping are not starved for long.
duration_ms = std::min(duration_ms, 500u);
if (!radio_) {
C5RadioConfig rc; // default channel 180 / 10 dBm is fine: this probes PHY polling
rc.laboratory_transmission = true; // capability, not a specific channel's RF content
radio_ = std::make_unique<C5Radio>(rc);
const auto error = radio_->start();
if (error != ESP_OK) {
ESP_LOGE(TAG, "radio start failed for CCA sample: %s", esp_err_to_name(error));
radio_.reset();
return link::Code::rejected;
}
}
const auto result = radio_->sample_cca(duration_ms);
link::Writer w;
w.u32(result.samples);
w.u32(result.duration_us);
w.u32(result.min_delta_us);
w.u32(result.max_delta_us);
w.u32(result.busy_count);
w.i32(result.first_cca);
w.i32(result.last_cca);
w.i32(result.noise_floor_dbm);
w.i32(result.cca_total_cycles_delta);
w.i32(result.cca_busy_cycles_delta);
w.i32(result.cca_status);
detail = w.out;
return result.samples > 0 ? link::Code::accepted : link::Code::rejected;
}
void Station::record(std::uint8_t kind, link::Bytes bytes) {
if (mirror_ == Mirror::off) return;
if (record_bytes_ + bytes.size() > 8192 || records_.size() >= 32) { overflow_ = true; return; }
record_bytes_ += bytes.size();
records_.push_back(TestRecord {kind, std::move(bytes)});
}
} // namespace microbu
#endif // CONFIG_MICROBU_TEST_CHANNEL
@@ -1,79 +0,0 @@
#include "test_channel.hpp"
#if CONFIG_MICROBU_TEST_CHANNEL
#include <algorithm>
namespace microbu {
link::Bytes test_channel_execute(Station& station, const link::Bytes& request) {
using link::Code;
Code result = Code::malformed;
std::deque<TestRecord> records;
link::Bytes extra;
if (!request.empty()) {
link::Reader r(request, 1);
switch (request[0]) {
case 0x01: { // MIRROR mode
const auto mode = r.u8();
if (r.done() && mode <= 2) { station.test_mirror(static_cast<Station::Mirror>(mode)); result = Code::accepted; }
break;
}
case 0x02: { // INJECT source destination gnpdu
vanetza::MacAddress source, destination;
r.bytes(source.octets.data(), 6);
r.bytes(destination.octets.data(), 6);
auto gnpdu = r.rest();
if (r.ok() && !gnpdu.empty()) result = station.test_inject(source, destination, std::move(gnpdu));
break;
}
case 0x03: { // GN_REQUEST traffic class payload
const auto tc = r.u8();
auto payload = r.rest();
if (r.ok() && !payload.empty()) result = station.test_gn_request(tc, std::move(payload));
break;
}
case 0x04: { // DRAIN
bool overflow = false;
records = station.test_drain(overflow, 1536 - 2);
result = overflow ? Code::resource_limit : Code::accepted;
break;
}
case 0x05: // RESET
station.test_reset();
result = Code::accepted;
break;
case 0x06: { // RADIO_BURST [channel u16 LE][power_quarter_db u8][mcs u8][count u16 LE][interval_ms u16 LE][payload_len u16 LE]
const auto channel = r.u16();
const auto power_quarter_db = r.u8();
const auto mcs = r.u8();
const auto count = r.u16();
const auto interval_ms = r.u16();
const auto payload_len = r.u16();
if (r.done()) {
const double power_dbm = power_quarter_db / 4.0;
result = station.test_radio_burst(channel, power_dbm, mcs, count, interval_ms, payload_len);
}
break;
}
case 0x07: { // CCA_SAMPLE [duration_ms u16 LE] -- CCA polling feasibility probe
const auto duration_ms = r.u16();
if (r.done()) result = station.test_cca_sample(duration_ms, extra);
break;
}
default:
result = Code::unknown_opcode;
}
}
link::Writer w;
w.u8(static_cast<std::uint8_t>(result));
w.u8(static_cast<std::uint8_t>(std::min<std::size_t>(records.size(), 255)));
for (const auto& record : records) {
w.u8(record.kind);
w.u16(static_cast<std::uint16_t>(record.bytes.size()));
w.bytes(record.bytes);
}
w.bytes(extra);
return w.out;
}
} // namespace microbu
#endif // CONFIG_MICROBU_TEST_CHANNEL
@@ -1,15 +0,0 @@
#pragma once
// Test channel (serial frame type 0x11, test firmware only): software lower tester and the
// diagnostic hooks the ETSI campaigns need. Not part of the phone interface, never on BLE.
#include "link_protocol.hpp"
#include "station.hpp"
namespace microbu {
/// @brief Executes one software-lower-tester request against the station.
/// @param station Station to execute the request against.
/// @param request Raw test-channel request bytes.
/// @return Reply bytes: [result][record count]{[kind][length u16 LE][bytes]}.
link::Bytes test_channel_execute(Station& station, const link::Bytes& request);
} // namespace microbu
-4
View File
@@ -1,4 +0,0 @@
# Name, Type, SubType, Offset, Size, Flags
nvs,data,nvs,0x9000,0x6000,
phy_init,data,phy,0xf000,0x1000,
factory,app,factory,0x10000,0x300000,
1 # Name Type SubType Offset Size Flags
2 nvs data nvs 0x9000 0x6000
3 phy_init data phy 0xf000 0x1000
4 factory app factory 0x10000 0x300000
@@ -1,46 +0,0 @@
# micrOBU firmware defaults (ESP32-C5, ESP-IDF 6.0.2). vanetza-idf requirements first.
CONFIG_IDF_TARGET="esp32c5"
CONFIG_COMPILER_CXX_EXCEPTIONS=y
CONFIG_COMPILER_CXX_RTTI=y
CONFIG_COMPILER_OPTIMIZATION_SIZE=y
CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192
CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
CONFIG_PARTITION_TABLE_CUSTOM=y
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv"
# Network profile: BTP + GeoNetworking, no facilities codecs (the phone hosts the VBS)
CONFIG_VANETZA_IDF_PROFILE_NETWORK=y
CONFIG_VANETZA_IDF_SECURITY=y
CONFIG_VANETZA_IDF_SECURITY_VERIFY=y
CONFIG_VANETZA_IDF_NVS_CREDENTIALS=y
CONFIG_VANETZA_IDF_PKI=n
CONFIG_VANETZA_IDF_HIL=n
CONFIG_VANETZA_IDF_RADIO_C5=y
CONFIG_ESP_WIFI_STATIC_RX_BUFFER_NUM=6
CONFIG_ESP_WIFI_DYNAMIC_RX_BUFFER_NUM=16
CONFIG_ESP_WIFI_DYNAMIC_TX_BUFFER_NUM=16
CONFIG_ESP_WIFI_MGMT_SBUF_NUM=16
# Serial link on the native USB Serial/JTAG port; ESP_LOG is carried as LOG frames
CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y
CONFIG_ESP_CONSOLE_SECONDARY_NONE=y
CONFIG_ESP_PANIC_HANDLER_IRAM=y
CONFIG_LOG_DEFAULT_LEVEL_INFO=y
CONFIG_LOG_COLORS=n
# Station-internal BLE GATT link: NimBLE, maximum ATT value, persistent bonds.
CONFIG_BT_ENABLED=y
CONFIG_BT_BLUEDROID_ENABLED=n
CONFIG_BT_NIMBLE_ENABLED=y
CONFIG_BT_NIMBLE_ATT_PREFERRED_MTU=512
CONFIG_BT_NIMBLE_SM_SC=y
CONFIG_BT_NIMBLE_NVS_PERSIST=y
CONFIG_BT_NIMBLE_MAX_CONNECTIONS=1
CONFIG_BT_NIMBLE_MAX_BONDS=1
CONFIG_BT_NIMBLE_MAX_CCCDS=8
CONFIG_BT_NIMBLE_MSYS_1_BLOCK_COUNT=12
CONFIG_BT_NIMBLE_MSYS_2_BLOCK_COUNT=12
CONFIG_BT_NIMBLE_ROLE_CENTRAL=n
CONFIG_BT_NIMBLE_ROLE_OBSERVER=n
# Hardware ECDSA/ECC/SHA (ACT-030)
CONFIG_MBEDTLS_HARDWARE_ECC=y
CONFIG_MBEDTLS_HARDWARE_ECDSA_VERIFY=y
CONFIG_MBEDTLS_HARDWARE_SHA=y
CONFIG_MBEDTLS_HARDWARE_AES=n
-4
View File
@@ -1,4 +0,0 @@
# PKI
This folder contains the registered Urban Mobility Lab L0 root certificate and its encrypted private key. The passphrase is stored separately in the physical envelope.
@@ -1,12 +0,0 @@
db573e93234e36675bbdb49837b1c4d418029924973834525206c15f7f5fb08a private/UML_L0_RCA_private_encrypted.pem
cd86499c5a5c48f82abd9424b0d8534a4e7a57761a7af189a998ee4387b07ec3 rca/AFD566A8034ED5DB_0_Urban-Mobility-Lab-Root-CA_L0_2026-09-11.txt
7168e341764188e140ef591634a9d2488d7fdacda667ee53afd566a8034ed5db rca/AFD566A8034ED5DB.oer
b75c6bac678f3a2351eebe74330fa9739428540cc3d8d439255f87cdf77febda rca/certificate_report.json
6fbfc8c9e03610ece1dfd27f2edc63f3b1cf96237f26aa93e9e044eabeffb1e9 rca/UML_L0_RCA_public.pem
36734132bcdd7b72068ee1ea2db2c38aa977066c2896f85c31a5249580e473b3 README.txt
832aec95189f06b8813da7dcd3835c1471d6af1def110fc33b46829a0681c555 reference-generator/create_new_root.py
4cb9fa6982d1f5bfa8a1495e97786df5543944e20a336fcd2899f151969e9b47 reference-generator/PROVENANCE.txt
cdf955c247b4cbb9bb71f419fe087abbf3e8aa91fc51e02e8c882b952ff82e16 reference-generator/README.md
fb833826404e5ba5f6643cf1d7113e280859155a0c84c693d47599f223f49b49 reference-generator/rebuild_registered_rca.py
c821c147d786634f38e02d7c4750da0814d1b9487c8f030ac386589c81617e77 reference-generator/src/create_new_root.rs
57e5aa94540e00e8d6cd9691cddc8672d8a9af3bdaed3dde386a0233682965f7 reference-generator/src/generate_root.rs
-52
View File
@@ -1,52 +0,0 @@
Urban Mobility Lab L0 RCA
!!THIS ROOT MUST NOT BE REGENERATED OR REPLACED.!!
The EU CCMS L0 ECTL registration is tied to this exact certificate and key:
certificate rca/AFD566A8034ED5DB.oer
HashedId8 AFD566A8034ED5DB
private key private/UML_L0_RCA_private_encrypted.pem
algorithm NIST P-256; encrypted PKCS#8 PEM
valid 2026-09-14 23:59:55 UTC through 2031-09-15
The certificate was submitted on 2026-09-11 and registered in the EU signing
session on 2026-09-14. The private-key passphrase is in the separate physical
envelope. Never put the key or passphrase in Git, cloud storage, Chat or email. If the key or passphrase is lost, contact the EU CCMS CPOC
for revocation/re-keying; creating another local root will not restore this identity.
Files:
rca/AFD566A8034ED5DB.oer registered public COER certificate
rca/UML_L0_RCA_public.pem matching public key
rca/certificate_report.json decoded permissions/profile
rca/*_2026-09-11.txt submitted descriptive information
private/*.pem encrypted private key; keep offline
reference-generator/ pinned Rust/c-its reproduction path
CHECKSUMS.sha256 integrity values for the files above
The two workflows in `reference-generator/README.md` are intentionally separate:
one reproduces the registered certificate with its registered key, the other creates
a new, unregistered root key and candidate certificate for testing or a deliberate
EU registration/re-key process.
Routine use:
Use the `vidf_issue` tool. Run these commands from this directory;
OpenSSL asks for the root passphrase interactively.
vidf_issue show rca/AFD566A8034ED5DB.oer
vidf_issue verify rca/AFD566A8034ED5DB.oer
vidf_issue authority --issuer rca/AFD566A8034ED5DB.oer --issuer-key private/UML_L0_RCA_private_encrypted.pem --name "Urban Mobility Lab L0 AA" --id UML_AA --years 3 --out chain
vidf_issue ticket --issuer chain/UML_AA.oer --issuer-key chain/UML_AA.vkey --root rca/AFD566A8034ED5DB.oer --id UML_AT --hours 24 --permission 638:01 --permission 141 --permission 36:01FFFC --out chain
vidf_issue verify chain/UML_AT.oer chain/UML_AA.oer rca/AFD566A8034ED5DB.oer
The `.vkey` files created in `chain/` are unencrypted private keys. Keep them
offline and delete them when no longer required. Tickets last 24 hours by default.
Distribution lists:
vidf_issue ctl --issuer rca/AFD566A8034ED5DB.oer --issuer-key private/UML_L0_RCA_private_encrypted.pem --aa chain/UML_AA.oer=https://cits-dc.uml-hamburg.de/aa/ --dc https://cits-dc.uml-hamburg.de/ --sequence 1 --out lists/ctl-AFD566A8034ED5DB.oer
vidf_issue crl --issuer rca/AFD566A8034ED5DB.oer --issuer-key private/UML_L0_RCA_private_encrypted.pem --out lists/crl-AFD566A8034ED5DB.oer
Increase the CTL sequence for every update. Reissue the CTL whenever the AA changes.
@@ -1,853 +0,0 @@
This file contains copies of the GNU Lesser General Public License as well
as the GNU General Public License, both in their third version.
# GNU Lesser General Public License (LGPL) v3
```
GNU LESSER GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
This version of the GNU Lesser General Public License incorporates
the terms and conditions of version 3 of the GNU General Public
License, supplemented by the additional permissions listed below.
0. Additional Definitions.
As used herein, "this License" refers to version 3 of the GNU Lesser
General Public License, and the "GNU GPL" refers to version 3 of the GNU
General Public License.
"The Library" refers to a covered work governed by this License,
other than an Application or a Combined Work as defined below.
An "Application" is any work that makes use of an interface provided
by the Library, but which is not otherwise based on the Library.
Defining a subclass of a class defined by the Library is deemed a mode
of using an interface provided by the Library.
A "Combined Work" is a work produced by combining or linking an
Application with the Library. The particular version of the Library
with which the Combined Work was made is also called the "Linked
Version".
The "Minimal Corresponding Source" for a Combined Work means the
Corresponding Source for the Combined Work, excluding any source code
for portions of the Combined Work that, considered in isolation, are
based on the Application, and not on the Linked Version.
The "Corresponding Application Code" for a Combined Work means the
object code and/or source code for the Application, including any data
and utility programs needed for reproducing the Combined Work from the
Application, but excluding the System Libraries of the Combined Work.
1. Exception to Section 3 of the GNU GPL.
You may convey a covered work under sections 3 and 4 of this License
without being bound by section 3 of the GNU GPL.
2. Conveying Modified Versions.
If you modify a copy of the Library, and, in your modifications, a
facility refers to a function or data to be supplied by an Application
that uses the facility (other than as an argument passed when the
facility is invoked), then you may convey a copy of the modified
version:
a) under this License, provided that you make a good faith effort to
ensure that, in the event an Application does not supply the
function or data, the facility still operates, and performs
whatever part of its purpose remains meaningful, or
b) under the GNU GPL, with none of the additional permissions of
this License applicable to that copy.
3. Object Code Incorporating Material from Library Header Files.
The object code form of an Application may incorporate material from
a header file that is part of the Library. You may convey such object
code under terms of your choice, provided that, if the incorporated
material is not limited to numerical parameters, data structure
layouts and accessors, or small macros, inline functions and templates
(ten or fewer lines in length), you do both of the following:
a) Give prominent notice with each copy of the object code that the
Library is used in it and that the Library and its use are
covered by this License.
b) Accompany the object code with a copy of the GNU GPL and this license
document.
4. Combined Works.
You may convey a Combined Work under terms of your choice that,
taken together, effectively do not restrict modification of the
portions of the Library contained in the Combined Work and reverse
engineering for debugging such modifications, if you also do each of
the following:
a) Give prominent notice with each copy of the Combined Work that
the Library is used in it and that the Library and its use are
covered by this License.
b) Accompany the Combined Work with a copy of the GNU GPL and this license
document.
c) For a Combined Work that displays copyright notices during
execution, include the copyright notice for the Library among
these notices, as well as a reference directing the user to the
copies of the GNU GPL and this license document.
d) Do one of the following:
0) Convey the Minimal Corresponding Source under the terms of this
License, and the Corresponding Application Code in a form
suitable for, and under terms that permit, the user to
recombine or relink the Application with a modified version of
the Linked Version to produce a modified Combined Work, in the
manner specified by section 6 of the GNU GPL for conveying
Corresponding Source.
1) Use a suitable shared library mechanism for linking with the
Library. A suitable mechanism is one that (a) uses at run time
a copy of the Library already present on the user's computer
system, and (b) will operate properly with a modified version
of the Library that is interface-compatible with the Linked
Version.
e) Provide Installation Information, but only if you would otherwise
be required to provide such information under section 6 of the
GNU GPL, and only to the extent that such information is
necessary to install and execute a modified version of the
Combined Work produced by recombining or relinking the
Application with a modified version of the Linked Version. (If
you use option 4d0, the Installation Information must accompany
the Minimal Corresponding Source and Corresponding Application
Code. If you use option 4d1, you must provide the Installation
Information in the manner specified by section 6 of the GNU GPL
for conveying Corresponding Source.)
5. Combined Libraries.
You may place library facilities that are a work based on the
Library side by side in a single library together with other library
facilities that are not Applications and are not covered by this
License, and convey such a combined library under terms of your
choice, if you do both of the following:
a) Accompany the combined library with a copy of the same work based
on the Library, uncombined with any other library facilities,
conveyed under the terms of this License.
b) Give prominent notice with the combined library that part of it
is a work based on the Library, and explaining where to find the
accompanying uncombined form of the same work.
6. Revised Versions of the GNU Lesser General Public License.
The Free Software Foundation may publish revised and/or new versions
of the GNU Lesser General Public License from time to time. Such new
versions will be similar in spirit to the present version, but may
differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the
Library as you received it specifies that a certain numbered version
of the GNU Lesser General Public License "or any later version"
applies to it, you have the option of following the terms and
conditions either of that published version or of any later version
published by the Free Software Foundation. If the Library as you
received it does not specify a version number of the GNU Lesser
General Public License, you may choose any version of the GNU Lesser
General Public License ever published by the Free Software Foundation.
If the Library as you received it specifies that a proxy can decide
whether future versions of the GNU Lesser General Public License shall
apply, that proxy's public statement of acceptance of any version is
permanent authorization for you to choose that version for the
Library.
```
# GNU General Public License (GPL) v3
```
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<http://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<http://www.gnu.org/philosophy/why-not-lgpl.html>.
```
Binary file not shown.
Binary file not shown.
@@ -1,3 +0,0 @@
C8BB336F2F49189F UML_AA.oer
CDF1045B135A4E8F UML_AT.oer
C06FBC0DE448E3BC UML_AT.oer
@@ -1,55 +0,0 @@
EU CCMS CPOC L0 ECTL — RCA descriptive information
===================================================
Full Company Name / RCA Operator:
Hochschule für Angewandte Wissenschaften Hamburg (HAW Hamburg)
Urban Mobility Lab
RCA Name:
Urban Mobility Lab L0 Root CA
RCA Distribution Centre URL:
https://cits-dc.uml-hamburg.de
Contact:
uml-haw@proton.me
CertificateID:
0_Urban-Mobility-Lab-Root-CA_L0
Certificate HashedID8:
AFD566A8034ED5DB
CertificateID / acronym explanation:
- 0: CPA-ID reserved for the L0 environment.
- Urban-Mobility-Lab-Root-CA: Root CA operated for the Urban Mobility Lab at HAW Hamburg.
- L0: EU CCMS L0 research/test environment.
Purpose of testing:
The RCA is used for research, field testing and proof-of-concept activities of the
Urban Mobility Lab in the context of the micrOBU project. micrOBU investigates the
integration of vulnerable road users into C-ITS through development of a lightweight,
compact and low-cost VRU ITS-S using an MCU + mobile-phone architecture.
Contextual constraints / deviations:
- This is a new RCA and the first L0 submission for this RCA. No existing L0 entry is
intended to be replaced or revoked.
- The implementation, including the C-ITS protocol stack, signing functionality and
VRU Awareness Basic Service, is under active development. Full implementation
conformity is not claimed.
- The declared RCA Distribution Centre is the intended permanent DC URL. At the time
of this submission, the cits-dc.uml-hamburg.de subdomain and the required /getctl
and /getcrl endpoints are still being commissioned and are not yet operational.
- The RCA intentionally includes ITS-AID 638 (VRU Awareness basic service) with
bitmap SSP range 01/FF for L0 VAM/VBS research. ITS-AID 638 is a standardized ETSI
ITS-AID but is not contained in the current CPOC Protocol Release 3.3 Table 4 list;
its inclusion is therefore an intentional L0 profile deviation.
- The RCA private key is generated and maintained locally as an encrypted PKCS#8
NIST P-256 key for this L0 research environment. This software-based key storage is
not claimed to satisfy production/L2 cryptographic-module requirements.
Re-key / revocation / relationship to existing certificates:
New RCA / first submission. No existing L0 RCA certificate is affected.
Submission date:
2026-09-11
@@ -1,4 +0,0 @@
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAERRYsQMPF/6+MZf5Zg3hsnyMZR39W
2i1rbusEm/7vDHX0OIzpTnfk2NZEGf/cRVLyrhOjtplgcqMzHdZ1bGr56A==
-----END PUBLIC KEY-----
@@ -1,161 +0,0 @@
{
"id8": "AFD566A8034ED5DB",
"subject": {
"type": "hostname",
"hostname": "0_Urban-Mobility-Lab-Root-CA_L0"
},
"valid_from": "2026-09-14T23:58:51Z",
"valid_until": "2031-09-15T05:04:51Z",
"app_permissions": [
{
"type": "certificate_trust_list",
"tlm": false,
"root_ca": false,
"enrollment_authority": true,
"authorization_authority": true,
"distribution_centre": true
},
{
"type": "certificate_revocation_list"
}
],
"cert_issue_permissions": [
{
"chain_length": {
"start": 2,
"end": 2
},
"end_entity_authorization": true,
"end_entity_enrollment": true,
"subject_permissions": {
"type": "explicit",
"permissions": [
{
"psid": "cam",
"auth": {
"type": "bitmap",
"subordinate_may_have": "01FFFF",
"subordinate_must_have": "FF0000"
}
},
{
"psid": "denm",
"auth": {
"type": "bitmap",
"subordinate_may_have": "02FFFFFFFF",
"subordinate_must_have": "FF00000000"
}
},
{
"psid": "tlm",
"auth": {
"type": "bitmap",
"subordinate_may_have": "01E0",
"subordinate_must_have": "FF1F"
}
},
{
"psid": "rlt",
"auth": {
"type": "bitmap",
"subordinate_may_have": "01C0",
"subordinate_must_have": "FF3F"
}
},
{
"psid": "ivim",
"auth": {
"type": "bitmap",
"subordinate_may_have": "01000000FFFF",
"subordinate_must_have": "FF0000000000"
}
},
{
"psid": "tlc_req",
"auth": {
"type": "bitmap",
"subordinate_may_have": "02FFFFE0",
"subordinate_must_have": "FF00001F"
}
},
{
"psid": "gm_mgmt",
"auth": {
"type": "all"
}
},
{
"psid": "cert_req",
"auth": {
"type": "bitmap",
"subordinate_may_have": "01C0",
"subordinate_must_have": "FF3F"
}
},
{
"psid": "tlc_stat",
"auth": {
"type": "bitmap",
"subordinate_may_have": "01",
"subordinate_must_have": "FF"
}
},
{
"psid": "vru",
"auth": {
"type": "bitmap",
"subordinate_may_have": "01",
"subordinate_must_have": "FF"
}
},
{
"psid": "cp",
"auth": {
"type": "bitmap",
"subordinate_may_have": "01",
"subordinate_must_have": "FF"
}
},
{
"psid": "poi",
"auth": {
"type": "bitmap",
"subordinate_may_have": "01",
"subordinate_must_have": "FF"
}
}
]
}
},
{
"chain_length": {
"start": 1,
"end": 1
},
"end_entity_authorization": true,
"end_entity_enrollment": false,
"subject_permissions": {
"type": "explicit",
"permissions": [
{
"psid": "cert_req",
"auth": {
"type": "bitmap",
"subordinate_may_have": "013E",
"subordinate_must_have": "FFC1"
}
}
]
}
}
],
"cert_request_permissions": [],
"signature": {
"type": "self_signed",
"verifies": true
},
"public_key": {
"curve": "nistP256",
"k": "0245162C40C3C5FFAF8C65FE5983786C9F2319477F56DA2D6B6EEB049BFEEF0C75"
}
}
@@ -1,28 +0,0 @@
Reference notes
===============
Pinned upstream commit:
e3bb3b82480d6df4237e2a8c35ea0dd7eade25b4
The upstream commit's root generator:
1. constructs a ToBeSignedCertificate;
2. serializes it using rasn::oer::encode();
3. calls PrivateKey::sign(hash_alg, &tbs_bytes, None);
4. inserts the returned IEEE 1609.2 Signature into an ExplicitCertificate.
The pinned c-its PrivateKey::sign() implementation for SHA-256 computes:
SHA256(
SHA256(tbs_bytes)
||
SHA256(signer_info)
)
For a self-signed root, signer_info is the empty byte string because the root
generator passes None.
This bundle uses that exact upstream code for the cryptographic operation.
`rebuild_registered_rca.py` keeps the registered TBS/public key unchanged and uses
the registered encrypted key. `create_new_root.py` creates a new encrypted key and
replaces the template public key for a separate, unregistered candidate root. In
both cases the scalar reaches Rust only through standard input.
@@ -1,36 +0,0 @@
# UML L0 RCA generation tools
Both tools use the Rust/c-its certificate path pinned to commit
`e3bb3b82480d6df4237e2a8c35ea0dd7eade25b4`in https://github.com/TheEnbyperor/c-its. Requirements are Python 3.10+,
`cryptography`, Git, Rust/Cargo, and internet access for the first build.
## Rebuild the registered root
```text
python rebuild\_registered\_rca.py
```
Uses `private/UML\_L0\_RCA\_private\_encrypted.pem`. It succeeds only when the result is
byte-for-byte identical to the registered `AFD566A8034ED5DB.oer`. Use this to prove
how the registered certificate was made or to verify the registered key. It never
creates a key and does not alter the registered files.
## Create a separate new root
```text
python create\_new\_root.py
```
Creates a new P-256 key and candidate root certificate. It copies the registered
root's reviewed TBS profile, including CertificateID, permissions, region and
validity, but replaces the public key and signature. The result therefore has a
different HashedId8 and is **not EU-registered**.
Use this only for an isolated test root or a deliberate EU registration/re-key
process. Before submission, review the inherited validity/profile and coordinate
revocation or registration with the EU CCMS CPOC. Files appear under a directory
named `CANDIDATE\_SUBMISSION\_NOT\_REGISTERED`; that name is a warning, not approval.
In both workflows the private scalar is passed to the local Rust process through
standard input and is never stored unencrypted by these tools.
@@ -1,408 +0,0 @@
#!/usr/bin/env python3
"""
Create a separate NEW UML L0 RCA keypair and candidate certificate
using the exact TheEnbyperor/c-its reference commit e3bb3b82480d6df4237e2a8c35ea0dd7eade25b4.
THIS DOES NOT REPLACE OR RECREATE THE EU-REGISTERED ROOT. The result is not trusted
or registered until it completes a deliberate registration/re-key process.
The new private key never leaves this machine and is permanently stored only as
encrypted PKCS#8 PEM. A 32-byte private scalar is passed to the already-built
local Rust process over stdin and is never written to disk unencrypted.
"""
from __future__ import annotations
import getpass
import hashlib
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
import tarfile
import zipfile
from datetime import datetime
UPSTREAM_COMMIT = "e3bb3b82480d6df4237e2a8c35ea0dd7eade25b4"
TEMPLATE_SHA256 = "7168E341764188E140EF591634A9D2488D7FDACDA667EE53AFD566A8034ED5DB"
CERTIFICATE_ID = "0_Urban-Mobility-Lab-Root-CA_L0"
HERE = Path(__file__).resolve().parent
ROOT = HERE.parent
TEMPLATE = ROOT / "rca" / "AFD566A8034ED5DB.oer"
PATCH_SOURCE = HERE / "src" / "create_new_root.rs"
BUNDLED_SOURCE = HERE / "vendor" / "c-its-source-e3bb3b8.zip"
VENDORED_CRATES = HERE / "vendor" / "cargo-crates.tar.gz"
UPSTREAM_DIR = HERE / "_build_cits"
CARGO_VENDOR_DIR = HERE / "_cargo_vendor"
def die(msg: str) -> None:
raise SystemExit("\nERROR: " + msg)
def run(cmd, *, cwd=None, input_bytes=None, capture=False, env=None):
print("+", " ".join(str(x) for x in cmd))
return subprocess.run(
cmd,
cwd=cwd,
input=input_bytes,
check=True,
text=False,
capture_output=capture,
env=env,
)
def require_program(name: str) -> None:
if shutil.which(name) is None:
die(
f"'{name}' is not installed or not on PATH. "
"Install Git and the Rust toolchain (cargo/rustc) first. "
"On Windows, WSL2 Ubuntu is a good fallback if native compilation causes problems."
)
def validate_submission_date(s: str) -> str:
try:
datetime.strptime(s, "%Y-%m-%d")
except ValueError:
die("Submission date must be YYYY-MM-DD.")
return s
def prepare_upstream() -> None:
"""Create a disposable build tree from the bundled pinned source."""
if not BUNDLED_SOURCE.is_file() or not VENDORED_CRATES.is_file():
die("Bundled c-its source or Cargo crate archive is missing.")
if UPSTREAM_DIR.exists():
shutil.rmtree(UPSTREAM_DIR)
if CARGO_VENDOR_DIR.exists():
shutil.rmtree(CARGO_VENDOR_DIR)
UPSTREAM_DIR.mkdir()
CARGO_VENDOR_DIR.mkdir()
with zipfile.ZipFile(BUNDLED_SOURCE) as archive:
archive.extractall(UPSTREAM_DIR)
with tarfile.open(VENDORED_CRATES, "r:gz") as archive:
archive.extractall(CARGO_VENDOR_DIR)
# Replace only the root-generator utility. Security/crypto/ASN.1 implementation
# remains the pinned upstream commit.
shutil.copy2(PATCH_SOURCE, UPSTREAM_DIR / "src" / "util" / "generate_root.rs")
shutil.copy2(TEMPLATE, UPSTREAM_DIR / TEMPLATE.name)
# Host-build fix for Windows/native desktop targets:
# ieee80211 pulls embedded defmt support through default features, but a
# normal host executable has no defmt transport/logger providing symbols
# such as _defmt_panic, _defmt_acquire and _defmt_write.
cargo_toml = UPSTREAM_DIR / "Cargo.toml"
cargo_text = cargo_toml.read_text(encoding="utf-8")
original_ieee = 'ieee80211 = "0.5.9"'
patched_ieee = 'ieee80211 = { version = "0.5.9", default-features = false }'
if original_ieee in cargo_text:
cargo_text = cargo_text.replace(original_ieee, patched_ieee, 1)
cargo_toml.write_text(cargo_text, encoding="utf-8")
print("Applied host-build patch: ieee80211 default features disabled (defmt removed).")
elif patched_ieee in cargo_text:
print("Host-build patch already present.")
else:
die("Expected ieee80211 dependency line not found in pinned Cargo.toml.")
cargo_config = UPSTREAM_DIR / ".cargo" / "config.toml"
cargo_config.parent.mkdir()
cargo_config.write_text(
'[source.crates-io]\nreplace-with = "vendored-sources"\n\n'
'[source.vendored-sources]\ndirectory = "../_cargo_vendor"\n\n'
'[net]\noffline = true\n',
encoding="utf-8",
)
def build_generator() -> Path:
print("\nBuilding the pinned c-its generator BEFORE generating any private key...")
try:
run(
[
"cargo", "build", "--offline", "--release",
"--features", "build-binary",
"--bin", "c-its-generate-root",
],
cwd=UPSTREAM_DIR,
)
except subprocess.CalledProcessError as exc:
die(
"The pinned c-its host build failed. No RCA private key has been generated. "
"If the remaining linker error still mentions _defmt_*, delete the package's "
"_upstream_cits directory and rerun this V2 script. "
f"Cargo exit code: {exc.returncode}"
)
exe = UPSTREAM_DIR / "target" / "release" / (
"c-its-generate-root.exe" if os.name == "nt" else "c-its-generate-root"
)
if not exe.exists():
die(f"Build reported success but executable is missing: {exe}")
return exe
def generate_keypair_and_certificate(exe: Path) -> Path:
try:
from cryptography.hazmat.primitives.asymmetric import ec, utils
from cryptography.hazmat.primitives import hashes, serialization
except ImportError:
die("Python package 'cryptography' is required. Run: python -m pip install cryptography")
submission_date = validate_submission_date(
input("\nActual submission date (YYYY-MM-DD): ").strip()
)
while True:
pw1 = getpass.getpass("Choose NEW RCA private-key passphrase: ")
pw2 = getpass.getpass("Repeat passphrase: ")
if pw1 != pw2:
print("Passphrases do not match. Try again.")
continue
if len(pw1) < 12:
print("Use at least 12 characters.")
continue
break
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
root = HERE / f"UML_L0_RCA_NEW_{stamp}"
submit = root / "CANDIDATE_SUBMISSION_NOT_REGISTERED"
private_dir = root / "PRIVATE_DO_NOT_SHARE"
public_dir = root / "PUBLIC_AND_VALIDATION"
rust_out = root / "_rust_output"
for d in (submit, private_dir, public_dir, rust_out):
d.mkdir(parents=True, exist_ok=False)
print("\nGenerating a fresh NIST P-256 keypair locally...")
key = ec.generate_private_key(ec.SECP256R1())
encrypted_pem = key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.BestAvailableEncryption(pw1.encode("utf-8")),
)
public_pem = key.public_key().public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
private_path = private_dir / "UML_L0_RCA_private_encrypted.pem"
public_path = public_dir / "UML_L0_RCA_public.pem"
private_path.write_bytes(encrypted_pem)
public_path.write_bytes(public_pem)
try:
private_path.chmod(0o600)
except OSError:
pass
# The scalar is kept in RAM and sent only over stdin to the local Rust binary.
scalar = bytearray(key.private_numbers().private_value.to_bytes(32, "big"))
env = os.environ.copy()
env["UML_TEMPLATE"] = str(UPSTREAM_DIR / TEMPLATE.name)
env["UML_OUTPUT_DIR"] = str(rust_out)
try:
proc = run(
[str(exe)],
cwd=UPSTREAM_DIR,
input_bytes=(bytes(scalar).hex().upper() + "\n").encode("ascii"),
capture=True,
env=env,
)
finally:
for i in range(len(scalar)):
scalar[i] = 0
stdout = proc.stdout.decode("utf-8", errors="replace")
stderr = proc.stderr.decode("utf-8", errors="replace")
print(stdout)
if stderr.strip():
print(stderr, file=sys.stderr)
m = re.search(r"^RESULT_ID8=([0-9A-F]{16})$", stdout, re.MULTILINE)
if not m:
die("Rust generator succeeded but RESULT_ID8 was not found.")
id8 = m.group(1)
oer_src = rust_out / f"{id8}.oer"
if not oer_src.exists():
die(f"Expected certificate not found: {oer_src}")
cert_bytes = oer_src.read_bytes()
cert_sha256 = hashlib.sha256(cert_bytes).hexdigest().upper()
if cert_sha256[-16:] != id8:
die("Independent Python HashedID8 check failed.")
# Independently reconstruct the IEEE/ETSI self-signed prehash from the TBS
# emitted by rasn and verify the c-its ECDSA signature with Python cryptography.
details = {}
for line in (rust_out / "signing_details.txt").read_text(encoding="utf-8").splitlines():
if "=" in line:
k, v = line.split("=", 1)
details[k] = v
tbs = (rust_out / "tbs.oer").read_bytes()
h_tbs = hashlib.sha256(tbs).digest()
h_empty = hashlib.sha256(b"").digest()
prehash = hashlib.sha256(h_tbs + h_empty).digest()
if details.get("TBS_SHA256") != h_tbs.hex().upper():
die("Independent TBS hash disagrees with Rust output.")
if details.get("SIGNING_PREHASH_SHA256") != prehash.hex().upper():
die("Independent IEEE/ETSI signing prehash disagrees with Rust output.")
r = int(details["ECDSA_R"], 16)
s = int(details["ECDSA_S"], 16)
der_sig = utils.encode_dss_signature(r, s)
try:
key.public_key().verify(
der_sig,
prehash,
ec.ECDSA(utils.Prehashed(hashes.SHA256())),
)
except Exception as exc:
die(f"Independent Python ECDSA verification FAILED: {exc}")
# Copy only the public submission certificate.
oer_submit = submit / f"{id8}.oer"
shutil.copy2(oer_src, oer_submit)
txt_name = f"{id8}_{CERTIFICATE_ID}_{submission_date}.txt"
txt_submit = submit / txt_name
txt_submit.write_text(f"""EU CCMS CPOC L0 ECTL — RCA descriptive information
===================================================
Full Company Name / RCA Operator:
Hochschule für Angewandte Wissenschaften Hamburg (HAW Hamburg)
Urban Mobility Lab
RCA Name:
Urban Mobility Lab L0 Root CA
RCA Distribution Centre URL:
https://cits-dc.uml-hamburg.de
Contact:
uml-haw@proton.me
CertificateID:
{CERTIFICATE_ID}
Certificate HashedID8:
{id8}
CertificateID / acronym explanation:
- 0: CPA-ID reserved for the L0 environment.
- Urban-Mobility-Lab-Root-CA: Root CA operated for the Urban Mobility Lab at HAW Hamburg.
- L0: EU CCMS L0 research/test environment.
Purpose of testing:
The RCA is used for research, field testing and proof-of-concept activities of the
Urban Mobility Lab in the context of the micrOBU project. micrOBU investigates the
integration of vulnerable road users into C-ITS through development of a lightweight,
compact and low-cost VRU ITS-S using an MCU + mobile-phone architecture.
Contextual constraints / deviations:
- This is a candidate new RCA. It is not registered and must not be deployed until
the EU CCMS CPOC has completed the intended registration or re-key procedure.
- The implementation, including the C-ITS protocol stack, signing functionality and
VRU Awareness Basic Service, is under active development. Full implementation
conformity is not claimed.
- The declared RCA Distribution Centre is the intended permanent DC URL. At the time
of this submission, the cits-dc.uml-hamburg.de subdomain and the required /getctl
and /getcrl endpoints are still being commissioned and are not yet operational.
- The RCA intentionally includes ITS-AID 638 (VRU Awareness basic service) with
bitmap SSP range 01/FF for L0 VAM/VBS research. ITS-AID 638 is a standardized ETSI
ITS-AID but is not contained in the current CPOC Protocol Release 3.3 Table 4 list;
its inclusion is therefore an intentional L0 profile deviation.
- The RCA private key is generated and maintained locally as an encrypted PKCS#8
NIST P-256 key for this L0 research environment. This software-based key storage is
not claimed to satisfy production/L2 cryptographic-module requirements.
Re-key / revocation / relationship to existing certificates:
Candidate new RCA. Coordinate its relationship to existing registrations with the
EU CCMS CPOC before submission or use.
Submission date:
{submission_date}
""", encoding="utf-8")
# Preserve public validation evidence.
shutil.copy2(rust_out / "certificate_report.json", public_dir / "certificate_report.json")
shutil.copy2(rust_out / "signing_details.txt", public_dir / "signing_details.txt")
shutil.copy2(rust_out / "tbs.oer", public_dir / "tbs.oer")
spki_der = key.public_key().public_bytes(
encoding=serialization.Encoding.DER,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
spki_sha256 = hashlib.sha256(spki_der).hexdigest().upper()
manifest = f"""UML L0 RCA generation manifest
=============================
Reference implementation:
https://github.com/TheEnbyperor/c-its
Pinned commit:
{UPSTREAM_COMMIT}
Reference root-generator behavior:
- rasn::oer encoding
- c_its::security::crypto::PrivateKey::sign()
- self-signed signer-certificate input = empty byte string
- NIST P-256 / SHA-256
Template certificate SHA-256:
{TEMPLATE_SHA256}
NEW certificate:
{id8}.oer
Certificate SHA-256:
{cert_sha256}
HashedID8:
{id8}
New public-key SPKI SHA-256:
{spki_sha256}
Validation:
PASS - c-its parsed final certificate
PASS - c-its self-signature verification
PASS - c-its parse/re-encode stability
PASS - Python independently recomputed HashedID8
PASS - Python independently recomputed IEEE/ETSI signing prehash
PASS - Python cryptography independently verified ECDSA signature over that prehash
PRIVATE KEY:
{private_path.name}
Encrypted PKCS#8 PEM. DO NOT SUBMIT OR UPLOAD.
"""
(public_dir / "generation_manifest.txt").write_text(manifest, encoding="utf-8")
# Remove duplicate Rust certificate; the canonical submission copy is in SUBMIT.
shutil.rmtree(rust_out)
print("\nSUCCESS")
print("A NEW, UNREGISTERED keypair and candidate certificate were generated locally.")
print("Do not deploy it as the registered root. EU registration/re-keying is required.")
print(f"\nCANDIDATE SUBMISSION FILES (NOT REGISTERED):\n {oer_submit}\n {txt_submit}")
print(f"\nKEEP PRIVATE:\n {private_path}")
print(f"\nSAFE PUBLIC/VALIDATION FILES:\n {public_dir}")
return root
def main() -> None:
require_program("cargo")
if not TEMPLATE.exists():
die(f"Missing bundled template: {TEMPLATE}")
got = hashlib.sha256(TEMPLATE.read_bytes()).hexdigest().upper()
if got != TEMPLATE_SHA256:
die(f"Bundled template hash mismatch: {got}")
prepare_upstream()
exe = build_generator()
root = generate_keypair_and_certificate(exe)
print(f"\nResult folder: {root}")
if __name__ == "__main__":
main()
@@ -1,168 +0,0 @@
#!/usr/bin/env python3
"""Rebuild and verify the registered UML L0 RCA with its existing private key."""
from __future__ import annotations
import getpass
import hashlib
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tarfile
import zipfile
UPSTREAM_COMMIT = "e3bb3b82480d6df4237e2a8c35ea0dd7eade25b4"
REGISTERED_SHA256 = "7168E341764188E140EF591634A9D2488D7FDACDA667EE53AFD566A8034ED5DB"
REGISTERED_ID8 = "AFD566A8034ED5DB"
HERE = Path(__file__).resolve().parent
ROOT = HERE.parent
REGISTERED_CERT = ROOT / "rca" / f"{REGISTERED_ID8}.oer"
REGISTERED_PUBLIC_KEY = ROOT / "rca" / "UML_L0_RCA_public.pem"
REGISTERED_PRIVATE_KEY = ROOT / "private" / "UML_L0_RCA_private_encrypted.pem"
PATCH_SOURCE = HERE / "src" / "generate_root.rs"
BUNDLED_SOURCE = HERE / "vendor" / "c-its-source-e3bb3b8.zip"
VENDORED_CRATES = HERE / "vendor" / "cargo-crates.tar.gz"
UPSTREAM_DIR = HERE / "_build_cits"
CARGO_VENDOR_DIR = HERE / "_cargo_vendor"
OUTPUT_DIR = HERE / "rebuild-output"
TEMPLATE_NAME = "registered_rca.oer"
def die(message: str) -> None:
raise SystemExit(f"\nERROR: {message}")
def run(command: list[str], *, cwd: Path, input_bytes: bytes | None = None,
capture: bool = False, env: dict[str, str] | None = None):
print("+", " ".join(command))
return subprocess.run(
command, cwd=cwd, input=input_bytes, check=True, capture_output=capture, env=env
)
def require_program(name: str) -> None:
if shutil.which(name) is None:
die(f"'{name}' is required and was not found on PATH.")
def verify_inputs() -> None:
for path in (REGISTERED_CERT, REGISTERED_PUBLIC_KEY, REGISTERED_PRIVATE_KEY, PATCH_SOURCE):
if not path.is_file():
die(f"Required file is missing: {path}")
actual = hashlib.sha256(REGISTERED_CERT.read_bytes()).hexdigest().upper()
if actual != REGISTERED_SHA256:
die(f"Registered certificate hash mismatch: {actual}")
def prepare_upstream() -> None:
if not BUNDLED_SOURCE.is_file() or not VENDORED_CRATES.is_file():
die("Bundled c-its source or Cargo crate archive is missing.")
if UPSTREAM_DIR.exists():
shutil.rmtree(UPSTREAM_DIR)
if CARGO_VENDOR_DIR.exists():
shutil.rmtree(CARGO_VENDOR_DIR)
UPSTREAM_DIR.mkdir()
CARGO_VENDOR_DIR.mkdir()
with zipfile.ZipFile(BUNDLED_SOURCE) as archive:
archive.extractall(UPSTREAM_DIR)
with tarfile.open(VENDORED_CRATES, "r:gz") as archive:
archive.extractall(CARGO_VENDOR_DIR)
shutil.copy2(PATCH_SOURCE, UPSTREAM_DIR / "src" / "util" / "generate_root.rs")
shutil.copy2(REGISTERED_CERT, UPSTREAM_DIR / TEMPLATE_NAME)
cargo_toml = UPSTREAM_DIR / "Cargo.toml"
text = cargo_toml.read_text(encoding="utf-8")
old = 'ieee80211 = "0.5.9"'
new = 'ieee80211 = { version = "0.5.9", default-features = false }'
if old in text:
cargo_toml.write_text(text.replace(old, new, 1), encoding="utf-8")
elif new not in text:
die("Expected ieee80211 dependency was not found in the pinned source.")
cargo_config = UPSTREAM_DIR / ".cargo" / "config.toml"
cargo_config.parent.mkdir()
cargo_config.write_text(
'[source.crates-io]\nreplace-with = "vendored-sources"\n\n'
'[source.vendored-sources]\ndirectory = "../_cargo_vendor"\n\n'
'[net]\noffline = true\n',
encoding="utf-8",
)
def build_generator() -> Path:
run(
["cargo", "build", "--offline", "--release", "--features", "build-binary", "--bin", "c-its-generate-root"],
cwd=UPSTREAM_DIR,
)
executable = UPSTREAM_DIR / "target" / "release" / (
"c-its-generate-root.exe" if os.name == "nt" else "c-its-generate-root"
)
if not executable.is_file():
die(f"Generator executable is missing: {executable}")
return executable
def load_registered_scalar() -> bytearray:
try:
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ec
except ImportError:
die("Install the Python package 'cryptography'.")
password = getpass.getpass("Registered RCA private-key passphrase: ").encode("utf-8")
try:
key = serialization.load_pem_private_key(REGISTERED_PRIVATE_KEY.read_bytes(), password=password)
except Exception as exc:
die(f"Could not unlock the registered private key: {exc}")
if not isinstance(key, ec.EllipticCurvePrivateKey) or not isinstance(key.curve, ec.SECP256R1):
die("Registered key is not NIST P-256.")
expected_public = serialization.load_pem_public_key(REGISTERED_PUBLIC_KEY.read_bytes())
if key.public_key().public_numbers() != expected_public.public_numbers():
die("Private key does not match the registered public key.")
return bytearray(key.private_numbers().private_value.to_bytes(32, "big"))
def rebuild(executable: Path) -> None:
scalar = load_registered_scalar()
if OUTPUT_DIR.exists():
shutil.rmtree(OUTPUT_DIR)
OUTPUT_DIR.mkdir()
env = os.environ.copy()
env["UML_TEMPLATE"] = str(UPSTREAM_DIR / TEMPLATE_NAME)
env["UML_OUTPUT_DIR"] = str(OUTPUT_DIR)
try:
result = run(
[str(executable)], cwd=UPSTREAM_DIR,
input_bytes=(bytes(scalar).hex().upper() + "\n").encode("ascii"),
capture=True, env=env,
)
finally:
for index in range(len(scalar)):
scalar[index] = 0
sys.stdout.write(result.stdout.decode("utf-8", errors="replace"))
sys.stderr.write(result.stderr.decode("utf-8", errors="replace"))
rebuilt = OUTPUT_DIR / f"{REGISTERED_ID8}.oer"
if not rebuilt.is_file():
die(f"Expected rebuilt certificate is missing: {rebuilt}")
if rebuilt.read_bytes() != REGISTERED_CERT.read_bytes():
die("Rebuilt certificate is not byte-for-byte identical to the EU-registered certificate.")
print("\nPASS: rebuilt certificate exactly matches the registered certificate.")
print(f"Validation output: {OUTPUT_DIR}")
def main() -> None:
require_program("cargo")
verify_inputs()
prepare_upstream()
rebuild(build_generator())
if __name__ == "__main__":
main()
@@ -1,179 +0,0 @@
use std::io::{self, Read};
use std::path::PathBuf;
use sha2::{Digest, Sha256};
const EXPECTED_TEMPLATE_SHA256: &str =
"7168E341764188E140EF591634A9D2488D7FDACDA667EE53AFD566A8034ED5DB";
fn err(msg: impl Into<String>) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, msg.into())
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let template_path = PathBuf::from(
std::env::var("UML_TEMPLATE")
.unwrap_or_else(|_| "registered_rca.oer".to_string()),
);
let output_dir = PathBuf::from(
std::env::var("UML_OUTPUT_DIR").unwrap_or_else(|_| "uml_output".to_string()),
);
let template_bytes = std::fs::read(&template_path)?;
let template_sha256 = hex::encode_upper(Sha256::digest(&template_bytes));
if template_sha256 != EXPECTED_TEMPLATE_SHA256 {
return Err(err(format!(
"Template SHA-256 mismatch. Expected {}, got {}",
EXPECTED_TEMPLATE_SHA256, template_sha256
))
.into());
}
// Decode the registered certificate only as the reviewed profile template.
let template: rasn_its::ts103097::EtsiTs103097Certificate =
rasn::oer::decode(&template_bytes)?;
// The wrapper sends a newly generated P-256 scalar over stdin. It is never
// written to disk unencrypted.
let mut scalar_hex = String::new();
std::io::stdin().read_to_string(&mut scalar_hex)?;
let scalar = hex::decode(scalar_hex.trim())?;
if scalar.len() != 32 {
return Err(err(format!(
"Expected a 32-byte NIST P-256 private scalar, received {} bytes",
scalar.len()
))
.into());
}
let secret_key = p256::SecretKey::from_slice(&scalar)
.map_err(|_| err("Invalid NIST P-256 private scalar"))?;
let private_key =
c_its::security::crypto::PrivateKey::P256(secret_key.into());
let mut tbs_cert = template.to_be_signed.clone();
tbs_cert.verify_key_indicator =
rasn_its::ieee1609dot2::VerificationKeyIndicator::VerificationKey(
private_key.public_key().encode(),
);
// This is deliberately the exact encoding/signing route introduced by
// TheEnbyperor/c-its commit e3bb3b82480d6df4237e2a8c35ea0dd7eade25b4.
let tbs_bytes = rasn::oer::encode(&tbs_cert)?;
let hash_alg = c_its::security::crypto::HashAlgorithm::Sha256;
let signature = private_key
.sign(hash_alg, &tbs_bytes, None)
.ok_or_else(|| err("c-its signing failed"))?;
let (r_hex, s_hex) = match &signature {
c_its::security::crypto::Signature::P256(sig) => (
hex::encode_upper(sig.r().to_bytes()),
hex::encode_upper(sig.s().to_bytes()),
),
_ => return Err(err("Unexpected signature algorithm").into()),
};
// Construct the certificate exactly like the upstream root generator.
let raw_cert = rasn_its::ts103097::EtsiTs103097Certificate::try_from(
rasn_its::ieee1609dot2::Certificate::from(
rasn_its::ieee1609dot2::ExplicitCertificate::new(
rasn_its::ieee1609dot2::CertificateBase {
version: 3,
r#type: rasn_its::ieee1609dot2::CertificateType::Explicit,
issuer: rasn_its::ieee1609dot2::IssuerIdentifier::VSelf(
hash_alg.into(),
),
to_be_signed: tbs_cert,
signature: Some(signature.as_signature()),
},
)
.map_err(|_| err("Could not construct ExplicitCertificate"))?,
),
)
.map_err(|_| err("Certificate does not satisfy EtsiTs103097Certificate constraints"))?;
let cert = c_its::security::certs::Certificate::new(&raw_cert);
let encoded = cert.to_bytes();
// Parse the actual final bytes from scratch and make the upstream c-its verifier
// validate the self-signature.
let reparsed = c_its::security::certs::Certificate::parse(&encoded)
.map_err(err)?;
let report = reparsed.report().map_err(err)?;
match report.signature() {
c_its::security::certs::CertificateSignature::SelfSigned { verifies: true } => {}
other => {
return Err(err(format!(
"FINAL CERTIFICATE SELF-SIGNATURE DID NOT VERIFY: {:?}",
other
))
.into())
}
}
let reencoded = reparsed.to_bytes();
if reencoded != encoded {
return Err(err("Final certificate is not stable across parse/re-encode").into());
}
let id8 = hex::encode_upper(report.id8());
let cert_sha256 = hex::encode_upper(Sha256::digest(&encoded));
if &cert_sha256[cert_sha256.len() - 16..] != id8 {
return Err(err("HashedID8 does not match low-order 8 bytes of SHA-256").into());
}
// Compute the IEEE/ETSI self-signed certificate signing prehash independently
// from the signature object:
// SHA256( SHA256(COER/OER-TBS) || SHA256(empty signer certificate) )
let tbs_hash = Sha256::digest(&tbs_bytes);
let empty_hash = Sha256::digest([]);
let mut outer = Sha256::new();
outer.update(&tbs_hash);
outer.update(&empty_hash);
let signing_prehash = outer.finalize();
std::fs::create_dir_all(&output_dir)?;
std::fs::write(output_dir.join(format!("{}.oer", id8)), &encoded)?;
std::fs::write(output_dir.join("tbs.oer"), &tbs_bytes)?;
std::fs::write(
output_dir.join("certificate_report.json"),
serde_json::to_vec_pretty(&report)?,
)?;
let signing_details = format!(
concat!(
"UPSTREAM_COMMIT=e3bb3b82480d6df4237e2a8c35ea0dd7eade25b4\n",
"TEMPLATE_SHA256={}\n",
"CERTIFICATE_SHA256={}\n",
"HASHEDID8={}\n",
"TBS_LENGTH={}\n",
"TBS_SHA256={}\n",
"EMPTY_SHA256={}\n",
"SIGNING_PREHASH_SHA256={}\n",
"ECDSA_R={}\n",
"ECDSA_S={}\n",
"CITS_SELF_SIGNATURE_VERIFIES=true\n",
"PARSE_REENCODE_STABLE=true\n"
),
template_sha256,
cert_sha256,
id8,
tbs_bytes.len(),
hex::encode_upper(tbs_hash),
hex::encode_upper(empty_hash),
hex::encode_upper(signing_prehash),
r_hex,
s_hex,
);
std::fs::write(output_dir.join("signing_details.txt"), signing_details)?;
println!("RESULT_ID8={}", id8);
println!(
"RESULT_CERT={}",
output_dir.join(format!("{}.oer", id8)).display()
);
println!("CITS_SELF_SIGNATURE_VERIFIES=true");
Ok(())
}
@@ -1,176 +0,0 @@
use std::io::{self, Read};
use std::path::PathBuf;
use sha2::{Digest, Sha256};
const EXPECTED_TEMPLATE_SHA256: &str =
"7168E341764188E140EF591634A9D2488D7FDACDA667EE53AFD566A8034ED5DB";
fn err(msg: impl Into<String>) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, msg.into())
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let template_path = PathBuf::from(
std::env::var("UML_TEMPLATE")
.unwrap_or_else(|_| "registered_rca.oer".to_string()),
);
let output_dir = PathBuf::from(
std::env::var("UML_OUTPUT_DIR").unwrap_or_else(|_| "uml_output".to_string()),
);
let template_bytes = std::fs::read(&template_path)?;
let template_sha256 = hex::encode_upper(Sha256::digest(&template_bytes));
if template_sha256 != EXPECTED_TEMPLATE_SHA256 {
return Err(err(format!(
"Template SHA-256 mismatch. Expected {}, got {}",
EXPECTED_TEMPLATE_SHA256, template_sha256
))
.into());
}
// Decode the registered certificate. Its TBS, including its public key, is kept
// unchanged so this tool cannot silently create a different root identity.
let template: rasn_its::ts103097::EtsiTs103097Certificate =
rasn::oer::decode(&template_bytes)?;
// The wrapper unlocks the registered P-256 key and sends its scalar over stdin.
// It is never written to disk unencrypted.
let mut scalar_hex = String::new();
std::io::stdin().read_to_string(&mut scalar_hex)?;
let scalar = hex::decode(scalar_hex.trim())?;
if scalar.len() != 32 {
return Err(err(format!(
"Expected a 32-byte NIST P-256 private scalar, received {} bytes",
scalar.len()
))
.into());
}
let secret_key = p256::SecretKey::from_slice(&scalar)
.map_err(|_| err("Invalid NIST P-256 private scalar"))?;
let private_key =
c_its::security::crypto::PrivateKey::P256(secret_key.into());
let tbs_cert = template.to_be_signed.clone();
// This is deliberately the exact encoding/signing route introduced by
// TheEnbyperor/c-its commit e3bb3b82480d6df4237e2a8c35ea0dd7eade25b4.
let tbs_bytes = rasn::oer::encode(&tbs_cert)?;
let hash_alg = c_its::security::crypto::HashAlgorithm::Sha256;
let signature = private_key
.sign(hash_alg, &tbs_bytes, None)
.ok_or_else(|| err("c-its signing failed"))?;
let (r_hex, s_hex) = match &signature {
c_its::security::crypto::Signature::P256(sig) => (
hex::encode_upper(sig.r().to_bytes()),
hex::encode_upper(sig.s().to_bytes()),
),
_ => return Err(err("Unexpected signature algorithm").into()),
};
// Construct the certificate exactly like the upstream root generator.
let raw_cert = rasn_its::ts103097::EtsiTs103097Certificate::try_from(
rasn_its::ieee1609dot2::Certificate::from(
rasn_its::ieee1609dot2::ExplicitCertificate::new(
rasn_its::ieee1609dot2::CertificateBase {
version: 3,
r#type: rasn_its::ieee1609dot2::CertificateType::Explicit,
issuer: rasn_its::ieee1609dot2::IssuerIdentifier::VSelf(
hash_alg.into(),
),
to_be_signed: tbs_cert,
signature: Some(signature.as_signature()),
},
)
.map_err(|_| err("Could not construct ExplicitCertificate"))?,
),
)
.map_err(|_| err("Certificate does not satisfy EtsiTs103097Certificate constraints"))?;
let cert = c_its::security::certs::Certificate::new(&raw_cert);
let encoded = cert.to_bytes();
// Parse the actual final bytes from scratch and make the upstream c-its verifier
// validate the self-signature.
let reparsed = c_its::security::certs::Certificate::parse(&encoded)
.map_err(err)?;
let report = reparsed.report().map_err(err)?;
match report.signature() {
c_its::security::certs::CertificateSignature::SelfSigned { verifies: true } => {}
other => {
return Err(err(format!(
"FINAL CERTIFICATE SELF-SIGNATURE DID NOT VERIFY: {:?}",
other
))
.into())
}
}
let reencoded = reparsed.to_bytes();
if reencoded != encoded {
return Err(err("Final certificate is not stable across parse/re-encode").into());
}
let id8 = hex::encode_upper(report.id8());
let cert_sha256 = hex::encode_upper(Sha256::digest(&encoded));
if &cert_sha256[cert_sha256.len() - 16..] != id8 {
return Err(err("HashedID8 does not match low-order 8 bytes of SHA-256").into());
}
// Compute the IEEE/ETSI self-signed certificate signing prehash independently
// from the signature object:
// SHA256( SHA256(COER/OER-TBS) || SHA256(empty signer certificate) )
let tbs_hash = Sha256::digest(&tbs_bytes);
let empty_hash = Sha256::digest([]);
let mut outer = Sha256::new();
outer.update(&tbs_hash);
outer.update(&empty_hash);
let signing_prehash = outer.finalize();
std::fs::create_dir_all(&output_dir)?;
std::fs::write(output_dir.join(format!("{}.oer", id8)), &encoded)?;
std::fs::write(output_dir.join("tbs.oer"), &tbs_bytes)?;
std::fs::write(
output_dir.join("certificate_report.json"),
serde_json::to_vec_pretty(&report)?,
)?;
let signing_details = format!(
concat!(
"UPSTREAM_COMMIT=e3bb3b82480d6df4237e2a8c35ea0dd7eade25b4\n",
"TEMPLATE_SHA256={}\n",
"CERTIFICATE_SHA256={}\n",
"HASHEDID8={}\n",
"TBS_LENGTH={}\n",
"TBS_SHA256={}\n",
"EMPTY_SHA256={}\n",
"SIGNING_PREHASH_SHA256={}\n",
"ECDSA_R={}\n",
"ECDSA_S={}\n",
"CITS_SELF_SIGNATURE_VERIFIES=true\n",
"PARSE_REENCODE_STABLE=true\n"
),
template_sha256,
cert_sha256,
id8,
tbs_bytes.len(),
hex::encode_upper(tbs_hash),
hex::encode_upper(empty_hash),
hex::encode_upper(signing_prehash),
r_hex,
s_hex,
);
std::fs::write(output_dir.join("signing_details.txt"), signing_details)?;
println!("RESULT_ID8={}", id8);
println!(
"RESULT_CERT={}",
output_dir.join(format!("{}.oer", id8)).display()
);
println!("CITS_SELF_SIGNATURE_VERIFIES=true");
Ok(())
}
-461
View File
@@ -1,461 +0,0 @@
# VRU ITS-S station-internal link (phone ⇄ micrOBU)
Project interface **IF-PROJ-NF-BLE-001** made concrete, plus its DEC-VBS-004
companions. One VRU ITS-S (ETSI EN 302 665 clause 4.5.1) is split over two
physical units at the NF-SAP (DEC-VBS-003):
| Unit | Hosts |
|---|---|
| Phone (app) | VRU basic service, VAM encode/decode, HMI, PoTi, credential manager (TS 102 941 client) |
| micrOBU (ESP32-C5, this firmware) | BTP-B, GeoNetworking SHB/GBC, SN-SAP security entity (TS 103 097 signing, ticket pool, identifier change, SN-DECAP), ITS-G5 access |
Everything that crosses the link is a standards-defined primitive
(BTP-DATA.request/indication of TS 102 723-11 / TS 103 836-5-1 Annex A; the
PoTi data set of EN 302 890-2 Tables 2 and 3; the SF-SAP identifier-change
primitives of TS 102 723-9 Tables 10 to 21; the legacy/reference MF-SET parameter
semantics of TS 103 175 Table 11) or
one of three project-defined management messages (station configuration,
credential provisioning with a `VCR1` bundle, status). No SN-, MN- or MI-SAP
primitive crosses the link (ACT-015).
The control/reference transport is the micrOBU's wired native USB Serial/JTAG
port. Encrypted BLE GATT is an experimental alternative and shall not replace
the wired architecture until ACT-021's controlled coexistence experiment shows
that concurrent BLE does not disturb C5 PHY/CCA observation, ITS-G5 RF behavior,
timing, reliability, or power measurements. The message layer below is transport
independent. Nothing here is an ETSI on-air format or a conformance claim for
the phone-to-micrOBU link.
Implementations: firmware `firmware/main/link_protocol.*`,
`ble_framing.*` and `ble_link.*` (C++); phone-side protocol module
`microbuapp/station-link/` and Android central
`microbuapp/app/src/main/java/com/hawhamburg/micr0bu/data/transport/BleStationLinkTransport.kt`
(Kotlin); bench reference `station-link/python/microbu_link/`
(Python, with serial and BLE adapters used by the phone emulator, TTCN SUT
bridge and unit tests). Golden
vectors and fragmentation are checked across the Python, C++ and Kotlin
implementations.
## Message layer (version 1)
A reassembled message is at most **512 octets**. All integers
are **little-endian** (the app's existing serial framing convention);
ETSI-encoded payloads inside (UPER VAMs, COER certificates, GeoNetworking
octets) are opaque and keep their own byte order.
```
octet 0 opcode
octet 1 flags bit 0: FRAGMENT_FIRST, bit 1: FRAGMENT_MORE (see Fragmentation); other bits 0
octets 2-3 sequence u16, see below
octets 4- body opcode specific
```
Sequence: every request the phone sends carries its own counter; the micrOBU
answers with a `RESULT` (or the typed reply named below) carrying the **same
sequence**. Messages the micrOBU originates (indications, events, status) carry the
micrOBU's own counter and are never answered, except `SF_IDCHANGE_EVENT` which the
phone answers with `SF_IDCHANGE_EVENT_RESPONSE` echoing the event's sequence.
### Phone → micrOBU
| Opcode | Name | Reply | Standard basis |
|---|---|---|---|
| 0x01 | STATION_CONFIGURE | RESULT + station info | project management (MIB values the micrOBU cannot know) |
| 0x02 | POTI_UPDATE | none (RESULT only on error) | EN 302 890-2 Table 2 (NF-SAP) and Table 3 (SF-SAP) minimum data sets, plus speed/heading for the GN position vector |
| 0x03 | BTP_DATA_REQUEST | RESULT | TS 102 723-11 clause 5 / TS 103 836-5-1 Annex A.2; VBS PCI TS 103 300-3 Table 4 |
| 0x04 | CREDENTIALS_PROVISION | RESULT + apply report (last segment) | DEC-VBS-004: one `VCR1` bundle (vanetza_idf/credentials.hpp) |
| 0x05 | CREDENTIALS_ERASE | RESULT | project |
| 0x06 | SF_IDCHANGE_SUBSCRIBE | RESULT + subscription handle | TS 102 723-9 Tables 10/11 |
| 0x07 | SF_IDCHANGE_UNSUBSCRIBE | RESULT | Tables 14/15 |
| 0x08 | SF_IDCHANGE_EVENT_RESPONSE | none | Table 13 |
| 0x09 | SF_IDCHANGE_TRIGGER | RESULT | Tables 16/17 |
| 0x0A | SF_ID_LOCK | RESULT + lock handle | Tables 18/19 |
| 0x0B | SF_ID_UNLOCK | RESULT | Tables 20/21 |
| 0x0C | STATUS_REQUEST | STATUS | project |
### micrOBU → phone
| Opcode | Name | Standard basis |
|---|---|---|
| 0x80 | RESULT | project (the library's `Result` plus link codes) |
| 0x81 | BTP_DATA_INDICATION | TS 103 836-5-1 Annex A.3 with the GN-DATA.indication parameters it preserves |
| 0x82 | SF_IDCHANGE_EVENT | TS 102 723-9 Table 12 |
| 0x83 | MF_SET_REQUEST | TS 103 175 Table 11 (DCC F-Params of Table 13) |
| 0x84 | STATUS | project |
### Bodies
**RESULT (0x80)**: `u8 result`, `u8 detail_length`, `detail[]`. `result` is
`vanetza_idf::Result` (0 accepted, 1 invalid_argument, 2 unsupported, 3
wrong_entry_point, 4 security_unavailable, 5 resource_limit, 6 rejected, 7
time_regression, 8 identity_change_pending) or a link code: 0x10
unknown_opcode, 0x11 malformed, 0x12 not_configured (no STATION_CONFIGURE
yet), 0x13 busy, 0x14 no_credentials. `detail` depends on the request:
| Request | detail |
|---|---|
| STATION_CONFIGURE | `u8[8] gn_address` (as on the wire, TS 103 836-4-1 clause 6.3), `u8[8] identifier` (HashedId8 of the current authorization ticket, zeros when unsecured), `u8 credentials_loaded`, `u8 tickets` |
| CREDENTIALS_PROVISION (last segment) | `u8 roots`, `u8 authorities`, `u8 tickets` accepted (ApplyReport) |
| SF_IDCHANGE_SUBSCRIBE | `u64 subscription` |
| SF_ID_LOCK | `u64 lock_handle` |
| others | empty |
**STATION_CONFIGURE (0x01)**: `u8 station_type` (TS 102 894-2 StationType, 2 =
cyclist), `u8 security` (0 unsecured, 1 secured: itsGnSecurity), `u8
address_configuration` (0 AUTO with the given MID, 1 ANONYMOUS: MID from the
ticket digest, TS 103 836-4-1 clause 10.2.1.4), `u8[6] mid` (AUTO only),
`u8 beaconing` (0/1, GN-MGMT beacons), `u16 channel_number` (ITS-G5 channel,
180 = 5 900 MHz), `u8 transmit_power_dbm`, `u8 radio` (0 off, 1 receive only, 2
transmit and receive — laboratory transmission stays an explicit choice), `u8
default_traffic_class` (raw TC octet used when a request carries none), `u8
default_lifetime` (raw GN Lifetime octet). Configuring (re)creates the stack on the micrOBU;
a request before it is answered `not_configured`.
**POTI_UPDATE (0x02)**: `u64 timestamp` (TimestampIts, TAI ms since
2004-01-01; also synchronises the micrOBU's ITS clock), `i32 latitude`, `i32
longitude` (1/10 µdeg, WGS84), `u16 semi_major`, `u16 semi_minor` (cm, 95 %
horizontal confidence ellipse), `u16 orientation` (0.1°, semi-major axis from
north), `u8 flags` (bit 0 altitude present, bit 1 speed present, bit 2 heading
present, bit 3 position accuracy indicator), `i32 altitude` (cm), `u16 speed`
(0.01 m/s), `u16 heading` (0.1°). The micrOBU answers only when it rejects the
update (a `RESULT` with `time_regression` or `invalid_argument`); accepted
updates are silent so a 10 Hz PoTi does not double the link traffic.
**BTP_DATA_REQUEST (0x03)** (TS 103 300-3 Table 4 order):
```
u8 btp_type 0 BTP-A, 1 BTP-B
u16 destination_port
u16 destination_port_info (BTP-B) or source_port (BTP-A)
u8 gn_packet_transport_type 1 SHB, 2 GBC (3 TSB, 4 GAC, 5 GUC are refused: unsupported)
u8 gn_communication_profile 0 unspecified, 1 ITS-G5, 2 LTE-V2X
u8 gn_security_profile 0 not given (station configuration), 1 unsecured, 2 secured
u8 gn_traffic_class raw TC octet (TS 103 836-4-1 clause 9.7.5); 0xFF = station default
u8 gn_maximum_packet_lifetime raw Lifetime octet (clause 9.6.4); 0xFF = station default
u8 gn_maximum_hop_limit 0 = station default
u16 gn_repetition_interval_ms 0 = no repetition
u16 gn_repetition_maximum_ms
u32 its_aid ITS-AID (TS 102 965), the SN-ENCAP input
u8 permissions_length SSP octets (TS 103 097 / TS 102 965) …
u8 permissions[]
u8 context_length SN-ENCAP context_information (vam_cluster = 01) …
u8 context[]
if GBC: u8 shape (0 circle, 1 rectangle, 2 ellipse), i32 latitude, i32 longitude,
u16 distance_a, u16 distance_b (m), u16 angle (°)
u16 length of the FL-SDU (the VAM)
u8 fl_sdu[length]
```
The micrOBU hands this to `NF_SAP::BTP_DATA_request_submit`. `RESULT.result` is
the library's acceptance (accepted = submitted for protocol processing, not
proof of transmission). A secured request whose signing the entity refuses
(no valid ticket, identifier change pending) is visible in `STATUS` counters.
**BTP_DATA_INDICATION (0x81)**: `u8 btp_type`, `u16 destination_port`, `u16
destination_port_info` (or source_port), `u8 gn_packet_transport_type`, `u8
gn_traffic_class`, `u8 gn_remaining_packet_lifetime` (raw, 0xFF absent), `u8
gn_remaining_hop_limit` (0xFF absent), `u8[8] source_gn_address`, `u32
source_timestamp` (TST ms), `i32 source_latitude`, `i32 source_longitude`,
`u8 security_report` (0 unsecured, 1 + `VerificationReport` ordinal: 1
Success, 2 False_Signature, …), `u32 its_aid` (0 when absent), `u8
permissions_length`, `permissions[]`, `u8 certificate_present`, `u8[8]
certificate_id`, `u8 area_present`, [`u8 shape`, `i32 lat`, `i32 lon`, `u16
a`, `u16 b`, `u16 angle`], `u16 length`, `received_fl_sdu[]`.
**CREDENTIALS_PROVISION (0x04)**: `u16 total_length`, `u16 offset`, `u8
segment_length`, `segment[]`. The segments of one bundle arrive in order;
`offset + segment_length == total_length` ends it. The micrOBU decodes the bundle,
applies it through the security entity's checks (`security::apply`), saves it
in the NVS credential store when every item was accepted, and rebuilds the
station with it. Keys travel in the clear: the transport must be a trusted
one (a paired, encrypted BLE link; a USB cable on the bench).
**SF_IDCHANGE_SUBSCRIBE (0x06)**: `u8 subscriber_data_length`,
`subscriber_data[]`. **SF_IDCHANGE_UNSUBSCRIBE (0x07)**: `u64 subscription`.
**SF_IDCHANGE_EVENT (0x82)**: `u64 subscription`, `u8 command` (0 PREPARE, 1
COMMIT, 2 ABORT, 3 DEREG), `u8[8] id` (HashedId8 of the next ticket), `u8
subscriber_data_length`, `subscriber_data[]`. **SF_IDCHANGE_EVENT_RESPONSE
(0x08)**: `u64 subscription`, `u8 return_code` — sent for PREPARE and COMMIT
with the event's sequence; the service's response timeout (500 ms) applies
across the link. **SF_IDCHANGE_TRIGGER (0x09)**: empty. **SF_ID_LOCK (0x0A)**:
`u8 duration_seconds`. **SF_ID_UNLOCK (0x0B)**: `u64 lock_handle`.
**MF_SET_REQUEST (0x83)**: `u32 fac_id`, `u8 command_ref`, `u8 count`, then
`count` × (`u8 f_param_no`, `u32 value`) with TS 103 175 Table 13 numbers (0
CHANNEL_NUMBER, 1 AVAILABLE_RESOURCE). The micrOBU sends it only for measurements
its access layer actually made; it never invents a channel load (ACT-021).
**STATUS (0x84)**: `u32 uptime_ms`, `u8 configured`, `u8[8] gn_address`,
`u8[8] identifier`, `u8 change_pending`, `u8 tickets`, `u32 signed_messages`,
`u32 refused_no_ticket`, `u32 refused_change_pending`, `u32
refused_permission`, `u32 sign_failed`, `u32 verified`, `u32 rejected`
(SN-DECAP rejections of every kind), `u32 requests_accepted`, `u32
requests_refused`, `u32 indications`, `u32 radio_submitted`, `u32
radio_failed`, `u32 radio_received`, `u32 radio_dropped`, `u32
link_rx_frames`, `u32 link_crc_errors`, `u32 link_malformed`, `u32
poti_updates`, `u64 its_time` (current micrOBU ITS clock, ms; 0 before the first
POTI_UPDATE). The micrOBU sends it every second unsolicited and on STATUS_REQUEST.
### Fragmentation
A message longer than the transport's unit (ATT_MTU − 3 for a notification; a
GATT client's long write handles 512 B values itself) is sent as fragments,
each with the full 4-octet header and the same sequence: the first with
FRAGMENT_FIRST, every one but the last with FRAGMENT_MORE. The receiver
concatenates the bodies. The serial transport carries whole messages and never
sets these bits.
## Transports
### Serial (bench and conformance tests)
Native USB Serial/JTAG of the micrOBU's ESP32-C5 (VID 303A, PID 1001), the app's Phase 03
framing (`obu-firmware/main/serial_link.h`, `SerialFrame.kt`):
```
[0xAA][0x55][type:1][length:2 LE][payload][crc16:2 LE]
```
CRC-16/CCITT-FALSE (poly 0x1021, init 0xFFFF) over type + length + payload.
Frame types of this firmware:
| Type | Payload |
|---|---|
| 0x10 | one link message (above), ≤ 512 octets |
| 0x11 | test channel request (PC → micrOBU) / reply (micrOBU → PC), ≤ 1 536 octets, only in a test build |
| 0x7F | UTF-8 log line from the firmware (ESP_LOG output; the console stays free of binary frames) |
The maximum frame payload is 1 536 octets (a mirrored GNPDU can exceed 512).
Frames the phone does not know are ignored; text outside frames (ROM boot
messages) is skipped by the sync/CRC search.
### BLE GATT (production transport)
The transport uses the Nordic UART Service UUID layout so standard BLE tools
can inspect it. It is a byte transport for the project message layer, not the
Nordic UART wire protocol:
| GATT object | UUID | Properties | Direction |
|---|---|---|---|
| Primary service | `6e400001-b5a3-f393-e0a9-e50e24dcca9e` | primary | — |
| RX/request | `6e400002-b5a3-f393-e0a9-e50e24dcca9e` | write, write without response | phone → micrOBU |
| TX/notification | `6e400003-b5a3-f393-e0a9-e50e24dcca9e` | notify | micrOBU → phone |
The peripheral advertises the service and the name `micrOBU-XXXX`, where the
suffix is derived from its Bluetooth MAC. GATT access requires an authenticated,
encrypted connection. The ESP32 uses LE Secure Connections, bonding and a
fresh six-digit passkey printed to the USB log (DisplayOnly); NimBLE persists
the bond in NVS. Android presents the normal system pairing UI and retains the
bond. This protects the credential bundle in transit; it is not application-
layer encryption.
Android requests an ATT MTU of 517; the ESP32 preference is 512. The Android
writer uses `ATT_MTU - 3`, capped at 512, after negotiation (509 octets with
the current ESP32 configuration) and falls back to 20 octets.
The ESP32 notification path deliberately emits values of at most 20 octets so
it also works at the mandatory default MTU. Every value uses the fragmentation
flags above and repeats the link header; a complete message is limited to 512
octets. Writes are acknowledged and serialized. Notifications are enabled
before the Android adapter reports `READY`. The USB test channel is never
exposed over BLE.
## Test channel (test firmware only, USB only)
`CONFIG_MICROBU_TEST_CHANNEL` compiles a software lower tester and the
diagnostic hooks the ETSI campaigns need, behind frame type 0x11. It is not
part of the phone interface and never exists on BLE. Requests: `u8 command`,
reply `u8 result`, `u8 record_count`, records `[kind u8][length u16 LE][bytes]`:
| Command | Body | Effect |
|---|---|---|
| 0x01 MIRROR | `u8 mode` (0 off, 1 mirror, 2 divert) | every AL_DATA.request the stack makes is copied (mirror) or kept from the radio (divert) and queued as kind-1 records |
| 0x02 INJECT | `u8[6] source`, `u8[6] destination`, `gnpdu[]` | AL_DATA.indication from the lower tester into the stack |
| 0x03 GN_REQUEST | `u8 traffic_class`, `payload[]` | GN-DATA.request SHB with next header "Any" (the Security ATS GN-MGMT/GENMSG cases; N-SAP, not a phone primitive) |
| 0x04 DRAIN | – | returns the queued records: kind 1 AL_DATA.request (the GNPDU as submitted), kind 2 BTP-DATA.indication (`[type][dest port][port info][SDU]`), kind 3 GN-DATA.indication payload |
| 0x05 RESET | – | drops the queue and the mirror mode |
The queue is bounded (32 records, 8 KiB); an overflow is reported as
`resource_limit` on the next DRAIN and the records lost are counted.
## Phone emulator, SUT bridge and campaigns (PC side)
`python/microbu_link/` is the phone side of the message layer (codec, serial
transport, request/reply client, the small VBS of `vbs.py`), shared by:
- `python/phone_emulator.py` — the phone half of the VRU ITS-S on a PC:
configures the micrOBU, provisions a `VCR1` bundle, feeds PoTi (static or a
bicycle on a circle), assembles VAMs with asn1tools from the ETSI ASN.1 of the
submodule and hands them over with the PCI of TS 103 300-3 Table 4 under the
generation rules of clause 6.4, subscribes to the identifier-change events
(stop on PREPARE, new StationId after COMMIT), prints the indications, status
and log lines; `--divert --pcap` records what the micrOBU signs for an
independent verifier. Its default synthetic trajectory is a 20 m-radius circle
at 2 m/s centred at 53.5652470 N, 10.0082390 E over the Außenalster's open
water; `--lat`, `--lon`, `--radius` and `--speed` override it. Radio emission
remains opt-in through `--radio txrx`. The center is not itself a transmitted
position: generated positions lie on the circle perimeter. The firmware's
standalone BOOT-button VAM is a separate fixed payload and does not use these
phone-emulator coordinates.
- `python/sut_bridge.py` — the SUT process of the vanetza-idf ETSI adapters
(hex-line protocol of `hil_sut.hpp`) with the micrOBU behind the phone
interfaces: BTP-DATA.request, PoTi and credentials over the link, the software
lower tester over the USB test channel, CAM/DENM stimuli assembled on the PC.
`--listen host:port` serves the protocol over TCP; `python/sut_tcp_relay.py`
inside WSL is the SUT executable that forwards it (WSL interop drops piped
stdin of Windows executables).
```powershell
# Windows: the micrOBU on COM11, the campaign pool packed as a bundle
python sut_bridge.py --port COM11 --bundle pool.vcr --listen 0.0.0.0:7788
```
`LinkClient` is transport-independent. `SerialTransport` and `BleTransport`
implement the same `TransportAdapter.write(frame_type, payload)` / `close()`
boundary and feed the same receive callback; configure, PoTi, credential,
BTP-DATA, status and event code is therefore identical. Select only the adapter
at the command line:
```powershell
cd implementation\station-link\python
python -m pip install -r requirements.txt
# Safe first BLE run: hand VAMs to an unsecured station, with ITS-G5 RF off.
python phone_emulator.py --ble --unsecured --radio off --period 1000 --duration 10 --verbose `
--report ble-vam-session.json
# Select a particular advertisement when several micrOBUs are present.
python phone_emulator.py --ble micrOBU-1234 --unsecured --radio off --duration 10
```
For the signed run, replace `--unsecured` with `--bundle <path-to-chain.vcr>`
(or use credentials already retained in the micrOBU NVS). Change `--radio off`
to `--radio txrx` only for the intended laboratory RF test. The USB test
channel has no BLE equivalent, so `--mirror`, `--divert` and `--pcap` are
rejected with `--ble`; all station-link/VAM behavior above that adapter is the
same.
Instead of an offline `--bundle`, `--pki-url` makes the emulator obtain an
enrolment credential and authorization ticket from `tools/local_pki.py`, build
the same `VCR1` bundle, and provision it before starting the VBS. Supply
`--pki-root`, `--pki-ea`, `--pki-aa`, `--pki-canonical-key`, `--pki-its-id`,
`--pki-issue-tool`, `--pki-client-tool`, and `--pki-bundle-tool` together; use
`--pki-hours` to override the 24-hour AT validity. The message flow and retained
host-side proof are documented in `docs/idf/evidence/enrolment-authorization-01`
and the EA/AA section of `docs/idf/test-campaigns.md` in vanetza-idf.
`local_pki.py` is a separate localhost HTTP process, normally bound to
`127.0.0.1`; it is not imported into or hosted by the emulator. Consequently,
the lab authority can later be replaced by a hosted PKI without changing the
phone flow: set `--pki-url` to the hosted access point and keep the same S3/S4
HTTP interface.
## End-to-end hardware workflow: Online PKI, Transmission & Reception
This workflow runs the full online TS 102 941 enrolment/authorization and 5.9 GHz ITS-G5 over-the-air transmission between two ESP32-C5 boards:
- **COM4 (TX)**: micrOBU running firmware microbu_firmware.bin
- **COM5 (RX)**: independent receiver running 2x2map-0.3.0 bridge firmware
### 1. Initialize the lab PKI hierarchy (run once)
`powershell
$dir = "C:\microbu-pki-wt\pki-lab"
New-Item -ItemType Directory -Force -Path "$dir\chain", "$dir\issued"
& C:\Strawberry\c\bin\openssl.exe ecparam -name prime256v1 -genkey -noout -out "$dir\root.pem"
& C:\Strawberry\c\bin\openssl.exe ecparam -name prime256v1 -genkey -noout -out "$dir\canonical.pem"
$issue = "C:\bachelorthesis_micrOBU\implementation\external\vanetza-idf\build-host-sec\vidf_issue.exe"
& $issue root --key "$dir\root.pem" --name "lab root" --id ROOT --out "$dir\chain"
& $issue authority --issuer "$dir\chain\ROOT.oer" --issuer-key "$dir\root.pem" --name "lab EA" --id EA --out "$dir\chain"
& $issue authority --issuer "$dir\chain\ROOT.oer" --issuer-key "$dir\root.pem" --name "lab AA" --id AA --out "$dir\chain"
`
### 2. Start the localhost PKI server (Terminal 1)
`powershell
python C:\microbu-pki-wt\implementation\external\vanetza-idf\ports\esp_idf\tools\local_pki.py `
--issue-tool C:\bachelorthesis_micrOBU\implementation\external\vanetza-idf\build-host-sec\vidf_issue.exe `
--ea C:\microbu-pki-wt\pki-lab\chain\EA.oer `
--ea-key C:\microbu-pki-wt\pki-lab\chain\EA.vkey `
--ea-enc-key C:\microbu-pki-wt\pki-lab\chain\EA.ekey `
--canonical-key C:\microbu-pki-wt\pki-lab\canonical.pem `
--aa C:\microbu-pki-wt\pki-lab\chain\AA.oer `
--aa-key C:\microbu-pki-wt\pki-lab\chain\AA.vkey `
--aa-enc-key C:\microbu-pki-wt\pki-lab\chain\AA.ekey `
--dir C:\microbu-pki-wt\pki-lab\issued `
--port 8092
`
### 3. Start the independent v2x2map receiver (Terminal 2)
`powershell
python C:\microbu-pki-wt\tools\vendor\v2x2map-0.3.0\bridge\its_g5_bridge.py `
--port COM5 `
--node-id V2X2MAP:001122334455 `
--no-mqtt `
--open-browser `
--pcap-out C:\microbu-pki-wt\rx_v2x2map.pcap
`
- Automatically opens the live dashboard at http://127.0.0.1:8080.
- --pcap-out <file.pcap> continuously records all received 802.11 frames to disk.
- Alternatively, click **Record** and **Stop** in the web dashboard: a direct **Download PCAP** button appears to download the file, which is also saved under ools/vendor/v2x2map-0.3.0/bridge/recordings/.
### 4. Transmit signed VAMs via Phone Emulator (Terminal 3)
`powershell
python C:\microbu-pki-wt\implementation\station-link\python\phone_emulator.py `
--port COM4 `
--radio txrx `
--mirror `
--pcap C:\microbu-pki-wt\tx_vams.pcap `
--pki-url http://127.0.0.1:8092/ `
--pki-root C:\microbu-pki-wt\pki-lab\chain\ROOT.oer `
--pki-ea C:\microbu-pki-wt\pki-lab\chain\EA.oer `
--pki-aa C:\microbu-pki-wt\pki-lab\chain\AA.oer `
--pki-canonical-key C:\microbu-pki-wt\pki-lab\canonical.pem `
--pki-its-id vidf-vru-station `
--pki-issue-tool C:\bachelorthesis_micrOBU\implementation\external\vanetza-idf\build-host-sec\vidf_issue.exe `
--pki-client-tool C:\microbu-pki-wt\implementation\external\vanetza-idf\ports\esp_idf\tools\pki_client.py `
--pki-bundle-tool C:\microbu-pki-wt\implementation\external\vanetza-idf\ports\esp_idf\tools\credential_bundle.py `
--duration 30
`
- --mirror --pcap tx_vams.pcap ensures the transmitter saves an exact PCAP capture of all generated and signed GNPDUs.
- Omit --duration 30 to transmit continuously until Ctrl+C.
### Where recordings are saved
| Recording | Destination | Description |
|---|---|---|
| **Transmitter (TX) PCAP** | --pcap <filename> | Local 802.11 PCAP captured via the USB test channel (--mirror copies all frames while the radio transmits). |
| **Receiver (RX) PCAP (CLI)** | --pcap-out <filename> | Continuous capture of all over-the-air frames received by the ESP32-C5 on COM5. |
| **Receiver (RX) PCAP (Web)** | ools/vendor/v2x2map-0.3.0/bridge/recordings/ | Saved automatically when clicking **Record** and **Stop** in the web dashboard, with a direct **Download PCAP** button in the browser. |
```sh
# WSL: the security campaigns with the compiled AtsSecurity (docs/idf/test-campaigns.md of the submodule)
python3 run_etsi.py --titan ... --binary /root/vidf-ats-security/AtsSecurity \
--config tests/etsi_security_gn.cfg --expected-cases tests/etsi_security_gn_cases.json \
--sut /usr/bin/python3 --sut-args "/root/sut_tcp_relay.py --connect <windows host>:7788" \
--pool /root/vidf-pool --out /root/vidf-results/security-gn-microbu
```
Retained runs of 2026-09-14: `experiments/raw/microbu-link-20260914/`
(manifests in `experiments/manifests/test-runs/microbu-link-20260914-*.yaml`).
## BLE verification (2026-09-16)
The retained host verification is described by
`experiments/manifests/test-runs/microbu-link-20260916-ble-host-01.yaml`:
- Python/C++ protocol and framing suite: 8 tests passed, including the common
serial/BLE adapter contract and a 512-octet
message split into 32 default-MTU values, reassembly, malformed interleaving
rejection and recovery.
- Kotlin/JVM station-link suite: 4 tests passed against the Python/C++ golden
bytes, including fragmentation, recovery and credential segmentation.
- Android `:app:compileDebugKotlin`: passed with the BLE central and manifest
permissions included.
- ESP-IDF 6.0.2 ESP32-C5 build: passed with NimBLE and the encrypted GATT
peripheral; the binary is `0x1d8780` bytes and leaves 38% of the app partition.
No ESP32 serial device or Pixel 9 was attached to this laptop during this run,
so pairing, notification exchange, the <= 2 s reconnection requirement and the
connection-loss-rate requirement were not measured. `TEST-AC-SYS-MICROBU-013` and
`TEST-AC-SYS-MICROBU-014` intentionally remain planned hardware tests.
@@ -1,743 +0,0 @@
{
"arguments": {
"port": "COM5",
"ble": null,
"connect_timeout": 20.0,
"bundle": "chain.vcr",
"erase_credentials": false,
"unsecured": false,
"radio": "txrx",
"channel": 180,
"power": 10,
"no_beacons": false,
"lat": 53.565247,
"lon": 10.008239,
"speed": 2.0,
"radius": 20.0,
"poti_rate": 5.0,
"period": 5,
"station_id": null,
"ssp": "01",
"no_vam": false,
"id_change_every": null,
"duration": 15.0,
"mirror": false,
"divert": false,
"pcap": null,
"report": "dcc_gate_check.json",
"asn1": null,
"timeout": 6.0,
"verbose": true,
"quiet_log": false
},
"station": {
"gn_address": "0800be0de448e3bc",
"identifier": "c06fbc0de448e3bc",
"tickets": 1
},
"vams_generated": 242,
"results_accepted": 0,
"results_refused": 0,
"results_pending": 0,
"timing": {
"stream_seconds": 15.052010699990205,
"generation_rate_hz": 16.077586232393354,
"generation_interval_ms": {
"samples": 241,
"mean": 62.20173734458573,
"p95": 63.81820002570748,
"max": 73.63060000352561
},
"vam_assembly_ms": {
"samples": 242,
"mean": 0.1734210780055901,
"p95": 0.33850001636892557,
"max": 0.5439000087790191
},
"request_to_result_ms": null
},
"frames_captured": 0,
"final_status": {
"uptime_ms": 121265,
"configured": 1,
"gn_address": "0800be0de448e3bc",
"identifier": "c06fbc0de448e3bc",
"change_pending": 0,
"tickets": 1,
"signed_messages": 242,
"refused_no_ticket": 0,
"refused_change_pending": 0,
"refused_permission": 0,
"sign_failed": 0,
"verified": 0,
"rejected": 0,
"requests_accepted": 242,
"requests_refused": 0,
"indications": 0,
"radio_submitted": 242,
"radio_failed": 0,
"radio_received": 0,
"radio_dropped": 0,
"link_rx_frames": 313,
"link_crc_errors": 0,
"link_malformed": 0,
"poti_updates": 62,
"its_time_ms": 717107692039
},
"events": [
"22:34:30 serial connected: COM5",
"22:34:30 PoTi trajectory: centre=53.5652470,10.0082390 radius=20.0 m speed=2.0 m/s",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:31 station configured: gn_address=080062d0e89fe341 identifier=ba1862d0e89fe341 credentials=True tickets=2",
"22:34:31 credentials provisioned: 1 roots, 1 authorities, 1 tickets",
"22:34:31 station rebuilt: gn_address=0800be0de448e3bc identifier=c06fbc0de448e3bc",
"22:34:31 SF-IDCHANGE-SUBSCRIBE: subscription 1",
"22:34:31 VAM 1 (36 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:31 STATUS its_time=717107676782 signed=2 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=2/0 ind=0 radio tx/fail/rx/drop=2/0/0/0 id=c06fbc0de448e3bc pending=0 tickets=1",
"22:34:31 VAM 2 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:31 VAM 3 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:31 VAM 4 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:31 VAM 5 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:32 VAM 6 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:32 VAM 7 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:32 VAM 8 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:32 VAM 9 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:32 VAM 10 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:32 VAM 11 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:32 VAM 12 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:32 VAM 13 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:32 VAM 14 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:32 VAM 15 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:32 VAM 16 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:32 VAM 17 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:32 STATUS its_time=717107677782 signed=18 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=18/0 ind=0 radio tx/fail/rx/drop=18/0/0/0 id=c06fbc0de448e3bc pending=0 tickets=1",
"22:34:32 VAM 18 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:32 VAM 19 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:32 VAM 20 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:32 VAM 21 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:33 VAM 22 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:33 VAM 23 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:33 VAM 24 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:33 VAM 25 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:33 VAM 26 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:33 VAM 27 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:33 VAM 28 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:33 VAM 29 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:33 VAM 30 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:33 VAM 31 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:33 VAM 32 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:33 VAM 33 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:33 STATUS its_time=717107678782 signed=34 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=34/0 ind=0 radio tx/fail/rx/drop=34/0/0/0 id=c06fbc0de448e3bc pending=0 tickets=1",
"22:34:33 VAM 34 (36 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:33 VAM 35 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:33 VAM 36 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:33 VAM 37 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:34 VAM 38 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:34 VAM 39 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:34 VAM 40 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:34 VAM 41 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:34 VAM 42 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:34 VAM 43 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:34 VAM 44 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:34 VAM 45 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:34 VAM 46 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:34 VAM 47 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:34 VAM 48 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:34 VAM 49 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:34 STATUS its_time=717107679782 signed=50 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=50/0 ind=0 radio tx/fail/rx/drop=50/0/0/0 id=c06fbc0de448e3bc pending=0 tickets=1",
"22:34:34 VAM 50 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:34 VAM 51 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:34 VAM 52 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:34 VAM 53 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:35 VAM 54 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:35 VAM 55 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:35 VAM 56 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:35 VAM 57 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:35 VAM 58 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:35 VAM 59 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:35 VAM 60 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:35 VAM 61 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:35 VAM 62 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:35 VAM 63 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:35 VAM 64 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:35 VAM 65 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:35 STATUS its_time=717107680782 signed=66 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=66/0 ind=0 radio tx/fail/rx/drop=66/0/0/0 id=c06fbc0de448e3bc pending=0 tickets=1",
"22:34:35 VAM 66 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:35 VAM 67 (36 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:35 VAM 68 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:35 VAM 69 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:36 VAM 70 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:36 VAM 71 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:36 VAM 72 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:36 VAM 73 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:36 VAM 74 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:36 VAM 75 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:36 VAM 76 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:36 VAM 77 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:36 VAM 78 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:36 VAM 79 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:36 VAM 80 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:36 VAM 81 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:36 STATUS its_time=717107681782 signed=82 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=82/0 ind=0 radio tx/fail/rx/drop=82/0/0/0 id=c06fbc0de448e3bc pending=0 tickets=1",
"22:34:36 VAM 82 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:36 VAM 83 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:36 VAM 84 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:36 VAM 85 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:37 VAM 86 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:37 VAM 87 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:37 VAM 88 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:37 VAM 89 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:37 VAM 90 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:37 VAM 91 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:37 VAM 92 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:37 VAM 93 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:37 VAM 94 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:37 VAM 95 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:37 VAM 96 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:37 VAM 97 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:37 STATUS its_time=717107682782 signed=98 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=98/0 ind=0 radio tx/fail/rx/drop=98/0/0/0 id=c06fbc0de448e3bc pending=0 tickets=1",
"22:34:37 VAM 98 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:37 VAM 99 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:37 VAM 100 (36 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:37 VAM 101 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:38 VAM 102 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:38 VAM 103 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:38 VAM 104 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:38 VAM 105 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:38 VAM 106 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:38 VAM 107 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:38 VAM 108 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:38 VAM 109 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:38 VAM 110 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:38 VAM 111 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:38 VAM 112 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:38 VAM 113 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:38 STATUS its_time=717107683782 signed=114 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=114/0 ind=0 radio tx/fail/rx/drop=114/0/0/0 id=c06fbc0de448e3bc pending=0 tickets=1",
"22:34:38 VAM 114 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:38 VAM 115 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:38 VAM 116 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:38 VAM 117 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:39 VAM 118 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:39 VAM 119 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:39 VAM 120 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:39 VAM 121 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:39 VAM 122 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:39 VAM 123 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:39 VAM 124 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:39 VAM 125 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:39 VAM 126 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:39 VAM 127 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:39 VAM 128 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:39 VAM 129 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:39 VAM 130 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:39 STATUS its_time=717107684816 signed=131 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=131/0 ind=0 radio tx/fail/rx/drop=131/0/0/0 id=c06fbc0de448e3bc pending=0 tickets=1",
"22:34:39 VAM 131 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:39 VAM 132 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:39 VAM 133 (36 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:40 VAM 134 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:40 VAM 135 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:40 VAM 136 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:40 VAM 137 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:40 VAM 138 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:40 VAM 139 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:40 VAM 140 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:40 VAM 141 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:40 VAM 142 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:40 VAM 143 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:40 VAM 144 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:40 VAM 145 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:40 VAM 146 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:40 STATUS its_time=717107685816 signed=147 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=147/0 ind=0 radio tx/fail/rx/drop=147/0/0/0 id=c06fbc0de448e3bc pending=0 tickets=1",
"22:34:40 VAM 147 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:40 VAM 148 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:40 VAM 149 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:41 VAM 150 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:41 VAM 151 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:41 VAM 152 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:41 VAM 153 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:41 VAM 154 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:41 VAM 155 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:41 VAM 156 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:41 VAM 157 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:41 VAM 158 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:41 VAM 159 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:41 VAM 160 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:41 VAM 161 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:41 VAM 162 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:41 STATUS its_time=717107686816 signed=163 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=163/0 ind=0 radio tx/fail/rx/drop=163/0/0/0 id=c06fbc0de448e3bc pending=0 tickets=1",
"22:34:41 VAM 163 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:41 VAM 164 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:41 VAM 165 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:42 VAM 166 (36 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:42 VAM 167 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:42 VAM 168 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:42 VAM 169 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:42 VAM 170 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:42 VAM 171 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:42 VAM 172 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:42 VAM 173 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:42 VAM 174 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:42 VAM 175 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:42 VAM 176 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:42 VAM 177 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:42 VAM 178 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:42 STATUS its_time=717107687816 signed=179 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=179/0 ind=0 radio tx/fail/rx/drop=179/0/0/0 id=c06fbc0de448e3bc pending=0 tickets=1",
"22:34:42 VAM 179 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:42 VAM 180 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:42 VAM 181 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:43 VAM 182 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:43 VAM 183 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:43 VAM 184 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:43 VAM 185 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:43 VAM 186 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:43 VAM 187 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:43 VAM 188 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:43 VAM 189 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:43 VAM 190 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:43 VAM 191 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:43 VAM 192 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:43 VAM 193 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:43 VAM 194 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:43 STATUS its_time=717107688816 signed=195 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=195/0 ind=0 radio tx/fail/rx/drop=195/0/0/0 id=c06fbc0de448e3bc pending=0 tickets=1",
"22:34:43 VAM 195 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:43 VAM 196 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:43 VAM 197 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:44 VAM 198 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:44 VAM 199 (36 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:44 VAM 200 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:44 VAM 201 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:44 VAM 202 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:44 VAM 203 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:44 VAM 204 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:44 VAM 205 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:44 VAM 206 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:44 VAM 207 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:44 VAM 208 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:44 VAM 209 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:44 VAM 210 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:44 VAM 211 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:44 STATUS its_time=717107689846 signed=212 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=212/0 ind=0 radio tx/fail/rx/drop=212/0/0/0 id=c06fbc0de448e3bc pending=0 tickets=1",
"22:34:44 VAM 212 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:44 VAM 213 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:45 VAM 214 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:45 VAM 215 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:45 VAM 216 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:45 VAM 217 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:45 VAM 218 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:45 VAM 219 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:45 VAM 220 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:45 VAM 221 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:45 VAM 222 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:45 VAM 223 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:45 VAM 224 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:45 VAM 225 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:45 VAM 226 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:45 VAM 227 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:45 STATUS its_time=717107690846 signed=228 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=228/0 ind=0 radio tx/fail/rx/drop=228/0/0/0 id=c06fbc0de448e3bc pending=0 tickets=1",
"22:34:45 VAM 228 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:45 VAM 229 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:45 VAM 230 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:46 VAM 231 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:46 VAM 232 (36 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:46 VAM 233 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:46 VAM 234 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:46 VAM 235 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:46 VAM 236 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:46 VAM 237 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:46 VAM 238 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:46 VAM 239 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:46 VAM 240 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:46 VAM 241 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:46 VAM 242 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:46 STATUS its_time=717107691846 signed=242 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=242/0 ind=0 radio tx/fail/rx/drop=242/0/0/0 id=c06fbc0de448e3bc pending=0 tickets=1",
"22:34:47 final STATUS: signed=242 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=242/0 ind=0 radio tx/fail/rx/drop=242/0/0/0"
],
"micrOBU_log": [
"I (964) phy: libbtbb version: 92325d6, Mar 10 2026, 10:22:25",
"I (974) cits_ble: NimBLE host task running",
"I (974) NimBLE: GAP procedure initiated: stop advertising.",
"I (974) NimBLE: GAP procedure initiated: stop advertising.",
"I (974) NimBLE: GAP procedure initiated: advertise; ",
"I (974) NimBLE: disc_mode=2",
"I (974) NimBLE: adv_channel_map=0 own_addr_type=0 adv_filter_policy=0 adv_itvl_min=0 adv_itvl_max=0",
"I (974) NimBLE: ",
"I (974) cits_ble: BLE advertising started as 'micrOBU-0196'",
"I (974) cits_ble: simple_ble initialized (fixed passkey 123456)",
"I (974) microbu: micrOBU firmware on vanetza-idf, link protocol version 1, test channel enabled",
"I (974) board: LED GPIO 27 (mode: normal_tx (normally OFF))",
"I (974) microbu: station task ready; the phone/PC configures the station over BLE or USB Serial-JTAG",
"I (105454) pp: pp rom version: 78a72e9d5",
"I (105454) net80211: net80211 rom version: 78a72e9d5",
"I (105464) wifi:",
"wifi driver task: 4082f18c, prio:23, stack:6656, core=0",
"I (105464) wifi:",
"wifi firmware version: 00ad238",
"I (105464) wifi:",
"wifi certification version: v7.0",
"I (105464) wifi:",
"config NVS flash: disabled",
"I (105464) wifi:",
"config nano formatting: disabled",
"I (105464) wifi:",
"mac_version:HAL_MAC_ESP32AX_752MP_ECO2,ut_version:N, band mode:0x3",
"I (105464) wifi:",
"Init data frame dynamic rx buffer num: 16",
"I (105464) wifi:",
"Init static rx mgmt buffer num: 5",
"I (105464) wifi:",
"Init management short buffer num: 16",
"I (105464) wifi:",
"Init dynamic tx buffer num: 16",
"I (105464) wifi:",
"Init static tx FG buffer num: 2",
"I (105464) wifi:",
"Init static rx buffer size: 1700 (rxctrl:64, csi:512)",
"I (105464) wifi:",
"Init static rx buffer num: 6",
"I (105464) wifi:",
"Init dynamic rx buffer num: 16",
"I (105464) wifi_init: rx ba win: 6",
"I (105464) wifi_init: accept mbox: 6",
"I (105464) wifi_init: tcpip mbox: 32",
"I (105464) wifi_init: udp mbox: 6",
"I (105464) wifi_init: tcp mbox: 6",
"I (105464) wifi_init: tcp tx win: 5760",
"I (105464) wifi_init: tcp rx win: 5760",
"I (105464) wifi_init: tcp mss: 1440",
"I (105464) wifi_init: WiFi IRAM OP enabled",
"I (105464) wifi_init: WiFi RX IRAM OP enabled",
"I (105464) wifi_init: WiFi SLP IRAM OP enabled",
"I (105464) wifi:",
"11ax coex: WDEVAX_PTI0(0x55777555), WDEVAX_PTI1(0x00003377).",
"I (105464) wifi:",
"mode : sta (10:bd:a3:cc:01:94)",
"I (105464) wifi:",
"enable tsf",
"I (105464) wifi:",
"wifi set band mode, new band mode 2, old band mode 3",
"I (105474) wifi:",
"enable tsf",
"I (105474) wifi:",
"Set ps type: 0, coexist: 1",
"I (105474) wifi:",
"Coexist!!! Wi-Fi station would only keep waked when available",
"I (105474) wifi:",
"ic_enable_sniffer",
"I (105474) c5_radio: ITS-G5 802.11p radio started on channel 180 (5900 MHz), TX/RX",
"I (105474) station: radio on channel 180, transmit and receive",
"I (105594) station: credentials from NVS: 1 roots, 2 authorities, 2 tickets (result 0)",
"I (105604) link: station configured: security 1, radio 2, result 0",
"I (105964) station: credentials from NVS: 1 roots, 1 authorities, 1 tickets (result 0)",
"I (105964) station: credentials provisioned: 1 roots, 1 authorities, 1 tickets",
"I (106084) station: credentials from NVS: 1 roots, 1 authorities, 1 tickets (result 0)",
"I (106084) link: station configured: security 1, radio 2, result 0",
"I (106204) station: credentials from NVS: 1 roots, 1 authorities, 1 tickets (result 0)",
"I (106204) link: SF_IDCHANGE_SUBSCRIBE handle=1 result=0",
"I (106264) station: heap before sign: free=37632 min=33820 dma=21688 stack_hwm=6864",
"I (106324) station: heap before sign: free=38124 min=27472 dma=22180 stack_hwm=6816",
"I (106384) station: heap before sign: free=38132 min=27472 dma=22188 stack_hwm=6816",
"I (106444) station: heap before sign: free=38132 min=27472 dma=22188 stack_hwm=6816",
"I (106504) station: heap before sign: free=38132 min=27472 dma=22188 stack_hwm=6816",
"I (106564) station: heap before sign: free=38132 min=27472 dma=22188 stack_hwm=6816",
"I (106624) station: heap before sign: free=38132 min=27472 dma=22188 stack_hwm=6816",
"I (106694) station: heap before sign: free=38132 min=27472 dma=22188 stack_hwm=6816",
"I (106754) station: heap before sign: free=38132 min=27472 dma=22188 stack_hwm=6816",
"I (106814) station: heap before sign: free=38132 min=27472 dma=22188 stack_hwm=6816",
"I (106874) station: heap before sign: free=38132 min=27472 dma=22188 stack_hwm=6816",
"I (106944) station: heap before sign: free=38132 min=27472 dma=22188 stack_hwm=6816",
"I (107004) station: heap before sign: free=38132 min=27472 dma=22188 stack_hwm=6816",
"I (107064) station: heap before sign: free=38132 min=27472 dma=22188 stack_hwm=6816",
"I (107124) station: heap before sign: free=38132 min=27472 dma=22188 stack_hwm=6816",
"I (107194) station: heap before sign: free=38132 min=27472 dma=22188 stack_hwm=6816",
"I (107254) station: heap before sign: free=38132 min=27472 dma=22188 stack_hwm=6816",
"I (107314) station: heap before sign: free=38132 min=27472 dma=22188 stack_hwm=6816",
"I (107374) station: heap before sign: free=38132 min=27472 dma=22188 stack_hwm=6816",
"I (107434) station: heap before sign: free=38132 min=27472 dma=22188 stack_hwm=6816",
"I (107504) station: heap before sign: free=38132 min=27472 dma=22188 stack_hwm=6816",
"I (107564) station: heap before sign: free=38132 min=27472 dma=22188 stack_hwm=6816",
"I (107634) station: heap before sign: free=38132 min=27472 dma=22188 stack_hwm=6816",
"I (107694) station: heap before sign: free=38132 min=27472 dma=22188 stack_hwm=6816",
"I (107754) station: heap before sign: free=38132 min=27472 dma=22188 stack_hwm=6816",
"I (107814) station: heap before sign: free=37628 min=27472 dma=21684 stack_hwm=6816",
"I (107874) station: heap before sign: free=38132 min=27460 dma=22188 stack_hwm=6816",
"I (107944) station: heap before sign: free=38132 min=27460 dma=22188 stack_hwm=6816",
"I (108014) station: heap before sign: free=38136 min=27460 dma=22192 stack_hwm=6816",
"I (108064) station: heap before sign: free=38124 min=27460 dma=22180 stack_hwm=6816",
"I (108124) station: heap before sign: free=38124 min=27460 dma=22180 stack_hwm=6816",
"I (108184) station: heap before sign: free=38124 min=27460 dma=22180 stack_hwm=6816",
"I (108254) station: heap before sign: free=38124 min=27460 dma=22180 stack_hwm=6816",
"I (108314) station: heap before sign: free=38124 min=27460 dma=22180 stack_hwm=6816",
"I (108374) station: heap before sign: free=38124 min=27460 dma=22180 stack_hwm=6816",
"I (108444) station: heap before sign: free=38124 min=27460 dma=22180 stack_hwm=6816",
"I (108504) station: heap before sign: free=38124 min=27460 dma=22180 stack_hwm=6816",
"I (108564) station: heap before sign: free=38124 min=27460 dma=22180 stack_hwm=6816",
"I (108624) station: heap before sign: free=38124 min=27460 dma=22180 stack_hwm=6816",
"I (108684) station: heap before sign: free=38124 min=27460 dma=22180 stack_hwm=6816",
"I (108744) station: heap before sign: free=38124 min=27460 dma=22180 stack_hwm=6816",
"I (108814) station: heap before sign: free=38124 min=27460 dma=22180 stack_hwm=6816",
"I (108874) station: heap before sign: free=38124 min=27460 dma=22180 stack_hwm=6816",
"I (108934) station: heap before sign: free=38124 min=27460 dma=22180 stack_hwm=6816",
"I (108994) station: heap before sign: free=38124 min=27460 dma=22180 stack_hwm=6816",
"I (109064) station: heap before sign: free=38124 min=27460 dma=22180 stack_hwm=6816",
"I (109124) station: heap before sign: free=38124 min=27460 dma=22180 stack_hwm=6816",
"I (109184) station: heap before sign: free=38124 min=27460 dma=22180 stack_hwm=6816",
"I (109244) station: heap before sign: free=38124 min=27460 dma=22180 stack_hwm=6816",
"I (109304) station: heap before sign: free=38124 min=27460 dma=22180 stack_hwm=6816",
"I (109374) station: heap before sign: free=37616 min=27460 dma=21672 stack_hwm=6816",
"I (109434) station: heap before sign: free=38120 min=27444 dma=22176 stack_hwm=6816",
"I (109494) station: heap before sign: free=38120 min=27444 dma=22176 stack_hwm=6816",
"I (109554) station: heap before sign: free=38120 min=27444 dma=22176 stack_hwm=6816",
"I (109614) station: heap before sign: free=38120 min=27444 dma=22176 stack_hwm=6816",
"I (109684) station: heap before sign: free=38120 min=27444 dma=22176 stack_hwm=6816",
"I (109744) station: heap before sign: free=38120 min=27444 dma=22176 stack_hwm=6816",
"I (109804) station: heap before sign: free=38120 min=27444 dma=22176 stack_hwm=6816",
"I (109864) station: heap before sign: free=38120 min=27444 dma=22176 stack_hwm=6816",
"I (109934) station: heap before sign: free=38120 min=27444 dma=22176 stack_hwm=6816",
"I (109994) station: heap before sign: free=38120 min=27444 dma=22176 stack_hwm=6816",
"I (110054) station: heap before sign: free=38120 min=27444 dma=22176 stack_hwm=6816",
"I (110114) station: heap before sign: free=38120 min=27444 dma=22176 stack_hwm=6816",
"I (110174) station: heap before sign: free=38120 min=27444 dma=22176 stack_hwm=6816",
"I (110244) station: heap before sign: free=38120 min=27444 dma=22176 stack_hwm=6816",
"I (110304) station: heap before sign: free=38120 min=27444 dma=22176 stack_hwm=6816",
"I (110364) station: heap before sign: free=38120 min=27444 dma=22176 stack_hwm=6816",
"I (110424) station: heap before sign: free=38120 min=27444 dma=22176 stack_hwm=6816",
"I (110494) station: heap before sign: free=38120 min=27444 dma=22176 stack_hwm=6816",
"I (110554) station: heap before sign: free=38120 min=27444 dma=22176 stack_hwm=6816",
"I (110614) station: heap before sign: free=38120 min=27444 dma=22176 stack_hwm=6816",
"I (110674) station: heap before sign: free=38120 min=27444 dma=22176 stack_hwm=6816",
"I (110744) station: heap before sign: free=38120 min=27444 dma=22176 stack_hwm=6816",
"I (110804) station: heap before sign: free=38120 min=27444 dma=22176 stack_hwm=6816",
"I (110864) station: heap before sign: free=38120 min=27444 dma=22176 stack_hwm=6816",
"I (110924) station: heap before sign: free=37616 min=27444 dma=21672 stack_hwm=6816",
"I (110984) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6816",
"I (111054) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6816",
"I (111114) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6816",
"I (111174) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6816",
"I (111234) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6816",
"I (111304) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6816",
"I (111364) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6816",
"I (111424) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6816",
"I (111484) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6816",
"I (111554) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6816",
"I (111614) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6816",
"I (111674) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6816",
"I (111734) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6816",
"I (111804) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6816",
"I (111864) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6816",
"I (111924) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6816",
"I (111984) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6720",
"I (112044) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6720",
"I (112104) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6720",
"I (112174) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6720",
"I (112234) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6720",
"I (112294) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6720",
"I (112354) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6720",
"I (112424) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6720",
"I (112484) station: heap before sign: free=37608 min=27444 dma=21664 stack_hwm=6720",
"I (112544) station: heap before sign: free=38112 min=27444 dma=22168 stack_hwm=6720",
"I (112604) station: heap before sign: free=38112 min=27444 dma=22168 stack_hwm=6720",
"I (112674) station: heap before sign: free=38112 min=27444 dma=22168 stack_hwm=6720",
"I (112734) station: heap before sign: free=38112 min=27444 dma=22168 stack_hwm=6720",
"I (112794) station: heap before sign: free=38112 min=27444 dma=22168 stack_hwm=6720",
"I (112864) station: heap before sign: free=38112 min=27444 dma=22168 stack_hwm=6720",
"I (112924) station: heap before sign: free=38112 min=27444 dma=22168 stack_hwm=6720",
"I (112984) station: heap before sign: free=38112 min=27444 dma=22168 stack_hwm=6720",
"I (113044) station: heap before sign: free=38112 min=27444 dma=22168 stack_hwm=6720",
"I (113104) station: heap before sign: free=38112 min=27444 dma=22168 stack_hwm=6720",
"I (113174) station: heap before sign: free=38112 min=27444 dma=22168 stack_hwm=6720",
"I (113234) station: heap before sign: free=38112 min=27444 dma=22168 stack_hwm=6720",
"I (113294) station: heap before sign: free=38112 min=27444 dma=22168 stack_hwm=6720",
"I (113354) station: heap before sign: free=38112 min=27444 dma=22168 stack_hwm=6720",
"I (113414) station: heap before sign: free=38112 min=27444 dma=22168 stack_hwm=6720",
"I (113484) station: heap before sign: free=38112 min=27444 dma=22168 stack_hwm=6720",
"I (113544) station: heap before sign: free=38112 min=27444 dma=22168 stack_hwm=6720",
"I (113604) station: heap before sign: free=38112 min=27444 dma=22168 stack_hwm=6720",
"I (113664) station: heap before sign: free=38112 min=27444 dma=22168 stack_hwm=6720",
"I (113724) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6720",
"I (113794) station: heap before sign: free=38112 min=27444 dma=22168 stack_hwm=6720",
"I (113854) station: heap before sign: free=38112 min=27444 dma=22168 stack_hwm=6720",
"I (113914) station: heap before sign: free=38112 min=27444 dma=22168 stack_hwm=6720",
"I (113984) station: heap before sign: free=38112 min=27444 dma=22168 stack_hwm=6720",
"I (114044) station: heap before sign: free=37608 min=27444 dma=21664 stack_hwm=6720",
"I (114104) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6720",
"I (114164) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6720",
"I (114224) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6720",
"I (114294) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6720",
"I (114354) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6720",
"I (114414) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6720",
"I (114474) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6720",
"I (114544) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6720",
"I (114604) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6720",
"I (114664) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6720",
"I (114724) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6720",
"I (114784) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6720",
"I (114844) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6720",
"I (114904) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6720",
"I (114974) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6720",
"I (115034) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6720",
"I (115094) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6720",
"I (115154) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6720",
"I (115214) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6720",
"I (115274) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6720",
"I (115344) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6720",
"I (115404) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6720",
"I (115464) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6720",
"I (115524) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6720",
"I (115584) station: heap before sign: free=37612 min=27444 dma=21668 stack_hwm=6720",
"I (115654) station: heap before sign: free=38116 min=27432 dma=22172 stack_hwm=6720",
"I (115714) station: heap before sign: free=38116 min=27432 dma=22172 stack_hwm=6720",
"I (115774) station: heap before sign: free=38116 min=27432 dma=22172 stack_hwm=6720",
"I (115834) station: heap before sign: free=38116 min=27432 dma=22172 stack_hwm=6720",
"I (115904) station: heap before sign: free=38116 min=27432 dma=22172 stack_hwm=6720",
"I (115964) station: heap before sign: free=38116 min=27432 dma=22172 stack_hwm=6720",
"I (116024) station: heap before sign: free=38116 min=27432 dma=22172 stack_hwm=6720",
"I (116094) station: heap before sign: free=38116 min=27432 dma=22172 stack_hwm=6720",
"I (116154) station: heap before sign: free=38116 min=27432 dma=22172 stack_hwm=6720",
"I (116214) station: heap before sign: free=38116 min=27432 dma=22172 stack_hwm=6720",
"I (116274) station: heap before sign: free=38116 min=27432 dma=22172 stack_hwm=6720",
"I (116344) station: heap before sign: free=38116 min=27432 dma=22172 stack_hwm=6720",
"I (116404) station: heap before sign: free=38116 min=27432 dma=22172 stack_hwm=6720",
"I (116464) station: heap before sign: free=38116 min=27432 dma=22172 stack_hwm=6720",
"I (116524) station: heap before sign: free=38116 min=27432 dma=22172 stack_hwm=6720",
"I (116594) station: heap before sign: free=38116 min=27432 dma=22172 stack_hwm=6720",
"I (116654) station: heap before sign: free=38116 min=27432 dma=22172 stack_hwm=6720",
"I (116714) station: heap before sign: free=38116 min=27432 dma=22172 stack_hwm=6720",
"I (116774) station: heap before sign: free=38116 min=27432 dma=22172 stack_hwm=6720",
"I (116834) station: heap before sign: free=38116 min=27432 dma=22172 stack_hwm=6720",
"I (116904) station: heap before sign: free=38116 min=27432 dma=22172 stack_hwm=6720",
"I (116964) station: heap before sign: free=38116 min=27432 dma=22172 stack_hwm=6720",
"I (117024) station: heap before sign: free=38116 min=27432 dma=22172 stack_hwm=6720",
"I (117084) station: heap before sign: free=38116 min=27432 dma=22172 stack_hwm=6720",
"I (117144) station: heap before sign: free=37612 min=27432 dma=21668 stack_hwm=6720",
"I (117214) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (117274) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (117334) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (117394) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (117464) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (117524) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (117584) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (117644) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (117704) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (117774) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (117834) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (117894) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (117954) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (118024) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (118084) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (118144) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (118204) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (118264) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (118334) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (118394) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (118454) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (118514) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (118574) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (118644) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (118704) station: heap before sign: free=37620 min=27432 dma=21676 stack_hwm=6720",
"I (118764) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (118824) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (118894) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (118954) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (119014) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (119074) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (119144) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (119204) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (119264) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (119324) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (119384) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (119444) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (119514) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (119574) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (119634) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (119694) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (119764) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (119824) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (119884) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (119944) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (120004) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (120064) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (120134) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (120194) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (120254) station: heap before sign: free=37620 min=27432 dma=21676 stack_hwm=6720",
"I (120314) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (120374) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (120434) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (120504) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (120564) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (120624) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (120694) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (120754) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (120814) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (120874) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (120934) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (120994) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (121064) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (121124) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (121184) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (121254) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720"
]
}
Binary file not shown.
@@ -1,11 +0,0 @@
"""Phone side of the micrOBU station-internal link (implementation/station-link/README.md).
``messages`` is the transport-independent message layer, ``serial_transport`` the framing
over native USB Serial/JTAG, ``ble_transport`` the laptop/phone-equivalent BLE GATT client,
and ``client`` the shared request/reply API with unsolicited messages delivered to callbacks.
"""
from .messages import * # noqa: F401,F403
from .transport import TransportAdapter # noqa: F401
from .serial_transport import FrameType, SerialTransport, encode_frame, FrameDecoder # noqa: F401
from .ble_transport import BleTransport # noqa: F401
from .client import LinkClient, LinkError, TestChannel # noqa: F401
@@ -1,199 +0,0 @@
"""BLE GATT transport for the station-link message layer.
The ESP32 exposes the Nordic-UART-shaped service (NUS) documented in the station-link
README. Values contain binary station-link messages (up to 512 bytes). Bleak
runs on a private asyncio thread while the LinkClient remains a synchronous request/reply API.
Fixed passkey bonding is used (passkey: 123456).
"""
from __future__ import annotations
import asyncio
import queue
import threading
from typing import Callable, Optional
from . import messages as m
from .serial_transport import FrameType
# Structured ETSI C-ITS Station Service UUIDs
SERVICE_UUID = '0000c175-ba5e-4c17-8000-00805f9b34fb'
BTP_REQUEST_UUID = '0000c176-ba5e-4c17-8000-00805f9b34fb'
BTP_INDICATION_UUID = '0000c177-ba5e-4c17-8000-00805f9b34fb'
POTI_UUID = '0000c178-ba5e-4c17-8000-00805f9b34fb'
STATUS_UUID = '0000c179-ba5e-4c17-8000-00805f9b34fb'
ID_EVENT_UUID = '0000c17a-ba5e-4c17-8000-00805f9b34fb'
CONFIG_UUID = '0000c17b-ba5e-4c17-8000-00805f9b34fb'
RESULT_UUID = '0000c17c-ba5e-4c17-8000-00805f9b34fb'
FIXED_PASSKEY = 123456
class BleTransport:
"""Synchronous LinkClient adapter backed by Bleak on a private event loop.
Transmits complete station-link messages directly without ATT fragmentation chunking
or queue delays.
"""
def __init__(self, target: Optional[str], on_frame: Callable[[int, bytes], None],
connect_timeout: float = 20.0, operation_timeout: float = 5.0):
self.target = target or None
self.on_frame = on_frame
self.connect_timeout = connect_timeout
self.operation_timeout = operation_timeout
self.attribute_value = m.MAXIMUM_MESSAGE
self.device_name = ''
self.device_address = ''
self.error: Optional[BaseException] = None
self.client = None
self.loop = asyncio.new_event_loop()
self.stop_event = None
self.ready = threading.Event()
self.delivery: 'queue.Queue[Optional[bytes]]' = queue.Queue()
self.delivery_thread = threading.Thread(target=self._deliver, name='microbu-ble-delivery', daemon=True)
self.delivery_thread.start()
self.thread = threading.Thread(target=self._thread_main, name='microbu-ble-asyncio', daemon=True)
self.thread.start()
if not self.ready.wait(connect_timeout + 5):
self.close()
raise TimeoutError('BLE scan/connect did not finish')
if self.error:
error = self.error
self.close()
raise ConnectionError('BLE station-link connection failed: %s' % error) from error
def _thread_main(self):
asyncio.set_event_loop(self.loop)
try:
self.loop.run_until_complete(self._session())
except BaseException as error:
self.error = error
self.ready.set()
finally:
self.loop.close()
async def _session(self):
from bleak import BleakClient, BleakScanner
target = self.target.lower() if self.target else None
def matches(device, advertisement):
names = (device.name or '', advertisement.local_name or '')
services = [uuid.lower() for uuid in advertisement.service_uuids]
if target:
return target == device.address.lower() or any(target == name.lower() for name in names)
return SERVICE_UUID in services or any(name.lower().startswith('microbu-') for name in names)
device = await BleakScanner.find_device_by_filter(matches, timeout=self.connect_timeout)
if device is None:
wanted = self.target or 'advertised micrOBU service'
raise TimeoutError('no BLE device matching %s' % wanted)
self.device_name = device.name or 'micrOBU'
self.device_address = device.address
self.stop_event = asyncio.Event()
self.client = BleakClient(device, timeout=self.connect_timeout, pair=True,
disconnected_callback=self._disconnected)
await self.client.connect()
# LE Secure Connections + passkey display takes ~1-3 s; wait for the link
# to be encrypted before attempting CCCD writes on authenticated characteristics.
for _ in range(30):
await asyncio.sleep(0.1)
try:
# Attempt a read on STATUS (Read|Encrypt) as an encryption probe.
# Once it succeeds (or gives ATT error other than InsufficientEncryption)
# the link is encrypted and CCCD writes on notify characteristics will work.
await self.client.read_gatt_char(STATUS_UUID)
break
except Exception as exc:
exc_str = str(exc).lower()
if 'insufficient' in exc_str or 'encrypt' in exc_str or 'auth' in exc_str:
continue # still pairing
break # any other error — proceed anyway
# Subscribe to peripheral notification characteristics that matter for normal operation
# (BTP_INDICATION, ID_EVENT, RESULT). STATUS is polled or read on demand, but can be notified if supported.
subscribed_uuids = set()
for notif_uuid in (RESULT_UUID, BTP_INDICATION_UUID, ID_EVENT_UUID):
for attempt in range(3):
try:
await self.client.start_notify(notif_uuid, self._notification)
subscribed_uuids.add(notif_uuid)
break
except Exception as exc:
if attempt < 2:
await asyncio.sleep(0.5)
else:
print(f'BLE: start_notify on {notif_uuid[:8]} failed: {exc}')
self.attribute_value = min(self.client.mtu_size - 3, m.MAXIMUM_MESSAGE)
self.ready.set()
await self.stop_event.wait()
if self.client.is_connected:
await self.client.disconnect()
def _disconnected(self, _client):
if self.stop_event and not self.stop_event.is_set():
self.error = ConnectionError('micrOBU disconnected')
self.stop_event.set()
def _notification(self, characteristic, data: bytearray):
# Direct immediate message delivery: each GATT notification is one complete link message
self.delivery.put(bytes(data))
def _deliver(self):
while True:
message = self.delivery.get()
if message is None:
return
try:
self.on_frame(FrameType.LINK, message)
except Exception as error:
print('BLE message handler error:', error)
def write(self, frame_type: int, payload: bytes):
if frame_type != FrameType.LINK:
raise NotImplementedError('the USB test/log channels do not exist over BLE')
if self.error:
raise ConnectionError(str(self.error))
if self.client is None or not self.client.is_connected or self.loop.is_closed():
raise ConnectionError('BLE station link is not ready')
# Route write to the specific ETSI C-ITS GATT characteristic.
# Check opcode in link header (payload[0])
target_uuid = CONFIG_UUID
if payload:
op = payload[0]
if op == m.Opcode.BTP_DATA_REQUEST:
target_uuid = BTP_REQUEST_UUID
elif op == m.Opcode.POTI_UPDATE:
target_uuid = POTI_UUID
elif op == m.Opcode.SF_IDCHANGE_EVENT_RESPONSE:
target_uuid = ID_EVENT_UUID
else:
target_uuid = CONFIG_UUID
async def send():
for attempt in range(4):
try:
await self.client.write_gatt_char(target_uuid, bytes(payload), response=True)
return
except Exception as error:
if 'insufficient resource' not in str(error).lower() or attempt == 3:
raise
await asyncio.sleep(0.1 * (attempt + 1))
future = asyncio.run_coroutine_threadsafe(send(), self.loop)
try:
future.result(timeout=self.operation_timeout)
except Exception as error:
raise ConnectionError('BLE write failed: %s' % error) from error
def close(self):
if self.stop_event and not self.loop.is_closed():
self.loop.call_soon_threadsafe(self.stop_event.set)
if hasattr(self, 'thread') and self.thread.is_alive() and threading.current_thread() is not self.thread:
self.thread.join(timeout=self.operation_timeout)
self.delivery.put(None)
if self.delivery_thread.is_alive() and threading.current_thread() is not self.delivery_thread:
self.delivery_thread.join(timeout=1)
@@ -1,245 +0,0 @@
"""Request/reply client of the station-internal link plus the USB test channel."""
from __future__ import annotations
import queue
import threading
import time
from typing import Callable, Dict, List, Optional, Tuple
from . import messages as m
from .serial_transport import FrameType, SerialTransport
from .transport import TransportAdapter
class LinkError(RuntimeError):
def __init__(self, result: m.Result, request: str):
super().__init__('%s refused: %s' % (request, result.name))
self.result = result
class LinkClient:
"""The phone's view of the micrOBU: send requests, await their RESULT, receive the
micrOBU's own messages (indications, identifier-change events, status, log lines)
through callbacks. Thread-safe for one request at a time."""
def __init__(self, port: Optional[str] = None, timeout: float = 3.0,
transport_factory: Optional[Callable[[Callable[[int, bytes], None]], TransportAdapter]] = None):
self.timeout = timeout
self.sequence = 0
self.lock = threading.Lock()
self.replies: 'queue.Queue[m.Message]' = queue.Queue()
self.on_indication: Optional[Callable[[m.BtpDataIndication], None]] = None
self.on_id_event: Optional[Callable[[m.IdChangeEvent, int], None]] = None
self.on_mf_set: Optional[Callable[[m.MfSetRequest], None]] = None
self.on_status: Optional[Callable[[m.Status], None]] = None
self.on_log: Optional[Callable[[str], None]] = None
self.last_status: Optional[m.Status] = None
self.test_replies: 'queue.Queue[bytes]' = queue.Queue()
if transport_factory:
self.transport = transport_factory(self._on_frame)
elif port:
self.transport = SerialTransport(port, self._on_frame)
else:
raise ValueError('port or transport_factory is required')
def close(self):
self.transport.close()
# ---- receive side ----
def _on_frame(self, frame_type: int, payload: bytes):
if frame_type == FrameType.LOG:
if self.on_log:
self.on_log(payload.decode('utf-8', 'replace'))
return
if frame_type == FrameType.TEST:
self.test_replies.put(payload)
return
if frame_type != FrameType.LINK:
return
try:
message = m.decode_message(payload)
except m.DecodeError:
return
op = message.header.opcode
if op == m.Opcode.RESULT or (op == m.Opcode.STATUS and message.header.sequence == self._awaited_status):
self.replies.put(message)
elif op == m.Opcode.BTP_DATA_INDICATION:
if self.on_indication:
self.on_indication(m.BtpDataIndication.decode(message.body))
elif op == m.Opcode.SF_IDCHANGE_EVENT:
if self.on_id_event:
self.on_id_event(m.IdChangeEvent.decode(message.body), message.header.sequence)
elif op == m.Opcode.MF_SET_REQUEST:
if self.on_mf_set:
self.on_mf_set(m.MfSetRequest.decode(message.body))
elif op == m.Opcode.STATUS:
self.last_status = m.Status.decode(message.body)
if self.on_status:
self.on_status(self.last_status)
_awaited_status = -1
# ---- send side ----
def send(self, opcode: int, body: bytes = b'', sequence: Optional[int] = None) -> int:
"""Send one message without waiting (indication responses, PoTi updates)."""
if sequence is None:
self.sequence = (self.sequence + 1) & 0xFFFF
sequence = self.sequence
self.transport.write(FrameType.LINK, m.encode_message(m.Message(m.Header(opcode, 0, sequence), body)))
return sequence
def request(self, opcode: int, body: bytes = b'', timeout: Optional[float] = None) -> m.Result:
"""Send a request and wait for its RESULT (raises LinkError unless accepted)."""
with self.lock:
while not self.replies.empty(): # stale replies of timed-out requests
self.replies.get_nowait()
sequence = self.send(opcode, body)
deadline = time.monotonic() + (timeout or self.timeout)
while True:
remaining = deadline - time.monotonic()
if remaining <= 0:
raise TimeoutError('no RESULT for opcode 0x%02x (sequence %d)' % (opcode, sequence))
try:
reply = self.replies.get(timeout=remaining)
except queue.Empty:
continue
if reply.header.sequence != sequence or reply.header.opcode != m.Opcode.RESULT:
continue
result = m.Result.decode(reply.body)
if result.code != m.Code.accepted:
raise LinkError(result, m.Opcode(opcode).name)
return result
def request_async(self, opcode: int, body: bytes = b'') -> int:
"""Submit a request without serializing on its RESULT.
The peer still emits the ordinary per-request RESULT. Call
receive_result() to consume it. This is used for the 100 ms VAM stream,
where a BLE connection interval must not become the service period.
"""
with self.lock:
return self.send(opcode, body)
def receive_result(self, timeout: Optional[float] = None) -> Tuple[int, m.Result]:
reply = self.replies.get(timeout=self.timeout if timeout is None else timeout)
if reply.header.opcode != m.Opcode.RESULT:
raise ValueError('expected RESULT, got opcode 0x%02x' % reply.header.opcode)
return reply.header.sequence, m.Result.decode(reply.body)
# ---- primitives ----
def configure(self, config: m.StationConfigure) -> m.StationInfo:
# Starting the ESP32-C5 ITS-G5 radio initializes the Wi-Fi/PHY driver
# before the station can reply. Keep ordinary request timeouts short,
# but allow this idempotent lifecycle operation to finish on hardware.
return m.StationInfo.decode(self.request(m.Opcode.STATION_CONFIGURE, config.encode(), timeout=30).detail)
def poti(self, update: m.PotiUpdate):
"""Silent unless refused; a refusal arrives as a RESULT that request() ignores."""
self.send(m.Opcode.POTI_UPDATE, update.encode())
def btp_data_request(self, request: m.BtpDataRequest) -> m.Result:
return self.request(m.Opcode.BTP_DATA_REQUEST, request.encode())
def provision(self, bundle: bytes) -> Tuple[int, int, int]:
result = None
for segment in m.provision_segments(bundle):
result = self.request(m.Opcode.CREDENTIALS_PROVISION, segment.encode(), timeout=20)
assert result is not None and len(result.detail) == 3, 'apply report expected'
return result.detail[0], result.detail[1], result.detail[2]
def erase_credentials(self):
self.request(m.Opcode.CREDENTIALS_ERASE, timeout=10)
def subscribe(self, subscriber_data: bytes = b'') -> int:
result = self.request(m.Opcode.SF_IDCHANGE_SUBSCRIBE, bytes([len(subscriber_data)]) + subscriber_data)
return int.from_bytes(result.detail, 'little')
def unsubscribe(self, subscription: int):
self.request(m.Opcode.SF_IDCHANGE_UNSUBSCRIBE, subscription.to_bytes(8, 'little'))
def event_response(self, event: m.IdChangeEvent, event_sequence: int, return_code: bool):
self.send(m.Opcode.SF_IDCHANGE_EVENT_RESPONSE, m.IdChangeEventResponse(event.subscription, return_code).encode(),
sequence=event_sequence)
def trigger(self):
self.request(m.Opcode.SF_IDCHANGE_TRIGGER)
def lock(self, seconds: int) -> int:
return int.from_bytes(self.request(m.Opcode.SF_ID_LOCK, bytes([seconds])).detail, 'little')
def unlock(self, handle: int):
self.request(m.Opcode.SF_ID_UNLOCK, handle.to_bytes(8, 'little'))
def status(self, timeout: float = 5.0) -> m.Status:
with self.lock:
while not self.replies.empty():
self.replies.get_nowait()
self.sequence = (self.sequence + 1) & 0xFFFF
self._awaited_status = self.sequence
try:
self.transport.write(FrameType.LINK, m.encode_message(m.Message(m.Header(m.Opcode.STATUS_REQUEST, 0, self.sequence))))
deadline = time.monotonic() + timeout
while True:
remaining = deadline - time.monotonic()
if remaining <= 0:
raise TimeoutError('no STATUS')
reply = self.replies.get(timeout=remaining)
if reply.header.opcode == m.Opcode.STATUS and reply.header.sequence == self.sequence:
self.last_status = m.Status.decode(reply.body)
return self.last_status
finally:
self._awaited_status = -1
class TestChannel:
"""USB test channel (frame type 0x11): software lower tester and campaign hooks."""
MIRROR, INJECT, GN_REQUEST, DRAIN, RESET = 0x01, 0x02, 0x03, 0x04, 0x05
def __init__(self, client: LinkClient, timeout: float = 3.0):
self.client = client
self.timeout = timeout
def execute(self, request: bytes) -> Tuple[int, List[Tuple[int, bytes]]]:
with self.client.lock:
while not self.client.test_replies.empty():
self.client.test_replies.get_nowait()
self.client.transport.write(FrameType.TEST, request)
reply = self.client.test_replies.get(timeout=self.timeout)
result, count = reply[0], reply[1]
records, at = [], 2
for _ in range(count):
kind = reply[at]
length = int.from_bytes(reply[at + 1:at + 3], 'little')
records.append((kind, reply[at + 3:at + 3 + length]))
at += 3 + length
if at != len(reply):
raise ValueError('trailing test channel octets')
return result, records
def mirror(self, mode: int):
result, _ = self.execute(bytes([self.MIRROR, mode]))
if result != 0:
raise RuntimeError('mirror mode refused: %d' % result)
def inject(self, source: bytes, destination: bytes, gnpdu: bytes) -> int:
result, _ = self.execute(bytes([self.INJECT]) + source + destination + gnpdu)
return result
def gn_request(self, traffic_class: int, payload: bytes) -> int:
result, _ = self.execute(bytes([self.GN_REQUEST, traffic_class]) + payload)
return result
def drain(self) -> Tuple[bool, List[Tuple[int, bytes]]]:
"""All queued records (several frames when needed); returns (overflow, records)."""
records: List[Tuple[int, bytes]] = []
overflow = False
while True:
result, batch = self.execute(bytes([self.DRAIN]))
overflow = overflow or result == m.Code.resource_limit
records.extend(batch)
if not batch:
return overflow, records
def reset(self):
self.execute(bytes([self.RESET]))
@@ -1,466 +0,0 @@
"""Message layer version 1 of the micrOBU station-internal link.
Mirror of the firmware's ``link_protocol.hpp``; the two are checked against each other by
``tests/test_station_link.py``. All integers little-endian; ETSI payloads opaque.
"""
from __future__ import annotations
import enum
import struct
from dataclasses import dataclass, field
from typing import List, Optional
MAXIMUM_MESSAGE = 512
HEADER_SIZE = 4
class Opcode(enum.IntEnum):
STATION_CONFIGURE = 0x01
POTI_UPDATE = 0x02
BTP_DATA_REQUEST = 0x03
CREDENTIALS_PROVISION = 0x04
CREDENTIALS_ERASE = 0x05
SF_IDCHANGE_SUBSCRIBE = 0x06
SF_IDCHANGE_UNSUBSCRIBE = 0x07
SF_IDCHANGE_EVENT_RESPONSE = 0x08
SF_IDCHANGE_TRIGGER = 0x09
SF_ID_LOCK = 0x0A
SF_ID_UNLOCK = 0x0B
STATUS_REQUEST = 0x0C
RESULT = 0x80
BTP_DATA_INDICATION = 0x81
SF_IDCHANGE_EVENT = 0x82
MF_SET_REQUEST = 0x83
STATUS = 0x84
class Code(enum.IntEnum):
"""RESULT codes: vanetza_idf::Result first, then link codes."""
accepted = 0
invalid_argument = 1
unsupported = 2
wrong_entry_point = 3
security_unavailable = 4
resource_limit = 5
rejected = 6
time_regression = 7
identity_change_pending = 8
unknown_opcode = 0x10
malformed = 0x11
not_configured = 0x12
busy = 0x13
no_credentials = 0x14
FLAG_FRAGMENT_FIRST = 0x01
FLAG_FRAGMENT_MORE = 0x02
class IdChangeCommand(enum.IntEnum):
PREPARE = 0
COMMIT = 1
ABORT = 2
DEREG = 3
class TransportType(enum.IntEnum):
SHB = 1
GBC = 2
TSB = 3
GAC = 4
GUC = 5
VERIFICATION_REPORTS = ['unsecured', 'Success', 'False_Signature', 'Invalid_Certificate', 'Revoked_Certificate',
'Inconsistent_Chain', 'Invalid_Timestamp', 'Duplicate_Message', 'Invalid_Mobility_Data',
'Unsigned_Message', 'Signer_Certificate_Not_Found', 'Unsupported_Signer_Identifier_Type',
'Incompatible_Protocol', 'Configuration_Problem']
class DecodeError(ValueError):
pass
class _Reader:
def __init__(self, data: bytes, at: int = 0):
self.data = data
self.at = at
def _take(self, n: int) -> bytes:
if self.at + n > len(self.data):
raise DecodeError('truncated body')
chunk = self.data[self.at:self.at + n]
self.at += n
return chunk
def u8(self) -> int: return self._take(1)[0]
def u16(self) -> int: return struct.unpack('<H', self._take(2))[0]
def u32(self) -> int: return struct.unpack('<I', self._take(4))[0]
def u64(self) -> int: return struct.unpack('<Q', self._take(8))[0]
def i32(self) -> int: return struct.unpack('<i', self._take(4))[0]
def bytes(self, n: int) -> bytes: return self._take(n)
def rest(self) -> bytes:
chunk = self.data[self.at:]
self.at = len(self.data)
return chunk
def done(self):
if self.at != len(self.data):
raise DecodeError('trailing octets')
@dataclass
class Header:
opcode: int
flags: int = 0
sequence: int = 0
@dataclass
class Message:
header: Header
body: bytes = b''
def encode_message(message: Message) -> bytes:
if HEADER_SIZE + len(message.body) > MAXIMUM_MESSAGE:
raise ValueError('message exceeds %d octets' % MAXIMUM_MESSAGE)
return struct.pack('<BBH', int(message.header.opcode), message.header.flags, message.header.sequence) + bytes(message.body)
def decode_message(octets: bytes) -> Message:
if len(octets) < HEADER_SIZE or len(octets) > MAXIMUM_MESSAGE:
raise DecodeError('bad message length %d' % len(octets))
opcode, flags, sequence = struct.unpack('<BBH', octets[:HEADER_SIZE])
return Message(Header(opcode, flags, sequence), bytes(octets[HEADER_SIZE:]))
# ---- bodies ----------------------------------------------------------------------------
@dataclass
class StationConfigure:
station_type: int = 2
security: int = 1
address_configuration: int = 1
mid: bytes = b'\x02\x00\x00\x00\x00\x01'
beaconing: int = 1
channel_number: int = 180
transmit_power_dbm: int = 10
radio: int = 0
default_traffic_class: int = 2
default_lifetime: int = 0x05
def encode(self) -> bytes:
assert len(self.mid) == 6
return struct.pack('<BBB6sBHBBBB', self.station_type, self.security, self.address_configuration, self.mid,
self.beaconing, self.channel_number, self.transmit_power_dbm, self.radio,
self.default_traffic_class, self.default_lifetime)
@classmethod
def decode(cls, body: bytes) -> 'StationConfigure':
if len(body) != 16:
raise DecodeError('StationConfigure length')
f = struct.unpack('<BBB6sBHBBBB', body)
return cls(*f)
@dataclass
class StationInfo:
"""RESULT detail of STATION_CONFIGURE."""
gn_address: bytes
identifier: bytes
credentials_loaded: bool
tickets: int
@classmethod
def decode(cls, detail: bytes) -> 'StationInfo':
if len(detail) != 18:
raise DecodeError('StationInfo length')
return cls(detail[:8], detail[8:16], detail[16] != 0, detail[17])
@dataclass
class PotiUpdate:
timestamp_ms: int
latitude: int
longitude: int
semi_major_cm: int = 0
semi_minor_cm: int = 0
orientation_deci_degree: int = 0
altitude_cm: Optional[int] = None
speed_cm_s: Optional[int] = None
heading_deci_degree: Optional[int] = None
pai: bool = False
def encode(self) -> bytes:
flags = ((1 if self.altitude_cm is not None else 0) | (2 if self.speed_cm_s is not None else 0) |
(4 if self.heading_deci_degree is not None else 0) | (8 if self.pai else 0))
return struct.pack('<QiiHHHBiHH', self.timestamp_ms, self.latitude, self.longitude, self.semi_major_cm,
self.semi_minor_cm, self.orientation_deci_degree, flags, self.altitude_cm or 0,
self.speed_cm_s or 0, self.heading_deci_degree or 0)
@classmethod
def decode(cls, body: bytes) -> 'PotiUpdate':
if len(body) != 31:
raise DecodeError('PotiUpdate length')
t, lat, lon, a, b, o, flags, alt, spd, hdg = struct.unpack('<QiiHHHBiHH', body)
return cls(t, lat, lon, a, b, o, alt if flags & 1 else None, spd if flags & 2 else None,
hdg if flags & 4 else None, bool(flags & 8))
@dataclass
class DestinationArea:
shape: int = 0
latitude: int = 0
longitude: int = 0
distance_a: int = 0
distance_b: int = 0
angle: int = 0
def encode(self) -> bytes:
return struct.pack('<BiiHHH', self.shape, self.latitude, self.longitude, self.distance_a, self.distance_b, self.angle)
@classmethod
def read(cls, r: _Reader) -> 'DestinationArea':
return cls(r.u8(), r.i32(), r.i32(), r.u16(), r.u16(), r.u16())
@dataclass
class BtpDataRequest:
fl_sdu: bytes
btp_type: int = 1
destination_port: int = 2018
destination_port_info: int = 0
gn_packet_transport_type: int = TransportType.SHB
gn_communication_profile: int = 1
gn_security_profile: int = 0
gn_traffic_class: int = 0xFF
gn_maximum_packet_lifetime: int = 0xFF
gn_maximum_hop_limit: int = 0
gn_repetition_interval_ms: int = 0
gn_repetition_maximum_ms: int = 0
its_aid: int = 638
permissions: bytes = b''
context: bytes = b''
area: Optional[DestinationArea] = None
def encode(self) -> bytes:
if len(self.permissions) > 31:
raise ValueError('SSP longer than 31 octets')
out = struct.pack('<BHHBBBBBBHHI', self.btp_type, self.destination_port, self.destination_port_info,
self.gn_packet_transport_type, self.gn_communication_profile, self.gn_security_profile,
self.gn_traffic_class, self.gn_maximum_packet_lifetime, self.gn_maximum_hop_limit,
self.gn_repetition_interval_ms, self.gn_repetition_maximum_ms, self.its_aid)
out += bytes([len(self.permissions)]) + self.permissions + bytes([len(self.context)]) + self.context
if self.gn_packet_transport_type == TransportType.GBC:
out += (self.area or DestinationArea()).encode()
out += struct.pack('<H', len(self.fl_sdu)) + self.fl_sdu
return out
@classmethod
def decode(cls, body: bytes) -> 'BtpDataRequest':
r = _Reader(body)
f = struct.unpack('<BHHBBBBBBHHI', r.bytes(19))
permissions = r.bytes(r.u8())
context = r.bytes(r.u8())
area = DestinationArea.read(r) if f[3] == TransportType.GBC else None
sdu = r.bytes(r.u16())
r.done()
return cls(sdu, *f, permissions=permissions, context=context, area=area)
@dataclass
class BtpDataIndication:
received_fl_sdu: bytes
btp_type: int = 1
destination_port: int = 0
destination_port_info: int = 0
gn_packet_transport_type: int = 1
gn_traffic_class: int = 0
gn_remaining_packet_lifetime: int = 0xFF
gn_remaining_hop_limit: int = 0xFF
source_gn_address: bytes = b'\0' * 8
source_timestamp: int = 0
source_latitude: int = 0
source_longitude: int = 0
security_report: int = 0
its_aid: int = 0
permissions: bytes = b''
certificate_id: Optional[bytes] = None
area: Optional[DestinationArea] = None
@property
def report_name(self) -> str:
return VERIFICATION_REPORTS[self.security_report] if self.security_report < len(VERIFICATION_REPORTS) else str(self.security_report)
def encode(self) -> bytes:
out = struct.pack('<BHHBBBB8sIiiBI', self.btp_type, self.destination_port, self.destination_port_info,
self.gn_packet_transport_type, self.gn_traffic_class, self.gn_remaining_packet_lifetime,
self.gn_remaining_hop_limit, self.source_gn_address, self.source_timestamp,
self.source_latitude, self.source_longitude, self.security_report, self.its_aid)
out += bytes([len(self.permissions)]) + self.permissions
out += bytes([1 if self.certificate_id else 0]) + (self.certificate_id or b'\0' * 8)
out += bytes([1 if self.area else 0]) + (self.area.encode() if self.area else b'')
out += struct.pack('<H', len(self.received_fl_sdu)) + self.received_fl_sdu
return out
@classmethod
def decode(cls, body: bytes) -> 'BtpDataIndication':
r = _Reader(body)
f = struct.unpack('<BHHBBBB8sIiiBI', r.bytes(34))
permissions = r.bytes(r.u8())
present = r.u8()
certificate = r.bytes(8)
area = DestinationArea.read(r) if r.u8() else None
sdu = r.bytes(r.u16())
r.done()
return cls(sdu, *f, permissions=permissions, certificate_id=certificate if present else None, area=area)
@dataclass
class CredentialsProvision:
total_length: int
offset: int
segment: bytes
def encode(self) -> bytes:
return struct.pack('<HHB', self.total_length, self.offset, len(self.segment)) + self.segment
@classmethod
def decode(cls, body: bytes) -> 'CredentialsProvision':
r = _Reader(body)
total, offset = r.u16(), r.u16()
segment = r.bytes(r.u8())
r.done()
return cls(total, offset, segment)
def provision_segments(bundle: bytes, segment_size: int = 200) -> List[CredentialsProvision]:
"""Split a VCR1 bundle into CREDENTIALS_PROVISION messages."""
return [CredentialsProvision(len(bundle), at, bundle[at:at + segment_size]) for at in range(0, len(bundle), segment_size)]
@dataclass
class IdChangeEvent:
subscription: int
command: int
id: bytes
subscriber_data: bytes = b''
def encode(self) -> bytes:
return struct.pack('<QB8sB', self.subscription, self.command, self.id, len(self.subscriber_data)) + self.subscriber_data
@classmethod
def decode(cls, body: bytes) -> 'IdChangeEvent':
r = _Reader(body)
s, c, i = struct.unpack('<QB8s', r.bytes(17))
data = r.bytes(r.u8())
r.done()
return cls(s, c, i, data)
@dataclass
class IdChangeEventResponse:
subscription: int
return_code: bool
def encode(self) -> bytes:
return struct.pack('<QB', self.subscription, 1 if self.return_code else 0)
@classmethod
def decode(cls, body: bytes) -> 'IdChangeEventResponse':
if len(body) != 9:
raise DecodeError('IdChangeEventResponse length')
s, c = struct.unpack('<QB', body)
return cls(s, c != 0)
@dataclass
class MfSetRequest:
fac_id: int = 0
command_ref: int = 0
params: List[tuple] = field(default_factory=list) # (f_param_no, value)
def encode(self) -> bytes:
out = struct.pack('<IBB', self.fac_id, self.command_ref, len(self.params))
for no, value in self.params:
out += struct.pack('<BI', no, value)
return out
@classmethod
def decode(cls, body: bytes) -> 'MfSetRequest':
r = _Reader(body)
fac, ref, count = struct.unpack('<IBB', r.bytes(6))
params = [struct.unpack('<BI', r.bytes(5)) for _ in range(count)]
r.done()
return cls(fac, ref, params)
STATUS_FORMAT = '<IB8s8sBB' + 'I' * 7 + 'I' * 3 + 'I' * 4 + 'I' * 4 + 'Q'
STATUS_FIELDS = ['uptime_ms', 'configured', 'gn_address', 'identifier', 'change_pending', 'tickets',
'signed_messages', 'refused_no_ticket', 'refused_change_pending', 'refused_permission',
'sign_failed', 'verified', 'rejected', 'requests_accepted', 'requests_refused', 'indications',
'radio_submitted', 'radio_failed', 'radio_received', 'radio_dropped', 'link_rx_frames',
'link_crc_errors', 'link_malformed', 'poti_updates', 'its_time_ms']
@dataclass
class Status:
uptime_ms: int = 0
configured: int = 0
gn_address: bytes = b'\0' * 8
identifier: bytes = b'\0' * 8
change_pending: int = 0
tickets: int = 0
signed_messages: int = 0
refused_no_ticket: int = 0
refused_change_pending: int = 0
refused_permission: int = 0
sign_failed: int = 0
verified: int = 0
rejected: int = 0
requests_accepted: int = 0
requests_refused: int = 0
indications: int = 0
radio_submitted: int = 0
radio_failed: int = 0
radio_received: int = 0
radio_dropped: int = 0
link_rx_frames: int = 0
link_crc_errors: int = 0
link_malformed: int = 0
poti_updates: int = 0
its_time_ms: int = 0
def encode(self) -> bytes:
return struct.pack(STATUS_FORMAT, *[getattr(self, name) for name in STATUS_FIELDS])
@classmethod
def decode(cls, body: bytes) -> 'Status':
if len(body) != struct.calcsize(STATUS_FORMAT):
raise DecodeError('Status length %d' % len(body))
return cls(**dict(zip(STATUS_FIELDS, struct.unpack(STATUS_FORMAT, body))))
@dataclass
class Result:
code: int
detail: bytes = b''
def encode(self) -> bytes:
return bytes([int(self.code), len(self.detail)]) + self.detail
@classmethod
def decode(cls, body: bytes) -> 'Result':
r = _Reader(body)
code = r.u8()
detail = r.bytes(r.u8())
r.done()
return cls(code, detail)
@property
def name(self) -> str:
try:
return Code(self.code).name
except ValueError:
return 'code_0x%02x' % self.code
@@ -1,136 +0,0 @@
"""Serial transport of the station-internal link (the app's Phase 03 framing).
``[AA][55][type][length LE][payload][crc16 LE]``, CRC-16/CCITT-FALSE over type+length+payload.
Frame types: 0x10 link message, 0x11 test channel, 0x7F log line. Requires pyserial.
"""
from __future__ import annotations
import enum
import struct
import threading
from typing import Callable, List, Tuple
MAXIMUM_PAYLOAD = 1536
class FrameType(enum.IntEnum):
LINK = 0x10
TEST = 0x11
LOG = 0x7F
def crc16_ccitt_false(data: bytes) -> int:
crc = 0xFFFF
for octet in data:
crc ^= octet << 8
for _ in range(8):
crc = ((crc << 1) ^ 0x1021) & 0xFFFF if crc & 0x8000 else (crc << 1) & 0xFFFF
return crc
def encode_frame(frame_type: int, payload: bytes) -> bytes:
if len(payload) > MAXIMUM_PAYLOAD:
raise ValueError('frame payload exceeds %d octets' % MAXIMUM_PAYLOAD)
head = struct.pack('<BH', int(frame_type), len(payload)) + payload
return b'\xaa\x55' + head + struct.pack('<H', crc16_ccitt_false(head))
class FrameDecoder:
"""Byte-at-a-time state machine, the app's SerialFrameDecoder with the larger payload limit."""
def __init__(self):
self.state = 'SYNC0'
self.type = 0
self.length = 0
self.payload = bytearray()
self.crc = 0
self.crc_errors = 0
self.frames = 0
def feed(self, data: bytes) -> List[Tuple[int, bytes]]:
out = []
for b in data:
s = self.state
if s == 'SYNC0':
self.state = 'SYNC1' if b == 0xAA else 'SYNC0'
elif s == 'SYNC1':
self.state = 'TYPE' if b == 0x55 else ('SYNC1' if b == 0xAA else 'SYNC0')
elif s == 'TYPE':
self.type = b
self.state = 'LEN_LO'
elif s == 'LEN_LO':
self.length = b
self.state = 'LEN_HI'
elif s == 'LEN_HI':
self.length |= b << 8
self.payload = bytearray()
if self.length > MAXIMUM_PAYLOAD:
self.state = 'SYNC0'
else:
self.state = 'CRC_LO' if self.length == 0 else 'PAYLOAD'
elif s == 'PAYLOAD':
self.payload.append(b)
if len(self.payload) >= self.length:
self.state = 'CRC_LO'
elif s == 'CRC_LO':
self.crc = b
self.state = 'CRC_HI'
elif s == 'CRC_HI':
self.crc |= b << 8
head = struct.pack('<BH', self.type, self.length) + bytes(self.payload)
if crc16_ccitt_false(head) == self.crc:
self.frames += 1
out.append((self.type, bytes(self.payload)))
else:
self.crc_errors += 1
self.state = 'SYNC0'
return out
class SerialTransport:
"""Reader thread plus a locked writer over one pyserial port."""
def __init__(self, port: str, on_frame: Callable[[int, bytes], None], baudrate: int = 115200):
import serial
self.port = serial.Serial(port=None, baudrate=baudrate, timeout=0.05, write_timeout=2)
# do not toggle DTR/RTS: the USB Serial/JTAG controller would reset the board
self.port.dtr = False
self.port.rts = False
self.port.port = port
self.port.open()
self.on_frame = on_frame
self.decoder = FrameDecoder()
self.write_lock = threading.Lock()
self.running = True
self.thread = threading.Thread(target=self._reader, name='microbu-link-rx', daemon=True)
self.thread.start()
def _reader(self):
while self.running:
try:
data = self.port.read(4096)
except Exception:
if self.running:
continue
return
if data:
for frame_type, payload in self.decoder.feed(data):
try:
self.on_frame(frame_type, payload)
except Exception as error: # a handler failure must not stop the reader
print('frame handler error:', error)
def write(self, frame_type: int, payload: bytes):
frame = encode_frame(frame_type, payload)
with self.write_lock:
if self.port.write(frame) != len(frame):
raise IOError('incomplete serial write')
def close(self):
self.running = False
try:
self.port.cancel_read()
except Exception:
pass
self.thread.join(timeout=1)
self.port.close()
@@ -1,12 +0,0 @@
"""Common adapter boundary for station-link transports."""
from __future__ import annotations
from typing import Protocol
class TransportAdapter(Protocol):
"""The only transport surface used by LinkClient."""
def write(self, frame_type: int, payload: bytes) -> None: ...
def close(self) -> None: ...
@@ -1,239 +0,0 @@
"""A small VRU basic service for the phone emulator: VAM assembly with asn1tools from the
ETSI ASN.1 modules, the individual-VAM generation rules of ETSI TS 103 300-3 V2.3.1
clause 6.4 (items 1 to 4, Tables 16 and 17), the VBS PCI of Table 4, and a PoTi stand-in.
It is a test stand-in for the phone app's VBS, not a conformant VBS: no clustering,
no redundancy mitigation, no reception management, no motion prediction.
"""
from __future__ import annotations
import math
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
from . import messages as m
ITS_EPOCH_UNIX = 1072915200
LEAP_SECONDS_SINCE_2004 = 5 # TAI-UTC 37 s now, 32 s at the epoch (2017-01-01 was the last insertion)
# ETSI TS 103 300-3 V2.3.1 Table 16 / Table 17 recommended values
T_GEN_VAM_MIN_MS = 100
T_GEN_VAM_MAX_MS = 5000
T_GEN_VAM_LF_MIN_MS = 2000
MIN_POSITION_CHANGE_M = 4.0
MIN_SPEED_CHANGE_MPS = 0.5
MIN_ORIENTATION_CHANGE_DEG = 4.0
# TS 102 965 ITS-AID of the VRU service, TS 103 248 BTP port of VAM
ITS_AID_VRU = 638
BTP_PORT_VAM = 2018
def its_timestamp_ms(unix_seconds: Optional[float] = None) -> int:
"""TS 102 894-2 TimestampIts: TAI milliseconds since 2004-01-01T00:00:00Z."""
if unix_seconds is None:
unix_seconds = time.time()
return int((unix_seconds - ITS_EPOCH_UNIX + LEAP_SECONDS_SINCE_2004) * 1000)
def default_asn1_directory() -> Path:
here = Path(__file__).resolve()
return here.parents[3] / 'external' / 'vanetza-idf' / 'asn1' / 'release2'
class Codec:
"""UPER codec for VAM (and CAM/DENM for the displays) from the submodule's ASN.1 modules."""
def __init__(self, directory: Optional[Path] = None):
import asn1tools
directory = directory or default_asn1_directory()
cdd = directory / 'TS102894-2v241-CDD.asn'
self.vam = asn1tools.compile_files([cdd, directory / 'TS103300-3v231' / 'VAM-PDU-Descriptions.asn',
directory / 'TS103300-3v231' / 'motorcyclist-special-container.asn'], 'uper')
self.vam_type = self.vam.modules['VAM-PDU-Descriptions']['VAM']
self.cam_type = None
self.denm_type = None
try:
cam = asn1tools.compile_files([cdd, directory / 'TS103900v231-CAM.asn'], 'uper')
self.cam_type = cam.modules['CAM-PDU-Descriptions']['CAM']
except Exception:
pass
try:
denm = asn1tools.compile_files([cdd, directory / 'TS103831v231-DENM.asn'], 'uper')
self.denm_type = denm.modules['DENM-PDU-Description']['DENM']
except Exception:
pass
def encode_vam(self, vam: dict) -> bytes:
return self.vam_type.encode(vam)
def decode_vam(self, octets: bytes) -> dict:
return self.vam_type.decode(octets)
def decode_by_port(self, port: int, octets: bytes):
if port == BTP_PORT_VAM:
return 'VAM', self.decode_vam(octets)
if port == 2001 and self.cam_type:
return 'CAM', self.cam_type.decode(octets)
if port == 2002 and self.denm_type:
return 'DENM', self.denm_type.decode(octets)
return None, None
@dataclass
class PotiState:
"""What PoTi reports: WGS84 position with a 95 % confidence ellipse, kinematics, time."""
timestamp_ms: int
latitude: float
longitude: float
speed_mps: float = 0.0
heading_deg: float = 0.0
altitude_m: Optional[float] = None
semi_major_m: float = 1.5
semi_minor_m: float = 1.0
orientation_deg: float = 0.0
def poti_update(self) -> m.PotiUpdate:
return m.PotiUpdate(
timestamp_ms=self.timestamp_ms,
latitude=int(round(self.latitude * 1e7)), longitude=int(round(self.longitude * 1e7)),
semi_major_cm=min(65535, int(round(self.semi_major_m * 100))),
semi_minor_cm=min(65535, int(round(self.semi_minor_m * 100))),
orientation_deci_degree=int(round(self.orientation_deg * 10)) % 3600,
altitude_cm=None if self.altitude_m is None else int(round(self.altitude_m * 100)),
speed_cm_s=min(65535, int(round(self.speed_mps * 100))),
heading_deci_degree=int(round(self.heading_deg * 10)) % 3600,
pai=self.semi_major_m * 2 <= 40.0) # itsGnPaiInterval / 2, EN 302 636-4-1 / TS 103 836-4-1 clause 8.4
class PotiSimulator:
"""Static position, or a bicycle riding a circle (radius r at speed v) for the triggers."""
def __init__(self, latitude: float, longitude: float, speed_mps: float = 0.0, radius_m: float = 30.0,
altitude_m: Optional[float] = 12.0):
self.center = (latitude, longitude)
self.speed = speed_mps
self.radius = radius_m
self.altitude = altitude_m
self.start = time.time()
def state(self, now: Optional[float] = None) -> PotiState:
now = time.time() if now is None else now
if self.speed <= 0:
return PotiState(its_timestamp_ms(now), self.center[0], self.center[1], 0.0, 0.0, self.altitude)
angle = (now - self.start) * self.speed / self.radius # rad, counter-clockwise
lat = self.center[0] + math.degrees(self.radius * math.sin(angle) / 6371000.0)
lon = self.center[1] + math.degrees(self.radius * math.cos(angle) / (6371000.0 * math.cos(math.radians(self.center[0]))))
heading = (math.degrees(angle) * -1 + 0.0) % 360 # tangent of a counter-clockwise circle, clockwise from north
return PotiState(its_timestamp_ms(now), lat, lon, self.speed, heading, self.altitude)
def haversine_m(a: PotiState, b: PotiState) -> float:
r = 6371000.0
p1, p2 = math.radians(a.latitude), math.radians(b.latitude)
dp = p2 - p1
dl = math.radians(b.longitude - a.longitude)
h = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
return 2 * r * math.asin(math.sqrt(h))
@dataclass
class VbsLite:
"""VRU-ACTIVE-STANDALONE VBS state for one bicyclist (VRU profile 2, sub-profile bicyclist)."""
codec: Codec
station_id: int
t_gen_vam_ms: int = T_GEN_VAM_MIN_MS # T_GenVam, from the management entity (DCC) in a real VBS
fixed_period_ms: Optional[int] = None # test override: one VAM every period regardless of the triggers
active: bool = True # false between PREPARE and COMMIT of an identifier change
last_vam: Optional[PotiState] = None
last_vam_at_ms: int = 0
last_lf_at_ms: int = -10 ** 9
generated: int = 0
security_profile: int = 2 # Table 4: SECURED or UNSECURED
ssp: bytes = b'\x01' # SSP of the VRU ITS-AID carried in the authorization ticket
traffic_class: int = 0xFF # "same GN traffic class value as for the CAM": the station default
lifetime: int = 0x05 # GN maximum packet lifetime 1 s (Table 4: shall not exceed 1 000 ms)
def due(self, now: PotiState) -> bool:
if not self.active:
return False
elapsed = now.timestamp_ms - self.last_vam_at_ms
if self.fixed_period_ms is not None:
return self.last_vam is None or elapsed >= self.fixed_period_ms
if self.last_vam is None:
return True
if elapsed < max(self.t_gen_vam_ms, T_GEN_VAM_MIN_MS):
return False
if elapsed > T_GEN_VAM_MAX_MS:
return True # item 1
if haversine_m(now, self.last_vam) > MIN_POSITION_CHANGE_M:
return True # item 2
if abs(now.speed_mps - self.last_vam.speed_mps) > MIN_SPEED_CHANGE_MPS:
return True # item 3
delta = abs((now.heading_deg - self.last_vam.heading_deg + 180) % 360 - 180)
return delta > MIN_ORIENTATION_CHANGE_DEG # item 4
def assemble(self, now: PotiState) -> bytes:
"""VAM per TS 103 300-3 clause 7.3: basic + HF container, LF container every T_GenVamLFMin."""
include_lf = now.timestamp_ms - self.last_lf_at_ms >= T_GEN_VAM_LF_MIN_MS
vam = {
'header': {'protocolVersion': 3, 'messageId': 16, 'stationId': self.station_id},
'vam': {
'generationDeltaTime': now.timestamp_ms % 65536,
'vamParameters': {
'basicContainer': {
'stationType': 2, # cyclist
'referencePosition': {
'latitude': int(round(now.latitude * 1e7)),
'longitude': int(round(now.longitude * 1e7)),
'positionConfidenceEllipse': {
'semiMajorAxisLength': min(4094, int(round(now.semi_major_m * 100))),
'semiMinorAxisLength': min(4094, int(round(now.semi_minor_m * 100))),
'semiMajorAxisOrientation': int(round(now.orientation_deg * 10)) % 3600,
},
'altitude': ({'altitudeValue': max(-100000, min(800000, int(round(now.altitude_m * 100)))),
'altitudeConfidence': 'alt-020-00'} if now.altitude_m is not None
else {'altitudeValue': 800001, 'altitudeConfidence': 'unavailable'}),
},
},
'vruHighFrequencyContainer': {
'heading': {'value': int(round(now.heading_deg * 10)) % 3600, 'confidence': 50},
'speed': {'speedValue': min(16382, int(round(now.speed_mps * 100))), 'speedConfidence': 50},
'longitudinalAcceleration': {'longitudinalAccelerationValue': 0, 'longitudinalAccelerationConfidence': 102},
},
},
},
}
if include_lf:
vam['vam']['vamParameters']['vruLowFrequencyContainer'] = {
'profileAndSubprofile': ('bicyclistAndLightVruVehicle', 1), # bicyclist
'sizeClass': 1, # low
}
self.last_lf_at_ms = now.timestamp_ms
octets = self.codec.encode_vam(vam)
self.last_vam = now
self.last_vam_at_ms = now.timestamp_ms
self.generated += 1
return octets
def btp_data_request(self, vam: bytes) -> m.BtpDataRequest:
"""The VBS networking PCI of TS 103 300-3 Table 4 for one VAM."""
return m.BtpDataRequest(
fl_sdu=vam, btp_type=1, destination_port=BTP_PORT_VAM, destination_port_info=0,
gn_packet_transport_type=m.TransportType.SHB, gn_communication_profile=1,
gn_security_profile=self.security_profile, gn_traffic_class=self.traffic_class,
gn_maximum_packet_lifetime=self.lifetime, its_aid=ITS_AID_VRU, permissions=self.ssp)
# TS 103 300-3 clause 5.3.5: stop on PREPARE, resume with a new StationId after COMMIT
def prepare(self):
self.active = False
def commit(self, new_station_id: int):
self.station_id = new_station_id
self.active = True
self.last_vam = None
def abort(self):
self.active = True
@@ -1,491 +0,0 @@
#!/usr/bin/env python3
"""Phone emulator: the phone half of the VRU ITS-S, on a PC, over serial or BLE.
It does what the app will do over BLE: configure the micrOBU, provision credentials
(a VCR1 bundle), feed PoTi, run a small VRU basic service that assembles VAMs and hands
them to the micrOBU as BTP-DATA.request with the PCI of TS 103 300-3 Table 4, subscribe
to the identifier-change events (stop on PREPARE, new StationId after COMMIT), show the
BTP-DATA.indications the micrOBU passes up and its status/log lines.
phone_emulator.py --port COM11 --bundle chain.vcr --radio txrx --duration 30
phone_emulator.py --port COM11 --bundle chain.vcr --radio off --divert --pcap vams.pcap --duration 20
phone_emulator.py --ble --bundle chain.vcr --radio off --duration 20
--divert keeps the packets from the radio and mirrors them through the USB test channel
(test firmware only), --pcap writes those as IEEE 802.11 frames for an independent verifier
(c-its-pcap). BLE uses the same LinkClient API but cannot expose the USB-only test
channel. Dependencies are listed in requirements.txt.
The "TS 102 941 client" role (architecture/diagrams/microbu-architecture-2b-revision-b-security.drawio):
--pki-* replaces --bundle with a real online enrolment (EA) then authorization (AA) round
trip over HTTP (TS 102 941 V2.2.1 clause 6.2.3), building the VCR1 bundle from the
resulting AT instead of an offline-issued ticket:
phone_emulator.py --port COM11 --radio txrx --duration 30 \
--pki-url http://127.0.0.1:8090/ --pki-root ROOT.oer --pki-ea EA.oer --pki-aa AA.oer \
--pki-canonical-key canonical.pem --pki-its-id vidf-vru-station \
--pki-issue-tool vidf_issue.exe --pki-client-tool pki_client.py --pki-bundle-tool credential_bundle.py
--pki-url is a tools/local_pki.py access point; --pki-issue-tool/--pki-client-tool/
--pki-bundle-tool are vanetza-idf's ports/esp_idf/{tools/local_pki.py's vidf_issue binary,
tools/pki_client.py, tools/credential_bundle.py}. Lab tooling stands in for the phone's own
TS 102 941 stack; see docs/idf/evidence/enrolment-authorization-01 in vanetza-idf.
"""
from __future__ import annotations
import argparse
import json
import queue
import random
import struct
import subprocess
import sys
import tempfile
import threading
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from microbu_link import BleTransport, LinkClient, LinkError, TestChannel, messages as m # noqa: E402
from microbu_link.vbs import Codec, PotiSimulator, VbsLite, its_timestamp_ms # noqa: E402
WILDCARD_BSSID = b'\xff' * 6
GEONETWORKING_ETHERTYPE = b'\x89\x47'
# Simulation-only trajectory over open water in the middle of the Außenalster.
# Keeping the synthetic VRU away from roads prevents it from appearing to be a
# real road user in receiving applications. This does not eliminate RF airtime;
# actual transmission remains opt-in through ``--radio txrx``.
AUSSENALSTER_CENTER_LAT = 53.565247
AUSSENALSTER_CENTER_LON = 10.008239
AUSSENALSTER_RADIUS_M = 20.0
AUSSENALSTER_SPEED_MPS = 2.0
def tool_command(path, *arguments):
"""Build a portable command line for a native tool or Python script."""
executable = str(path)
prefix = [sys.executable, executable] if Path(executable).suffix.lower() == '.py' else [executable]
return prefix + [str(argument) for argument in arguments]
def run_tool(path, *arguments):
"""Run one PKI helper and turn its diagnostic into a library-style error."""
command = tool_command(path, *arguments)
try:
result = subprocess.run(command, capture_output=True, text=True, check=False)
except OSError as error:
raise RuntimeError('%s failed: %s' % (Path(path).name, error)) from error
if result.returncode:
diagnostic = result.stderr.strip() or result.stdout.strip() or 'exit status %d' % result.returncode
raise RuntimeError('%s failed: %s' % (Path(path).name, diagnostic))
return result
def write_pcap(path, frames):
"""IEEE 802.11 data frames (linktype 105), LLC/SNAP 0x8947, like capture_pcap.py."""
with open(path, 'wb') as out:
out.write(struct.pack('<IHHiIII', 0xa1b2c3d4, 2, 4, 0, 0, 65535, 105))
for sequence, (timestamp, source, pdu) in enumerate(frames):
header = (struct.pack('<HH', 0x0008, 0) + WILDCARD_BSSID + source + WILDCARD_BSSID +
struct.pack('<H', (sequence & 0x0fff) << 4))
frame = header + b'\xaa\xaa\x03\x00\x00\x00' + GEONETWORKING_ETHERTYPE + pdu
out.write(struct.pack('<IIII', int(timestamp), int((timestamp % 1) * 1_000_000), len(frame), len(frame)))
out.write(frame)
class Emulator:
def __init__(self, args):
self.args = args
self.codec = Codec(Path(args.asn1) if args.asn1 else None)
self.log_lines = []
if args.ble is not None:
self.client = LinkClient(timeout=args.timeout, transport_factory=lambda receive: BleTransport(
args.ble or None, receive, connect_timeout=args.connect_timeout,
operation_timeout=max(args.timeout, 5.0)))
else:
self.client = LinkClient(args.port, timeout=args.timeout)
self.client.on_log = self.on_log
self.client.on_indication = self.on_indication
self.client.on_id_event = self.on_id_event
self.client.on_mf_set = lambda mf: self.say('MF-SET fac_id=%d params=%s' % (mf.fac_id, mf.params))
self.client.on_status = self.on_status
self.poti = PotiSimulator(args.lat, args.lon, args.speed, args.radius)
self.vbs = VbsLite(self.codec, args.station_id or random.randrange(1, 2 ** 32 - 1),
fixed_period_ms=args.period, security_profile=2,
ssp=bytes.fromhex(args.ssp))
self.subscription = None
self.stop = threading.Event()
self.lock = threading.Lock()
self.frames = [] # (unix time, source mac, gnpdu) from the test channel
self.events = []
self.station_info = None
self.test = TestChannel(self.client) if (args.divert or args.mirror) else None
self.pending = {}
self.pending_lock = threading.Lock()
self.results_accepted = 0
self.results_refused = 0
self.assembly_ms = []
self.request_latency_ms = []
self.generation_times = []
self.stream_started = None
self.stream_ended = None
# ---- output ----
def say(self, text):
line = '%s %s' % (time.strftime('%H:%M:%S'), text)
print(line, flush=True)
self.events.append(line)
def on_log(self, line):
if self.args.quiet_log:
return
print(' [micrOBU] %s' % line, flush=True)
self.log_lines.append(line)
def on_status(self, s: m.Status):
if self.args.verbose:
self.say('STATUS its_time=%d signed=%d refused(no ticket %d, pending %d, permission %d) failed=%d '
'req ok/refused=%d/%d ind=%d radio tx/fail/rx/drop=%d/%d/%d/%d id=%s pending=%d tickets=%d' % (
s.its_time_ms, s.signed_messages, s.refused_no_ticket, s.refused_change_pending,
s.refused_permission, s.sign_failed, s.requests_accepted, s.requests_refused, s.indications,
s.radio_submitted, s.radio_failed, s.radio_received, s.radio_dropped, s.identifier.hex(),
s.change_pending, s.tickets))
def on_indication(self, ind: m.BtpDataIndication):
kind, decoded = None, None
try:
kind, decoded = self.codec.decode_by_port(ind.destination_port, ind.received_fl_sdu)
except Exception as error:
kind = 'undecodable (%s)' % error
summary = ''
if isinstance(decoded, dict):
header = decoded.get('header', {})
summary = ' stationId=%s' % header.get('stationId')
self.say('BTP-DATA.indication port=%d %d B from %s report=%s its_aid=%d ssp=%s cert=%s %s%s' % (
ind.destination_port, len(ind.received_fl_sdu), ind.source_gn_address.hex(), ind.report_name, ind.its_aid,
ind.permissions.hex(), ind.certificate_id.hex() if ind.certificate_id else '-', kind or 'port %d' % ind.destination_port, summary))
# TS 103 300-3 clause 5.3.5 through the SF-SAP identifier-change events
def on_id_event(self, event: m.IdChangeEvent, sequence: int):
command = m.IdChangeCommand(event.command).name
self.say('SF-IDCHANGE-EVENT %s id=%s' % (command, event.id.hex()))
if event.command == m.IdChangeCommand.PREPARE:
self.vbs.prepare()
self.client.event_response(event, sequence, True)
elif event.command == m.IdChangeCommand.COMMIT:
new_id = random.randrange(1, 2 ** 32 - 1)
self.vbs.commit(new_id)
self.say('VBS resumes with StationId %d' % new_id)
self.client.event_response(event, sequence, True)
elif event.command == m.IdChangeCommand.ABORT:
self.vbs.abort()
# ---- session ----
def acquire_online_credentials(self) -> bytes:
"""Obtain an EC and AT from the online PKI and pack the station's VCR1 bundle."""
a = self.args
with tempfile.TemporaryDirectory(prefix='microbu-pki-') as temporary_directory:
temporary = Path(temporary_directory)
ec_certificate = temporary / 'EC.oer'
ec_key = temporary / 'EC.vkey'
at_certificate = temporary / 'AT.oer'
at_key = temporary / 'AT.vkey'
run_tool(a.pki_client_tool, '--issue-tool', a.pki_issue_tool, '--pki', a.pki_url,
'enrol', '--ea', a.pki_ea, '--canonical-key', a.pki_canonical_key,
'--its-id', a.pki_its_id, '--out', ec_certificate, '--out-key', ec_key)
self.say('online PKI: enrolment credential obtained')
run_tool(a.pki_client_tool, '--issue-tool', a.pki_issue_tool, '--pki', a.pki_url,
'authorize', '--ea', a.pki_ea, '--aa', a.pki_aa,
'--ec', ec_certificate, '--ec-key', ec_key, '--hours', a.pki_hours,
'--out', at_certificate, '--out-key', at_key)
self.say('online PKI: authorization ticket obtained')
pool = temporary / 'pool'
pool.mkdir()
(pool / 'ROOT.oer').write_bytes(Path(a.pki_root).read_bytes())
(pool / 'AA.oer').write_bytes(Path(a.pki_aa).read_bytes())
(pool / 'AT.oer').write_bytes(at_certificate.read_bytes())
(pool / 'AT.vkey').write_bytes(at_key.read_bytes())
bundle_path = temporary / 'credentials.vcr'
run_tool(a.pki_bundle_tool, 'build', '--pool', pool, '--root', 'ROOT',
'--aa', 'AA', '--at', 'AT', '--out', bundle_path)
bundle = bundle_path.read_bytes()
self.say('online PKI: VCR1 credential bundle built')
return bundle
def start(self):
a = self.args
if a.ble is not None:
transport = self.client.transport
self.say('BLE connected: %s (%s), GATT value=%d B' %
(transport.device_name, transport.device_address, transport.attribute_value))
else:
self.say('serial connected: %s' % a.port)
self.say('PoTi trajectory: centre=%.7f,%.7f radius=%.1f m speed=%.1f m/s' %
(a.lat, a.lon, a.radius, a.speed))
config = m.StationConfigure(security=1, address_configuration=1,
beaconing=0 if a.no_beacons else 1, channel_number=a.channel,
transmit_power_dbm=a.power, radio={'off': 0, 'rx': 1, 'txrx': 2}[a.radio])
if self.test: # armed before the station exists: even its first beacon reaches the tester
self.test.reset()
self.test.mirror(2 if a.divert else 1)
self.say('test channel: %s' % ('divert (nothing reaches the radio)' if a.divert else 'mirror'))
self.station_info = self.client.configure(config)
self.say('station configured: gn_address=%s identifier=%s credentials=%s tickets=%d' % (
self.station_info.gn_address.hex(), self.station_info.identifier.hex(),
self.station_info.credentials_loaded, self.station_info.tickets))
if a.erase_credentials:
self.client.erase_credentials()
self.say('credentials erased')
if a.bundle or a.pki_url:
bundle = self.acquire_online_credentials() if a.pki_url else Path(a.bundle).read_bytes()
roots, authorities, tickets = self.client.provision(bundle)
self.say('credentials provisioned: %d roots, %d authorities, %d tickets' % (roots, authorities, tickets))
self.station_info = self.client.configure(config) # the station was rebuilt with them
self.say('station rebuilt: gn_address=%s identifier=%s' % (self.station_info.gn_address.hex(), self.station_info.identifier.hex()))
# first PoTi fix before anything is sent: the micrOBU needs time and position
self.client.poti(self.poti.state().poti_update())
try:
self.subscription = self.client.subscribe(b'VBS')
self.say('SF-IDCHANGE-SUBSCRIBE: subscription %d' % self.subscription)
except TimeoutError:
self.subscription = 1
self.say('SF-IDCHANGE-SUBSCRIBE acknowledgement missed; proceeding with fallback subscription')
def poti_loop(self):
period = 1.0 / self.args.poti_rate
while not self.stop.is_set():
with self.lock:
self.client.poti(self.poti.state().poti_update())
self.stop.wait(period)
def drain_loop(self):
while not self.stop.is_set():
self.stop.wait(0.5)
self.drain()
def drain(self):
if not self.test:
return
with self.lock:
overflow, records = self.test.drain()
if overflow:
self.say('test channel queue overflowed: records were lost')
for kind, data in records:
if kind == 1:
source = self.station_info.gn_address[2:8] if self.station_info else b'\0' * 6
self.frames.append((time.time(), source, data))
secured = len(data) > 4 and (data[0] & 0x0f) == 2
self.say('AL_DATA.request %d B GNPDU (%s)' % (len(data), 'secured' if secured else 'unsecured'))
def vbs_loop(self):
deadline = time.time() + self.args.duration if self.args.duration else None
next_trigger = time.time() + self.args.id_change_every if self.args.id_change_every else None
fixed_period = self.args.period / 1000.0 if self.args.period is not None else None
next_vam = time.perf_counter() if fixed_period is not None else None
self.stream_started = time.perf_counter()
while not self.stop.is_set():
now = self.poti.state()
due = time.perf_counter() >= next_vam if next_vam is not None else self.vbs.due(now)
if due and not self.args.no_vam:
assembly_started = time.perf_counter()
vam = self.vbs.assemble(now)
request = self.vbs.btp_data_request(vam)
encoded_request = request.encode()
assembled_at = time.perf_counter()
self.assembly_ms.append((assembled_at - assembly_started) * 1000.0)
self.generation_times.append(assembled_at)
if next_vam is not None:
next_vam += fixed_period
if next_vam < assembled_at:
next_vam = assembled_at + fixed_period
try:
with self.lock:
self.client.btp_data_request(request)
self.say('VAM %d (%d B, StationId %d) -> BTP-DATA.request accepted' % (self.vbs.generated, len(vam), self.vbs.station_id))
except LinkError as error:
self.say('VAM %d refused: %s' % (self.vbs.generated, error.result.name))
except TimeoutError as error:
self.say('VAM %d: %s' % (self.vbs.generated, error))
if next_trigger and time.time() >= next_trigger:
next_trigger = time.time() + self.args.id_change_every
try:
with self.lock:
self.client.trigger()
self.say('SF-IDCHANGE-TRIGGER sent')
except LinkError as error:
self.say('SF-IDCHANGE-TRIGGER refused: %s' % error.result.name)
if deadline and time.time() >= deadline:
break
wait = 0.01
if next_vam is not None:
wait = min(wait, max(0.0, next_vam - time.perf_counter()))
self.stop.wait(wait)
self.stream_ended = time.perf_counter()
def run(self):
self.start()
threads = [threading.Thread(target=self.poti_loop, daemon=True)]
if self.test:
threads.append(threading.Thread(target=self.drain_loop, daemon=True))
for t in threads:
t.start()
try:
self.vbs_loop()
except KeyboardInterrupt:
pass
finally:
self.stop.set()
time.sleep(0.3)
self.drain()
try:
with self.lock:
status = self.client.status()
self.say('final STATUS: signed=%d refused(no ticket %d, pending %d, permission %d) failed=%d req ok/refused=%d/%d '
'ind=%d radio tx/fail/rx/drop=%d/%d/%d/%d' % (
status.signed_messages, status.refused_no_ticket, status.refused_change_pending,
status.refused_permission, status.sign_failed, status.requests_accepted, status.requests_refused,
status.indications, status.radio_submitted, status.radio_failed, status.radio_received, status.radio_dropped))
except Exception as error:
self.say('final STATUS unavailable: %s' % error)
status = None
if self.subscription is not None:
try:
with self.lock:
self.client.unsubscribe(self.subscription)
except Exception:
pass
if self.test:
try:
self.test.mirror(0)
except Exception:
pass
if self.args.pcap and self.frames:
write_pcap(self.args.pcap, self.frames)
self.say('%d frames written to %s' % (len(self.frames), self.args.pcap))
if self.args.report:
def summary(values):
if not values:
return None
ordered = sorted(values)
return {
'samples': len(ordered),
'mean': sum(ordered) / len(ordered),
'p95': ordered[min(len(ordered) - 1, int(len(ordered) * 0.95))],
'max': ordered[-1],
}
intervals_ms = [(right - left) * 1000.0 for left, right in
zip(self.generation_times, self.generation_times[1:])]
stream_seconds = ((self.stream_ended or time.perf_counter()) - self.stream_started
if self.stream_started is not None else 0.0)
Path(self.args.report).write_text(json.dumps({
'arguments': {k: v for k, v in vars(self.args).items()},
'station': None if not self.station_info else {'gn_address': self.station_info.gn_address.hex(),
'identifier': self.station_info.identifier.hex(),
'tickets': self.station_info.tickets},
'vams_generated': self.vbs.generated,
'results_accepted': self.results_accepted,
'results_refused': self.results_refused,
'results_pending': len(self.pending),
'timing': {
'stream_seconds': stream_seconds,
'generation_rate_hz': self.vbs.generated / stream_seconds if stream_seconds else 0.0,
'generation_interval_ms': summary(intervals_ms),
'vam_assembly_ms': summary(self.assembly_ms),
'request_to_result_ms': summary(self.request_latency_ms),
},
'frames_captured': len(self.frames),
'final_status': None if not status else {k: (v.hex() if isinstance(v, bytes) else v) for k, v in vars(status).items()},
'events': self.events,
'micrOBU_log': self.log_lines,
}, indent=1), encoding='utf-8')
self.say('report written to %s' % self.args.report)
self.client.close()
def build_argument_parser():
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
transport = p.add_mutually_exclusive_group(required=True)
transport.add_argument('--port', help='serial port of the micrOBU (native USB Serial/JTAG)')
transport.add_argument('--ble', nargs='?', const='', metavar='ADDRESS_OR_NAME',
help='use BLE; optionally select a device address or advertised name')
p.add_argument('--connect-timeout', type=float, default=20.0, help='BLE discovery/pairing timeout in seconds')
p.add_argument('--bundle', help='VCR1 credential bundle to provision (credential_bundle.py build)')
p.add_argument('--pki-url', help='EA/AA access point (tools/local_pki.py), e.g. http://127.0.0.1:8090/')
p.add_argument('--pki-root', metavar='ROOT.oer', help='root CA certificate for the VCR1 trust anchor')
p.add_argument('--pki-ea', metavar='EA.oer', help='EA certificate used for enrolment')
p.add_argument('--pki-aa', metavar='AA.oer', help='AA certificate used for authorization and the VCR1 chain')
p.add_argument('--pki-canonical-key', metavar='KEY', help='canonical private key for initial enrolment')
p.add_argument('--pki-its-id', metavar='ID', help='canonical station identifier for enrolment')
p.add_argument('--pki-issue-tool', metavar='PATH', help='vidf_issue binary built with VIDF_PKI=ON')
p.add_argument('--pki-client-tool', metavar='PATH', help='tools/pki_client.py')
p.add_argument('--pki-bundle-tool', metavar='PATH', help='tools/credential_bundle.py')
p.add_argument('--pki-hours', type=int, default=24, metavar='N', help='AT validity in hours (default: 24)')
p.add_argument('--erase-credentials', action='store_true')
p.add_argument('--unsecured', action='store_true', help='itsGnSecurity off (no signing, AUTO address)')
p.add_argument('--radio', choices=['off', 'rx', 'txrx'], default='off', help='micrOBU radio mode (txrx = laboratory transmission)')
p.add_argument('--channel', type=int, default=180)
p.add_argument('--power', type=int, default=10, help='transmit power dBm')
p.add_argument('--no-beacons', action='store_true')
p.add_argument('--lat', type=float, default=AUSSENALSTER_CENTER_LAT,
help='circle centre latitude (default: middle of the Außenalster)')
p.add_argument('--lon', type=float, default=AUSSENALSTER_CENTER_LON,
help='circle centre longitude (default: middle of the Außenalster)')
p.add_argument('--speed', type=float, default=AUSSENALSTER_SPEED_MPS,
help='m/s on the simulated circle (0 = stationary; default: 2)')
p.add_argument('--radius', type=float, default=AUSSENALSTER_RADIUS_M,
help='simulated circle radius in metres (default: 20)')
p.add_argument('--poti-rate', type=float, default=5.0, help='PoTi updates per second')
p.add_argument('--period', type=int, help='fixed VAM period in ms instead of the clause 6.4 triggers')
p.add_argument('--station-id', type=int)
p.add_argument('--ssp', default='01', help='SSP octets of the VRU ITS-AID (hex)')
p.add_argument('--no-vam', action='store_true')
p.add_argument('--id-change-every', type=float, help='trigger an identifier change every N seconds')
p.add_argument('--duration', type=float, help='seconds to run (default: until Ctrl-C)')
p.add_argument('--mirror', action='store_true', help='test channel: copy transmissions (radio still transmits)')
p.add_argument('--divert', action='store_true', help='test channel: keep transmissions from the radio')
p.add_argument('--pcap', help='write mirrored/diverted GNPDUs as an 802.11 pcap')
p.add_argument('--report', help='write a JSON session report')
p.add_argument('--asn1', help='directory with the ETSI ASN.1 modules (default: the submodule)')
p.add_argument('--timeout', type=float, default=6.0)
p.add_argument('--verbose', action='store_true', help='print every STATUS')
p.add_argument('--quiet-log', action='store_true', help='hide the micrOBU log lines')
return p
def validate_arguments(parser, args):
"""Validate credential-source invariants after argument parsing."""
required_pki = ('pki_root', 'pki_ea', 'pki_aa', 'pki_canonical_key', 'pki_its_id',
'pki_issue_tool', 'pki_client_tool', 'pki_bundle_tool')
if args.bundle and args.pki_url:
parser.error('--bundle and --pki-url are mutually exclusive')
if args.pki_url:
missing = ['--' + name.replace('_', '-') for name in required_pki if not getattr(args, name)]
if missing:
parser.error('--pki-url requires %s' % ', '.join(missing))
if args.pki_hours <= 0:
parser.error('--pki-hours must be greater than zero')
elif any(getattr(args, name) for name in required_pki):
parser.error('--pki-* options require --pki-url')
def main():
parser = build_argument_parser()
args = parser.parse_args()
validate_arguments(parser, args)
if args.ble is not None and (args.mirror or args.divert or args.pcap):
parser.error('--mirror, --divert and --pcap require the USB serial test channel')
if args.unsecured:
parser.error('All radio transmissions must be signed (unsecured mode not permitted)')
try:
Emulator(args).run()
except ConnectionError as error:
parser.exit(2, 'BLE connection error: %s\n' % error)
if __name__ == '__main__':
main()
@@ -1,3 +0,0 @@
asn1tools
bleak>=3,<4
pyserial
@@ -1,38 +0,0 @@
#!/usr/bin/env python3
"""Print micrOBU LOG frames without participating in the station-link session."""
from __future__ import annotations
import argparse
import time
import serial
from microbu_link.serial_transport import FrameDecoder, FrameType
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--port', required=True)
parser.add_argument('--duration', type=float, default=15.0)
parser.add_argument('--reset', action='store_true', help='pulse EN through USB Serial/JTAG before monitoring')
args = parser.parse_args()
port = serial.Serial(args.port, 115200, timeout=0.05, write_timeout=1)
port.dtr = False
port.rts = False
if args.reset:
port.rts = True
time.sleep(0.1)
port.rts = False
decoder = FrameDecoder()
deadline = time.monotonic() + args.duration
try:
while time.monotonic() < deadline:
for frame_type, payload in decoder.feed(port.read(4096)):
if frame_type == FrameType.LOG:
print(payload.decode('utf-8', 'replace'), flush=True)
finally:
port.close()
if __name__ == '__main__':
main()
@@ -1,273 +0,0 @@
#!/usr/bin/env python3
"""SUT process for the vanetza-idf ETSI adapters, with the micrOBU behind the phone interfaces.
The adapters (ports/esp_idf/tests/etsi_*_adapter.cpp) drive a SUT through the hex-line
diagnostic protocol of hil_sut.hpp. This bridge answers that protocol the way the phone
half of the VRU ITS-S would: BTP-DATA.request, PoTi time/position and credential
provisioning go over the station-internal link; the software lower tester (observing
AL_DATA.request, injecting AL_DATA.indication) and the GN-DATA.request of the Security
ATS use the USB test channel of the test firmware. The CAM/DENM carrier stimuli of the
Security ATS are assembled here with asn1tools, as a phone would assemble them.
Commands (first octet, as in hil_sut.hpp): 0 reset; 1 BTP-DATA.request; 2 AL_DATA.indication;
3 GN-DATA.request SHB; 4 position; 5 advance the ITS clock (reports transmissions); 6 carrier
CAM/DENM now; 7 periodic carrier; 8 carrier with the next clock advance; 9 credential bundle.
Reply: [result][count]{[kind][u16 BE length][bytes]}, kind 1 AL_DATA.request, 2 BTP-DATA.indication,
3 GN-DATA.indication.
sut_bridge.py --port COM11 --bundle certificates.vcr # stdin/stdout, like serial_sut.py
sut_bridge.py --port COM11 --bundle certificates.vcr --listen 0.0.0.0:7788 # TCP for a WSL relay
WSL interop drops piped stdin of Windows executables (docs/idf/test-campaigns.md), so the
campaigns use --listen here and sut_tcp_relay.py inside WSL as the SUT executable.
"""
from __future__ import annotations
import argparse
import socket
import struct
import sys
import threading
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from microbu_link import LinkClient, LinkError, TestChannel, messages as m # noqa: E402
from microbu_link.vbs import Codec # noqa: E402
RESULT_REJECTED = 6
def log(text):
print('[sut_bridge] ' + text, file=sys.stderr, flush=True)
class Bridge:
def __init__(self, args):
self.args = args
self.codec = Codec(Path(args.asn1) if args.asn1 else None)
self.client = LinkClient(args.port, timeout=args.timeout)
self.client.on_log = lambda line: log('micrOBU: ' + line) if args.verbose else None
self.test = TestChannel(self.client, timeout=args.timeout)
self.bundle = Path(args.bundle).read_bytes() if args.bundle else None
self.secured = self.bundle is not None
self.configured = False
self.provisioned = False
self.latitude, self.longitude = 52.0, 13.0
self.time_ms = None
self.carrier_interval = [0, 0]
self.carrier_next = [0, 0]
self.carrier_pending = [None, None]
self.denm_sequence = 0
self.lock = threading.Lock()
# ---- station handling ----
def reset(self):
self.test.reset()
config = m.StationConfigure(
station_type=5 if self.args.station_type is None else self.args.station_type,
security=1 if self.secured else 0,
address_configuration=1 if (self.secured and self.args.anonymous) else 0,
mid=b'\x02\x00\x00\x00\x00\x01',
beaconing=1 if self.secured else 0, # the Security ATS observes GN-MGMT beacons; BTP/GN PICS are unsecured
radio=0)
info = self.client.configure(config)
if self.secured and self.bundle and not self.provisioned:
# the campaign's pool, whatever an earlier session left in the micrOBU's NVS
roots, authorities, tickets = self.client.provision(self.bundle)
log('bundle provisioned: %d roots, %d authorities, %d tickets' % (roots, authorities, tickets))
self.provisioned = True
info = self.client.configure(config)
log('station: gn_address=%s identifier=%s credentials=%s tickets=%d' % (
info.gn_address.hex(), info.identifier.hex(), info.credentials_loaded, info.tickets))
self.test.mirror(2) # divert: every AL_DATA.request comes to the lower tester, nothing to a radio
self.configured = True
self.carrier_interval = [0, 0]
self.carrier_pending = [None, None]
if self.time_ms is None:
self.time_ms = int((time.time() - 1072915200 + 5) * 1000)
self.poti()
return 0
def poti(self):
update = m.PotiUpdate(self.time_ms, int(round(self.latitude * 1e7)), int(round(self.longitude * 1e7)),
semi_major_cm=150, semi_minor_cm=100, speed_cm_s=0, heading_deci_degree=0, pai=True)
self.client.poti(update)
def carrier(self, kind, sequence=0):
"""Syntactically valid Release 2 CAM (kind 0) or DENM (kind 1), the hil_sut.cpp carriers."""
if kind == 0:
if not self.codec.cam_type:
return 2
cam = {'header': {'protocolVersion': 2, 'messageId': 2, 'stationId': 42},
'cam': {'generationDeltaTime': self.time_ms % 65536,
'camParameters': {
'basicContainer': {'stationType': 5, 'referencePosition': {
'latitude': 520000000, 'longitude': 130000000,
'positionConfidenceEllipse': {'semiMajorAxisLength': 4095, 'semiMinorAxisLength': 4095, 'semiMajorAxisOrientation': 3601},
'altitude': {'altitudeValue': 800001, 'altitudeConfidence': 'unavailable'}}},
'highFrequencyContainer': ('rsuContainerHighFrequency', {})}}}
sdu = self.codec.cam_type.encode(cam)
request = m.BtpDataRequest(sdu, btp_type=1, destination_port=2001, destination_port_info=0,
gn_packet_transport_type=m.TransportType.SHB, its_aid=36, permissions=b'')
else:
if not self.codec.denm_type:
return 2
denm = {'header': {'protocolVersion': 2, 'messageId': 1, 'stationId': 42},
'denm': {'management': {
'actionId': {'originatingStationId': 42, 'sequenceNumber': sequence},
'detectionTime': self.time_ms, 'referenceTime': self.time_ms,
'eventPosition': {'latitude': 520000000, 'longitude': 130000000,
'positionConfidenceEllipse': {'semiMajorConfidence': 4095, 'semiMinorConfidence': 4095, 'semiMajorOrientation': 3601},
'altitude': {'altitudeValue': 800001, 'altitudeConfidence': 'unavailable'}},
'validityDuration': 600, 'stationType': 5}}}
sdu = self.codec.denm_type.encode(denm)
request = m.BtpDataRequest(sdu, btp_type=1, destination_port=2002, destination_port_info=0,
gn_packet_transport_type=m.TransportType.GBC, its_aid=37, permissions=b'',
area=m.DestinationArea(0, 520000000, 130000000, 500, 0, 0))
return self.btp(request)
def btp(self, request):
try:
self.client.btp_data_request(request)
return 0
except LinkError as error:
return int(error.result.code)
# ---- the diagnostic protocol ----
def execute(self, command: bytes) -> bytes:
with self.lock:
return self._execute(command)
def _execute(self, c: bytes) -> bytes:
result = 1
try:
if len(c) == 1 and c[0] == 0:
result = self.reset()
elif len(c) > 1 and c[0] == 9:
self.bundle = bytes(c[1:])
self.secured = True
self.provisioned = False # applied by the next reset, as hil_sut.cpp does
result = 0
elif not self.configured:
result = RESULT_REJECTED
elif len(c) >= 6 and c[0] == 1 and c[1] <= 1:
port, info = struct.unpack('>HH', c[2:6])
request = m.BtpDataRequest(bytes(c[6:]), btp_type=c[1], destination_port=port, destination_port_info=info,
its_aid=self.args.its_aid, permissions=b'')
result = self.btp(request)
elif len(c) > 13 and c[0] == 2:
result = self.test.inject(bytes(c[1:7]), bytes(c[7:13]), bytes(c[13:]))
elif len(c) >= 2 and c[0] == 3:
result = self.test.gn_request(c[1], bytes(c[2:]))
elif len(c) == 9 and c[0] == 4:
lat, lon = struct.unpack('>ii', c[1:9])
self.latitude, self.longitude = lat / 1e7, lon / 1e7
self.poti()
result = 0
elif len(c) == 9 and c[0] == 5:
microseconds = struct.unpack('>Q', c[1:9])[0]
self.time_ms = microseconds // 1000
self.poti()
result = 0
for kind in (0, 1):
if self.carrier_pending[kind] is not None:
sequence = self.carrier_pending[kind]
self.carrier_pending[kind] = None
result = self.carrier(kind, sequence)
elif self.carrier_interval[kind] and self.time_ms >= self.carrier_next[kind]:
self.carrier_next[kind] = self.time_ms + self.carrier_interval[kind]
if kind == 1:
self.denm_sequence += 1
result = self.carrier(kind, self.denm_sequence if kind == 1 else 0)
elif len(c) in (2, 4) and c[0] in (6, 8) and c[1] < 2:
sequence = struct.unpack('>H', c[2:4])[0] if len(c) == 4 else 0
if c[0] == 6:
result = self.carrier(c[1], sequence)
else:
self.carrier_pending[c[1]] = sequence
result = 0
elif len(c) == 4 and c[0] == 7 and c[1] < 2:
self.carrier_interval[c[1]] = struct.unpack('>H', c[2:4])[0]
self.carrier_next[c[1]] = self.time_ms
result = 0
elif c and c[0] > 9:
result = 2
except LinkError as error:
result = int(error.result.code)
except TimeoutError as error:
log('timeout: %s' % error)
result = RESULT_REJECTED
records = []
if self.configured:
try:
overflow, records = self.test.drain()
if overflow:
result = 5
except Exception as error:
log('drain failed: %s' % error)
out = bytes([result & 0xFF, len(records)])
for kind, data in records:
out += struct.pack('>BH', kind, len(data)) + data
return out
def serve_stdio(self):
for line in sys.stdin:
line = line.strip()
if not line:
continue
print(self.execute(bytes.fromhex(line)).hex(), flush=True)
def serve_tcp(self, host, port):
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind((host, port))
server.listen(1)
log('listening on %s:%d' % (host, port))
while True:
connection, peer = server.accept()
log('relay connected from %s:%d' % peer)
with connection, connection.makefile('rb') as reader, connection.makefile('wb') as writer:
for line in reader:
line = line.strip()
if not line:
continue
writer.write(self.execute(bytes.fromhex(line.decode())).hex().encode() + b'\n')
writer.flush()
log('relay disconnected')
def close(self):
try:
self.test.mirror(0)
except Exception:
pass
self.client.close()
def main():
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument('--port', required=True)
p.add_argument('--bundle', help='VCR1 credential bundle: secured profile with these credentials')
p.add_argument('--anonymous', action='store_true', help='itsGnLocalAddrConfMethod ANONYMOUS')
p.add_argument('--station-type', type=int)
p.add_argument('--its-aid', type=int, default=36, help='ITS-AID for command 1 (BTP-DATA.request) in a secured profile')
p.add_argument('--listen', help='host:port to serve the hex-line protocol over TCP instead of stdin/stdout')
p.add_argument('--asn1')
p.add_argument('--timeout', type=float, default=5.0)
p.add_argument('--verbose', action='store_true')
args = p.parse_args()
bridge = Bridge(args)
try:
if args.listen:
host, port = args.listen.rsplit(':', 1)
bridge.serve_tcp(host, int(port))
else:
bridge.serve_stdio()
except KeyboardInterrupt:
pass
finally:
bridge.close()
if __name__ == '__main__':
main()
@@ -1,32 +0,0 @@
#!/usr/bin/env python3
"""stdin/stdout <-> TCP relay: the SUT executable an ETSI adapter forks inside WSL, forwarding
the hex-line protocol to sut_bridge.py --listen on the Windows side (which owns the COM port).
run_etsi.py ... --sut /usr/bin/python3 --sut-args "/root/sut_tcp_relay.py --connect 172.x.x.x:7788"
"""
import argparse
import socket
import sys
def main():
p = argparse.ArgumentParser(description=__doc__)
p.add_argument('--connect', required=True, help='host:port of sut_bridge.py --listen')
args = p.parse_args()
host, port = args.connect.rsplit(':', 1)
with socket.create_connection((host, int(port))) as connection:
reader = connection.makefile('rb')
for line in sys.stdin:
line = line.strip()
if not line:
continue
connection.sendall(line.encode() + b'\n')
reply = reader.readline()
if not reply:
sys.exit(2)
sys.stdout.write(reply.decode().strip() + '\n')
sys.stdout.flush()
if __name__ == '__main__':
main()
@@ -1,49 +0,0 @@
# Firmware build artifacts (idf.py)
firmware/build/
firmware/managed_components/
firmware/dependencies.lock
firmware/sdkconfig.old
firmware/sdkconfig.backup*
# iOS app source is developed privately and not published in this repo
ios/
# iOS build artifacts (xcodebuild / SwiftPM)
ios/**/build/
ios/**/build-sim/
ios/**/.build/
ios/**/DerivedData/
ios/**/xcuserdata/
*.xcuserstate
# Python venv / pycache / PyInstaller
bridge/__pycache__/
bridge/build/
bridge/dist/
bridge/*.egg-info/
bridge/config/
# Android / Gradle
android/.gradle/
android/build/
android/app/build/
android/local.properties
android/.idea/
android/captures/
android/.kotlin/
*.iml
# OS / IDE
.DS_Store
Thumbs.db
.vscode/
# Captured frame logs
*.itsg5
# On-device USB diagnostics dumps
blelog.txt
*.key
*.pem
website/
@@ -1,24 +0,0 @@
MIT License
Copyright (c) 2026 Peter Holzhauser and contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
This repository contains a fork of opentrafficmap/its-g5-receiver-firmware
(also under MIT-style terms) — see firmware/main/ for upstream files.
@@ -1,35 +0,0 @@
# V2X2MAP bridge (vendored, bridge-only)
This is a pruned copy of the `bridge/` component of
[V2X2MAP](https://github.com/711it/v2x2map) 0.3.0, a receiver/live-map tool
for ITS-G5 V2X traffic. V2X2MAP is itself a fork of
[opentrafficmap/its-g5-receiver-firmware](https://codeberg.org/opentrafficmap/its-g5-receiver-firmware)
extended with BLE streaming, an Android app and a Windows installer — see
[the upstream project](https://github.com/711it/v2x2map) for the full
firmware/Android/iOS sources and license (MIT, `LICENSE` in this directory).
## Why it is here
`bridge/` is the host-side Python tool (serial/BLE ingestion, dashboard,
recording, VAM/CAM decoding) used for prototyping and to sanity-check VAM
decoding independently of this project's own `external/vanetza-idf`
stack. It has been locally modified to decode VAMs (`bridge/vam.py`,
`bridge/test_vam.py`, `bridge/asn1/`) and, since 2026-09-14, to look inside
signed GeoNetworking packets (`bridge/secured.py`: a bounded COER walk of the
TS 103 097 `Ieee1609Dot2Data` that returns the unsecured payload, PSID,
generation time and signer; the signature is **not** verified, the dashboard
says "signed … not verified").
The Android app and the receiver firmware from the original project are not
carried into this repository; this project uses its own ESP32-C5 firmware
(`firmware/`, `external/vanetza-idf/`) instead.
`bridge/firmware/*.bin` is a small set of prebuilt images kept only so the
bridge's recording/decode workflow can be exercised against a receiver board
without a separate firmware build.
## Use
See `bridge/its_g5_bridge.py` and `bridge/dashboard_server.py` for the
serial/BLE bridge and local dashboard; `bridge/vam.py` for the VAM decode
path. `bridge/requirements.txt` lists its Python dependencies (separate from
this project's own `station-link/python/requirements.txt`).
@@ -1,50 +0,0 @@
These ASN.1 files are taken from their respective standardisation documents and are **not** licensed the same way as Vanetza itself.
Please refer to the referenced standardisation documents for details about the applicable Intellectual Property Rights (IPR).
To the best of my knowledge, ETSI does not object to bundling ASN.1 files of their published documents along with software.
Some editorial changes may have been applied, e.g. removal of trailing whitespace etc.
Code generated from the files located here can be found in the *vanetza/asn1/its* directory.
# Standardisation documents
- *EN302637-2v141-CAM.asn* from ETSI EN 302 637-2 v1.4.1 (2019-04)
- *EN302637-3v131-DENM.asn* from ETSI EN 302 637-3 v1.3.1 (2019-04)
- *TS102894-2v131-CDD.asn* from ETSI TS 102 894-2 v1.3.1 (2018-08)
- *TS102941v131-\*.asn* from ETSI TS 102 941 v1.3.1 (2019-02)
- *TS103097v131.asn* from ETSI TS 103 097 v1.3.1 (2017-10)
- *TS103097v211-\*.asn* from ETSI TS 103 097 v2.1.1 (2021-10)
- *TR103562v211.asn* from ETSI TR 103 562 v2.1.1 (2019-12)
- *IEEE1609dot2.asn* and *IEEE1609dot2BaseTypes.asn* from IEEE 1609.2 as printed in ETSI TS 103 097 v1.3.1
# ETSI WebSVN
You may find these ITS ASN.1 modules also in ETSI's public SVN repository: http://oldforge.etsi.org/websvn/listing.php?repname=ITS.ITS_ASN1
While above source is still online (in March 2020), ETSI seems to have moved to https://forge.etsi.org/rep/ITS/ITS_ASN1 by now.
This newer source also contains a license file explicitly. A copy is included below for reference.
> Copyright 2019 ETSI
>
> Redistribution and use in source and binary forms, with or without
> modification, are permitted provided that the following conditions are met:
> 1. Redistributions of source code must retain the above copyright notice,
> this list of conditions and the following disclaimer.
> 2. Redistributions in binary form must reproduce the above copyright notice,
> this list of conditions and the following disclaimer in the documentation
> and/or other materials provided with the distribution.
> 3. Neither the name of the copyright holder nor the names of its contributors
> may be used to endorse or promote products derived from this software without
> specific prior written permission.
>
> THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
> ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
> WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
> IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
> INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
> BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
> DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
> LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE
> OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED
> OF THE POSSIBILITY OF SUCH DAMAGE.
IEEE1609dot2.asn and IEEE1609dot2BaseTypes.asn (IEEE Std 1609.2 as printed in ETSI TS 103 097 v1.3.1) are copied from vanetza-idf/asn1 for verify.py; same terms as above.
@@ -1,306 +0,0 @@
IEEE1609dot2 {
iso(1) identified-organization(3) ieee(111) standards-association-numbered-series-standards(2) wave-stds(1609) dot2(2) base (1) schema (1) major-version-2(2)
}
--******************************************************************************
--
-- IEEE P1609.2 Data Types
--
--******************************************************************************
DEFINITIONS AUTOMATIC TAGS ::= BEGIN
EXPORTS ALL;
IMPORTS
CrlSeries,
EccP256CurvePoint,
EciesP256EncryptedKey,
EncryptionKey,
GeographicRegion,
GroupLinkageValue,
HashAlgorithm,
HashedId3,
HashedId8,
Hostname,
IValue,
LinkageValue,
Opaque,
Psid,
PsidSsp,
PsidSspRange,
PublicEncryptionKey,
PublicVerificationKey,
SequenceOfHashedId3,
SequenceOfPsidSsp,
SequenceOfPsidSspRange,
ServiceSpecificPermissions,
Signature,
SubjectAssurance,
SymmetricEncryptionKey,
ThreeDLocation,
Time64,
Uint3,
Uint8,
Uint16,
Uint32,
ValidityPeriod
FROM IEEE1609dot2BaseTypes {
iso(1) identified-organization(3) ieee(111) standards-association-numbered-series-standards(2) wave-stds(1609) dot2(2) base(1) base-types(2) major-version-2 (2)
};
--
--*********************************************************************
--
-- Structures for describing secured data
--
--*********************************************************************
-- Necessary to get certain tools to generate sample PDUs
-- TestIeee1609Dot2Data ::= Ieee1609Dot2Data
-- TestCertificate ::= Certificate
-- this structure belongs later in the file but putting it here avoids
-- compiler errors with certain tools
SignedDataPayload ::= SEQUENCE {
data Ieee1609Dot2Data OPTIONAL,
extDataHash HashedData OPTIONAL,
...
}
(WITH COMPONENTS {..., data PRESENT} |
WITH COMPONENTS {..., extDataHash PRESENT})
Ieee1609Dot2Data ::= SEQUENCE {
protocolVersion Uint8(3),
content Ieee1609Dot2Content
}
Ieee1609Dot2Content ::= CHOICE {
unsecuredData Opaque,
signedData SignedData,
encryptedData EncryptedData,
signedCertificateRequest Opaque,
...
}
SignedData ::= SEQUENCE {
hashId HashAlgorithm,
tbsData ToBeSignedData,
signer SignerIdentifier,
signature Signature
}
SignerIdentifier ::= CHOICE {
digest HashedId8,
certificate SequenceOfCertificate,
self NULL,
...
}
ToBeSignedData ::= SEQUENCE {
payload SignedDataPayload,
headerInfo HeaderInfo
}
HashedData::= CHOICE {
sha256HashedData OCTET STRING (SIZE(32)),
...
}
HeaderInfo ::= SEQUENCE {
psid Psid,
generationTime Time64 OPTIONAL,
expiryTime Time64 OPTIONAL,
generationLocation ThreeDLocation OPTIONAL,
p2pcdLearningRequest HashedId3 OPTIONAL,
missingCrlIdentifier MissingCrlIdentifier OPTIONAL,
encryptionKey EncryptionKey OPTIONAL,
...,
inlineP2pcdRequest SequenceOfHashedId3 OPTIONAL,
requestedCertificate Certificate OPTIONAL
}
MissingCrlIdentifier ::= SEQUENCE {
cracaId HashedId3,
crlSeries CrlSeries,
...
}
Countersignature ::= Ieee1609Dot2Data (WITH COMPONENTS {...,
content (WITH COMPONENTS {...,
signedData (WITH COMPONENTS {...,
tbsData (WITH COMPONENTS {...,
payload (WITH COMPONENTS {...,
data ABSENT,
extDataHash PRESENT
}),
headerInfo(WITH COMPONENTS {...,
generationTime PRESENT,
expiryTime ABSENT,
generationLocation ABSENT,
p2pcdLearningRequest ABSENT,
missingCrlIdentifier ABSENT,
encryptionKey ABSENT
})
})
})
})
})
--**********************************************************************
--
-- Structures for describing encrypted data
--
--**********************************************************************
EncryptedData ::= SEQUENCE {
recipients SequenceOfRecipientInfo,
ciphertext SymmetricCiphertext
}
RecipientInfo ::= CHOICE {
pskRecipInfo PreSharedKeyRecipientInfo,
symmRecipInfo SymmRecipientInfo,
certRecipInfo PKRecipientInfo,
signedDataRecipInfo PKRecipientInfo,
rekRecipInfo PKRecipientInfo
}
SequenceOfRecipientInfo ::= SEQUENCE OF RecipientInfo
PreSharedKeyRecipientInfo ::= HashedId8
SymmRecipientInfo ::= SEQUENCE {
recipientId HashedId8,
encKey SymmetricCiphertext
}
PKRecipientInfo ::= SEQUENCE {
recipientId HashedId8,
encKey EncryptedDataEncryptionKey
}
EncryptedDataEncryptionKey ::= CHOICE {
eciesNistP256 EciesP256EncryptedKey,
eciesBrainpoolP256r1 EciesP256EncryptedKey,
...
}
SymmetricCiphertext ::= CHOICE {
aes128ccm AesCcmCiphertext,
...
}
AesCcmCiphertext ::= SEQUENCE {
nonce OCTET STRING (SIZE (12)),
ccmCiphertext Opaque -- 16 bytes longer than plaintext
}
--**********************************************************************
--
-- Certificates and other security management data structures
--
--**********************************************************************
-- Certificates are implicit (type = implicit, toBeSigned includes
-- reconstruction value, signature absent) or explicit (type = explicit,
-- toBeSigned includes verification key, signature present).
Certificate ::= CertificateBase (ImplicitCertificate | ExplicitCertificate)
SequenceOfCertificate ::= SEQUENCE OF Certificate
CertificateBase ::= SEQUENCE {
version Uint8(3),
type CertificateType,
issuer IssuerIdentifier,
toBeSigned ToBeSignedCertificate,
signature Signature OPTIONAL
}
CertificateType ::= ENUMERATED {
explicit,
implicit,
...
}
ImplicitCertificate ::= CertificateBase (WITH COMPONENTS {...,
type(implicit),
toBeSigned (WITH COMPONENTS {...,
verifyKeyIndicator(WITH COMPONENTS {reconstructionValue})
}),
signature ABSENT
})
ExplicitCertificate ::= CertificateBase (WITH COMPONENTS {...,
type(explicit),
toBeSigned(WITH COMPONENTS {...,
verifyKeyIndicator(WITH COMPONENTS {verificationKey})
}),
signature PRESENT
})
IssuerIdentifier ::= CHOICE {
sha256AndDigest HashedId8,
self HashAlgorithm,
...,
sha384AndDigest HashedId8
}
ToBeSignedCertificate ::= SEQUENCE {
id CertificateId,
cracaId HashedId3,
crlSeries CrlSeries,
validityPeriod ValidityPeriod,
region GeographicRegion OPTIONAL,
assuranceLevel SubjectAssurance OPTIONAL,
appPermissions SequenceOfPsidSsp OPTIONAL,
certIssuePermissions SequenceOfPsidGroupPermissions OPTIONAL,
certRequestPermissions SequenceOfPsidGroupPermissions OPTIONAL,
canRequestRollover NULL OPTIONAL,
encryptionKey PublicEncryptionKey OPTIONAL,
verifyKeyIndicator VerificationKeyIndicator,
...
}
(WITH COMPONENTS { ..., appPermissions PRESENT} |
WITH COMPONENTS { ..., certIssuePermissions PRESENT} |
WITH COMPONENTS { ..., certRequestPermissions PRESENT})
CertificateId ::= CHOICE {
linkageData LinkageData,
name Hostname,
binaryId OCTET STRING(SIZE(1..64)),
none NULL,
...
}
LinkageData ::= SEQUENCE {
iCert IValue,
linkage-value LinkageValue,
group-linkage-value GroupLinkageValue OPTIONAL
}
EndEntityType ::= BIT STRING { app (0), enrol (1) } (SIZE (8)) (ALL EXCEPT {})
PsidGroupPermissions ::= SEQUENCE {
subjectPermissions SubjectPermissions,
minChainLength INTEGER DEFAULT 1,
chainLengthRange INTEGER DEFAULT 0,
eeType EndEntityType DEFAULT {app} -- vanetza-idf: IEEE Std 1609.2-2022 6.4.28 (was '00'H)
}
SequenceOfPsidGroupPermissions ::= SEQUENCE OF PsidGroupPermissions
SubjectPermissions ::= CHOICE {
explicit SequenceOfPsidSspRange,
all NULL,
...
}
VerificationKeyIndicator ::= CHOICE {
verificationKey PublicVerificationKey,
reconstructionValue EccP256CurvePoint,
...
}
END
@@ -1,330 +0,0 @@
IEEE1609dot2BaseTypes {
iso(1) identified-organization(3) ieee(111) standards-association-numbered-series-standards(2) wave-stds(1609) dot2(2) base(1) base-types(2) major-version-2(2)
}
--
--********************************************************************
-- IEEE P1609.2 Base Data Types
--
--********************************************************************
DEFINITIONS AUTOMATIC TAGS ::= BEGIN
EXPORTS ALL;
-- ------------------------------------------------------------------
--
-- Integers
--
-- ------------------------------------------------------------------
Uint3 ::= INTEGER (0..7) -- (hex) 07
Uint8 ::= INTEGER (0..255) -- (hex) ff
Uint16 ::= INTEGER (0..65535) -- (hex) ff ff
Uint32 ::= INTEGER (0..4294967295) -- (hex) ff ff ff ff
Uint64 ::= INTEGER (0..18446744073709551615) -- (hex) ff ff ff ff ff ff ff ff
SequenceOfUint8 ::= SEQUENCE OF Uint8
SequenceOfUint16 ::= SEQUENCE OF Uint16
-- ------------------------------------------------------------------
--
-- OCTET STRING types
--
-- ------------------------------------------------------------------
Opaque ::= OCTET STRING
HashedId10 ::= OCTET STRING (SIZE(10))
HashedId8 ::= OCTET STRING (SIZE(8))
HashedId3 ::= OCTET STRING (SIZE(3))
SequenceOfHashedId3 ::= SEQUENCE OF HashedId3
-- ------------------------------------------------------------------
--
-- Time
--
-- ------------------------------------------------------------------
Time32 ::= Uint32
Time64 ::= Uint64
ValidityPeriod ::= SEQUENCE {
start Time32,
duration Duration
}
Duration ::= CHOICE {
microseconds Uint16,
milliseconds Uint16,
seconds Uint16,
minutes Uint16,
hours Uint16,
sixtyHours Uint16,
years Uint16
}
-- ------------------------------------------------------------------
--
-- Location
--
-- ------------------------------------------------------------------
GeographicRegion ::= CHOICE {
circularRegion CircularRegion,
rectangularRegion SequenceOfRectangularRegion,
polygonalRegion PolygonalRegion,
identifiedRegion SequenceOfIdentifiedRegion,
...
}
CircularRegion ::= SEQUENCE {
center TwoDLocation,
radius Uint16
}
RectangularRegion ::= SEQUENCE {
northWest TwoDLocation,
southEast TwoDLocation
}
SequenceOfRectangularRegion ::= SEQUENCE OF RectangularRegion
PolygonalRegion ::= SEQUENCE SIZE(3..MAX) OF TwoDLocation
TwoDLocation ::= SEQUENCE {
latitude Latitude,
longitude Longitude
}
IdentifiedRegion ::= CHOICE {
countryOnly CountryOnly,
countryAndRegions CountryAndRegions,
countryAndSubregions CountryAndSubregions,
...
}
SequenceOfIdentifiedRegion ::= SEQUENCE OF IdentifiedRegion
CountryOnly ::= Uint16
CountryAndRegions ::= SEQUENCE {
countryOnly CountryOnly,
regions SequenceOfUint8
}
CountryAndSubregions ::= SEQUENCE {
country CountryOnly,
regionAndSubregions SequenceOfRegionAndSubregions
}
RegionAndSubregions ::= SEQUENCE {
region Uint8,
subregions SequenceOfUint16
}
SequenceOfRegionAndSubregions ::= SEQUENCE OF RegionAndSubregions
ThreeDLocation ::= SEQUENCE {
latitude Latitude,
longitude Longitude,
elevation Elevation
}
Latitude ::= NinetyDegreeInt
Longitude ::= OneEightyDegreeInt
Elevation ::= ElevInt
NinetyDegreeInt ::= INTEGER {
min (-900000000),
max (900000000),
unknown (900000001)
} (-900000000..900000001)
KnownLatitude ::= NinetyDegreeInt (min..max) -- Minus 90deg to +90deg in microdegree intervals
UnknownLatitude ::= NinetyDegreeInt (unknown)
OneEightyDegreeInt ::= INTEGER {
min (-1799999999),
max (1800000000),
unknown (1800000001)
} (-1799999999..1800000001)
KnownLongitude ::= OneEightyDegreeInt (min..max)
UnknownLongitude ::= OneEightyDegreeInt (unknown)
ElevInt ::= Uint16 -- Range is from -4096 to 61439 in units of one-tenth of a meter
-- ------------------------------------------------------------------
--
-- Crypto
--
-- ------------------------------------------------------------------
Signature ::= CHOICE {
ecdsaNistP256Signature EcdsaP256Signature,
ecdsaBrainpoolP256r1Signature EcdsaP256Signature,
...,
ecdsaBrainpoolP384r1Signature EcdsaP384Signature
}
EcdsaP256Signature ::= SEQUENCE {
rSig EccP256CurvePoint,
sSig OCTET STRING (SIZE (32))
}
EcdsaP384Signature ::= SEQUENCE {
rSig EccP384CurvePoint,
sSig OCTET STRING (SIZE (48))
}
EccP256CurvePoint ::= CHOICE {
x-only OCTET STRING (SIZE (32)),
fill NULL, -- consistency with 1363 / X9.62
compressed-y-0 OCTET STRING (SIZE (32)),
compressed-y-1 OCTET STRING (SIZE (32)),
uncompressedP256 SEQUENCE {
x OCTET STRING (SIZE (32)),
y OCTET STRING (SIZE (32))
}
}
EccP384CurvePoint ::= CHOICE {
x-only OCTET STRING (SIZE (48)),
fill NULL, -- consistency w 1363 / X9.62
compressed-y-0 OCTET STRING (SIZE (48)),
compressed-y-1 OCTET STRING (SIZE (48)),
uncompressedP384 SEQUENCE {
x OCTET STRING (SIZE (48)),
y OCTET STRING (SIZE (48))
}
}
SymmAlgorithm ::= ENUMERATED {
aes128Ccm,
...
}
HashAlgorithm ::= ENUMERATED {
sha256,
...,
sha384
}
EciesP256EncryptedKey ::= SEQUENCE {
v EccP256CurvePoint,
c OCTET STRING (SIZE (16)),
t OCTET STRING (SIZE (16))
}
EncryptionKey ::= CHOICE {
public PublicEncryptionKey,
symmetric SymmetricEncryptionKey
}
PublicEncryptionKey ::= SEQUENCE {
supportedSymmAlg SymmAlgorithm,
publicKey BasePublicEncryptionKey
}
BasePublicEncryptionKey ::= CHOICE {
eciesNistP256 EccP256CurvePoint,
eciesBrainpoolP256r1 EccP256CurvePoint,
...
}
PublicVerificationKey ::= CHOICE {
ecdsaNistP256 EccP256CurvePoint,
ecdsaBrainpoolP256r1 EccP256CurvePoint,
...,
ecdsaBrainpoolP384r1 EccP384CurvePoint
}
SymmetricEncryptionKey ::= CHOICE {
aes128Ccm OCTET STRING(SIZE(16)),
...
}
-- ------------------------------------------------------------------
--
-- PSID / ITS-AID
--
-- ------------------------------------------------------------------
PsidSsp ::= SEQUENCE {
psid Psid,
ssp ServiceSpecificPermissions OPTIONAL
}
SequenceOfPsidSsp ::= SEQUENCE OF PsidSsp
Psid ::= INTEGER (0..MAX)
SequenceOfPsid ::= SEQUENCE OF Psid
ServiceSpecificPermissions ::= CHOICE {
opaque OCTET STRING (SIZE(0..MAX)),
...,
bitmapSsp BitmapSsp
}
BitmapSsp ::= OCTET STRING (SIZE(0..31))
PsidSspRange ::= SEQUENCE {
psid Psid,
sspRange SspRange OPTIONAL
}
SequenceOfPsidSspRange ::= SEQUENCE OF PsidSspRange
SspRange ::= CHOICE {
opaque SequenceOfOctetString,
all NULL,
... ,
bitmapSspRange BitmapSspRange
}
BitmapSspRange ::= SEQUENCE {
sspValue OCTET STRING (SIZE(1..32)),
sspBitmask OCTET STRING (SIZE(1..32))
}
SequenceOfOctetString ::= SEQUENCE (SIZE (0..MAX)) OF OCTET STRING (SIZE(0..MAX))
-- ------------------------------------------------------------------
--
-- Goes in certs
--
-- ------------------------------------------------------------------
SubjectAssurance ::= OCTET STRING (SIZE(1))
CrlSeries ::= Uint16
-- ------------------------------------------------------------------
--
-- Pseudonym Linkage
--
-- ------------------------------------------------------------------
IValue ::= Uint16
Hostname ::= UTF8String (SIZE(0..255))
LinkageValue ::= OCTET STRING (SIZE(9))
GroupLinkageValue ::= SEQUENCE {
jValue OCTET STRING (SIZE(4)),
value OCTET STRING (SIZE(9))
}
LaId ::= OCTET STRING (SIZE(2))
LinkageSeed ::= OCTET STRING (SIZE(16))
END
File diff suppressed because it is too large Load Diff
@@ -1,229 +0,0 @@
-- Draft V0.0.4_2.2.1 - VAM TS 103 300-1 ASN.1 module
-- Based on the official version available at @url https://forge.etsi.org/rep/ITS/asn1/vam-ts103300_3/-/tree/v2.1.1
-- Modified to import from the CDD module V2.1.1
VAM-PDU-Descriptions {itu-t(0) identified-organization(4) etsi(0) itsDomain(5)
wg1(1) 103300 vam(1) major-version-3(3) minor-version-1(1)}
DEFINITIONS AUTOMATIC TAGS ::=
BEGIN
IMPORTS
AccelerationChangeIndication, BasicContainer, CartesianAngle, ClusterBreakupInfo,
ClusterJoinInfo, ClusterLeaveInfo, Curvature, CurvatureCalculationMode,
DeltaTimeQuarterSecond, GenerationDeltaTime, HeadingChangeIndication, ItsPduHeader, LanePosition,
LateralAcceleration, LongitudinalAcceleration, PathHistory, PathPredicted,
SequenceOfSafeDistanceIndication, SequenceOfTrajectoryInterceptionIndication,
SequenceOfTrajectoryInterceptionIndication, Speed, StabilityChangeIndication, StationId,
TrajectoryInterceptionIndication, VerticalAcceleration, VruClusterInformation, VruDeviceUsage,
VruEnvironment, VruExteriorLights, GeneralizedLanePosition, VruProfileAndSubprofile, VruMovementControl,
VruSizeClass, YawRate, Wgs84Angle
FROM ETSI-ITS-CDD {itu-t (0) identified-organization (4) etsi (0) itsDomain (5) wg1 (1) 102894 cdd (2) major-version-3 (4) minor-version-3 (3) }
WITH SUCCESSORS
;
VAM ::= SEQUENCE {
header ItsPduHeaderVam,
vam VruAwareness
}
/**
* @details ItsPduHeaderVam
* The ITS PDU header for the VAM.
*
* This DF includes DEs for the VAM _protocolVersion_, the VAM message type identifier _messageID_
* and the station identifier _stationID_ of the originating ITS-S.
*
* @category: Communication information
* @revision: V2.2.1
*/
ItsPduHeaderVam ::= ItsPduHeader(WITH COMPONENTS {..., protocolVersion(3), messageId(vam)})
/**
* @details VruAwareness
* VAM payload.
*
* It includes the time stamp of the VAM and the VAM different containers
*
* @category: Communication information
* @revision: V2.2.1
*/
VruAwareness ::= SEQUENCE {
generationDeltaTime GenerationDeltaTime,
vamParameters VamParameters
}
/**
* @details VamParameters
* The VAM payload includes the @ref BasicContainer and @ref VruHighFrequencyContainer.
* The VAM payload may also include additional containers: @ref VruLowFrequencyContainer,
* @ref VruClusterInformationContainer, @ref VruClusterOperationContainer and @ref VruMotionPredictionContainer.
* The selection of the additional containers depends on the dissemination criteria,
* e.g. _vruCluster_ or _MotionDynamicPrediction_ availability.
*
* @category: Communication information
* @revision: V2.2.1
**/
VamParameters ::= SEQUENCE {
basicContainer BasicContainer,
vruHighFrequencyContainer VruHighFrequencyContainer,
vruLowFrequencyContainer VruLowFrequencyContainer OPTIONAL,
vruClusterInformationContainer VruClusterInformationContainer OPTIONAL,
vruClusterOperationContainer VruClusterOperationContainer OPTIONAL,
vruMotionPredictionContainer VruMotionPredictionContainer OPTIONAL,
...
}
/**
* @details VruHighFrequencyContainer
* The VRU HF container of the VAM contains potentially fast-changing status information of the VRU ITS-S.
* It includes the following components (setting indications are specified in clause 7.3.3 of TS 103 300-3):
*
* @field heading: heading and heading confidence of the originating VRU with regards to the true north.
* @field speed: speed in moving direction and speed confidence of the originating VRU.
* @field longitudinalAcceleration: longitudinal acceleration of the originating VRU.
* @field curvature: related to the actual trajectory of the originating VRU vehicle.
_(recommended for VRU Profile 2)_
* @field curvatureCalculationMode: indicates whether vehicle yaw-rate is used in the calculation of
* the curvature of the VRU vehicle ITS-S that originates the VAM. _(recommended for VRU Profile 2)_
* @field yawRate: yaw rate of originating VRU vehicle. _(recommended for VRU Profile 2)_
* @field lateralAcceleration: originating VRU lateral acceleration in the street plane.
* This field shall be present if the data is available at the originating ITS-S. _(recommended for VRU Profile 2)_
* @field verticalAcceleration: vertical acceleration of the originating VRU.
* This field shall be present if the data is available at the originating ITS-S.
* @field vruLanePosition: lane position of the referencePosition of a VRU, which is either a VRU-specific non-traffic lane
* or a standard traffic lane. This field shall be present if the data is available at the originating ITS-S.
* @field environment: provides contextual awareness of the VRU among other road users.
* This field shall be present only if the data is available at the originating ITS-S.
* @field movementControl: indicates the mechanism used by the VRU to control the longitudinal movement of the VRU vehicle.
* This field shall be present only if the data is available at the originating ITS-S. _(recommended for VRU Profile 2)_
* @field orientation : complements the dimensions of the VRU vehicle by defining the angle of the VRU vehicle longitudinal
* axis with regards to the WGS84 north. _(recommended for VRU Profile 2)_
* @field rollAngle: provides the angle and angle accuracy between the ground plane and the current orientation of a vehicle's
* y-axis with respect to the ground plane about the x-axis according to the ISO 8855.
* This field shall be present only if the data is available at the originating ITS-S. _(recommended for VRU Profile 2)_
* @field deviceUsage: provides indications from the personal device about the potential
* activity of the VRU. This field shall be present only if the data is available at the originating ITS-S.
* _(recommended for VRU Profile 1)_
*
* @category: VRU information
* @revision: V2.2.1
*/
VruHighFrequencyContainer ::= SEQUENCE {
heading Wgs84Angle,
speed Speed,
longitudinalAcceleration LongitudinalAcceleration,
curvature Curvature OPTIONAL,
curvatureCalculationMode CurvatureCalculationMode OPTIONAL,
yawRate YawRate OPTIONAL,
lateralAcceleration LateralAcceleration OPTIONAL,
verticalAcceleration VerticalAcceleration OPTIONAL,
vruLanePosition GeneralizedLanePosition OPTIONAL,
environment VruEnvironment OPTIONAL,
movementControl VruMovementControl OPTIONAL,
orientation Wgs84Angle OPTIONAL,
rollAngle CartesianAngle OPTIONAL,
deviceUsage VruDeviceUsage OPTIONAL,
...
}
/**
* @details VruLowFrequencyContainer
* The VRU LF container of the VAM contains potentially slow-changing information of the VRU ITS-S.
* It is mandatory with higher periodicity as specified in clause 6.2 or when VRU cluster operation container is present.
* It includes the following components (setting indications are specified in clause 7.3.4 of TS 103 300-3):
*
* @field profileAndSubprofile: profile of the ITS-S that originates the VAM, including sub-profile information.
* @field sizeClass: information about the size of the VRU.
* @field exteriorLights: status of the most important exterior lights switches of the VRU ITS-S that originates the VAM.
* _(conditional mandatory as specified in clause 7.3.4 of TS 103 300-3)_
*
* @category: VRU information
* @revision: V2.2.1
*/
VruLowFrequencyContainer ::= SEQUENCE {
profileAndSubprofile VruProfileAndSubprofile,
sizeClass VruSizeClass OPTIONAL,
exteriorLights VruExteriorLights OPTIONAL,
...
}
/**
* @details VruClusterInformationContainer
* The VRU Cluster Information container of the VAM provides the information/parameters relevant to the VRU cluster.
* It is mandatory if the VAM is transmitted by VRU cluster leader.
* It includes the following components (setting indications are specified in clause 7.3.5 of TS 103 300-3):
*
* @field vruClusterInformation: set of parammeters releated to the VRU cluster.
* When transmitted by a VRU ITS-S, the clusterId and clusterBoundingBoxShape fields inside this DF shall be present.
* The clusterBoundingBoxShape is positioned with respect to the position sent in the BasicContainer.
*
* @category: VRU information
* @revision: V2.2.1
*/
VruClusterInformationContainer::= SEQUENCE{
vruClusterInformation VruClusterInformation (WITH COMPONENTS{..., clusterId, clusterBoundingBoxShape PRESENT}),
...
}
/**
* @details VruClusterOperationContainer
* The VRU Cluster Operation container of the VAM provides information relevant to change of cluster state and composition.
* It is mandatory if the VAM is transmitted by a VRU joining, leaving or breaking up a cluster.
* It includes the following components (setting indications are specified in clause 7.3.5 of TS 103 300-3).
* At least one of the fields below shall be present if the container is present in the VAM:
*
* @field clusterJoinInfo: indicates the intent of an individual VAM transmitter to join a cluster.
* @field clusterLeaveInfo : indicates that an individual VAM transmitter has recently left the VRU cluster.
* @field clusterBreakupInfo: indicates the intent of a cluster VAM transmitter to stop sending cluster VAMs.
* @field clusterIdChangeTimeInfo: indicates the intent of a cluster VAM transmitter to change cluster ID.
*
* @category: VRU information
* @revision: V2.2.1
*/
VruClusterOperationContainer ::= SEQUENCE {
clusterJoinInfo ClusterJoinInfo OPTIONAL,
clusterLeaveInfo ClusterLeaveInfo OPTIONAL,
clusterBreakupInfo ClusterBreakupInfo OPTIONAL,
clusterIdChangeTimeInfo DeltaTimeQuarterSecond OPTIONAL,
...
}
/**
* @details VruMotionPredictionContainer
* The VRU Motion Prediction container of the VAM carries the past and future motion state information of the VRU.
* It includes the following components (setting indications are specified in clause 7.3.6 of TS 103 300-3).
* At least one of the fields below shall be present if the container is present in the VAM:
*
* @field pathHistory: represents the VRU's recent movement over some past time and/or distance.
* It consists of a list of path points.
* @field pathPrediction: provides the set of predicted locations of the ITS-S, confidence values
* and the corresponding future time instants.
* @field safeDistance: provides indication of safe distance between an ego-VRU and up to 8 other ITS-S
* or entity on the road to indicate whether the ego-VRU is at a safe distance (that is less likely to
* physically collide) from another ITS-S or entity on the road.
* @field trajectoryInterceptionIndication: provides the indication for possible trajectory interception
* with up to 8 VRUs or other objects on the road..
* @field accelerationChangeIndication: provides an acceleration change indication of the VRU.
* When present this DF indicates an anticipated change in the VRU speed for period of actionDeltaTime.
* @field headingChangeIndication: provides additional data elements associated to heading change indicators
* such as a change of travel direction (left or right).
* The direction change action is performed for a period of actionDeltaTime.
* @field stabilityChangeIndication: provides an estimation of the VRU stability.
*
* @category: GeoReference information, VRU information
* @revision: V2.2.1
*/
VruMotionPredictionContainer ::= SEQUENCE {
pathHistory PathHistory OPTIONAL,
pathPrediction PathPredicted OPTIONAL,
safeDistance SequenceOfSafeDistanceIndication OPTIONAL,
trajectoryInterceptionIndication SequenceOfTrajectoryInterceptionIndication OPTIONAL,
accelerationChangeIndication AccelerationChangeIndication OPTIONAL,
headingChangeIndication HeadingChangeIndication OPTIONAL,
stabilityChangeIndication StabilityChangeIndication OPTIONAL,
...
}
END
@@ -1,232 +0,0 @@
"""
Builds v2x2map-bridge_<version>_all.deb for Debian/Ubuntu.
A .deb is an ar(1) archive containing three members:
debian-binary plain text "2.0\\n"
control.tar.gz DEBIAN/ metadata
data.tar.gz the files to install
No external tools required — pure Python (tarfile + gzip).
Run from V2X2MAP/bridge:
python build_deb.py
Output: dist/v2x2map-bridge_0.2.1_all.deb
"""
import gzip
import hashlib
import io
import os
import stat
import tarfile
import time
VERSION = "0.2.1"
PACKAGE = "v2x2map-bridge"
ARCH = "all"
# ── ar archive ───────────────────────────────────────────────────────────────
def _ar_header(name: str, size: int) -> bytes:
ts = str(int(time.time()))
return (
name.encode().ljust(16)[:16]
+ ts.encode().ljust(12)[:12]
+ b"0 " # uid
+ b"0 " # gid
+ b"100644 " # mode
+ str(size).encode().ljust(10)[:10]
+ b"`\n"
)
def ar_pack(members: list[tuple[str, bytes]]) -> bytes:
buf = bytearray(b"!<arch>\n")
for name, data in members:
buf += _ar_header(name, len(data))
buf += data
if len(data) % 2:
buf += b"\n" # ar entries must start on even offsets
return bytes(buf)
# ── tar.gz builder ────────────────────────────────────────────────────────────
def make_targz(entries: list) -> bytes:
"""
entries is a list of:
("./path/to/dir/", None, 0o755) → directory
("./path/to/file", bytes_content, 0o644) → regular file
"""
raw = io.BytesIO()
with gzip.GzipFile(fileobj=raw, mode="wb", mtime=0) as gz:
tf = tarfile.open(fileobj=gz, mode="w:")
for path, content, mode in entries:
info = tarfile.TarInfo(name=path)
info.mtime = 0
info.uid = 0
info.gid = 0
info.uname = "root"
info.gname = "root"
if content is None:
info.type = tarfile.DIRTYPE
info.mode = mode
tf.addfile(info)
else:
info.mode = mode
info.size = len(content)
tf.addfile(info, io.BytesIO(content))
tf.close()
return raw.getvalue()
# ── file content helpers ───────────────────────────────────────────────────────
def _read(rel: str) -> bytes:
with open(rel, "rb") as f:
return f.read()
def _txt(s: str) -> bytes:
return s.encode("utf-8")
# ── package metadata ──────────────────────────────────────────────────────────
WRAPPER = _txt("""\
#!/bin/bash
exec "${V2X2MAP_PYTHON:-python3}" /usr/lib/v2x2map/its_g5_bridge.py "$@"
""")
CONTROL = _txt(f"""\
Package: {PACKAGE}
Version: {VERSION}
Architecture: {ARCH}
Maintainer: pit711
Installed-Size: 250
Depends: python3 (>= 3.10), python3-serial, python3-paho-mqtt
Description: V2X2MAP ITS-G5 USB-to-MQTT bridge and live dashboard
Captures IEEE 802.11p / ITS-G5 frames from an ESP32-C5 via USB serial
and republishes them to one or more MQTT brokers.
.
Includes a live web dashboard (http://127.0.0.1:8080) with:
- Map view with type-specific vehicle/RSU markers
- Grouped frame log with filter chips
- Statistics tab (type breakdown, sparkline, broker status)
- MAC forensics tab (first/last seen, frame count, CSV export)
- Runtime PCAP recording
.
Config : ~/.config/v2x2map/v2x2map.cfg (JSON, created on first run)
Records : ~/v2x2map/recordings/its5_*.pcap
.
Quick start:
v2x2map-bridge --node-id V2X2MAP:d2cf13ed6293
.
Subsequent runs (reads config automatically):
v2x2map-bridge
""")
POSTINST = _txt("""\
#!/bin/bash
set -e
echo ""
echo " V2X2MAP ITS-G5 bridge installed."
echo " For VAM decoding install requirements.txt in a venv and set V2X2MAP_PYTHON to its python."
echo " Usage: v2x2map-bridge --node-id V2X2MAP:<12-hex-mac>"
echo " Dashboard opens at http://127.0.0.1:8080"
echo ""
echo " Serial port access: add your user to the 'dialout' group:"
echo " sudo usermod -aG dialout $USER (re-login required)"
echo ""
""")
PRERM = _txt("""\
#!/bin/bash
set -e
""")
COPYRIGHT = _txt(f"""\
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Upstream-Name: {PACKAGE}
Source: https://github.com/pit711/v2x2map
Files: *
Copyright: 2026 pit711
License: MIT
""")
# ── md5sums ───────────────────────────────────────────────────────────────────
def md5sums(file_entries: list[tuple[str, bytes]]) -> bytes:
lines = []
for path, content, _ in file_entries:
if content is not None:
h = hashlib.md5(content).hexdigest()
lines.append(f"{h} {path.lstrip('./')}")
return _txt("\n".join(lines) + "\n")
# ── build ─────────────────────────────────────────────────────────────────────
def build():
os.makedirs("dist", exist_ok=True)
bridge_py = _read("its_g5_bridge.py")
server_py = _read("dashboard_server.py")
dash_html = _read("dashboard.html")
# Files that will be installed (used for md5sums too)
data_files = [
("./usr/lib/v2x2map/its_g5_bridge.py", bridge_py, 0o644),
("./usr/lib/v2x2map/dashboard_server.py", server_py, 0o644),
("./usr/lib/v2x2map/dashboard.html", dash_html, 0o644),
("./usr/bin/v2x2map-bridge", WRAPPER, 0o755),
("./usr/share/doc/v2x2map-bridge/copyright", COPYRIGHT, 0o644),
]
for name in ("vam.py", "requirements.txt", "asn1/VAM-PDU-Descriptions.asn",
"asn1/TS102894-2v241-CDD.asn", "asn1/COPYRIGHT.md"):
data_files.append(("./usr/lib/v2x2map/" + name, _read(name), 0o644))
data_entries = [
("./", None, 0o755),
("./usr/", None, 0o755),
("./usr/lib/", None, 0o755),
("./usr/lib/v2x2map/", None, 0o755),
("./usr/bin/", None, 0o755),
("./usr/share/", None, 0o755),
("./usr/share/doc/", None, 0o755),
("./usr/share/doc/v2x2map-bridge/", None, 0o755),
] + data_files
ctrl_entries = [
("./", None, 0o755),
("./control", CONTROL, 0o644),
("./md5sums", md5sums(data_files), 0o644),
("./postinst", POSTINST, 0o755),
("./prerm", PRERM, 0o755),
]
control_tgz = make_targz(ctrl_entries)
data_tgz = make_targz(data_entries)
deb = ar_pack([
("debian-binary", b"2.0\n"),
("control.tar.gz", control_tgz),
("data.tar.gz", data_tgz),
])
name = f"{PACKAGE}_{VERSION}_{ARCH}.deb"
out = os.path.join("dist", name)
with open(out, "wb") as f:
f.write(deb)
kb = len(deb) // 1024
print(f"Built: {out} ({kb} KB)")
print(f"Install with:")
print(f" sudo apt install ./{name}")
print(f" -- or --")
print(f" sudo dpkg -i {name}")
if __name__ == "__main__":
build()
@@ -1,55 +0,0 @@
# Builds two Windows EXEs for V2X2MAP:
# dist\its-g5-bridge.exe -- bridge only, starts directly (reads config\v2x2map.cfg)
# dist\its-g5-setup.exe -- setup wizard + firmware flash
#
# Run from V2X2MAP\bridge:
# .\build_exe.ps1
$ErrorActionPreference = 'Continue'
# Refresh bundled firmware bins from the latest build/ if it exists.
$buildDir = Resolve-Path "..\firmware\build" -ErrorAction SilentlyContinue
if ($buildDir) {
Copy-Item "$buildDir\bootloader\bootloader.bin" ".\firmware\bootloader.bin" -Force
Copy-Item "$buildDir\partition_table\partition-table.bin" ".\firmware\partition-table.bin" -Force
Copy-Item "$buildDir\ota_data_initial.bin" ".\firmware\ota_data_initial.bin" -Force
Copy-Item "$buildDir\its-g5-receiver-firmware.bin" ".\firmware\firmware.bin" -Force
Write-Host "Firmware-Bins aktualisiert aus $buildDir"
} else {
Write-Host "Kein ..\firmware\build\ gefunden -- bestehende firmware\*.bin werden verwendet."
}
# Ensure required packages are available.
python -m pip install --quiet -r requirements.txt pyinstaller esptool pillow
# Generate icons
Write-Host ""
Write-Host "=== Generiere Icons ==="
python make_icons.py
if ($LASTEXITCODE -ne 0) { Write-Warning "Icon-Generierung fehlgeschlagen -- baue ohne Icons weiter." }
Write-Host ""
Write-Host "=== Baue its-g5-bridge.exe (Bridge ohne Wizard) ==="
python -m PyInstaller --noconfirm its-g5-bridge.spec
if ($LASTEXITCODE -ne 0) { Write-Error "Bridge-Build fehlgeschlagen."; exit 1 }
Write-Host ""
Write-Host "=== Baue its-g5-setup.exe (Setup-Wizard und Flash) ==="
python -m PyInstaller --noconfirm its-g5-setup.spec
if ($LASTEXITCODE -ne 0) { Write-Error "Setup-Build fehlgeschlagen."; exit 1 }
$bridge = (Get-Item "dist\its-g5-bridge.exe").Length / 1MB
$setup = (Get-Item "dist\its-g5-setup.exe").Length / 1MB
Write-Host ""
Write-Host "Fertig:"
Write-Host (" dist\its-g5-bridge.exe {0:N1} MB -- Doppelklick zum Starten" -f $bridge)
Write-Host (" dist\its-g5-setup.exe {0:N1} MB -- Einrichten und Flashen" -f $setup)
Write-Host ""
Write-Host "Ordnerstruktur nach dem ersten Start:"
Write-Host " dist\"
Write-Host " its-g5-bridge.exe"
Write-Host " its-g5-setup.exe"
Write-Host " config\"
Write-Host " v2x2map.cfg (von Setup geschrieben)"
Write-Host " recordings\"
Write-Host " its5_*.pcap (PCAP-Aufnahmen)"
@@ -1,790 +0,0 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>V2X2MAP — ITS-G5 Dashboard</title>
<meta name="description" content="V2X2MAP ITS-G5 live dashboard">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="">
<style>
* { box-sizing: border-box; }
html, body { margin:0; font-family:ui-monospace,Consolas,monospace; font-size:14px;
background:#0e1116; color:#dde; height:100%; }
/* ── Header ──────────────────────────────────────────────────────────────── */
header {
padding:7px 14px; background:#161b22; border-bottom:1px solid #30363d;
display:flex; gap:14px; align-items:center; flex-wrap:wrap; flex-shrink:0;
}
header h1 { font-size:15px; margin:0; color:#fff; font-weight:700; letter-spacing:.5px; }
.stat { font-size:12px; color:#8b949e; white-space:nowrap; }
.stat b { color:#58a6ff; }
#serial-dot { display:inline-block; width:9px; height:9px; border-radius:50%;
background:#444; margin-right:4px; vertical-align:middle; }
#serial-dot.up { background:#3fb950; }
#serial-dot.down { background:#da3633; }
#conn { font-size:12px; padding:3px 9px; border-radius:10px; }
#conn.up { background:#1f6feb; color:#fff; }
#conn.down { background:#da3633; color:#fff; }
.btn {
font-family:inherit; font-size:12px; cursor:pointer;
padding:3px 11px; border-radius:12px; border:1px solid #30363d;
background:#21262d; color:#8b949e;
}
.btn:hover:not([disabled]) { background:#30363d; color:#dde; }
.btn.on { background:#238636; color:#fff; border-color:#238636; }
.btn.on:hover { background:#2ea043; }
.btn.rec { background:#6e2020; color:#f47979; border-color:#6e2020; }
.btn.rec:hover { background:#8a2828; }
.btn[disabled] { opacity:.5; cursor:not-allowed; }
/* ── Layout ─────────────────────────────────────────────────────────────── */
main { display:grid; grid-template-columns:1fr 1fr; gap:1px;
background:#30363d; height:calc(100vh - 44px); min-height:0; }
#map { min-height:0; }
/* locate button */
.locate-btn { width:30px; height:30px; font-size:19px; line-height:28px; text-align:center;
background:#fff; border:none; cursor:pointer; display:block;
color:#333; border-radius:2px; }
.locate-btn:hover { background:#f4f4f4; }
/* ── Right panel ─────────────────────────────────────────────────────────── */
#rightpanel { background:#0d1117; display:flex; flex-direction:column; min-height:0; overflow:hidden; }
/* Tab bar */
#tab-bar { display:flex; border-bottom:1px solid #21262d; flex-shrink:0; }
.tab {
font-family:inherit; font-size:12px; padding:7px 14px; cursor:pointer;
border:none; background:none; color:#8b949e; border-bottom:2px solid transparent;
}
.tab:hover { color:#dde; background:#161b22; }
.tab.active { color:#58a6ff; border-bottom-color:#58a6ff; }
/* Tab panes */
.pane { display:none; flex:1; min-height:0; flex-direction:column; overflow:hidden; }
.pane.active { display:flex; }
/* ── Log pane ────────────────────────────────────────────────────────────── */
#log-filters {
display:flex; align-items:center; gap:6px; flex-wrap:wrap;
padding:6px 10px; border-bottom:1px solid #161b22; flex-shrink:0;
}
.fchip {
font-family:inherit; font-size:12px; padding:2px 9px; border-radius:10px;
border:1px solid #30363d; background:#21262d; color:#8b949e; cursor:pointer;
}
.fchip:hover { background:#30363d; }
.fchip.on { color:#fff; border-color:transparent; }
.fchip[data-type="VAM"].on { background:#009688; }
.fchip[data-type="CAM"].on { background:#1565c0; }
.fchip[data-type="DENM"].on { background:#bf360c; }
.fchip[data-type="SPATEM"].on { background:#2e7d32; }
.fchip[data-type="MAPEM"].on { background:#6a1b9a; }
.fchip[data-type="OTHER"].on { background:#546e7a; }
#station-list { flex:1; overflow-y:auto; }
#empty-log { padding:20px; color:#555; text-align:center; }
.srow {
display:grid;
grid-template-columns:32px 58px 96px 1fr 70px 36px 20px 20px;
align-items:center; padding:5px 8px; border-bottom:1px solid #161b22;
cursor:pointer; user-select:none; gap:0 4px;
}
.srow:hover { background:#161b22; }
.srow.stale { opacity:.4; }
.srow-icon { display:flex; align-items:center; justify-content:center; }
.srow-badge { font-size:12px; font-weight:700; overflow:hidden; text-overflow:ellipsis; }
.srow-id { font-size:11px; color:#79c0ff; }
.srow-sum { font-size:12px; color:#8b949e; overflow:hidden; text-overflow:ellipsis; }
.srow-time { font-size:11px; color:#555; text-align:right; }
.srow-count { font-size:11px; color:#555; text-align:right; }
.srow-lock { font-size:12px; text-align:center; }
.srow-chev { font-size:12px; color:#555; text-align:center; }
.frow {
display:grid; grid-template-columns:70px 60px 38px 1fr;
padding:3px 8px 3px 42px; background:#080c10;
border-bottom:1px solid #0d1117; gap:0 6px; cursor:default;
}
.frow-time { font-size:11px; color:#555; }
.frow-et { font-size:11px; font-weight:600; }
.frow-len { font-size:11px; color:#555; }
.frow-hex { font-size:11px; color:#79c0ff; overflow:hidden; text-overflow:ellipsis; }
.et-its { color:#7ee787; }
.et-wlan { color:#8b949e; }
.et-unk { color:#f0883e; }
/* ── Stats pane ──────────────────────────────────────────────────────────── */
#pane-stats { overflow-y:auto; padding:14px; gap:16px; }
.stats-card { background:#161b22; border:1px solid #30363d; border-radius:8px; padding:12px 16px; }
.stats-card h3 { margin:0 0 10px; font-size:13px; color:#8b949e; text-transform:uppercase; letter-spacing:.8px; }
.kv-grid { display:grid; grid-template-columns:1fr 1fr; gap:8px 24px; }
.kv { display:flex; flex-direction:column; }
.kv-key { font-size:11px; color:#555; }
.kv-value { font-size:15px; color:#58a6ff; font-weight:600; }
#type-table { width:100%; border-collapse:collapse; font-size:13px; }
#type-table th { color:#8b949e; text-align:left; padding:4px 8px; font-weight:500;
border-bottom:1px solid #30363d; }
#type-table td { padding:5px 8px; border-bottom:1px solid #161b22; }
#type-table td:last-child { text-align:right; color:#555; }
.broker-row { display:flex; align-items:center; gap:8px; padding:5px 0;
border-bottom:1px solid #161b22; font-size:12px; }
.broker-dot { width:9px; height:9px; border-radius:50%; background:#555; flex-shrink:0; }
.broker-dot.up { background:#3fb950; }
/* rate sparkline */
#sparkline { display:flex; align-items:flex-end; gap:1px; height:48px;
margin-top:8px; }
.spark-bar { flex:1; background:#1f6feb; border-radius:1px 1px 0 0; min-height:2px; }
/* ── MAC pane ────────────────────────────────────────────────────────────── */
#pane-macs { overflow:hidden; }
#mac-toolbar { padding:8px 10px; border-bottom:1px solid #161b22; flex-shrink:0; display:flex; gap:8px; }
#mac-search {
font-family:inherit; font-size:13px; background:#161b22; border:1px solid #30363d;
color:#dde; padding:4px 10px; border-radius:6px; flex:1;
}
#mac-search::placeholder { color:#555; }
#mac-table-wrap { flex:1; overflow-y:auto; }
#mac-table { width:100%; border-collapse:collapse; font-size:12px; }
#mac-table th {
position:sticky; top:0; background:#161b22; color:#8b949e; padding:5px 8px;
text-align:left; font-weight:500; border-bottom:1px solid #30363d;
cursor:pointer; user-select:none; white-space:nowrap;
}
#mac-table th:hover { color:#dde; }
#mac-table td { padding:4px 8px; border-bottom:1px solid #161b22; white-space:nowrap; }
#mac-table tr:hover td { background:#161b22; }
.mac-addr { color:#79c0ff; font-family:inherit; }
.mac-types { color:#8b949e; font-size:11px; }
#mac-empty { padding:20px; color:#555; text-align:center; }
/* Leaflet popup */
.leaflet-popup-content { font-family:ui-monospace,Consolas,monospace; font-size:13px; line-height:1.6; }
.leaflet-popup-content .lbl { color:#666; display:inline-block; min-width:72px; }
.mi { background:none; border:none; }
.mi svg { display:block; filter:drop-shadow(0 0 2px rgba(0,0,0,.7)); }
</style>
</head>
<body>
<header>
<h1>V2X2MAP</h1>
<span class="stat">node <b id="node">?</b></span>
<span class="stat">total <b id="ntotal">0</b></span>
<span class="stat">60s <b id="nrate">0</b></span>
<span class="stat">B/s <b id="nbytes">—</b></span>
<span class="stat">last <b id="last">–</b></span>
<span class="stat"><span id="serial-dot"></span><span id="serial-lbl">—</span></span>
<span style="flex:1"></span>
<button id="rec-btn" class="btn" type="button" title="Start/stop PCAP recording">⏺ Record</button>
<button id="mqtt-toggle" class="btn" type="button">MQTT: <span id="mqtt-state-text">off</span></button>
<span id="conn" class="down">disconnected</span>
</header>
<main>
<div id="map"></div>
<div id="rightpanel">
<div id="tab-bar">
<button class="tab active" data-tab="log">Log</button>
<button class="tab" data-tab="stats">Statistics</button>
<button class="tab" data-tab="macs">MAC Analysis</button>
</div>
<!-- ── Log pane ─────────────────────────────────────────────────────── -->
<div id="pane-log" class="pane active">
<div id="log-filters">
<button class="fchip on" data-type="VAM">VAM</button>
<button class="fchip on" data-type="CAM">CAM</button>
<button class="fchip on" data-type="DENM">DENM</button>
<button class="fchip on" data-type="SPATEM">SPATEM</button>
<button class="fchip on" data-type="MAPEM">MAPEM</button>
<button class="fchip on" data-type="OTHER">Other</button>
</div>
<div id="station-list"><div id="empty-log">Waiting for frames...</div></div>
</div>
<!-- ── Stats pane ────────────────────────────────────────────────────── -->
<div id="pane-stats" class="pane">
<div class="stats-card">
<h3>Session</h3>
<div class="kv-grid">
<div class="kv"><span class="kv-key">Uptime</span><span class="kv-value" id="st-uptime">—</span></div>
<div class="kv"><span class="kv-key">Total frames</span><span class="kv-value" id="st-frames">0</span></div>
<div class="kv"><span class="kv-key">Rate (60 s)</span><span class="kv-value" id="st-rate">0 /min</span></div>
<div class="kv"><span class="kv-key">Frequency</span><span class="kv-value" id="st-freq">-</span></div>
<div class="kv"><span class="kv-key">Interval &plusmn; Jitter</span><span class="kv-value" id="st-interval">-</span></div>
<div class="kv"><span class="kv-key">Interval Range</span><span class="kv-value" id="st-range">-</span></div>
<div class="kv"><span class="kv-key">Total bytes</span><span class="kv-value" id="st-bytes">0</span></div>
</div>
<div style="margin-top:10px;font-size:11px;color:#555">Frames per second (last 60 s)</div>
<div id="sparkline"></div>
</div>
<div class="stats-card">
<h3>Message types</h3>
<table id="type-table">
<thead><tr><th>Type</th><th>Description</th><th>Frames</th></tr></thead>
<tbody id="type-tbody"></tbody>
</table>
</div>
<div class="stats-card">
<h3>MQTT Brokers</h3>
<div id="broker-list"><span style="color:#555;font-size:12px">No broker configured.</span></div>
</div>
<div class="stats-card">
<h3>Recording</h3>
<div id="rec-status" style="font-size:13px;color:#555">No active recording.</div>
<div style="margin-top:8px;display:flex;gap:8px;">
<button class="btn" id="rec-start-btn" onclick="startRecording()">Start</button>
<button class="btn" id="rec-stop-btn" onclick="stopRecording()" disabled>Stop</button>`r`n <a class="btn" id="rec-dl-btn" href="/api/record/download" download style="display:none;text-decoration:none;padding:5px 10px;font-size:13px;background:#2e7d32;color:#fff;">📥 Download PCAP</a>
</div>
</div>
</div>
<!-- ── MAC pane ──────────────────────────────────────────────────────── -->
<div id="pane-macs" class="pane">
<div id="mac-toolbar">
<input id="mac-search" type="search" placeholder="Filter MAC address..." oninput="renderMacTable()">
<button class="btn" onclick="exportMacCsv()">Export CSV</button>
</div>
<div id="mac-table-wrap">
<table id="mac-table">
<thead>
<tr>
<th onclick="sortMac('mac')">MAC ↕</th>
<th onclick="sortMac('type')">Type ↕</th>
<th onclick="sortMac('station')">Station ↕</th>
<th onclick="sortMac('first')">First seen ↕</th>
<th onclick="sortMac('last')">Last seen ↕</th>
<th onclick="sortMac('count')">Frames ↕</th>
<th>Position</th>
</tr>
</thead>
<tbody id="mac-tbody"><tr><td colspan="7" id="mac-empty">No MACs received yet.</td></tr></tbody>
</table>
</div>
</div>
</div>
</main>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<script>
'use strict';
// ─── Message type config ──────────────────────────────────────────────────
const MSG = {
VAM: { color:'#009688', short:'VAM', label:'Vulnerable road user (VAM)' },
CAM: { color:'#1976D2', short:'CAM', label:'Vehicle (CAM)' },
DENM: { color:'#E65100', short:'DENM', label:'Hazard warning (DENM)' },
MAPEM: { color:'#7B1FA2', short:'MAPEM', label:'Intersection map (MAPEM)' },
SPATEM: { color:'#388E3C', short:'SPATEM', label:'Signal phase (SPATEM)' },
IVIM: { color:'#C2185B', short:'IVIM', label:'In-vehicle info (IVIM)' },
SREM: { color:'#00838F', short:'SREM', label:'Signal request (SREM)' },
SSEM: { color:'#00838F', short:'SSEM', label:'Signal status (SSEM)' },
TLM: { color:'#AFB42B', short:'TLM', label:'Traffic light (TLM)' },
RTCMEM: { color:'#455A64', short:'RTCM', label:'Correction data (RTCMEM)' },
UNKNOWN:{ color:'#607D8B', short:'?', label:'Unknown' },
};
const PRIMARY = ['CAM','DENM','SPATEM','MAPEM','VAM'];
function typeConf(t) { return MSG[t] || MSG.UNKNOWN; }
// ─── SVG icons ────────────────────────────────────────────────────────────
function svg(w,h,vb,body){return`<svg width="${w}" height="${h}" viewBox="${vb}" xmlns="http://www.w3.org/2000/svg">${body}</svg>`;}
function carSvg(c,r){const rot=isFinite(r)?r:0;return svg(28,28,'-14 -14 28 28',`<g style="transform:rotate(${rot}deg);transform-origin:0 0"><path d="M0,-11 L8,8 L0,3 L-8,8 Z" fill="${c}" stroke="#fff" stroke-width="1.5"/></g>`);}
function tlSvg(c){return svg(22,30,'-11 -15 22 30',`<rect x="-7" y="-14" width="14" height="28" rx="4" fill="${c}"/><circle cx="0" cy="-7" r="4" fill="#fff" opacity=".35"/><circle cx="0" cy="0" r="4" fill="#fff" opacity=".35"/><circle cx="0" cy="7" r="4" fill="#fff" opacity=".35"/>`);}
function hazSvg(c){return svg(28,26,'-14 -13 28 26',`<path d="M0,-12 L12.5,10 L-12.5,10 Z" fill="${c}" stroke="#fff" stroke-width="1.5"/><text x="0" y="8" text-anchor="middle" fill="#fff" font-size="11" font-weight="bold" font-family="sans-serif">!</text>`);}
function intSvg(c){return svg(26,26,'-13 -13 26 26',`<line x1="0" y1="-12" x2="0" y2="12" stroke="${c}" stroke-width="4" stroke-linecap="round"/><line x1="-12" y1="0" x2="12" y2="0" stroke="${c}" stroke-width="4" stroke-linecap="round"/><circle cx="0" cy="0" r="4.5" fill="${c}" stroke="#fff" stroke-width="1.5"/>`);}
function dotSvg(c){return svg(20,20,'-10 -10 20 20',`<circle r="8" fill="${c}" stroke="#fff" stroke-width="1.5"/>`);}
function typeSvg(t,c,h){switch(t){case'CAM':return carSvg(c,h);case'SPATEM':return tlSvg(c);case'DENM':return hazSvg(c);case'MAPEM':return intSvg(c);default:return dotSvg(c);}}
function mkIcon(t,c,h){const sizes={CAM:[28,28],SPATEM:[22,30],DENM:[28,26],MAPEM:[26,26]};const[w,sz]=sizes[t]||[20,20];return L.divIcon({className:'mi',html:typeSvg(t,c,h),iconSize:[w,sz||w],iconAnchor:[(w)/2,(sz||w)/2]});}
// ─── Utilities ────────────────────────────────────────────────────────────
function esc(s){return String(s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));}
function fmtTime(ms){return new Date(ms).toLocaleTimeString();}
function fmtBytes(b){if(b>1e9)return(b/1e9).toFixed(2)+' GB';if(b>1e6)return(b/1e6).toFixed(1)+' MB';if(b>1e3)return(b/1e3).toFixed(1)+' KB';return b+' B';}
function fmtUptime(s){const h=Math.floor(s/3600),m=Math.floor((s%3600)/60),sec=s%60;return`${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(sec).padStart(2,'0')}`;}
function stationKey(f){const sid=f.station_id||(f.gn_addr?f.gn_addr.replace(/:/g,''):'anon');return`${f.msg_type||'UNKNOWN'}|${sid}`;}
// ─── Map ──────────────────────────────────────────────────────────────────
const map = L.map('map').setView([51.0, 10.0], 6);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
{attribution:'© OpenStreetMap',maxZoom:19}).addTo(map);
// Location button
const LocCtrl = L.Control.extend({
onAdd(){
const d=L.DomUtil.create('div','leaflet-bar leaflet-control');
const b=L.DomUtil.create('button','locate-btn',d);
b.innerHTML='⊕'; b.title='Show my location';
L.DomEvent.on(b,'click',L.DomEvent.stopPropagation)
.on(b,'click',L.DomEvent.preventDefault)
.on(b,'click',()=>map.locate({setView:true,maxZoom:16}));
return d;
}
});
new LocCtrl({position:'topleft'}).addTo(map);
let locCircle = null;
map.on('locationfound', e => {
if(locCircle) locCircle.remove();
locCircle = L.circle(e.latlng,{radius:e.accuracy/2,color:'#58a6ff',fillOpacity:.15}).addTo(map);
});
map.on('locationerror', () => alert('Location not available.'));
// ─── Vehicle tracking ─────────────────────────────────────────────────────
const vehicles = new Map();
const TRACK_MAX = 500;
const STALE_MS = 60_000;
let firstFix = false;
function popupHtml(v){
const f=v.lastFrame, conf=typeConf(f.msg_type);
const kmh=f.speed_mps!==undefined?(f.speed_mps*3.6).toFixed(1)+' km/h':'—';
const hdg=f.heading_deg!==undefined?f.heading_deg.toFixed(1)+'°':'—';
return`<div>
<div style="font-weight:bold;color:${conf.color};margin-bottom:4px">${esc(conf.label)}</div>
<div><span class="lbl">ID</span>${esc(f.gn_addr||'—')}</div>
<div><span class="lbl">Station</span>${esc(f.station_type_label||'—')}</div>
<div><span class="lbl">Position</span>${f.lat!==undefined?f.lat.toFixed(6)+', '+f.lon.toFixed(6):'—'}</div>
<div><span class="lbl">Speed</span>${kmh}</div>
<div><span class="lbl">Heading</span>${hdg}</div>
${f.vam ? `<div><span class="lbl">VAM station</span>${esc(String(f.vam.station_id))}</div>
<div><span class="lbl">VRU type</span>${esc(String(f.vam.station_type))}</div>
<div><span class="lbl">Generation</span>${esc(String(f.vam.generation_delta_time))} ms</div>
<div><span class="lbl">Altitude</span>${f.vam.altitude_m === undefined ? '—' : esc(String(f.vam.altitude_m))+' m'}</div>
<div><span class="lbl">Acceleration</span>${f.vam.longitudinal_acceleration_mps2 === undefined ? '—' : esc(String(f.vam.longitudinal_acceleration_mps2))+' m/s²'}</div><details><summary>VAM containers</summary><pre style="max-height:220px;overflow:auto;white-space:pre-wrap">${esc(JSON.stringify(f.vam.containers || {},null,2))}</pre></details>` : ''}
<div><span class="lbl">Frames</span>${v.count}</div>
<div><span class="lbl">Last seen</span>${fmtTime(v.lastMs)}</div>
${f.secured?`<div><span class="lbl">Security</span>🔒 signed${f.security?` (${esc(String(f.security.hash))}, signer ${esc(String(f.security.signer))}${f.security.signer_id?' '+esc(String(f.security.signer_id)):''}, PSID ${esc(String(f.security.psid))}; ${f.security.verified===true?'<b style="color:#2e7d32">signature verified</b>':f.security.verified===false?'<b style="color:#c62828">SIGNATURE INVALID</b>':'not verified'}${f.security.verification?' – '+esc(String(f.security.verification)):''})`:''}</div>`:''}
</div>`;
}
function onPositionFrame(f){
if(f.lat===undefined||f.lon===undefined)return;
const conf=typeConf(f.msg_type), key=stationKey(f), ll=[f.lat,f.lon];
const track=(f.msg_type==='CAM');
let v=vehicles.get(key);
if(!v){
const line=track?L.polyline([],{color:conf.color,weight:3,opacity:.8}).addTo(map):null;
v={count:0,lastFrame:f,lastMs:Date.now(),points:[],line,
marker:L.marker(ll,{icon:mkIcon(f.msg_type,conf.color,f.heading_deg)}).addTo(map)};
v.marker.bindPopup('',{autoPan:true,maxWidth:360});
v.marker.on('popupopen',()=>v.marker.setPopupContent(popupHtml(v)));
vehicles.set(key,v);
if(!firstFix){firstFix=true;map.setView(ll,14);}
}
v.count++;v.lastFrame=f;v.lastMs=Date.now();
if(track){v.points.push(ll);if(v.points.length>TRACK_MAX)v.points.shift();v.line.setLatLngs(v.points);}
v.marker.setLatLng(ll);
v.marker.setIcon(mkIcon(f.msg_type,conf.color,f.heading_deg));
if(v.marker.isPopupOpen())v.marker.setPopupContent(popupHtml(v));
}
setInterval(()=>{
const now=Date.now();
for(const[k,v]of vehicles){
if(now-v.lastMs>STALE_MS){v.marker.remove();if(v.line)v.line.remove();vehicles.delete(k);}
}
},10_000);
// ─── Station log model ────────────────────────────────────────────────────
const stations = new Map();
const MAX_STATIONS = 150;
const FRAME_HISTORY = 20;
function updateStation(f){
const key=stationKey(f);
let s=stations.get(key);
if(!s){
s={key,msgType:f.msg_type||'UNKNOWN',stationId:f.station_id,gnAddr:f.gn_addr,
frameCount:0,lastSeen:0,lastFrame:f,recentFrames:[],expanded:false,secured:null};
stations.set(key,s);
if(stations.size>MAX_STATIONS){
let ok=null,ot=Infinity;
for(const[k,v]of stations)if(v.lastSeen<ot){ok=k;ot=v.lastSeen;}
if(ok)stations.delete(ok);
}
}
s.frameCount++;s.lastSeen=Date.now();s.lastFrame=f;
if(f.secured!==undefined)s.secured=f.secured;
s.recentFrames.unshift(f);
if(s.recentFrames.length>FRAME_HISTORY)s.recentFrames.pop();
}
// ─── MAC analysis model ───────────────────────────────────────────────────
const macEntries = new Map(); // mac → {mac, firstSeen, lastSeen, count, types, lastLat, lastLon, stationType}
let macSortCol='last', macSortAsc=false;
function updateMac(f){
const mac=f.gn_addr; if(!mac)return;
let e=macEntries.get(mac);
const now=Date.now();
if(!e){e={mac,firstSeen:now,lastSeen:now,count:0,types:new Set(),lastLat:null,lastLon:null,stationType:'—'};macEntries.set(mac,e);}
e.lastSeen=now;e.count++;
if(f.msg_type)e.types.add(f.msg_type);
if(f.lat!==undefined){e.lastLat=f.lat;e.lastLon=f.lon;}
if(f.station_type_label)e.stationType=f.station_type_label;
}
// ─── Sparkline (rate history, one value per second) ───────────────────────
const sparkData=[]; // last 60 per-second counts
let sparkBucket=0, lastSparkSec=Math.floor(Date.now()/1000);
function tickSparkline(now){
const s=Math.floor(now/1000);
if(s!==lastSparkSec){
sparkData.push(sparkBucket); sparkBucket=0; lastSparkSec=s;
if(sparkData.length>60)sparkData.shift();
renderSparkline();
} else sparkBucket++;
}
function renderSparkline(){
const el=document.getElementById('sparkline'); if(!el)return;
const max=Math.max(1,...sparkData);
el.innerHTML=sparkData.map(v=>`<div class="spark-bar" style="height:${Math.max(4,Math.round(v/max*44))}px" title="${v}/s"></div>`).join('');
}
// ─── Type counts ─────────────────────────────────────────────────────────
const typeCounts={};
function updateTypeCounts(t){typeCounts[t]=(typeCounts[t]||0)+1;}
function renderTypeCounts(){
const tbody=document.getElementById('type-tbody'); if(!tbody)return;
const total=Object.values(typeCounts).reduce((a,b)=>a+b,0)||1;
const rows=Object.entries(typeCounts).sort((a,b)=>b[1]-a[1]).map(([t,n])=>{
const conf=typeConf(t);
return`<tr>
<td style="color:${conf.color};font-weight:700">${esc(conf.short)}</td>
<td style="color:#8b949e">${esc(conf.label)}</td>
<td>${n} <span style="color:#555">(${(n/total*100).toFixed(1)}%)</span></td>
</tr>`;
}).join('');
tbody.innerHTML=rows||'<tr><td colspan="3" style="color:#555;padding:8px">Noch keine Daten.</td></tr>';
}
// ─── Filter ───────────────────────────────────────────────────────────────
const filterActive=new Set(['CAM','DENM','SPATEM','MAPEM','VAM','OTHER']);
function isVisible(t){return PRIMARY.includes(t)?filterActive.has(t):filterActive.has('OTHER');}
document.querySelectorAll('.fchip').forEach(b=>{
b.addEventListener('click',()=>{
const t=b.dataset.type;
filterActive.has(t)?filterActive.delete(t):filterActive.add(t);
b.classList.toggle('on',filterActive.has(t));
renderStationList();
});
});
// ─── Tab switching ────────────────────────────────────────────────────────
document.querySelectorAll('.tab').forEach(tab=>{
tab.addEventListener('click',()=>{
document.querySelectorAll('.tab').forEach(t=>t.classList.remove('active'));
document.querySelectorAll('.pane').forEach(p=>p.classList.remove('active'));
tab.classList.add('active');
document.getElementById('pane-'+tab.dataset.tab).classList.add('active');
if(tab.dataset.tab==='stats'){renderTypeCounts();renderSparkline();}
if(tab.dataset.tab==='macs'){renderMacTable();}
});
});
// ─── Log rendering ────────────────────────────────────────────────────────
function buildSummary(s){
const f=s.lastFrame,cnt=`${s.frameCount}×`;
let d='';
switch(s.msgType){
case'VAM':d=f.vam ? `VRU ${f.station_type_label || f.vam.station_type} ${f.vam.speed_mps === undefined ? '—' : (f.vam.speed_mps*3.6).toFixed(1)+' km/h'} t=${f.vam.generation_delta_time} ms` : (f.vam_status || 'VAM payload unavailable');break;
case'CAM':{const ps=[];
if(f.speed_mps!==undefined&&f.speed_mps>0.5)ps.push((f.speed_mps*3.6).toFixed(0)+' km/h');
if(f.heading_deg!==undefined)ps.push(['N','NE','E','SE','S','SW','W','NW'][Math.round(f.heading_deg/45)%8]);
if(f.lat!==undefined)ps.push(f.lat.toFixed(4)+','+f.lon.toFixed(4));
d=ps.join(' ')||'—';break;}
case'SPATEM':d='Signal phase';break;
case'DENM':d='Hazard warning';break;
case'MAPEM':d='Intersection map';break;
default:d=f.len!==undefined?`len=${f.len}`:'—';
}
return`${cnt} ${d}`;
}
function etCls(et){if(et===0x8947)return'et-its';if(et==null)return'et-unk';return'et-wlan';}
function etLbl(et){if(et===0x8947)return'ITS-G5';if(et==null)return'?';return'0x'+et.toString(16).padStart(4,'0');}
const timeFmt=new Intl.DateTimeFormat(undefined,{hour:'2-digit',minute:'2-digit',second:'2-digit'});
function renderStationList(){
const now=Date.now();
const el=document.getElementById('station-list');
const sorted=[...stations.values()].filter(s=>isVisible(s.msgType)).sort((a,b)=>b.lastSeen-a.lastSeen);
if(!sorted.length){el.innerHTML='<div id="empty-log">Waiting for frames...</div>';return;}
let html='';
for(const s of sorted){
const conf=typeConf(s.msgType);
const stale=(now-s.lastSeen)>30_000;
const sid=s.stationId?'#'+s.stationId.slice(-8):'—';
const t=timeFmt.format(new Date(s.lastSeen));
const lock=s.secured===true?'🔒':'';
const chev=s.expanded?'▾':'▸';
html+=`<div class="srow${stale?' stale':''}" data-key="${esc(s.key)}">
<div class="srow-icon">${typeSvg(s.msgType,conf.color,s.lastFrame?.heading_deg)}</div>
<div class="srow-badge" style="color:${conf.color}">${esc(conf.short)}</div>
<div class="srow-id">${esc(sid)}</div>
<div class="srow-sum">${esc(buildSummary(s))}</div>
<div class="srow-time">${esc(t)}</div>
<div class="srow-count">${s.frameCount}×</div>
<div class="srow-lock">${lock}</div>
<div class="srow-chev">${chev}</div>
</div>`;
if(s.expanded){
for(const f of s.recentFrames){
const ft=timeFmt.format(new Date((f.sec||0)*1000+Math.floor((f.usec||0)/1000)));
html+=`<div class="frow">
<div class="frow-time">${esc(ft)}</div>
<div class="frow-et ${etCls(f.ether_type)}">${etLbl(f.ether_type)}</div>
<div class="frow-len">${f.len||0}</div>
<div class="frow-hex">${esc(f.hex||'')}</div>
</div>`;
}
}
}
el.innerHTML=html;
el.querySelectorAll('.srow[data-key]').forEach(row=>
row.addEventListener('click',()=>{
const s=stations.get(row.dataset.key);
if(s){s.expanded=!s.expanded;renderStationList();}
})
);
}
// ─── MAC table ────────────────────────────────────────────────────────────
function sortMac(col){
if(macSortCol===col)macSortAsc=!macSortAsc;else{macSortCol=col;macSortAsc=false;}
renderMacTable();
}
function renderMacTable(){
const filter=(document.getElementById('mac-search')?.value||'').toLowerCase();
const tbody=document.getElementById('mac-tbody'); if(!tbody)return;
let rows=[...macEntries.values()];
if(filter)rows=rows.filter(e=>e.mac.includes(filter)||[...e.types].join().toLowerCase().includes(filter));
rows.sort((a,b)=>{
let av,bv;
switch(macSortCol){
case'mac':av=a.mac;bv=b.mac;break;
case'type':av=[...a.types].join();bv=[...b.types].join();break;
case'station':av=a.stationType;bv=b.stationType;break;
case'first':av=a.firstSeen;bv=b.firstSeen;break;
case'count':av=a.count;bv=b.count;break;
default:av=a.lastSeen;bv=b.lastSeen;
}
return macSortAsc?(av>bv?1:-1):(av<bv?1:-1);
});
if(!rows.length){tbody.innerHTML='<tr><td colspan="7" id="mac-empty">No entries.</td></tr>';return;}
tbody.innerHTML=rows.map(e=>{
const types=[...e.types].map(t=>`<span style="color:${typeConf(t).color}">${typeConf(t).short}</span>`).join(' ');
const pos=e.lastLat!==null?`${e.lastLat.toFixed(5)}, ${e.lastLon.toFixed(5)}`:'—';
return`<tr>
<td class="mac-addr">${esc(e.mac)}</td>
<td class="mac-types">${types}</td>
<td style="color:#8b949e">${esc(e.stationType)}</td>
<td style="color:#555">${fmtTime(e.firstSeen)}</td>
<td style="color:#555">${fmtTime(e.lastSeen)}</td>
<td style="color:#58a6ff">${e.count}</td>
<td style="color:#555">${esc(pos)}</td>
</tr>`;
}).join('');
}
function exportMacCsv(){
const rows=[['MAC','Typen','Station','Erstes Signal','Letztes Signal','Frames','Lat','Lon']];
for(const e of macEntries.values()){
rows.push([e.mac,[...e.types].join(';'),e.stationType,
new Date(e.firstSeen).toISOString(),new Date(e.lastSeen).toISOString(),
e.count,e.lastLat??'',e.lastLon??'']);
}
const csv=rows.map(r=>r.map(v=>`"${String(v).replace(/"/g,'""')}"`).join(',')).join('\n');
const a=document.createElement('a');
a.href='data:text/csv;charset=utf-8,'+encodeURIComponent(csv);
a.download=`macs_${new Date().toISOString().slice(0,19).replace(/:/g,'-')}.csv`;
a.click();
}
// ─── Batch log refresh ────────────────────────────────────────────────────
const pendingFrames=[]; let logDirty=false;
function scheduleLog(){
if(logDirty)return; logDirty=true;
setTimeout(()=>{
logDirty=false;
if(pendingFrames.length){
const batch=pendingFrames.splice(0);
for(const f of batch){updateStation(f);updateMac(f);updateTypeCounts(f.msg_type||'UNKNOWN');}
}
renderStationList();
},300);
}
// ─── Header elements ──────────────────────────────────────────────────────
const elNode=document.getElementById('node');
const elTotal=document.getElementById('ntotal');
const elRate=document.getElementById('nrate');
const elBytes=document.getElementById('nbytes');
const elLast=document.getElementById('last');
const elSerial=document.getElementById('serial-dot');
const elSerLbl=document.getElementById('serial-lbl');
const connEl=document.getElementById('conn');
const mqttBtn=document.getElementById('mqtt-toggle');
const mqttTxt=document.getElementById('mqtt-state-text');
let total=0,lastBytesN=0,lastBytesT=Date.now();
const recent=[];
// ─── MQTT toggle ──────────────────────────────────────────────────────────
let mqttState={enabled:false,available:false};
function renderMqttState(){
const on=mqttState.enabled&&mqttState.available;
mqttBtn.classList.toggle('on',on);
mqttBtn.disabled=!mqttState.available;
mqttTxt.textContent=!mqttState.available?'—':mqttState.enabled?'on':'off';
mqttBtn.title=!mqttState.available?'No broker connected.':
mqttState.enabled?'Frames werden übertragen. Klicken zum Deaktivieren.':
'Frames bleiben lokal. Klicken zum Aktivieren.';
}
mqttBtn.addEventListener('click',()=>{
if(!mqttState.available)return;
mqttBtn.disabled=true;
fetch('/api/mqtt',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({enabled:!mqttState.enabled})})
.then(r=>r.json()).then(s=>{mqttState=s;renderMqttState();})
.catch(()=>{mqttBtn.disabled=false;});
});
// ─── Broker status ────────────────────────────────────────────────────────
function renderBrokers(list){
const el=document.getElementById('broker-list'); if(!el)return;
if(!list||!list.length){el.innerHTML='<span style="color:#555;font-size:12px">No broker configured.</span>';return;}
el.innerHTML=list.map(b=>`<div class="broker-row">
<div class="broker-dot${b.connected?' up':''}"></div>
<span style="font-size:12px">${esc(b.url||'?')}</span>
<span style="color:#555;font-size:11px">${b.connected?'connected':'disconnected'}</span>
</div>`).join('');
}
// ─── PCAP recording ───────────────────────────────────────────────────────
let recState={recording:false,file:'',frames:0};
function renderRecState(s){
recState=s||recState;
const el=document.getElementById('rec-status');
const recBtn=document.getElementById('rec-btn');
const startBtn=document.getElementById('rec-start-btn');
const stopBtn=document.getElementById('rec-stop-btn');
const dlBtn=document.getElementById('rec-dl-btn');
if(recState.recording){
if(el)el.innerHTML=`<span style="color:#f47979">⏺ Recording active</span> ${esc(recState.file)} <b>${recState.frames}</b> frames`;
if(recBtn){recBtn.classList.add('rec');recBtn.textContent='⏹ Recording...';}
if(startBtn)startBtn.disabled=true;
if(stopBtn)stopBtn.disabled=false;
if(dlBtn)dlBtn.style.display='none';
} else {
const info=recState.frames>0?`Last recording: ${esc(recState.file||'—')} (${recState.frames} frames)`:'No active recording.';
if(el)el.innerHTML=`<span style="color:#555">${info}</span>`;
if(recBtn){recBtn.classList.remove('rec');recBtn.textContent='⏺ Record';}
if(startBtn)startBtn.disabled=false;
if(stopBtn)stopBtn.disabled=true;
if(dlBtn)dlBtn.style.display=(recState.frames>0)?'inline-block':'none';
}
}
function startRecording(filename){
fetch('/api/record',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({action:'start',file:filename||''})})
.then(r=>r.json()).then(renderRecState).catch(console.error);
}
function stopRecording(){
fetch('/api/record',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({action:'stop'})})
.then(r=>r.json()).then(renderRecState).catch(console.error);
}
document.getElementById('rec-btn').addEventListener('click',()=>{
recState.recording?stopRecording():startRecording();
});
// ─── SSE ──────────────────────────────────────────────────────────────────
function connect(){
const es=new EventSource('/events');
es.onopen=()=>{connEl.textContent='connected';connEl.className='up';};
es.onerror=()=>{connEl.textContent='reconnecting...';connEl.className='down';};
es.addEventListener('hello',e=>{
const d=JSON.parse(e.data);
elNode.textContent=d.node_id||'?';
if(d.mqtt){mqttState=d.mqtt;renderMqttState();}
if(d.serial)applySerial(d.serial);
if(d.stats)applyStats(d.stats);
if(d.brokers)renderBrokers(d.brokers);
if(d.record)renderRecState(d.record);
});
es.addEventListener('frame',e=>{
const f=JSON.parse(e.data);
total++;
const now=Date.now();
elTotal.textContent=total;
elLast.textContent=new Date(now).toLocaleTimeString();
recent.push(now);
while(recent.length&&recent[0]<now-60_000)recent.shift();
elRate.textContent=recent.length;
tickSparkline(now);
if(f.lat!==undefined&&f.lon!==undefined)onPositionFrame(f);
pendingFrames.push(f);
scheduleLog();
});
es.addEventListener('stats',e=>applyStats(JSON.parse(e.data)));
es.addEventListener('mqtt_state',e=>{mqttState=JSON.parse(e.data);renderMqttState();});
es.addEventListener('serial_state',e=>applySerial(JSON.parse(e.data)));
es.addEventListener('broker_status',e=>renderBrokers(JSON.parse(e.data)));
es.addEventListener('record_state',e=>renderRecState(JSON.parse(e.data)));
}
function applyStats(s){
if(!s||!Object.keys(s).length)return;
document.getElementById('st-frames').textContent=s.frames||0;
document.getElementById('st-rate').textContent=(s.rate||0)+' /min';
if(s.freq_hz!==undefined&&s.freq_hz>0){
document.getElementById('st-freq').textContent=s.freq_hz.toFixed(2)+' Hz';
} else {
document.getElementById('st-freq').textContent='-';
}
if(s.mean_interval_ms!==undefined&&s.mean_interval_ms>0){
document.getElementById('st-interval').textContent=s.mean_interval_ms.toFixed(1)+' ms ± '+s.jitter_ms.toFixed(2)+' ms';
document.getElementById('st-range').textContent='['+s.min_interval_ms.toFixed(1)+' ms – '+s.max_interval_ms.toFixed(1)+' ms]';
} else {
document.getElementById('st-interval').textContent='-';
document.getElementById('st-range').textContent='-';
}
document.getElementById('st-bytes').textContent=fmtBytes(s.bytes||0);
if(s.uptime_s!==undefined)document.getElementById('st-uptime').textContent=fmtUptime(s.uptime_s);
const now=Date.now(),dt=(now-lastBytesT)/1000;
if(dt>0&&s.bytes!==undefined){
const bps=(s.bytes-lastBytesN)/dt;
elBytes.textContent=bps>1024?(bps/1024).toFixed(1)+' KB/s':bps.toFixed(0)+' B/s';
lastBytesN=s.bytes;lastBytesT=now;
}
}
function applySerial(s){
const up=s&&s.connected;
elSerial.className=up?'up':'down';
elSerLbl.textContent=up?(s.port||'USB'):'disconnected';
}
renderMqttState();
renderRecState();
connect();
</script>
</body>
</html>
@@ -1,289 +0,0 @@
"""
Local HTTP+SSE dashboard server for the ITS-G5 bridge (V2X2MAP).
Routes:
GET / → dashboard.html
GET /events → SSE stream
GET /api/mqtt → MQTT toggle state
GET /api/stats → last stats snapshot
GET /api/record → PCAP recording state
GET /api/brokers → configured broker list + status
POST /api/mqtt → toggle MQTT publishing {"enabled": bool}
POST /api/record → start/stop recording {"action": "start"|"stop", "file": "optional.pcap"}
Pure stdlib: http.server + threading + queue.
"""
from __future__ import annotations
import collections
import json
import logging
import os
import queue
import sys
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
_HERE = getattr(sys, "_MEIPASS", os.path.dirname(os.path.abspath(__file__)))
_HTML_PATH = os.path.join(_HERE, "dashboard.html")
_HISTORY_MAX = 200
_subs_lock = threading.Lock()
_subscribers: list[queue.Queue] = []
_history: collections.deque[bytes] = collections.deque(maxlen=_HISTORY_MAX)
_node_id = "?"
# MQTT state
_mqtt_enabled = False # off by default — user activates from dashboard
_mqtt_available = False
# Broker status {url: {"host": h, "port": p, "connected": bool}}
_broker_status: dict[str, dict] = {}
# Serial state
_serial_connected = False
_serial_port = ""
# Stats
_last_stats: dict = {}
# PCAP recording state (updated by bridge via set_record_callback)
_record_state: dict = {"recording": False, "file": "", "frames": 0}
_record_callback = None # callable(action: str, filename: str) → dict
# ---------------------------------------------------------------------------
# Public API used by the bridge
# ---------------------------------------------------------------------------
def is_mqtt_enabled() -> bool:
return _mqtt_enabled and _mqtt_available
def get_mqtt_state() -> dict:
return {"enabled": _mqtt_enabled, "available": _mqtt_available}
def set_mqtt_state(enabled: bool | None = None, available: bool | None = None) -> dict:
global _mqtt_enabled, _mqtt_available
if available is not None:
_mqtt_available = bool(available)
if enabled is not None:
_mqtt_enabled = bool(enabled)
_broadcast(_format_event("mqtt_state", get_mqtt_state()), persist=False)
return get_mqtt_state()
def register_brokers(urls: list[str]) -> None:
"""Call once at startup with the configured broker URLs."""
global _broker_status
_broker_status = {}
for url in urls:
_broker_status[url] = {"url": url, "connected": False}
def update_broker_status(host: str, port: int, connected: bool) -> None:
for entry in _broker_status.values():
if host in entry["url"] and str(port) in entry["url"]:
entry["connected"] = connected
_broadcast(_format_event("broker_status", list(_broker_status.values())), persist=False)
def broadcast_frame(frame: dict) -> None:
_broadcast(_format_event("frame", frame), persist=True)
def broadcast_stats(stats: dict) -> None:
global _last_stats
_last_stats = stats
_broadcast(_format_event("stats", stats), persist=False)
def broadcast_serial_state(connected: bool, port: str = "") -> None:
global _serial_connected, _serial_port
_serial_connected = connected
_serial_port = port
_broadcast(_format_event("serial_state", {"connected": connected, "port": port}),
persist=False)
def set_record_callback(fn) -> None:
global _record_callback
_record_callback = fn
def update_record_state(state: dict) -> None:
global _record_state
_record_state = state
_broadcast(_format_event("record_state", state), persist=False)
def get_record_state() -> dict:
return _record_state
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _format_event(name: str, data) -> bytes:
payload = json.dumps(data, separators=(",", ":"))
return f"event: {name}\ndata: {payload}\n\n".encode("utf-8")
def _broadcast(msg: bytes, persist: bool) -> None:
with _subs_lock:
if persist:
_history.append(msg)
dead = []
for q in _subscribers:
try:
q.put_nowait(msg)
except queue.Full:
dead.append(q)
for q in dead:
_subscribers.remove(q)
# ---------------------------------------------------------------------------
# HTTP handler
# ---------------------------------------------------------------------------
class _Handler(BaseHTTPRequestHandler):
def log_message(self, fmt, *args):
logging.debug("dashboard %s - %s", self.address_string(), fmt % args)
def do_GET(self): # noqa: N802
if self.path in ("/", "/index.html"):
self._serve_html()
elif self.path == "/events":
self._serve_sse()
elif self.path == "/api/mqtt":
self._serve_json(get_mqtt_state())
elif self.path == "/api/stats":
self._serve_json(_last_stats)
elif self.path == "/api/record":
self._serve_json(_record_state)
elif self.path == "/api/record/download":
fn = _record_state.get("file")
if fn and os.path.isfile(fn):
self.send_response(200)
self.send_header("Content-Type", "application/vnd.tcpdump.pcap")
self.send_header("Content-Disposition", f'attachment; filename="{os.path.basename(fn)}"')
self.send_header("Content-Length", str(os.path.getsize(fn)))
self.end_headers()
with open(fn, "rb") as f:
self.wfile.write(f.read())
else:
self.send_error(404, "no recording available")
elif self.path == "/api/brokers":
self._serve_json(list(_broker_status.values()))
else:
self.send_error(404)
def do_POST(self): # noqa: N802
length = int(self.headers.get("Content-Length") or 0)
raw = self.rfile.read(length) if length > 0 else b"{}"
try:
body = json.loads(raw.decode("utf-8") or "{}")
except (UnicodeDecodeError, json.JSONDecodeError):
self.send_error(400, "invalid JSON"); return
if self.path == "/api/mqtt":
if "enabled" not in body:
self.send_error(400, "missing 'enabled'"); return
self._serve_json(set_mqtt_state(enabled=bool(body["enabled"])))
elif self.path == "/api/record":
action = body.get("action", "")
filename = body.get("file", "")
if action not in ("start", "stop"):
self.send_error(400, "action must be 'start' or 'stop'"); return
if _record_callback:
result = _record_callback(action, filename)
self._serve_json(result)
else:
self.send_error(503, "recording not available")
else:
self.send_error(404)
def _serve_json(self, data):
body = json.dumps(data).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-cache")
self.end_headers()
self.wfile.write(body)
def _serve_html(self):
try:
with open(_HTML_PATH, "rb") as f:
body = f.read()
except OSError:
self.send_error(500, "dashboard.html not found"); return
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-cache")
self.end_headers()
self.wfile.write(body)
def _serve_sse(self):
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-cache")
self.send_header("Connection", "keep-alive")
self.send_header("X-Accel-Buffering", "no")
self.end_headers()
q: queue.Queue = queue.Queue(maxsize=1000)
with _subs_lock:
backlog = list(_history)
_subscribers.append(q)
try:
self.wfile.write(_format_event("hello", {
"node_id": _node_id,
"mqtt": get_mqtt_state(),
"serial": {"connected": _serial_connected, "port": _serial_port},
"stats": _last_stats,
"brokers": list(_broker_status.values()),
"record": _record_state,
}))
for msg in backlog:
self.wfile.write(msg)
self.wfile.flush()
while True:
try:
msg = q.get(timeout=15)
except queue.Empty:
self.wfile.write(b": ping\n\n")
self.wfile.flush()
continue
self.wfile.write(msg)
self.wfile.flush()
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError, OSError):
pass
finally:
with _subs_lock:
if q in _subscribers:
_subscribers.remove(q)
# ---------------------------------------------------------------------------
# Server startup
# ---------------------------------------------------------------------------
def start(port: int, node_id: str = "?") -> ThreadingHTTPServer:
global _node_id
_node_id = node_id
server = ThreadingHTTPServer(("127.0.0.1", port), _Handler)
t = threading.Thread(target=server.serve_forever, name="dashboard-http", daemon=True)
t.start()
logging.info("dashboard listening on http://127.0.0.1:%d", port)
return server

Some files were not shown because too many files have changed in this diff Show More