Compare commits
4
Commits
ca57e5702b
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c13c3891c9 | ||
|
|
d3fc9bf66c | ||
|
|
6e4d293c3a | ||
|
|
d107534eb2 |
@@ -54,3 +54,12 @@ sdkconfig.old
|
||||
# ESP-IDF component manager downloads (espressif/esp-boost for obu-firmware's vanetza-idf), ~125 MB.
|
||||
# dependencies.lock beside the project pins them and is committed; this is its cache.
|
||||
managed_components/
|
||||
|
||||
# The colleague's standalone ESP32-C5 VRU station (its own repository, HAW GitLab
|
||||
# urban-mobility-lab/microbu/microbu-esp32c5). Kept beside the project on the lab laptop, for
|
||||
# reference and because the V2X2MAP bridge runs from its tools/, but not part of this repository:
|
||||
# obu-firmware only needs its vanetza-idf, which is copied to obu-firmware/external/vanetza-idf.
|
||||
/microbu-esp32c5/
|
||||
|
||||
# draw.io keeps a backup beside an open diagram.
|
||||
*.drawio.bkp
|
||||
|
||||
@@ -5,6 +5,25 @@ Engineering to-do list. The reviewer-facing open items live in
|
||||
|
||||
## 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)
|
||||
|
||||
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
|
||||
on: the VAM must be decoded (cyclist, position) and show "signature verified" too.
|
||||
Confirmed by the user 2026-09-23: signed VAMs decode and verify.
|
||||
- [x] **VAM.** Transmit VAM: BTP port 2018, psid 638; with `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:
|
||||
≥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.
|
||||
|
||||
@@ -72,6 +72,12 @@
|
||||
android:foregroundServiceType="location"
|
||||
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
|
||||
android:name="androidx.core.content.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.UnitsSettingsScreen
|
||||
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.viewmodel.MqttViewModel
|
||||
import com.hawhamburg.micr0bu.viewmodel.SensorViewModel
|
||||
import com.hawhamburg.micr0bu.viewmodel.TripRecordingViewModel
|
||||
import com.hawhamburg.micr0bu.viewmodel.V2xLogViewModel
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
|
||||
@AndroidEntryPoint
|
||||
@@ -68,6 +70,7 @@ class MainActivity : AppCompatActivity() {
|
||||
private val viewModel: SensorViewModel by viewModels()
|
||||
private val mqttViewModel: MqttViewModel by viewModels()
|
||||
private val tripViewModel: TripRecordingViewModel by viewModels()
|
||||
private val logViewModel: V2xLogViewModel by viewModels()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
installSplashScreen()
|
||||
@@ -99,6 +102,8 @@ class MainActivity : AppCompatActivity() {
|
||||
val denmEvents by mqttViewModel.denmEvents.collectAsState()
|
||||
val spatIntersections by mqttViewModel.spatIntersections.collectAsState()
|
||||
val ownCamPosition by mqttViewModel.ownCamPosition.collectAsState()
|
||||
val v2xLogState by logViewModel.state.collectAsState()
|
||||
val v2xLogFiles by logViewModel.files.collectAsState()
|
||||
|
||||
MicrOBUTheme(darkTheme = state.darkTheme) {
|
||||
val view = LocalView.current
|
||||
@@ -365,6 +370,18 @@ class MainActivity : AppCompatActivity() {
|
||||
state = state,
|
||||
onDeveloperMode = viewModel::setDeveloperMode,
|
||||
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() },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.hawhamburg.micr0bu.data.mqtt.MqttRepository
|
||||
import com.hawhamburg.micr0bu.data.mqtt.ObuHardwarePreferences
|
||||
import com.hawhamburg.micr0bu.data.mqtt.RAW_CAM_TOPIC
|
||||
import com.hawhamburg.micr0bu.data.mqtt.RAW_DENM_TOPIC
|
||||
import com.hawhamburg.micr0bu.data.mqtt.RAW_MAPEM_TOPIC
|
||||
import com.hawhamburg.micr0bu.data.mqtt.RAW_SPATEM_TOPIC
|
||||
import com.hawhamburg.micr0bu.data.mqtt.RecvV2xMessage
|
||||
import com.hawhamburg.micr0bu.data.mqtt.UseCaseAlertPreferences
|
||||
@@ -241,6 +242,8 @@ class CamUseCaseRepository @Inject constructor(
|
||||
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}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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_DENM_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
|
||||
// raw topics, and because the Use Case app is still the only source of its own alert output.
|
||||
"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_DENM_TOPIC = "v2x/rx/denm"
|
||||
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.
|
||||
|
||||
@@ -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 SettingsUseCaseAlerts : Screen("settings/usecases", R.string.settings_usecase_alerts)
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -137,7 +137,7 @@ private fun MenuRow(label: String, onClick: () -> Unit) {
|
||||
// ── Sub-screen shell ────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun SubScreen(title: String, onBack: () -> Unit, content: @Composable () -> Unit) {
|
||||
internal fun SubScreen(title: String, onBack: () -> Unit, content: @Composable () -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -448,6 +448,7 @@ fun DeveloperSettingsScreen(
|
||||
state: SensorUiState,
|
||||
onDeveloperMode: (Boolean) -> Unit,
|
||||
onOpenSensorMonitor: () -> Unit,
|
||||
onOpenV2xLogger: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
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
|
||||
// a bench-diagnosis tool, not something a rider needs mid-ride.
|
||||
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) {
|
||||
Divider()
|
||||
DisabledRow(stringResource(R.string.settings_wifi), stringResource(R.string.settings_wifi_val))
|
||||
@@ -481,7 +485,7 @@ fun AboutSettingsScreen(onBack: () -> Unit) {
|
||||
// ── Internal components ─────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun SectionCard(content: @Composable () -> Unit) {
|
||||
internal fun SectionCard(content: @Composable () -> Unit) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
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))
|
||||
|
||||
@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
|
||||
private fun SettingToggleRow(
|
||||
@@ -723,7 +727,7 @@ private fun SettingToggleRow(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DisabledRow(label: String, value: String) {
|
||||
internal fun DisabledRow(label: String, value: String) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp),
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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_on">an</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>
|
||||
|
||||
@@ -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_on">on</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>
|
||||
|
||||
@@ -2,4 +2,6 @@
|
||||
<paths>
|
||||
<!-- Covers context.cacheDir — used for CSV exports -->
|
||||
<cache-path name="shared_csv" path="." />
|
||||
<!-- V2X data logs (V2xLogger), shared from Settings > Developer -->
|
||||
<files-path name="v2x_logs" path="v2x_logs/" />
|
||||
</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"))
|
||||
}
|
||||
}
|
||||
@@ -35,10 +35,10 @@ fit together, how it was verified, and what is still open. Hardware checks still
|
||||
|
||||
## Firmware (obu-firmware)
|
||||
|
||||
obu-firmware is now a port of `microbu-esp32c5/firmware`: the colleague's repository, of which
|
||||
this repository keeps a copy in `microbu-esp32c5/` (their commit cf4b99f plus our V2X2MAP signature
|
||||
verification; nothing is pushed to their repository). vanetza-idf is taken from
|
||||
`microbu-esp32c5/external/vanetza-idf`. It builds
|
||||
obu-firmware is now a port of `microbu-esp32c5/firmware`, from the colleague's own repository
|
||||
(not part of this one; nothing is pushed there). The C-ITS library it needs is copied into this
|
||||
repository as `obu-firmware/external/vanetza-idf` (their commit cf4b99f, unchanged), so
|
||||
obu-firmware builds from a plain clone. It builds
|
||||
with **ESP-IDF 6.0.2 only**: the raw-TX path uses private Wi-Fi driver structures that vanetza-idf
|
||||
pins to that version. The previous C firmware (IDF 6.1) is backed up as a full flash image in
|
||||
`firmware-backups/` (gitignored, restore command in its README.txt); its sources stay on disk,
|
||||
@@ -64,7 +64,7 @@ Main changes against the colleague's firmware:
|
||||
|
||||
## 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`
|
||||
(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
|
||||
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 660 KiB |
@@ -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
|
||||
@@ -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).
|
||||
@@ -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
|
||||
@@ -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, ©, 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, ©, 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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, ¶ms);
|
||||
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,
|
||||
¶ms, 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,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
|
||||
Binary file not shown.
@@ -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
|
||||
@@ -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.
Binary file not shown.
Binary file not shown.
@@ -1,3 +0,0 @@
|
||||
C8BB336F2F49189F UML_AA.oer
|
||||
CDF1045B135A4E8F UML_AT.oer
|
||||
C06FBC0DE448E3BC UML_AT.oer
|
||||
Binary file not shown.
-55
@@ -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(())
|
||||
}
|
||||
@@ -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 ± 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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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
|
||||
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user