Compare commits
5
Commits
034ef22336
...
83153a0971
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83153a0971 | ||
|
|
3eeccfb268 | ||
|
|
5ec3619cbe | ||
|
|
1ad123a6f8 | ||
|
|
83ccf335bb |
@@ -38,9 +38,9 @@ Both paths converge at `CamUseCaseRepository`, which normalises whatever arrived
|
|||||||
|
|
||||||
**DENM transmission**; CiT One path only. Triggers the stationary vehicle profile (`hln-sv`, causeCode 94) via the consider it Use Case API. This is a manual antenna and range test tool. It is never triggered by a detected event or a use case alert, and the control is hidden entirely on the ESP32-C5 path.
|
**DENM transmission**; CiT One path only. Triggers the stationary vehicle profile (`hln-sv`, causeCode 94) via the consider it Use Case API. This is a manual antenna and range test tool. It is never triggered by a detected event or a use case alert, and the control is hidden entirely on the ESP32-C5 path.
|
||||||
|
|
||||||
**Trip recording**; foreground service records all sensor streams and detects cycling events (braking, turning, stopping) using orientation-independent signal processing. Works fully offline with no OBU connected.
|
**Trip recording**; foreground service records all sensor streams and detects cycling manoeuvres (braking, turning, stopping) using orientation-independent signal processing. Works fully offline with no OBU connected. The detected manoeuvres are neither shown nor stored - their only effect is to raise the CAM transmit rate through the manoeuvre on the ESP32-C5 path.
|
||||||
|
|
||||||
**Trip review**; past trips displayed on an OpenStreetMap layer with detected events overlaid as coloured pins. Tap any pin for event details.
|
**Trip review**; past trips displayed as a route on an OpenStreetMap layer, with duration and distance.
|
||||||
|
|
||||||
**CSV export**; every sensor sample written to a timestamped CSV in real time. Trip exports additionally include the V2X messages received and their RSSI. Shareable via the standard Android share sheet.
|
**CSV export**; every sensor sample written to a timestamped CSV in real time. Trip exports additionally include the V2X messages received and their RSSI. Shareable via the standard Android share sheet.
|
||||||
|
|
||||||
|
|||||||
@@ -248,10 +248,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
val trip = trips.firstOrNull { it.id == tripId }
|
val trip = trips.firstOrNull { it.id == tripId }
|
||||||
|
|
||||||
if (trip != null) {
|
if (trip != null) {
|
||||||
TripReviewScreen(
|
TripReviewScreen(trip = trip)
|
||||||
trip = trip,
|
|
||||||
viewModel = tripViewModel,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package com.hawhamburg.micr0bu.data
|
||||||
|
|
||||||
|
import android.os.SystemClock
|
||||||
|
import android.util.Log
|
||||||
|
import com.hawhamburg.micr0bu.domain.asn1.ItsTime
|
||||||
|
import java.time.DateTimeException
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Puts the timestamps this phone transmits on GNSS time instead of its own wall clock.
|
||||||
|
*
|
||||||
|
* ## Why
|
||||||
|
* Every CAM carries a generationDeltaTime and every GeoNetworking header a TST, and receivers use
|
||||||
|
* them to judge how fresh a message is and in what order messages came. Both used to come straight
|
||||||
|
* from `System.currentTimeMillis()`, so they were only as good as the phone's clock setting. On
|
||||||
|
* 2026-09-10 the bench phone was 24 minutes fast: automatic time had no source (no SIM, and the
|
||||||
|
* lab Wi-Fi has no internet time), so it had not set the clock once in 69 hours, and every CAM
|
||||||
|
* went out stamped 24 minutes in the future. A bike-mounted phone on the road is in exactly that
|
||||||
|
* position. GNSS time depends on none of it.
|
||||||
|
*
|
||||||
|
* ## How
|
||||||
|
* [SystemClock.currentGnssTimeClock] (API 29, this app's minSdk) is a UTC clock the platform keeps
|
||||||
|
* synchronised from GNSS fixes. One reading of it taken alongside the wall clock gives the wall
|
||||||
|
* clock's error, which is then applied to the fix's own timestamp. When GNSS time is unavailable,
|
||||||
|
* typically indoors before any satellite fix since boot, the wall clock is used unchanged.
|
||||||
|
*
|
||||||
|
* Which clock is in use is logged whenever it changes, with the measured error, so a capture shows
|
||||||
|
* where a given run's timestamps came from.
|
||||||
|
*
|
||||||
|
* Only the transmit path uses this. Everything else in the app stays on the wall clock, because
|
||||||
|
* received messages, sensor samples and trip records are all stamped with it and must stay
|
||||||
|
* comparable with one another.
|
||||||
|
*/
|
||||||
|
object GnssTimeSource {
|
||||||
|
private const val TAG = "GnssTimeSource"
|
||||||
|
|
||||||
|
/** Whether the last correction used GNSS time; null before the first. For change-only logging. */
|
||||||
|
@Volatile private var lastUsedGnss: Boolean? = null
|
||||||
|
|
||||||
|
/** [systemMs], a wall-clock reading, moved onto GNSS time where GNSS time is available. */
|
||||||
|
fun correct(systemMs: Long): Long {
|
||||||
|
val systemNow = System.currentTimeMillis()
|
||||||
|
val gnssNow = try {
|
||||||
|
SystemClock.currentGnssTimeClock().millis()
|
||||||
|
} catch (e: DateTimeException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
noteSource(gnssNow, systemNow)
|
||||||
|
return ItsTime.onGnssTime(systemMs, gnssNow, systemNow)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun noteSource(gnssNow: Long?, systemNow: Long) {
|
||||||
|
val usingGnss = gnssNow != null
|
||||||
|
if (lastUsedGnss == usingGnss) return
|
||||||
|
lastUsedGnss = usingGnss
|
||||||
|
if (gnssNow != null) {
|
||||||
|
Log.i(TAG, "transmit timestamps now on GNSS time; phone clock is " +
|
||||||
|
"${"%+.1f".format((systemNow - gnssNow) / 1000.0)} s off")
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "GNSS time unavailable, transmit timestamps fall back to the phone clock, " +
|
||||||
|
"which has no automatic time source without a SIM or internet")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@ package com.hawhamburg.micr0bu.data
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
import com.hawhamburg.micr0bu.data.db.DetectedEventEntity
|
|
||||||
import com.hawhamburg.micr0bu.data.db.RecordedTripEntity
|
import com.hawhamburg.micr0bu.data.db.RecordedTripEntity
|
||||||
import com.hawhamburg.micr0bu.data.db.V2xMessageEntity
|
import com.hawhamburg.micr0bu.data.db.V2xMessageEntity
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -23,12 +22,15 @@ fun tripFileName(trip: RecordedTripEntity): String =
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds a single combined CSV for one trip: the raw sensor samples recorded alongside it, the
|
* Builds a single combined CSV for one trip: the raw sensor samples recorded alongside it, the
|
||||||
* events the detector fired, the GPS track, and every V2X message seen during the ride — all in
|
* GPS track, and every V2X message seen during the ride — all in one file, ordered by time.
|
||||||
* one file, ordered by time.
|
*
|
||||||
|
* Detected manoeuvres are deliberately absent. The detector exists to raise the CAM transmit
|
||||||
|
* rate (see EventDetector's KDoc); its output is not retained, so there is nothing to export
|
||||||
|
* beyond the per-trip count in the header.
|
||||||
*
|
*
|
||||||
* **Why one file rather than a zip of tables.** The point of the export is correlation: what was
|
* **Why one file rather than a zip of tables.** The point of the export is correlation: what was
|
||||||
* the bike doing when that CAM arrived, what did the detector make of it. Splitting those into
|
* the bike doing when that CAM arrived. Splitting those into separate files pushes the join
|
||||||
* separate files pushes the join onto whoever opens it. A leading `type` column keeps the rows
|
* onto whoever opens it. A leading `type` column keeps the rows
|
||||||
* distinguishable, which is the same shape the existing session CSV already uses, so the two
|
* distinguishable, which is the same shape the existing session CSV already uses, so the two
|
||||||
* remain readable by the same tooling.
|
* remain readable by the same tooling.
|
||||||
*
|
*
|
||||||
@@ -44,7 +46,6 @@ fun tripFileName(trip: RecordedTripEntity): String =
|
|||||||
suspend fun buildTripCsv(
|
suspend fun buildTripCsv(
|
||||||
context: Context,
|
context: Context,
|
||||||
trip: RecordedTripEntity,
|
trip: RecordedTripEntity,
|
||||||
events: List<DetectedEventEntity>,
|
|
||||||
v2xMessages: List<V2xMessageEntity>,
|
v2xMessages: List<V2xMessageEntity>,
|
||||||
): String = withContext(Dispatchers.IO) {
|
): String = withContext(Dispatchers.IO) {
|
||||||
buildString {
|
buildString {
|
||||||
@@ -59,7 +60,6 @@ suspend fun buildTripCsv(
|
|||||||
appendLine()
|
appendLine()
|
||||||
appendLine(
|
appendLine(
|
||||||
"type,timestamp_ms,timestamp_iso,lat,lon,speed_ms,heading_deg," +
|
"type,timestamp_ms,timestamp_iso,lat,lon,speed_ms,heading_deg," +
|
||||||
"event_type,confidence,peak_accel,peak_gyro,duration_ms," +
|
|
||||||
"station_id,station_type,is_own,yaw_rate_dps,rssi_dbm"
|
"station_id,station_type,is_own,yaw_rate_dps,rssi_dbm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -68,16 +68,6 @@ suspend fun buildTripCsv(
|
|||||||
appendLine(
|
appendLine(
|
||||||
"gps,${point.timestamp},${isoUtc.format(Date(point.timestamp))}," +
|
"gps,${point.timestamp},${isoUtc.format(Date(point.timestamp))}," +
|
||||||
"${point.lat},${point.lon},,," +
|
"${point.lat},${point.lon},,," +
|
||||||
",,,,," +
|
|
||||||
",,,"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
for (e in events) {
|
|
||||||
appendLine(
|
|
||||||
"event,${e.timestamp},${isoUtc.format(Date(e.timestamp))}," +
|
|
||||||
"${e.latitude},${e.longitude},${e.speedMps},," +
|
|
||||||
"${e.type},${e.confidence},${e.peakAccelMagnitude},${e.peakGyroMagnitude},${e.durationMs}," +
|
|
||||||
",,,,"
|
",,,,"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -86,14 +76,13 @@ suspend fun buildTripCsv(
|
|||||||
appendLine(
|
appendLine(
|
||||||
"v2x,${m.timestamp},${isoUtc.format(Date(m.timestamp))}," +
|
"v2x,${m.timestamp},${isoUtc.format(Date(m.timestamp))}," +
|
||||||
"${m.latitude},${m.longitude},${m.speedMps},${m.headingDeg}," +
|
"${m.latitude},${m.longitude},${m.speedMps},${m.headingDeg}," +
|
||||||
",,,,," +
|
|
||||||
"${m.stationId},${m.stationType},${m.isOwn},${m.yawRateDps ?: ""},${m.rssiDbm ?: ""}"
|
"${m.stationId},${m.stationType},${m.isOwn},${m.yawRateDps ?: ""},${m.rssiDbm ?: ""}"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Raw sensor samples, copied verbatim from the session CSV. Appended last rather than
|
// Raw sensor samples, copied verbatim from the session CSV. Appended last rather than
|
||||||
// merge-sorted in: a long ride is hundreds of thousands of rows, and sorting them against
|
// merge-sorted in: a long ride is hundreds of thousands of rows, and sorting them against
|
||||||
// the (comparatively tiny) event/V2X sets in memory would defeat the streaming that
|
// the (comparatively tiny) V2X set in memory would defeat the streaming that
|
||||||
// CsvExporter deliberately does. Each row carries its own timestamp, so sort on load.
|
// CsvExporter deliberately does. Each row carries its own timestamp, so sort on load.
|
||||||
val sessionCsv = trip.sessionId?.let { File(File(context.filesDir, "sessions"), "$it.csv") }
|
val sessionCsv = trip.sessionId?.let { File(File(context.filesDir, "sessions"), "$it.csv") }
|
||||||
if (sessionCsv != null && sessionCsv.exists()) {
|
if (sessionCsv != null && sessionCsv.exists()) {
|
||||||
@@ -110,12 +99,11 @@ suspend fun buildTripCsv(
|
|||||||
suspend fun shareTripCsv(
|
suspend fun shareTripCsv(
|
||||||
context: Context,
|
context: Context,
|
||||||
trip: RecordedTripEntity,
|
trip: RecordedTripEntity,
|
||||||
events: List<DetectedEventEntity>,
|
|
||||||
v2xMessages: List<V2xMessageEntity>,
|
v2xMessages: List<V2xMessageEntity>,
|
||||||
) {
|
) {
|
||||||
val fileName = tripFileName(trip)
|
val fileName = tripFileName(trip)
|
||||||
val cacheFile = File(context.cacheDir, fileName)
|
val cacheFile = File(context.cacheDir, fileName)
|
||||||
val csv = buildTripCsv(context, trip, events, v2xMessages)
|
val csv = buildTripCsv(context, trip, v2xMessages)
|
||||||
|
|
||||||
withContext(Dispatchers.IO) { cacheFile.writeText(csv) }
|
withContext(Dispatchers.IO) { cacheFile.writeText(csv) }
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,9 @@ package com.hawhamburg.micr0bu.data
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.hawhamburg.micr0bu.data.db.AppDatabase
|
import com.hawhamburg.micr0bu.data.db.AppDatabase
|
||||||
import com.hawhamburg.micr0bu.data.db.DetectedEventEntity
|
|
||||||
import com.hawhamburg.micr0bu.data.db.RecordedTripEntity
|
import com.hawhamburg.micr0bu.data.db.RecordedTripEntity
|
||||||
import com.hawhamburg.micr0bu.data.db.V2xMessageEntity
|
import com.hawhamburg.micr0bu.data.db.V2xMessageEntity
|
||||||
import com.hawhamburg.micr0bu.domain.cam.Cam
|
import com.hawhamburg.micr0bu.domain.cam.Cam
|
||||||
import com.hawhamburg.micr0bu.domain.detection.DetectedEvent
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@@ -15,7 +13,7 @@ import java.io.File
|
|||||||
private const val TAG = "TripRepository"
|
private const val TAG = "TripRepository"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Repository that abstracts Room access for trips and detected events.
|
* Repository that abstracts Room access for trips and V2X messages.
|
||||||
*
|
*
|
||||||
* All suspend functions are safe to call from a coroutine running on any
|
* All suspend functions are safe to call from a coroutine running on any
|
||||||
* dispatcher; Room executes the actual SQL on its own I/O thread pool.
|
* dispatcher; Room executes the actual SQL on its own I/O thread pool.
|
||||||
@@ -81,14 +79,11 @@ class TripRepository(db: AppDatabase, private val context: Context) {
|
|||||||
* One-shot snapshots for export. The Flow-returning variants above stay observable for the UI;
|
* One-shot snapshots for export. The Flow-returning variants above stay observable for the UI;
|
||||||
* an export wants a value it can write out, not a stream it has to unsubscribe from.
|
* an export wants a value it can write out, not a stream it has to unsubscribe from.
|
||||||
*/
|
*/
|
||||||
suspend fun getEventsForTripOnce(tripId: Long): List<DetectedEventEntity> =
|
|
||||||
dao.getEventsForTrip(tripId).first()
|
|
||||||
|
|
||||||
suspend fun getV2xMessagesForTripOnce(tripId: Long): List<V2xMessageEntity> =
|
suspend fun getV2xMessagesForTripOnce(tripId: Long): List<V2xMessageEntity> =
|
||||||
dao.getV2xMessagesForTrip(tripId).first()
|
dao.getV2xMessagesForTrip(tripId).first()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deletes a trip and everything belonging to it: detected events and V2X messages go via the
|
* Deletes a trip and everything belonging to it: V2X messages go via the
|
||||||
* schema's CASCADE foreign keys, and the CSV recorded alongside it is removed here.
|
* schema's CASCADE foreign keys, and the CSV recorded alongside it is removed here.
|
||||||
*
|
*
|
||||||
* The CSV is a plain file outside the database, so nothing deletes it implicitly - before
|
* The CSV is a plain file outside the database, so nothing deletes it implicitly - before
|
||||||
@@ -109,32 +104,6 @@ class TripRepository(db: AppDatabase, private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Events ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Persists a domain [DetectedEvent] for the given [tripId].
|
|
||||||
* Converts the domain model to the Room entity.
|
|
||||||
*/
|
|
||||||
suspend fun insertEvent(tripId: Long, event: DetectedEvent) =
|
|
||||||
dao.insertEvent(
|
|
||||||
DetectedEventEntity(
|
|
||||||
tripId = tripId,
|
|
||||||
timestamp = event.timestamp,
|
|
||||||
type = event.type.name,
|
|
||||||
confidence = event.confidence.name,
|
|
||||||
latitude = event.latitude,
|
|
||||||
longitude = event.longitude,
|
|
||||||
speedMps = event.speedMps.toFloat(),
|
|
||||||
peakAccelMagnitude = event.peakAccelMagnitude.toFloat(),
|
|
||||||
peakGyroMagnitude = event.peakGyroMagnitude.toFloat(),
|
|
||||||
durationMs = event.durationMs,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Emits events for [tripId] ordered by timestamp, updating whenever the DB changes. */
|
|
||||||
fun getEventsForTrip(tripId: Long): Flow<List<DetectedEventEntity>> =
|
|
||||||
dao.getEventsForTrip(tripId)
|
|
||||||
|
|
||||||
// ── V2X messages (Phase 03) ──────────────────────────────────────────────────
|
// ── V2X messages (Phase 03) ──────────────────────────────────────────────────
|
||||||
// Retention policy: only ever called while a trip is actively recording — see
|
// Retention policy: only ever called while a trip is actively recording — see
|
||||||
// V2xMessageEntity's KDoc and CamUseCaseRepository.processedCam's collector in
|
// V2xMessageEntity's KDoc and CamUseCaseRepository.processedCam's collector in
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import com.hawhamburg.micr0bu.domain.spat.SpatEvent
|
|||||||
import com.hawhamburg.micr0bu.domain.usecase.UseCaseAlert
|
import com.hawhamburg.micr0bu.domain.usecase.UseCaseAlert
|
||||||
import com.hawhamburg.micr0bu.domain.usecase.UseCaseDetectionEngine
|
import com.hawhamburg.micr0bu.domain.usecase.UseCaseDetectionEngine
|
||||||
import com.hawhamburg.micr0bu.domain.usecase.UseCaseType
|
import com.hawhamburg.micr0bu.domain.usecase.UseCaseType
|
||||||
|
import com.hawhamburg.micr0bu.service.CamPinger
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -104,6 +105,8 @@ class CamUseCaseRepository @Inject constructor(
|
|||||||
private val usbSerialTransport: UsbSerialTransport,
|
private val usbSerialTransport: UsbSerialTransport,
|
||||||
private val camCodec: RealAsn1UperCodec,
|
private val camCodec: RealAsn1UperCodec,
|
||||||
private val obuHardwarePrefs: ObuHardwarePreferences,
|
private val obuHardwarePrefs: ObuHardwarePreferences,
|
||||||
|
private val pseudonymManager: PseudonymManager,
|
||||||
|
private val camPinger: CamPinger,
|
||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
) {
|
) {
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
@@ -312,8 +315,8 @@ class CamUseCaseRepository @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Our own station ID. On the CiT One path it's learned from v2x/rx/obu_gnss; the ESP32-C5
|
// Our own station ID. On the CiT One path it's learned from v2x/rx/obu_gnss; the ESP32-C5
|
||||||
// path has no such topic, so it comes from the same persisted value CamTransmitLoop puts
|
// path has no such topic, so it follows the current transmit pseudonym, the same one
|
||||||
// in outgoing CAMs.
|
// CamTransmitLoop puts in outgoing CAMs, across every rotation.
|
||||||
//
|
//
|
||||||
// Without this the ID stayed null on the ESP32 path and the self-heard-TX filter in
|
// Without this the ID stayed null on the ESP32 path and the self-heard-TX filter in
|
||||||
// [handleCamFromSerial] never fired - so the phone's own CAMs, which the ESP32 hears back
|
// [handleCamFromSerial] never fired - so the phone's own CAMs, which the ESP32 hears back
|
||||||
@@ -321,10 +324,13 @@ class CamUseCaseRepository @Inject constructor(
|
|||||||
// sitting exactly on top of the ego position, fed into the detection engine as a
|
// sitting exactly on top of the ego position, fed into the detection engine as a
|
||||||
// collision partner for itself.
|
// collision partner for itself.
|
||||||
scope.launch {
|
scope.launch {
|
||||||
obuHardwarePrefs.obuHardwareFlow.collect { hardware ->
|
combine(obuHardwarePrefs.obuHardwareFlow, pseudonymManager.currentFlow) { hardware, pseudonym ->
|
||||||
|
hardware to pseudonym
|
||||||
|
}.collect { (hardware, pseudonym) ->
|
||||||
currentHardware = hardware
|
currentHardware = hardware
|
||||||
if (hardware == ObuHardware.ESP32_C5) {
|
if (hardware == ObuHardware.ESP32_C5) {
|
||||||
_ownStationId.value = obuHardwarePrefs.getOrCreateOwnStationId()
|
// currentFlow re-emits on every rotation, so this tracks the live identity.
|
||||||
|
_ownStationId.value = (pseudonym ?: pseudonymManager.current()).stationId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -339,11 +345,17 @@ class CamUseCaseRepository @Inject constructor(
|
|||||||
* recognised as our own rather than tracked as another road user. Also drives the OWN/REMOTE
|
* recognised as our own rather than tracked as another road user. Also drives the OWN/REMOTE
|
||||||
* badges in the raw message list.
|
* badges in the raw message list.
|
||||||
*
|
*
|
||||||
* The rule lives in [OwnStationIds], which explains why there are two such ids and what goes
|
* The rule lives in [OwnStationIds], which explains which ids count and what goes wrong when
|
||||||
* wrong when only one of them is checked.
|
* one is missed. The set passed in holds the current transmit pseudonym and the ones it most
|
||||||
|
* recently replaced, plus, on the CiT One path, the OBU's own id from obu_gnss. The bench
|
||||||
|
* ping id counts only while this phone's own pinger is running.
|
||||||
*/
|
*/
|
||||||
fun isOwnStationId(stationId: Long): Boolean =
|
fun isOwnStationId(stationId: Long): Boolean =
|
||||||
OwnStationIds.isOwn(stationId, _ownStationId.value)
|
OwnStationIds.isOwn(
|
||||||
|
stationId,
|
||||||
|
ownIds = pseudonymManager.ownStationIds() + setOfNotNull(_ownStationId.value),
|
||||||
|
benchPingIsOurs = camPinger.benchPingIsOurs(),
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Primary ego state source: `v2x/rx/obu_gnss`, ~4 Hz, carries position/speed/heading/yaw
|
* Primary ego state source: `v2x/rx/obu_gnss`, ~4 Hz, carries position/speed/heading/yaw
|
||||||
@@ -392,9 +404,11 @@ class CamUseCaseRepository @Inject constructor(
|
|||||||
|
|
||||||
private fun handleCam(payload: String, timestamp: Long) {
|
private fun handleCam(payload: String, timestamp: Long) {
|
||||||
val cam = CamParser.parse(payload, _ownStationId.value, timestamp) ?: return
|
val cam = CamParser.parse(payload, _ownStationId.value, timestamp) ?: return
|
||||||
// A bench ping the CiT One's radio picked up and relayed here. Not a road user, and not
|
// This phone's own bench ping, relayed back by the CiT One's radio: not a road user, and not
|
||||||
// ego state either: the ping is built from the same phone GNSS the engine already has.
|
// ego state either, since it is built from the same phone GNSS the engine already has.
|
||||||
if (cam.stationId == OwnStationIds.BENCH_PING) return
|
// Only while this phone is the one pinging, though. Another phone's pings carry the same
|
||||||
|
// fixed id and are genuine remote traffic to this one.
|
||||||
|
if (cam.stationId == OwnStationIds.BENCH_PING && camPinger.benchPingIsOurs()) return
|
||||||
if (cam.isOwn) {
|
if (cam.isOwn) {
|
||||||
// Third fallback - the CAM topic's own low-rate entry. onOwnCam() keeps whichever
|
// Third fallback - the CAM topic's own low-rate entry. onOwnCam() keeps whichever
|
||||||
// update is freshest, so this only actually wins when both obu_gnss and phone GNSS
|
// update is freshest, so this only actually wins when both obu_gnss and phone GNSS
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package com.hawhamburg.micr0bu.data.cam
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import com.hawhamburg.micr0bu.data.mqtt.ObuHardwarePreferences
|
||||||
|
import com.hawhamburg.micr0bu.domain.cam.Pseudonym
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the phone's transmit identity on the ESP32-C5 path and rotates it every
|
||||||
|
* [Pseudonym.ROTATION_INTERVAL_MS].
|
||||||
|
*
|
||||||
|
* A singleton because there must be exactly one of these. [com.hawhamburg.micr0bu.service.CamTransmitLoop]
|
||||||
|
* runs inside the foreground recording service and [CamUseCaseRepository] filters received frames;
|
||||||
|
* if each held its own identity, the phone could transmit under one pseudonym while its receive
|
||||||
|
* path recognised another, which brings back the ghost road user sitting on the ego position.
|
||||||
|
* The bench pinger deliberately does not use this: it keeps a fixed identity so pings stay
|
||||||
|
* recognisable in a capture.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class PseudonymManager @Inject constructor(
|
||||||
|
private val prefs: ObuHardwarePreferences,
|
||||||
|
) {
|
||||||
|
private val mutex = Mutex()
|
||||||
|
|
||||||
|
private val _current = MutableStateFlow<Pseudonym?>(null)
|
||||||
|
|
||||||
|
/** The identity in use, or null before the first call to [current] has loaded one. */
|
||||||
|
val currentFlow: StateFlow<Pseudonym?> = _current.asStateFlow()
|
||||||
|
|
||||||
|
/** Station IDs replaced most recently, newest first. See [ownStationIds]. */
|
||||||
|
@Volatile private var recentlyRetired: List<Long> = emptyList()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every station ID one of our own frames could still be carrying: the current pseudonym's and
|
||||||
|
* the ones it replaced most recently.
|
||||||
|
*
|
||||||
|
* The previous IDs matter because the ESP32 hears our own transmissions back. A frame sent just
|
||||||
|
* before a rotation can come back just after it, and if its ID no longer counted as ours it
|
||||||
|
* would be tracked as another road user sitting exactly on the ego position.
|
||||||
|
*/
|
||||||
|
fun ownStationIds(): Set<Long> = buildSet {
|
||||||
|
_current.value?.let { add(it.stationId) }
|
||||||
|
addAll(recentlyRetired)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pseudonym to transmit under right now, rotating first if the current one has expired.
|
||||||
|
*
|
||||||
|
* Rotation happens here, at the moment an identity is about to be used, rather than on a
|
||||||
|
* timer. Each frame therefore carries one complete identity chosen in a single step, so a
|
||||||
|
* rotation can never land between the CAM being built and its position vector being attached.
|
||||||
|
*
|
||||||
|
* Persisted, so an app restart inside the interval keeps the same identity. Only elapsed time
|
||||||
|
* rotates it, never a crash or a relaunch.
|
||||||
|
*/
|
||||||
|
suspend fun current(nowMs: Long = System.currentTimeMillis()): Pseudonym = mutex.withLock {
|
||||||
|
val existing = _current.value ?: prefs.loadPseudonym()
|
||||||
|
if (existing != null && !existing.isExpired(nowMs)) {
|
||||||
|
_current.value = existing
|
||||||
|
existing
|
||||||
|
} else {
|
||||||
|
val next = Pseudonym.generate(nowMs)
|
||||||
|
prefs.savePseudonym(next)
|
||||||
|
if (existing != null) {
|
||||||
|
recentlyRetired = (listOf(existing.stationId) + recentlyRetired).take(RETIRED_TO_KEEP)
|
||||||
|
}
|
||||||
|
_current.update { next }
|
||||||
|
Log.i(TAG, "pseudonym rotated: station ${existing?.stationId} -> ${next.stationId}")
|
||||||
|
next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "PseudonymManager"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A loopback arrives within milliseconds, so one previous ID would already be ample. Two
|
||||||
|
* costs nothing and covers a rotation that fires twice in quick succession after a clock
|
||||||
|
* correction.
|
||||||
|
*/
|
||||||
|
const val RETIRED_TO_KEEP = 2
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,10 +11,9 @@ import androidx.sqlite.db.SupportSQLiteDatabase
|
|||||||
entities = [
|
entities = [
|
||||||
SessionEntity::class,
|
SessionEntity::class,
|
||||||
RecordedTripEntity::class,
|
RecordedTripEntity::class,
|
||||||
DetectedEventEntity::class,
|
|
||||||
V2xMessageEntity::class,
|
V2xMessageEntity::class,
|
||||||
],
|
],
|
||||||
version = 4,
|
version = 5,
|
||||||
exportSchema = false,
|
exportSchema = false,
|
||||||
)
|
)
|
||||||
abstract class AppDatabase : RoomDatabase() {
|
abstract class AppDatabase : RoomDatabase() {
|
||||||
@@ -34,13 +33,29 @@ abstract class AppDatabase : RoomDatabase() {
|
|||||||
AppDatabase::class.java,
|
AppDatabase::class.java,
|
||||||
"micr0bu.db",
|
"micr0bu.db",
|
||||||
)
|
)
|
||||||
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4)
|
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5)
|
||||||
.build()
|
.build()
|
||||||
.also { INSTANCE = it }
|
.also { INSTANCE = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Migrations ────────────────────────────────────────────────────────
|
// ── Migrations ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drops `detected_events`. The cyclist event detector still runs, but its output is now
|
||||||
|
* consumed only by the CAM transmit-rate policy (see EventDetector's KDoc) and is no
|
||||||
|
* longer persisted, displayed, or exported, so the table had no reader left.
|
||||||
|
*
|
||||||
|
* `trips.eventCount` is deliberately kept. Dropping a column means recreating `trips`
|
||||||
|
* and copying every recorded ride across, which is real risk for one unused integer;
|
||||||
|
* the service still writes an accurate count into it and the CSV header still reports it.
|
||||||
|
*/
|
||||||
|
private val MIGRATION_4_5 = object : Migration(4, 5) {
|
||||||
|
override fun migrate(database: SupportSQLiteDatabase) {
|
||||||
|
database.execSQL("DROP INDEX IF EXISTS `index_detected_events_tripId`")
|
||||||
|
database.execSQL("DROP TABLE IF EXISTS `detected_events`")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Two additions:
|
* Two additions:
|
||||||
* - `trips.sessionId` links a trip to the CSV recording session captured alongside it, so
|
* - `trips.sessionId` links a trip to the CSV recording session captured alongside it, so
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
package com.hawhamburg.micr0bu.data.db
|
|
||||||
|
|
||||||
import androidx.room.ColumnInfo
|
|
||||||
import androidx.room.Entity
|
|
||||||
import androidx.room.ForeignKey
|
|
||||||
import androidx.room.PrimaryKey
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One detected cyclist event (braking / turning / stopping) linked to a
|
|
||||||
* [RecordedTripEntity] via the [tripId] foreign key.
|
|
||||||
*
|
|
||||||
* [type] and [confidence] are stored as the enum name strings so that the
|
|
||||||
* database remains human-readable.
|
|
||||||
*/
|
|
||||||
@Entity(
|
|
||||||
tableName = "detected_events",
|
|
||||||
foreignKeys = [
|
|
||||||
ForeignKey(
|
|
||||||
entity = RecordedTripEntity::class,
|
|
||||||
parentColumns = ["id"],
|
|
||||||
childColumns = ["tripId"],
|
|
||||||
onDelete = ForeignKey.CASCADE,
|
|
||||||
)
|
|
||||||
],
|
|
||||||
)
|
|
||||||
data class DetectedEventEntity(
|
|
||||||
@PrimaryKey(autoGenerate = true)
|
|
||||||
val id: Long = 0,
|
|
||||||
|
|
||||||
@ColumnInfo(index = true)
|
|
||||||
val tripId: Long,
|
|
||||||
|
|
||||||
/** Wall-clock epoch ms of the first qualifying sensor frame. */
|
|
||||||
val timestamp: Long,
|
|
||||||
|
|
||||||
/** EventType.name — one of BRAKING, TURNING, STOPPING. */
|
|
||||||
val type: String,
|
|
||||||
|
|
||||||
/** Confidence.name — one of HIGH, MEDIUM, LOW. */
|
|
||||||
val confidence: String,
|
|
||||||
|
|
||||||
val latitude: Double,
|
|
||||||
val longitude: Double,
|
|
||||||
val speedMps: Float,
|
|
||||||
val peakAccelMagnitude: Float,
|
|
||||||
val peakGyroMagnitude: Float,
|
|
||||||
|
|
||||||
/** Duration from first qualifying frame to emission (ms). */
|
|
||||||
val durationMs: Long,
|
|
||||||
)
|
|
||||||
@@ -27,17 +27,6 @@ interface TripDao {
|
|||||||
@Query("DELETE FROM trips WHERE id = :id")
|
@Query("DELETE FROM trips WHERE id = :id")
|
||||||
suspend fun deleteTripById(id: Long)
|
suspend fun deleteTripById(id: Long)
|
||||||
|
|
||||||
// ── Events ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
|
||||||
suspend fun insertEvent(event: DetectedEventEntity)
|
|
||||||
|
|
||||||
@Query("SELECT * FROM detected_events WHERE tripId = :tripId ORDER BY timestamp ASC")
|
|
||||||
fun getEventsForTrip(tripId: Long): Flow<List<DetectedEventEntity>>
|
|
||||||
|
|
||||||
@Query("SELECT COUNT(*) FROM detected_events WHERE tripId = :tripId")
|
|
||||||
suspend fun getEventCountForTrip(tripId: Long): Int
|
|
||||||
|
|
||||||
// ── V2X messages (Phase 03) ──────────────────────────────────────────────────
|
// ── V2X messages (Phase 03) ──────────────────────────────────────────────────
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
|||||||
@@ -6,12 +6,13 @@ import androidx.datastore.preferences.core.longPreferencesKey
|
|||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import androidx.datastore.preferences.preferencesDataStore
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
import com.hawhamburg.micr0bu.data.transport.ObuHardware
|
import com.hawhamburg.micr0bu.data.transport.ObuHardware
|
||||||
|
import com.hawhamburg.micr0bu.domain.cam.Pseudonym
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
import kotlin.random.Random
|
|
||||||
|
|
||||||
private val Context.obuHardwareDataStore by preferencesDataStore(name = "obu_hardware_prefs")
|
private val Context.obuHardwareDataStore by preferencesDataStore(name = "obu_hardware_prefs")
|
||||||
|
|
||||||
@@ -26,7 +27,11 @@ class ObuHardwarePreferences @Inject constructor(
|
|||||||
) {
|
) {
|
||||||
private object Keys {
|
private object Keys {
|
||||||
val OBU_HARDWARE = stringPreferencesKey("obu_hardware")
|
val OBU_HARDWARE = stringPreferencesKey("obu_hardware")
|
||||||
|
// The current transmit pseudonym. Three keys, but only ever read or written together;
|
||||||
|
// see loadPseudonym.
|
||||||
val OWN_STATION_ID = longPreferencesKey("own_station_id")
|
val OWN_STATION_ID = longPreferencesKey("own_station_id")
|
||||||
|
val OWN_MAC = stringPreferencesKey("own_mac")
|
||||||
|
val OWN_PSEUDONYM_CREATED_MS = longPreferencesKey("own_pseudonym_created_ms")
|
||||||
}
|
}
|
||||||
|
|
||||||
val obuHardwareFlow: Flow<ObuHardware> = context.obuHardwareDataStore.data.map { prefs ->
|
val obuHardwareFlow: Flow<ObuHardware> = context.obuHardwareDataStore.data.map { prefs ->
|
||||||
@@ -37,31 +42,36 @@ class ObuHardwarePreferences @Inject constructor(
|
|||||||
context.obuHardwareDataStore.edit { prefs -> prefs[Keys.OBU_HARDWARE] = hardware.id }
|
context.obuHardwareDataStore.edit { prefs -> prefs[Keys.OBU_HARDWARE] = hardware.id }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** This device's own CAM StationID, or null if one hasn't been assigned yet. */
|
/**
|
||||||
val ownStationIdFlow: Flow<Long?> = context.obuHardwareDataStore.data.map { prefs ->
|
* The transmit pseudonym last saved by [savePseudonym], or null if there is none.
|
||||||
prefs[Keys.OWN_STATION_ID]
|
*
|
||||||
|
* All three parts must be present. An install from before pseudonym rotation has a station ID
|
||||||
|
* but no MAC or creation time, and loads as null so that a complete new pseudonym is
|
||||||
|
* generated. Keeping the old ID alongside a fresh MAC would be exactly the partial rotation
|
||||||
|
* [Pseudonym] exists to rule out.
|
||||||
|
*
|
||||||
|
* Only [com.hawhamburg.micr0bu.data.cam.PseudonymManager] should call this: it is the one
|
||||||
|
* owner of the phone's transmit identity.
|
||||||
|
*/
|
||||||
|
suspend fun loadPseudonym(): Pseudonym? {
|
||||||
|
val prefs = context.obuHardwareDataStore.data.first()
|
||||||
|
val stationId = prefs[Keys.OWN_STATION_ID] ?: return null
|
||||||
|
val mac = prefs[Keys.OWN_MAC]?.let(::macFromHex) ?: return null
|
||||||
|
val createdAtMs = prefs[Keys.OWN_PSEUDONYM_CREATED_MS] ?: return null
|
||||||
|
return Pseudonym(stationId, mac, createdAtMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Persists [pseudonym] in a single edit, so a crash can never leave half an identity stored. */
|
||||||
* Returns this device's own CAM StationID, generating and persisting a random one on first
|
suspend fun savePseudonym(pseudonym: Pseudonym) {
|
||||||
* call.
|
context.obuHardwareDataStore.edit { p ->
|
||||||
*
|
p[Keys.OWN_STATION_ID] = pseudonym.stationId
|
||||||
* Replaces the previous hardcoded 0: receivers key on StationID to track a station across
|
p[Keys.OWN_MAC] = pseudonym.mac.joinToString("") { "%02x".format(it) }
|
||||||
* successive CAMs, so every MicrOBU broadcasting 0 makes two units in the same area
|
p[Keys.OWN_PSEUDONYM_CREATED_MS] = pseudonym.createdAtMs
|
||||||
* indistinguishable to any receiver — including this app's own detection engine, which
|
|
||||||
* dedupes remote stations by ID. Random rather than derived from a hardware identifier both
|
|
||||||
* because ETSI expects station IDs to be pseudonymous and because Android hardware IDs aren't
|
|
||||||
* readable without privileged permissions on modern versions.
|
|
||||||
*
|
|
||||||
* Range is 1..2^32-2: StationID is INTEGER(0..4294967295), and 0 is avoided so leftover
|
|
||||||
* placeholder traffic stays distinguishable from a real assignment.
|
|
||||||
*/
|
|
||||||
suspend fun getOrCreateOwnStationId(): Long {
|
|
||||||
val prefs = context.obuHardwareDataStore.edit { p ->
|
|
||||||
if (p[Keys.OWN_STATION_ID] == null) {
|
|
||||||
p[Keys.OWN_STATION_ID] = Random.nextLong(1L, 0xFFFF_FFFEL)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return prefs[Keys.OWN_STATION_ID]!!
|
|
||||||
}
|
private fun macFromHex(hex: String): ByteArray? =
|
||||||
|
if (hex.length != 12) null
|
||||||
|
else runCatching { ByteArray(6) { i -> hex.substring(2 * i, 2 * i + 2).toInt(16).toByte() } }
|
||||||
|
.getOrNull()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
package com.hawhamburg.micr0bu.data.transport
|
package com.hawhamburg.micr0bu.data.transport
|
||||||
|
|
||||||
|
import com.hawhamburg.micr0bu.domain.asn1.ItsTime
|
||||||
|
import com.hawhamburg.micr0bu.domain.cam.Cam
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
import kotlin.math.roundToLong
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Binary framing for the phone <-> ESP32-C5 link (Phase 03). Kotlin counterpart of the
|
* Binary framing for the phone <-> ESP32-C5 link (Phase 03). Kotlin counterpart of the
|
||||||
* firmware's `obu-firmware/main/serial_link.c`/`.h` — frame shape and CRC algorithm MUST stay
|
* firmware's `obu-firmware/main/serial_link.c`/`.h` — frame shape and CRC algorithm MUST stay
|
||||||
@@ -23,6 +28,15 @@ object SerialFrameType {
|
|||||||
/** ESP32 -> phone: periodic heartbeat + drop counters, independent of CAM traffic.
|
/** ESP32 -> phone: periodic heartbeat + drop counters, independent of CAM traffic.
|
||||||
* Payload layout is [EspLinkStatus] — see its KDoc. */
|
* Payload layout is [EspLinkStatus] — see its KDoc. */
|
||||||
const val STATUS: Int = 0x03
|
const val STATUS: Int = 0x03
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phone -> ESP32: a CAM together with the GeoNetworking Source Position Vector to transmit it
|
||||||
|
* under. Payload is the [GnPositionVector.PREFIX_SIZE]-byte [GnPositionVector] prefix, then
|
||||||
|
* the CAM UPER. Sent only to firmware whose heartbeat advertises
|
||||||
|
* [EspLinkStatus.supportsCamTxPv]; `serial_link.h` explains why this is a new type rather
|
||||||
|
* than a changed [CAM_TX].
|
||||||
|
*/
|
||||||
|
const val CAM_TX_PV: Int = 0x05
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -59,10 +73,22 @@ data class EspLinkStatus(
|
|||||||
val txFailures: Int,
|
val txFailures: Int,
|
||||||
/** Frames from the phone the firmware dropped on CRC mismatch. */
|
/** Frames from the phone the firmware dropped on CRC mismatch. */
|
||||||
val rxCrcErrors: Int,
|
val rxCrcErrors: Int,
|
||||||
|
/**
|
||||||
|
* What the firmware accepts, as `SERIAL_CAP_*` bits from `serial_link.h`. Byte 7 of the
|
||||||
|
* payload; 0 for firmware that predates it and sends only 7 bytes, which is exactly the answer
|
||||||
|
* the phone needs from such firmware: it accepts nothing beyond the original messages.
|
||||||
|
*/
|
||||||
|
val capabilities: Int = 0,
|
||||||
) {
|
) {
|
||||||
|
/** True when the firmware accepts [SerialFrameType.CAM_TX_PV]. */
|
||||||
|
val supportsCamTxPv: Boolean get() = capabilities and CAP_CAM_TX_PV != 0
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val PAYLOAD_SIZE = 7
|
const val PAYLOAD_SIZE = 7
|
||||||
|
|
||||||
|
/** Mirrors `SERIAL_CAP_CAM_TX_PV` in `serial_link.h`. */
|
||||||
|
const val CAP_CAM_TX_PV = 0x01
|
||||||
|
|
||||||
/** Returns null if [payload] isn't a well-formed status payload (e.g. older firmware). */
|
/** Returns null if [payload] isn't a well-formed status payload (e.g. older firmware). */
|
||||||
fun parse(payload: ByteArray): EspLinkStatus? {
|
fun parse(payload: ByteArray): EspLinkStatus? {
|
||||||
if (payload.size < PAYLOAD_SIZE) return null
|
if (payload.size < PAYLOAD_SIZE) return null
|
||||||
@@ -72,6 +98,7 @@ data class EspLinkStatus(
|
|||||||
oversizeDrops = u16(1),
|
oversizeDrops = u16(1),
|
||||||
txFailures = u16(3),
|
txFailures = u16(3),
|
||||||
rxCrcErrors = u16(5),
|
rxCrcErrors = u16(5),
|
||||||
|
capabilities = if (payload.size > PAYLOAD_SIZE) payload[7].toInt() and 0xFF else 0,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -161,6 +188,116 @@ data class V2xRxFrame(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The GeoNetworking Source Position Vector content sent with each CAM: the 24-byte little-endian
|
||||||
|
* prefix of a [SerialFrameType.CAM_TX_PV] payload. Must stay in lockstep with the layout at
|
||||||
|
* `SERIAL_MSG_CAM_TX_PV` in `serial_link.h`, which the firmware decodes into `gn_lpv_t`.
|
||||||
|
*
|
||||||
|
* Every field is something the ESP32-C5 cannot know by itself, since it has no GNSS and no clock
|
||||||
|
* on the OCB channel. That is why its GN header used to carry fixed bench placeholders instead,
|
||||||
|
* describing a stationary car at the bench while the CAM inside described the moving rider.
|
||||||
|
*/
|
||||||
|
data class GnPositionVector(
|
||||||
|
/** Pseudonym, 6 bytes: both the 802.11 source address and the GN_ADDR MID. */
|
||||||
|
val mac: ByteArray,
|
||||||
|
/** TS 102 894-2 StationType. */
|
||||||
|
val stationType: Int,
|
||||||
|
/** Position Accuracy Indicator. */
|
||||||
|
val pai: Boolean,
|
||||||
|
/** TimestampIts at which the position was acquired; reduced modulo 2^32 on the wire. */
|
||||||
|
val tstMs: Long,
|
||||||
|
/** 1/10 microdegree. */
|
||||||
|
val latTenMicroDeg: Int,
|
||||||
|
/** 1/10 microdegree. */
|
||||||
|
val lonTenMicroDeg: Int,
|
||||||
|
/** 0.01 m/s, within the GN field's 15-bit signed range. */
|
||||||
|
val speedCms: Int,
|
||||||
|
/** 0.1 degree from north, clockwise, 0..3599. */
|
||||||
|
val headingDeciDeg: Int,
|
||||||
|
) {
|
||||||
|
init {
|
||||||
|
require(mac.size == 6) { "a MAC is 6 bytes, got ${mac.size}" }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The 24-byte prefix, little-endian like the rest of this framing. */
|
||||||
|
fun toSerialPrefix(): ByteArray {
|
||||||
|
val out = ByteArray(PREFIX_SIZE)
|
||||||
|
mac.copyInto(out, destinationOffset = 0)
|
||||||
|
out[6] = stationType.toByte()
|
||||||
|
out[7] = (if (pai) 0x01 else 0x00).toByte()
|
||||||
|
putLe(out, 8, tstMs, 4)
|
||||||
|
putLe(out, 12, latTenMicroDeg.toLong(), 4)
|
||||||
|
putLe(out, 16, lonTenMicroDeg.toLong(), 4)
|
||||||
|
putLe(out, 20, speedCms.toLong(), 2)
|
||||||
|
putLe(out, 22, headingDeciDeg.toLong(), 2)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generated equals/hashCode would compare the MAC array by identity.
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (other !is GnPositionVector) return false
|
||||||
|
return mac.contentEquals(other.mac) && stationType == other.stationType &&
|
||||||
|
pai == other.pai && tstMs == other.tstMs && latTenMicroDeg == other.latTenMicroDeg &&
|
||||||
|
lonTenMicroDeg == other.lonTenMicroDeg && speedCms == other.speedCms &&
|
||||||
|
headingDeciDeg == other.headingDeciDeg
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
var h = mac.contentHashCode()
|
||||||
|
for (v in listOf(stationType, pai.hashCode(), tstMs.hashCode(), latTenMicroDeg,
|
||||||
|
lonTenMicroDeg, speedCms, headingDeciDeg)) h = 31 * h + v
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val PREFIX_SIZE = 24
|
||||||
|
|
||||||
|
/** The GN speed field is 15-bit signed, in 0.01 m/s. */
|
||||||
|
const val SPEED_MIN_CMS = -16384
|
||||||
|
const val SPEED_MAX_CMS = 16383
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Largest Android horizontal accuracy, in metres, that still sets the Position Accuracy
|
||||||
|
* Indicator.
|
||||||
|
*
|
||||||
|
* EN 302 636-4-1 sets PAI when the 95% semi-major confidence is below itsGnPaiInterval / 2,
|
||||||
|
* and itsGnPaiInterval defaults to 80 m, so the bound is 40 m at 95%. Android reports a 68%
|
||||||
|
* radius instead, and for a circular 2-D error the 95% radius is about 1.62 times the 68%
|
||||||
|
* one, so 40 m becomes about 24.7 m on Android's scale.
|
||||||
|
*/
|
||||||
|
const val PAI_MAX_ACCURACY_M = 24.7f
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The position vector for [cam], built from the same values the CAM payload carries, so
|
||||||
|
* the two layers of one frame describe the same station at the same moment. [accuracyM] is
|
||||||
|
* Android's horizontal accuracy; null or 0 means unknown and leaves PAI clear.
|
||||||
|
*/
|
||||||
|
fun fromCam(cam: Cam, accuracyM: Float?, mac: ByteArray): GnPositionVector =
|
||||||
|
GnPositionVector(
|
||||||
|
mac = mac,
|
||||||
|
stationType = cam.stationType,
|
||||||
|
pai = accuracyM != null && accuracyM > 0f && accuracyM <= PAI_MAX_ACCURACY_M,
|
||||||
|
tstMs = ItsTime.timestampIts(cam.timestamp),
|
||||||
|
// Same rounding as CamUperCodec's referencePosition, so the GN position and the
|
||||||
|
// CAM's own position agree to the last digit.
|
||||||
|
latTenMicroDeg = (cam.latitude * 1e7).roundToLong().toInt(),
|
||||||
|
lonTenMicroDeg = (cam.longitude * 1e7).roundToLong().toInt(),
|
||||||
|
// Clamped, never wrapped: a wrapped 15-bit speed flips sign and reads as reversing.
|
||||||
|
speedCms = if (cam.speedMps.isFinite()) {
|
||||||
|
(cam.speedMps * 100).roundToInt().coerceIn(SPEED_MIN_CMS, SPEED_MAX_CMS)
|
||||||
|
} else 0,
|
||||||
|
headingDeciDeg = if (cam.headingDeg.isFinite()) {
|
||||||
|
Math.floorMod((cam.headingDeg * 10).roundToInt(), 3600)
|
||||||
|
} else 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun putLe(out: ByteArray, offset: Int, value: Long, bytes: Int) {
|
||||||
|
for (i in 0 until bytes) out[offset + i] = ((value ushr (8 * i)) and 0xFF).toByte()
|
||||||
|
}
|
||||||
|
|
||||||
data class DecodedFrame(val type: Int, val payload: ByteArray)
|
data class DecodedFrame(val type: Int, val payload: ByteArray)
|
||||||
|
|
||||||
object SerialFrameEncoder {
|
object SerialFrameEncoder {
|
||||||
|
|||||||
@@ -329,12 +329,14 @@ class UsbSerialTransport @Inject constructor(
|
|||||||
prev.oversizeDrops != status.oversizeDrops ||
|
prev.oversizeDrops != status.oversizeDrops ||
|
||||||
prev.txFailures != status.txFailures ||
|
prev.txFailures != status.txFailures ||
|
||||||
prev.rxCrcErrors != status.rxCrcErrors ||
|
prev.rxCrcErrors != status.rxCrcErrors ||
|
||||||
prev.status != status.status
|
prev.status != status.status ||
|
||||||
|
prev.capabilities != status.capabilities
|
||||||
) {
|
) {
|
||||||
Log.i(TAG, "ESP32 counters: status=${status.status} " +
|
Log.i(TAG, "ESP32 counters: status=${status.status} " +
|
||||||
"oversizeDrops=${status.oversizeDrops} " +
|
"oversizeDrops=${status.oversizeDrops} " +
|
||||||
"txFailures=${status.txFailures} " +
|
"txFailures=${status.txFailures} " +
|
||||||
"rxCrcErrors=${status.rxCrcErrors}")
|
"rxCrcErrors=${status.rxCrcErrors} " +
|
||||||
|
"capabilities=${status.capabilities}")
|
||||||
}
|
}
|
||||||
_linkStatus.value = status
|
_linkStatus.value = status
|
||||||
}
|
}
|
||||||
@@ -386,6 +388,25 @@ class UsbSerialTransport @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Which frame type the last CAM went out as, so a change of path is logged once, not per CAM. */
|
||||||
|
@Volatile private var lastTxWithPositionVector: Boolean? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs whenever CAMs switch between [SerialFrameType.CAM_TX_PV] and legacy
|
||||||
|
* [SerialFrameType.CAM_TX]. Without it, "the GN header still says bench" has no visible cause
|
||||||
|
* in a logcat capture: it looks identical whether the firmware is old or the phone is.
|
||||||
|
*/
|
||||||
|
private fun noteTxPath(withPositionVector: Boolean, requested: Boolean) {
|
||||||
|
if (lastTxWithPositionVector == withPositionVector) return
|
||||||
|
lastTxWithPositionVector = withPositionVector
|
||||||
|
Log.i(TAG, when {
|
||||||
|
withPositionVector -> "CAM TX path: CAM_TX_PV, GN position vector supplied by the phone"
|
||||||
|
requested -> "CAM TX path: legacy CAM_TX, firmware has not advertised CAM_TX_PV yet; " +
|
||||||
|
"GN position vector is the firmware's bench placeholder"
|
||||||
|
else -> "CAM TX path: legacy CAM_TX, no position vector supplied"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encodes [camUperBytes] as a [SerialFrameType.CAM_TX] frame and writes it to the port.
|
* Encodes [camUperBytes] as a [SerialFrameType.CAM_TX] frame and writes it to the port.
|
||||||
* No-op (returns false) if not currently connected — callers (the CAM transmit loop) should
|
* No-op (returns false) if not currently connected — callers (the CAM transmit loop) should
|
||||||
@@ -394,15 +415,28 @@ class UsbSerialTransport @Inject constructor(
|
|||||||
* [consecutiveWriteFailures] so they can't stay invisible.
|
* [consecutiveWriteFailures] so they can't stay invisible.
|
||||||
*
|
*
|
||||||
* Blocking: writes with a 200 ms timeout, so call from a background dispatcher.
|
* Blocking: writes with a 200 ms timeout, so call from a background dispatcher.
|
||||||
|
*
|
||||||
|
* [positionVector], when given, travels with the CAM as a [SerialFrameType.CAM_TX_PV] frame so
|
||||||
|
* the ESP32 builds the GeoNetworking Source Position Vector from real values. It is used only
|
||||||
|
* once the heartbeat advertises [EspLinkStatus.supportsCamTxPv]. Until then, and against
|
||||||
|
* firmware that predates it, the CAM goes out as a plain [SerialFrameType.CAM_TX] exactly as
|
||||||
|
* before and the GN header carries the firmware's bench placeholders. Neither mixed-version
|
||||||
|
* combination breaks transmission; `serial_link.h` explains why.
|
||||||
*/
|
*/
|
||||||
fun sendCamTx(camUperBytes: ByteArray): Boolean {
|
fun sendCamTx(camUperBytes: ByteArray, positionVector: GnPositionVector? = null): Boolean {
|
||||||
val p = port
|
val p = port
|
||||||
if (p == null) {
|
if (p == null) {
|
||||||
_consecutiveWriteFailures.update { it + 1 }
|
_consecutiveWriteFailures.update { it + 1 }
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return try {
|
return try {
|
||||||
val frame = SerialFrameEncoder.encode(SerialFrameType.CAM_TX, camUperBytes)
|
val pv = positionVector?.takeIf { _linkStatus.value?.supportsCamTxPv == true }
|
||||||
|
noteTxPath(withPositionVector = pv != null, requested = positionVector != null)
|
||||||
|
val frame = if (pv != null) {
|
||||||
|
SerialFrameEncoder.encode(SerialFrameType.CAM_TX_PV, pv.toSerialPrefix() + camUperBytes)
|
||||||
|
} else {
|
||||||
|
SerialFrameEncoder.encode(SerialFrameType.CAM_TX, camUperBytes)
|
||||||
|
}
|
||||||
p.write(frame, /* timeout ms */ 200)
|
p.write(frame, /* timeout ms */ 200)
|
||||||
_consecutiveWriteFailures.value = 0
|
_consecutiveWriteFailures.value = 0
|
||||||
true
|
true
|
||||||
|
|||||||
@@ -34,9 +34,6 @@ object CamUperCodec {
|
|||||||
/** Encode buffer size — matches `cam.c`'s `cam_payload[96]`, the known-sufficient size. */
|
/** Encode buffer size — matches `cam.c`'s `cam_payload[96]`, the known-sufficient size. */
|
||||||
private const val ENCODE_BUFFER_BYTES = 96
|
private const val ENCODE_BUFFER_BYTES = 96
|
||||||
|
|
||||||
// TimestampIts epoch: 2004-01-01T00:00:00Z, in Unix epoch milliseconds.
|
|
||||||
private const val TS_ITS_EPOCH_MS = 1_072_915_200_000L
|
|
||||||
|
|
||||||
// ASN.1 "unavailable" sentinel values, straight from the CAM/ITS-Container modules (also
|
// ASN.1 "unavailable" sentinel values, straight from the CAM/ITS-Container modules (also
|
||||||
// documented inline in cam.c against each field).
|
// documented inline in cam.c against each field).
|
||||||
private const val HEADING_UNAVAILABLE = 3601
|
private const val HEADING_UNAVAILABLE = 3601
|
||||||
@@ -47,9 +44,13 @@ object CamUperCodec {
|
|||||||
private const val ACCEL_UNAVAILABLE = 161
|
private const val ACCEL_UNAVAILABLE = 161
|
||||||
private const val YAW_RATE_UNAVAILABLE = 32767
|
private const val YAW_RATE_UNAVAILABLE = 32767
|
||||||
|
|
||||||
/** Converts a wall-clock epoch-ms timestamp to a UPER GenerationDeltaTime (TimestampIts mod 65536). */
|
/**
|
||||||
|
* Converts a wall-clock epoch-ms timestamp to a UPER GenerationDeltaTime (TimestampIts mod
|
||||||
|
* 65536). Goes through [ItsTime], the same rule the GeoNetworking TST uses, so the two
|
||||||
|
* timestamps in one transmitted frame cannot disagree.
|
||||||
|
*/
|
||||||
fun generationDeltaTime(epochMs: Long): Int {
|
fun generationDeltaTime(epochMs: Long): Int {
|
||||||
val itsMs = epochMs - TS_ITS_EPOCH_MS
|
val itsMs = ItsTime.timestampIts(epochMs)
|
||||||
// floorMod so this stays well-defined even for epochMs before the ITS epoch (shouldn't
|
// floorMod so this stays well-defined even for epochMs before the ITS epoch (shouldn't
|
||||||
// happen with a real clock, but avoids a negative/UB result if it ever does).
|
// happen with a real clock, but avoids a negative/UB result if it ever does).
|
||||||
return Math.floorMod(itsMs, 65536L).toInt()
|
return Math.floorMod(itsMs, 65536L).toInt()
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.hawhamburg.micr0bu.domain.asn1
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ITS time, as used by every timestamp this app puts on the air.
|
||||||
|
*
|
||||||
|
* TimestampIts (ETSI TS 102 894-2) counts milliseconds from 2004-01-01T00:00:00Z. Two fields in a
|
||||||
|
* single transmitted frame come from it: the CAM's generationDeltaTime (modulo 65536) and the
|
||||||
|
* GeoNetworking Source Position Vector's TST (modulo 2^32). A receiver can compare the two, so
|
||||||
|
* they must follow one rule. Both go through here so they cannot drift apart.
|
||||||
|
*
|
||||||
|
* **Which clock.** The input should be GNSS time, not the phone's wall clock. A phone with no SIM
|
||||||
|
* and no internet time has no automatic time source at all, and the bench phone was found 24
|
||||||
|
* minutes fast that way. `GnssTimeSource` moves a timestamp onto GNSS time, using [onGnssTime],
|
||||||
|
* before it gets here.
|
||||||
|
*
|
||||||
|
* **Open question: leap seconds.** This is Unix time minus the 2004 epoch, with no leap-second
|
||||||
|
* term. If TimestampIts is read as TAI-based, the correct value is currently 5 s higher, for the
|
||||||
|
* five leap seconds inserted since 2004. Whichever reading turns out right, it is changed here and
|
||||||
|
* nowhere else. Settling it needs a frame from a third-party stack with a trusted clock, such as
|
||||||
|
* the RSU's CAM compared against GNSS time, and no such traffic was on air when this was written.
|
||||||
|
*/
|
||||||
|
object ItsTime {
|
||||||
|
/** 2004-01-01T00:00:00Z in Unix epoch milliseconds. */
|
||||||
|
const val EPOCH_MS = 1_072_915_200_000L
|
||||||
|
|
||||||
|
/** TimestampIts for wall-clock [epochMs], before any modulo is applied. */
|
||||||
|
fun timestampIts(epochMs: Long): Long = epochMs - EPOCH_MS
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves [systemMs], a reading of this phone's wall clock, onto GNSS time, using one pair of
|
||||||
|
* simultaneous readings of both clocks: [gnssNowMs] and [systemNowMs]. Their difference is the
|
||||||
|
* wall clock's error, whatever caused it, and the age of [systemMs] is preserved. Returns
|
||||||
|
* [systemMs] unchanged when there is no GNSS reading.
|
||||||
|
*
|
||||||
|
* Pure so the arithmetic can be tested apart from the Android clock API, which is where the
|
||||||
|
* readings come from (see `GnssTimeSource`).
|
||||||
|
*/
|
||||||
|
fun onGnssTime(systemMs: Long, gnssNowMs: Long?, systemNowMs: Long): Long =
|
||||||
|
if (gnssNowMs == null) systemMs else systemMs + (gnssNowMs - systemNowMs)
|
||||||
|
}
|
||||||
@@ -5,26 +5,27 @@ package com.hawhamburg.micr0bu.domain.cam
|
|||||||
* user when a frame comes back off the air.
|
* user when a frame comes back off the air.
|
||||||
*
|
*
|
||||||
* ## Why this exists
|
* ## Why this exists
|
||||||
* On the ESP32-C5 path the radio receives promiscuously, so it hears the phone's own
|
* A receiver that fails to recognise its own transmissions tracks itself: a station sitting exactly
|
||||||
* transmissions. Anything that decodes received CAMs has to recognise them, or the phone tracks
|
* on top of the ego position, moving at the ego's own speed and heading, handed to
|
||||||
* itself: a station sitting exactly on top of the ego position, moving at the ego's own speed and
|
* [com.hawhamburg.micr0bu.domain.usecase.UseCaseDetectionEngine] as a collision partner for itself.
|
||||||
* heading, handed to [com.hawhamburg.micr0bu.domain.usecase.UseCaseDetectionEngine] as a
|
* The phone's own frames can come back to it off the air, for example relayed by the CiT One's
|
||||||
* collision partner for itself.
|
* radio when a phone is connected to both OBUs at once.
|
||||||
*
|
*
|
||||||
* ## Why two IDs
|
* ## Which IDs count
|
||||||
* The phone transmits under two different station IDs by design:
|
* - The current transmit pseudonym used by [com.hawhamburg.micr0bu.service.CamTransmitLoop], and
|
||||||
|
* the one or two it most recently replaced. The pseudonym rotates every ten minutes (see
|
||||||
|
* [Pseudonym]), and a frame sent just before a rotation can come back just after it, so a
|
||||||
|
* retired ID has to stay ours for a while. `PseudonymManager.ownStationIds()` supplies these.
|
||||||
|
* - On the CiT One path, the OBU's own ID learned from obu_gnss.
|
||||||
|
* - [BENCH_PING], but only while this phone's own pinger is running or has just stopped. See
|
||||||
|
* [benchPingIsOurs].
|
||||||
*
|
*
|
||||||
* - [com.hawhamburg.micr0bu.service.CamTransmitLoop] uses the persisted per-install ID from
|
* ## Why the bench ID is conditional
|
||||||
* `ObuHardwarePreferences.getOrCreateOwnStationId()`, which is the real identity this station
|
* It used to count as ours unconditionally, on every phone, and that hid other phones' pings. On
|
||||||
* presents to the world.
|
* the 2026-09-10 bench one phone pinged through an ESP32 while a second phone watched through the
|
||||||
* - [com.hawhamburg.micr0bu.service.CamPinger] uses [BENCH_PING], a fixed and recognisable value,
|
* CiT One, and the watcher silently discarded every ping as its own frame heard back, although it
|
||||||
* so manual bench pings stay identifiable in captures and cannot be confused with the
|
* had sent none. A fixed ID shared by every MicrOBU is only ours on the phone actually using it.
|
||||||
* recording-driven stream when both run at once.
|
* The one case this cannot resolve is two phones pinging at the same time: each hides the other.
|
||||||
*
|
|
||||||
* That second ID is the whole reason this object exists. A filter that knew only the persisted ID
|
|
||||||
* let every bench ping return as a ghost road user, which is the bug this centralises the fix
|
|
||||||
* for. Keeping the rule in one place, in a layer with no Android dependencies, is what makes it
|
|
||||||
* testable and what stops the next transmit path from reintroducing the same gap.
|
|
||||||
*/
|
*/
|
||||||
object OwnStationIds {
|
object OwnStationIds {
|
||||||
|
|
||||||
@@ -34,21 +35,47 @@ object OwnStationIds {
|
|||||||
*/
|
*/
|
||||||
const val BENCH_PING = 999_999L
|
const val BENCH_PING = 999_999L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bench pinger's link-layer address, which the ESP32 writes into both the 802.11 source
|
||||||
|
* address and the GN_ADDR MID. It is the address the firmware always used for its fixed
|
||||||
|
* pseudonym, so bench traffic looks the same in a capture before and after the phone took
|
||||||
|
* over the GeoNetworking identity. A fresh copy each time, so no caller can alter it for all.
|
||||||
|
*/
|
||||||
|
val BENCH_PING_MAC: ByteArray get() = byteArrayOf(0x02, 0x00, 0x00, 0x00, 0x00, 0x01)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long after this phone's pinger stops its pings still count as ours. A frame sent just
|
||||||
|
* before Stop can arrive just after it, relayed through another radio. A relay takes a
|
||||||
|
* fraction of a second, so five seconds leaves ample margin without hiding a genuine sender
|
||||||
|
* for long.
|
||||||
|
*/
|
||||||
|
const val BENCH_PING_GRACE_MS = 5_000L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when station [BENCH_PING] on air is this phone's own ping: while [pingerActive], or
|
||||||
|
* within [BENCH_PING_GRACE_MS] of [pingerStoppedAtMs]. Both times must come from one monotonic
|
||||||
|
* clock. A [nowMs] earlier than the stop time means that clock is not monotonic after all, and
|
||||||
|
* the ping is then not claimed.
|
||||||
|
*/
|
||||||
|
fun benchPingIsOurs(pingerActive: Boolean, pingerStoppedAtMs: Long?, nowMs: Long): Boolean {
|
||||||
|
if (pingerActive) return true
|
||||||
|
val stoppedAt = pingerStoppedAtMs ?: return false
|
||||||
|
return nowMs - stoppedAt in 0..BENCH_PING_GRACE_MS
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* True when [stationId] is one this phone transmits under.
|
* True when [stationId] is one this phone transmits under.
|
||||||
*
|
*
|
||||||
* [persistedOwnId] is the per-install station ID, or null before it has been loaded. Station
|
* [ownIds] is every non-bench ID currently counted as ours: the current and recently retired
|
||||||
* ID 0 is never ours: it is the "not known yet" placeholder used while the ego identity is
|
* transmit pseudonyms, plus the CiT One's own ID on that path. [benchPingIsOurs] says whether
|
||||||
* still being resolved, and matching on it would swallow real traffic.
|
* [BENCH_PING] is ours right now; see the function of the same name.
|
||||||
*
|
*
|
||||||
* [BENCH_PING] counts as ours unconditionally, not merely while the pinger is running. A
|
* Station ID 0 is never ours: it is the "not known yet" placeholder used while the ego
|
||||||
* time-windowed check would still let a frame transmitted moments before Stop arrive
|
* identity is still being resolved, and matching on it would swallow real traffic.
|
||||||
* afterwards and be tracked as a stranger. The cost is that a genuine remote station using
|
|
||||||
* this ID would be ignored, which is not a real risk at a lab site and is the bargain that
|
|
||||||
* reserving a fixed ID already implies.
|
|
||||||
*/
|
*/
|
||||||
fun isOwn(stationId: Long, persistedOwnId: Long?): Boolean {
|
fun isOwn(stationId: Long, ownIds: Set<Long>, benchPingIsOurs: Boolean): Boolean {
|
||||||
if (stationId == 0L) return false
|
if (stationId == 0L) return false
|
||||||
return stationId == persistedOwnId || stationId == BENCH_PING
|
if (stationId == BENCH_PING) return benchPingIsOurs
|
||||||
|
return stationId in ownIds
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,10 +24,10 @@ object PhoneCamBuilder {
|
|||||||
* @param gyroZRadPerSec latest gyroscope z-axis reading, rad/s (device frame). Positive per
|
* @param gyroZRadPerSec latest gyroscope z-axis reading, rad/s (device frame). Positive per
|
||||||
* Android's convention is counter-clockwise around +Z; converted to the clockwise-positive
|
* Android's convention is counter-clockwise around +Z; converted to the clockwise-positive
|
||||||
* yaw rate convention already used by [Cam.yawRateDps] to match OBU/remote CAM data.
|
* yaw rate convention already used by [Cam.yawRateDps] to match OBU/remote CAM data.
|
||||||
* @param stationId this device's own station ID, from
|
* @param stationId the station ID to transmit under: the current pseudonym from
|
||||||
* [com.hawhamburg.micr0bu.data.mqtt.ObuHardwarePreferences.getOrCreateOwnStationId] — a
|
* [com.hawhamburg.micr0bu.data.cam.PseudonymManager], or the bench pinger's fixed ID.
|
||||||
* persisted random value, not a placeholder. Receivers use it to track this station across
|
* Receivers track a station across successive CAMs by this ID, which is why it only ever
|
||||||
* successive CAMs, so it must be stable for the life of the install and distinct per device.
|
* changes in a coordinated rotation together with the link-layer address.
|
||||||
* @param longitudinalAccelMps2 along-track acceleration, signed (positive = accelerating).
|
* @param longitudinalAccelMps2 along-track acceleration, signed (positive = accelerating).
|
||||||
* Derived from successive GNSS speed samples by [com.hawhamburg.micr0bu.service.CamTransmitLoop]
|
* Derived from successive GNSS speed samples by [com.hawhamburg.micr0bu.service.CamTransmitLoop]
|
||||||
* rather than from the accelerometer: CAM wants acceleration along the direction of travel,
|
* rather than from the accelerometer: CAM wants acceleration along the direction of travel,
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package com.hawhamburg.micr0bu.domain.cam
|
||||||
|
|
||||||
|
import kotlin.random.Random
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The identity this phone transmits under on the ESP32-C5 path: the CAM stationID, and the
|
||||||
|
* link-layer address the firmware writes into both the GeoNetworking GN_ADDR and the 802.11
|
||||||
|
* source address.
|
||||||
|
*
|
||||||
|
* ## Why the two change together
|
||||||
|
* A pseudonym only makes a station harder to follow if every identifier on the frame changes at
|
||||||
|
* the same moment. Rotating the address while keeping the stationID, or the reverse, leaves the
|
||||||
|
* unchanged one as a stable handle, so a receiver loses nothing and the rotation buys nothing.
|
||||||
|
* Holding both in one value that is only ever replaced whole makes a partial rotation impossible
|
||||||
|
* to express.
|
||||||
|
*
|
||||||
|
* ## Why every [ROTATION_INTERVAL_MS]
|
||||||
|
* Real ITS stacks change pseudonym every few minutes, 5 to 15 being typical, and the CiT One was
|
||||||
|
* seen rotating its station ID twice within one bench session. Ten minutes sits in that range.
|
||||||
|
*
|
||||||
|
* ## A limit worth stating
|
||||||
|
* Nothing this app transmits is signed (there is no ETSI TS 103 097 security), so rotation gives
|
||||||
|
* nominal unlinkability at best: an unsigned frame's content can still be correlated across a
|
||||||
|
* change. This is the correct behaviour to build on, not a privacy guarantee.
|
||||||
|
*/
|
||||||
|
data class Pseudonym(
|
||||||
|
val stationId: Long,
|
||||||
|
/** Six bytes, locally administered and unicast. See [generate]. */
|
||||||
|
val mac: ByteArray,
|
||||||
|
/** Wall-clock ms this pseudonym was created, for [isExpired]. */
|
||||||
|
val createdAtMs: Long,
|
||||||
|
) {
|
||||||
|
init {
|
||||||
|
require(mac.size == 6) { "a MAC is 6 bytes, got ${mac.size}" }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True once this pseudonym has been in use for [intervalMs], or if the clock has moved back
|
||||||
|
* past its creation time. The second case rotates rather than trusting a creation time that
|
||||||
|
* now lies in the future, which would otherwise pin one identity until the clock caught up.
|
||||||
|
*/
|
||||||
|
fun isExpired(nowMs: Long, intervalMs: Long = ROTATION_INTERVAL_MS): Boolean =
|
||||||
|
nowMs < createdAtMs || nowMs - createdAtMs >= intervalMs
|
||||||
|
|
||||||
|
// Generated equals/hashCode would compare the MAC array by identity, so two pseudonyms with
|
||||||
|
// the same bytes would compare unequal.
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (other !is Pseudonym) return false
|
||||||
|
return stationId == other.stationId && createdAtMs == other.createdAtMs &&
|
||||||
|
mac.contentEquals(other.mac)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int =
|
||||||
|
31 * (31 * stationId.hashCode() + mac.contentHashCode()) + createdAtMs.hashCode()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val ROTATION_INTERVAL_MS = 10 * 60_000L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A fresh identity. StationID is INTEGER(0..4294967295); 0 is avoided because it is the
|
||||||
|
* "not yet known" placeholder elsewhere in this app, and [OwnStationIds.BENCH_PING] is
|
||||||
|
* avoided so a rider can never be mistaken for the bench pinger.
|
||||||
|
*
|
||||||
|
* The MAC is random with the locally-administered bit set and the group bit clear. A
|
||||||
|
* source address must never be a group address, and a random one must not claim a real
|
||||||
|
* vendor's OUI. [OwnStationIds.BENCH_PING_MAC] is excluded for the same reason as the ID.
|
||||||
|
*/
|
||||||
|
fun generate(nowMs: Long, random: Random = Random.Default): Pseudonym {
|
||||||
|
var stationId: Long
|
||||||
|
do {
|
||||||
|
stationId = random.nextLong(1L, 0xFFFF_FFFEL)
|
||||||
|
} while (stationId == OwnStationIds.BENCH_PING)
|
||||||
|
|
||||||
|
var mac: ByteArray
|
||||||
|
do {
|
||||||
|
mac = random.nextBytes(6)
|
||||||
|
mac[0] = ((mac[0].toInt() and 0xFC) or 0x02).toByte()
|
||||||
|
} while (mac.contentEquals(OwnStationIds.BENCH_PING_MAC))
|
||||||
|
|
||||||
|
return Pseudonym(stationId, mac, nowMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,26 @@ package com.hawhamburg.micr0bu.domain.detection
|
|||||||
* All detection thresholds in one place.
|
* All detection thresholds in one place.
|
||||||
*
|
*
|
||||||
* Pass a custom instance to [EventDetector] to tune behaviour without
|
* Pass a custom instance to [EventDetector] to tune behaviour without
|
||||||
* recompiling. The defaults match the Phase A specification.
|
* recompiling.
|
||||||
|
*
|
||||||
|
* **These defaults are the values the app actually runs.** They are *not* the
|
||||||
|
* Phase A specification figures. Phase A specified a more sensitive detector;
|
||||||
|
* running it on a real bicycle over-triggered, so every signal threshold was
|
||||||
|
* raised and every sustain requirement lengthened. Those tuned values used to
|
||||||
|
* live as literals in `TripRecordingService`'s constructor, which meant the
|
||||||
|
* unit tests exercised the Phase A defaults and nothing exercised what shipped.
|
||||||
|
* They are the defaults now so that there is exactly one configuration.
|
||||||
|
*
|
||||||
|
* The original Phase A figures, kept for provenance:
|
||||||
|
* `brakingSpeedDropThreshold` 0.5, `brakingAccelStdDevThreshold` 1.2,
|
||||||
|
* `brakingSustainedFrames` 15, `turningGyroMeanThreshold` 0.4,
|
||||||
|
* `turningBearingChangeThreshold` 10.0, `turningSustainedFrames` 20,
|
||||||
|
* `stoppingSpeedThreshold` 0.5, `stoppingFrames` 100,
|
||||||
|
* `stoppingAccelStdDevThreshold` 0.15.
|
||||||
|
*
|
||||||
|
* What motivated each change was never recorded, and the effect on the
|
||||||
|
* false-positive and false-negative rates has never been measured. That
|
||||||
|
* remains open; sensitivity is deliberately unchanged by this consolidation.
|
||||||
*/
|
*/
|
||||||
data class DetectionConfig(
|
data class DetectionConfig(
|
||||||
|
|
||||||
@@ -17,42 +36,51 @@ data class DetectionConfig(
|
|||||||
* Minimum speed drop (m/s) from the reference speed at braking onset for
|
* Minimum speed drop (m/s) from the reference speed at braking onset for
|
||||||
* a frame to qualify as a braking frame.
|
* a frame to qualify as a braking frame.
|
||||||
*/
|
*/
|
||||||
val brakingSpeedDropThreshold: Double = 0.5,
|
val brakingSpeedDropThreshold: Double = 1.0,
|
||||||
|
|
||||||
/** Minimum accel std-dev (m/s²) required for a frame to count as braking. */
|
/** Minimum accel std-dev (m/s²) required for a frame to count as braking. */
|
||||||
val brakingAccelStdDevThreshold: Double = 1.2,
|
val brakingAccelStdDevThreshold: Double = 1.8,
|
||||||
|
|
||||||
/** Consecutive braking frames required before an event is emitted. */
|
/** Consecutive braking frames required before an event is emitted. */
|
||||||
val brakingSustainedFrames: Int = 15,
|
val brakingSustainedFrames: Int = 25,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Peak speed-drop rate (m/s per GPS update ≈ m/s²) above which the braking
|
* Peak *cumulative* speed drop (m/s) from the onset reference speed above
|
||||||
* confidence is upgraded from MEDIUM to HIGH.
|
* which the braking confidence is upgraded from MEDIUM to HIGH.
|
||||||
|
*
|
||||||
|
* This is a total drop for the episode, not a rate. It was previously
|
||||||
|
* named `brakingHighConfidenceRate` and documented as "m/s per GPS update
|
||||||
|
* ≈ m/s²", but the quantity it is compared against in
|
||||||
|
* [EventDetector.detectBraking] has always been the cumulative drop, which
|
||||||
|
* grows for as long as the episode lasts. The name was wrong, not the
|
||||||
|
* comparison: "the rider lost more than this much speed in one braking
|
||||||
|
* episode" is a coherent criterion, so the name was corrected to match the
|
||||||
|
* behaviour rather than the other way round. Detector output is unchanged.
|
||||||
*/
|
*/
|
||||||
val brakingHighConfidenceRate: Double = 1.5,
|
val brakingHighConfidencePeakDrop: Double = 1.5,
|
||||||
|
|
||||||
// ── TURNING ───────────────────────────────────────────────────────────────
|
// ── TURNING ───────────────────────────────────────────────────────────────
|
||||||
/** Minimum gyro mean (rad/s) required for a frame to qualify as turning. */
|
/** Minimum gyro mean (rad/s) required for a frame to qualify as turning. */
|
||||||
val turningGyroMeanThreshold: Double = 0.4,
|
val turningGyroMeanThreshold: Double = 0.6,
|
||||||
|
|
||||||
/** Bearing-change rate (°/s) that must be exceeded when speed is above the
|
/** Bearing-change rate (°/s) that must be exceeded when speed is above the
|
||||||
* minimum threshold for a HIGH-confidence turning confirmation. */
|
* minimum threshold for a HIGH-confidence turning confirmation. */
|
||||||
val turningBearingChangeThreshold: Double = 10.0,
|
val turningBearingChangeThreshold: Double = 15.0,
|
||||||
|
|
||||||
/** GPS speed (m/s) above which the bearing-change criterion is enforced. */
|
/** GPS speed (m/s) above which the bearing-change criterion is enforced. */
|
||||||
val turningMinSpeedThreshold: Double = 2.0,
|
val turningMinSpeedThreshold: Double = 2.0,
|
||||||
|
|
||||||
/** Consecutive turning frames required before an event is emitted. */
|
/** Consecutive turning frames required before an event is emitted. */
|
||||||
val turningSustainedFrames: Int = 20,
|
val turningSustainedFrames: Int = 30,
|
||||||
|
|
||||||
// ── STOPPING ─────────────────────────────────────────────────────────────
|
// ── STOPPING ─────────────────────────────────────────────────────────────
|
||||||
/** GPS speed (m/s) below which a frame is considered a potential stop. */
|
/** GPS speed (m/s) below which a frame is considered a potential stop. */
|
||||||
val stoppingSpeedThreshold: Double = 0.5,
|
val stoppingSpeedThreshold: Double = 0.3,
|
||||||
|
|
||||||
/** Consecutive stop frames required (> this value) before an event is emitted.
|
/** Consecutive stop frames required (> this value) before an event is emitted.
|
||||||
* At 50 Hz, 100 frames ≈ 2 s. */
|
* At 50 Hz, 150 frames ≈ 3 s. */
|
||||||
val stoppingFrames: Int = 100,
|
val stoppingFrames: Int = 150,
|
||||||
|
|
||||||
/** Maximum accel std-dev (m/s²) allowed for a frame to count as stationary. */
|
/** Maximum accel std-dev (m/s²) allowed for a frame to count as stationary. */
|
||||||
val stoppingAccelStdDevThreshold: Double = 0.15,
|
val stoppingAccelStdDevThreshold: Double = 0.10,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,6 +14,16 @@ import kotlin.math.abs
|
|||||||
* to [events] (a hot [SharedFlow]). Debounce is implemented with
|
* to [events] (a hot [SharedFlow]). Debounce is implemented with
|
||||||
* consecutive-frame counters, not timers.
|
* consecutive-frame counters, not timers.
|
||||||
*
|
*
|
||||||
|
* **Who consumes this.** The detector's live consumer is the CAM transmit-rate
|
||||||
|
* policy: [com.hawhamburg.micr0bu.service.TripRecordingService] forwards every
|
||||||
|
* emitted event to
|
||||||
|
* [com.hawhamburg.micr0bu.service.CamTransmitLoop.onDetectedEvent], which
|
||||||
|
* raises the CAM rate from 1 Hz to the elevated rate for a hold window so that
|
||||||
|
* nearby stations get denser updates *through* a manoeuvre rather than only at
|
||||||
|
* the instant it was detected. These thresholds therefore govern a V2X
|
||||||
|
* behaviour, not a statistic. Events are also persisted per trip for offline
|
||||||
|
* analysis and CSV export, but nothing in the UI displays them.
|
||||||
|
*
|
||||||
* GPS updates at 1 Hz whilst sensors fire at ~50 Hz. [speedMps] and
|
* GPS updates at 1 Hz whilst sensors fire at ~50 Hz. [speedMps] and
|
||||||
* [bearingChangeDegPerSec] should be the values from the last known GPS fix;
|
* [bearingChangeDegPerSec] should be the values from the last known GPS fix;
|
||||||
* the detector compares speed against a *reference speed at braking onset*
|
* the detector compares speed against a *reference speed at braking onset*
|
||||||
@@ -37,7 +47,7 @@ class EventDetector(private val config: DetectionConfig = DetectionConfig()) {
|
|||||||
private var brakingFrames = 0
|
private var brakingFrames = 0
|
||||||
private var brakingOnsetSpeed = 0.0 // reference speed when braking started
|
private var brakingOnsetSpeed = 0.0 // reference speed when braking started
|
||||||
private var brakingStartTime = 0L
|
private var brakingStartTime = 0L
|
||||||
private var peakBrakingDrop = 0.0 // peak speed drop observed during this window
|
private var peakBrakingDrop = 0.0 // peak CUMULATIVE drop from onset speed, m/s (not a rate)
|
||||||
private var peakAccelBraking = 0.0
|
private var peakAccelBraking = 0.0
|
||||||
|
|
||||||
// ── Turning state ─────────────────────────────────────────────────────────
|
// ── Turning state ─────────────────────────────────────────────────────────
|
||||||
@@ -124,7 +134,7 @@ class EventDetector(private val config: DetectionConfig = DetectionConfig()) {
|
|||||||
|
|
||||||
if (brakingFrames == config.brakingSustainedFrames) {
|
if (brakingFrames == config.brakingSustainedFrames) {
|
||||||
val confidence =
|
val confidence =
|
||||||
if (peakBrakingDrop > config.brakingHighConfidenceRate) Confidence.HIGH
|
if (peakBrakingDrop > config.brakingHighConfidencePeakDrop) Confidence.HIGH
|
||||||
else Confidence.MEDIUM
|
else Confidence.MEDIUM
|
||||||
|
|
||||||
_events.tryEmit(
|
_events.tryEmit(
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
package com.hawhamburg.micr0bu.service
|
package com.hawhamburg.micr0bu.service
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.os.SystemClock
|
||||||
import com.hawhamburg.micr0bu.data.GnssReading
|
import com.hawhamburg.micr0bu.data.GnssReading
|
||||||
|
import com.hawhamburg.micr0bu.data.GnssTimeSource
|
||||||
import com.hawhamburg.micr0bu.data.SensorRepository
|
import com.hawhamburg.micr0bu.data.SensorRepository
|
||||||
|
import com.hawhamburg.micr0bu.data.transport.GnPositionVector
|
||||||
import com.hawhamburg.micr0bu.data.transport.UsbSerialTransport
|
import com.hawhamburg.micr0bu.data.transport.UsbSerialTransport
|
||||||
import com.hawhamburg.micr0bu.domain.asn1.RealAsn1UperCodec
|
import com.hawhamburg.micr0bu.domain.asn1.RealAsn1UperCodec
|
||||||
import com.hawhamburg.micr0bu.domain.cam.OwnStationIds
|
import com.hawhamburg.micr0bu.domain.cam.OwnStationIds
|
||||||
@@ -68,6 +71,20 @@ class CamPinger @Inject constructor(
|
|||||||
/** False while the pinger is running but has no GNSS fix yet — nothing is being transmitted. */
|
/** False while the pinger is running but has no GNSS fix yet — nothing is being transmitted. */
|
||||||
val hasFix: StateFlow<Boolean> = _hasFix.asStateFlow()
|
val hasFix: StateFlow<Boolean> = _hasFix.asStateFlow()
|
||||||
|
|
||||||
|
/** [SystemClock.elapsedRealtime] when the pinger last stopped, or null if it never ran. */
|
||||||
|
@Volatile private var stoppedAtElapsedMs: Long? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True while station [OwnStationIds.BENCH_PING] on air is this phone's own ping: while the
|
||||||
|
* pinger runs, and briefly after it stops, so a frame sent just before Stop is not taken for a
|
||||||
|
* stranger. Uses elapsed realtime, so changing the wall clock cannot move the window.
|
||||||
|
*
|
||||||
|
* Otherwise that ID belongs to someone else, typically another MicrOBU phone pinging on the
|
||||||
|
* same bench, and must be shown like any remote station. See [OwnStationIds.benchPingIsOurs].
|
||||||
|
*/
|
||||||
|
fun benchPingIsOurs(): Boolean =
|
||||||
|
OwnStationIds.benchPingIsOurs(_isActive.value, stoppedAtElapsedMs, SystemClock.elapsedRealtime())
|
||||||
|
|
||||||
fun start() {
|
fun start() {
|
||||||
if (job?.isActive == true) return
|
if (job?.isActive == true) return
|
||||||
_sentCount.value = 0
|
_sentCount.value = 0
|
||||||
@@ -87,13 +104,17 @@ class CamPinger @Inject constructor(
|
|||||||
_hasFix.value = gnss != null
|
_hasFix.value = gnss != null
|
||||||
if (gnss != null) {
|
if (gnss != null) {
|
||||||
val cam = PhoneCamBuilder.build(
|
val cam = PhoneCamBuilder.build(
|
||||||
gnss = gnss,
|
// Stamped on GNSS time rather than the phone clock; see GnssTimeSource.
|
||||||
|
gnss = gnss.copy(timestamp = GnssTimeSource.correct(gnss.timestamp)),
|
||||||
gyroZRadPerSec = latestGyroZ,
|
gyroZRadPerSec = latestGyroZ,
|
||||||
stationId = OwnStationIds.BENCH_PING,
|
stationId = OwnStationIds.BENCH_PING,
|
||||||
longitudinalAccelMps2 = longitudinalAccel(gnss),
|
longitudinalAccelMps2 = longitudinalAccel(gnss),
|
||||||
)
|
)
|
||||||
val bytes = codec.encodeCam(cam)
|
val bytes = codec.encodeCam(cam)
|
||||||
if (usbSerialTransport.sendCamTx(bytes)) {
|
// Fixed bench identity on every layer, the link-layer address included, so a ping
|
||||||
|
// stays recognisable in a capture and never rotates.
|
||||||
|
val pv = GnPositionVector.fromCam(cam, gnss.accuracyM, OwnStationIds.BENCH_PING_MAC)
|
||||||
|
if (usbSerialTransport.sendCamTx(bytes, pv)) {
|
||||||
_sentCount.update { it + 1 }
|
_sentCount.update { it + 1 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -120,6 +141,9 @@ class CamPinger @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun stop() {
|
fun stop() {
|
||||||
|
// Only a real stop opens the grace window. stop() is also called unconditionally on
|
||||||
|
// teardown, and that must not make a phone that never pinged claim 999999 for a while.
|
||||||
|
if (_isActive.value) stoppedAtElapsedMs = SystemClock.elapsedRealtime()
|
||||||
job?.cancel()
|
job?.cancel()
|
||||||
job = null
|
job = null
|
||||||
_isActive.value = false
|
_isActive.value = false
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ package com.hawhamburg.micr0bu.service
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import com.hawhamburg.micr0bu.data.GnssReading
|
import com.hawhamburg.micr0bu.data.GnssReading
|
||||||
|
import com.hawhamburg.micr0bu.data.GnssTimeSource
|
||||||
import com.hawhamburg.micr0bu.data.SensorRepository
|
import com.hawhamburg.micr0bu.data.SensorRepository
|
||||||
|
import com.hawhamburg.micr0bu.data.cam.PseudonymManager
|
||||||
import com.hawhamburg.micr0bu.data.mqtt.ObuHardwarePreferences
|
import com.hawhamburg.micr0bu.data.mqtt.ObuHardwarePreferences
|
||||||
|
import com.hawhamburg.micr0bu.data.transport.GnPositionVector
|
||||||
import com.hawhamburg.micr0bu.data.transport.ObuHardware
|
import com.hawhamburg.micr0bu.data.transport.ObuHardware
|
||||||
import com.hawhamburg.micr0bu.data.transport.UsbSerialTransport
|
import com.hawhamburg.micr0bu.data.transport.UsbSerialTransport
|
||||||
import com.hawhamburg.micr0bu.domain.asn1.RealAsn1UperCodec
|
import com.hawhamburg.micr0bu.domain.asn1.RealAsn1UperCodec
|
||||||
@@ -49,6 +52,7 @@ class CamTransmitLoop @Inject constructor(
|
|||||||
private val obuHardwarePrefs: ObuHardwarePreferences,
|
private val obuHardwarePrefs: ObuHardwarePreferences,
|
||||||
private val usbSerialTransport: UsbSerialTransport,
|
private val usbSerialTransport: UsbSerialTransport,
|
||||||
private val codec: RealAsn1UperCodec,
|
private val codec: RealAsn1UperCodec,
|
||||||
|
private val pseudonymManager: PseudonymManager,
|
||||||
) {
|
) {
|
||||||
private val config = CamTransmitConfig()
|
private val config = CamTransmitConfig()
|
||||||
private val sensorRepository = SensorRepository(context)
|
private val sensorRepository = SensorRepository(context)
|
||||||
@@ -63,14 +67,6 @@ class CamTransmitLoop @Inject constructor(
|
|||||||
/** Previous GNSS fix, kept only to derive along-track acceleration — see [longitudinalAccel]. */
|
/** Previous GNSS fix, kept only to derive along-track acceleration — see [longitudinalAccel]. */
|
||||||
@Volatile private var previousGnss: GnssReading? = null
|
@Volatile private var previousGnss: GnssReading? = null
|
||||||
|
|
||||||
/**
|
|
||||||
* Own station id for the ESP32-C5 path, loaded once per [start] from
|
|
||||||
* [ObuHardwarePreferences.getOrCreateOwnStationId]. 0 means "not loaded yet" — the loop waits
|
|
||||||
* for the real value rather than beaconing as station 0, which would be indistinguishable
|
|
||||||
* from every other MicrOBU to any receiver.
|
|
||||||
*/
|
|
||||||
@Volatile var stationId: Long = 0L
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Call when a braking/turning/stopping event fires during an active trip — bumps the CAM
|
* Call when a braking/turning/stopping event fires during an active trip — bumps the CAM
|
||||||
* rate to [CamTransmitConfig.elevatedRateHz] for [ELEVATED_HOLD_MS] so nearby stations get
|
* rate to [CamTransmitConfig.elevatedRateHz] for [ELEVATED_HOLD_MS] so nearby stations get
|
||||||
@@ -90,7 +86,6 @@ class CamTransmitLoop @Inject constructor(
|
|||||||
elevatedUntilMs = 0L
|
elevatedUntilMs = 0L
|
||||||
previousGnss = null
|
previousGnss = null
|
||||||
job = scope.launch {
|
job = scope.launch {
|
||||||
stationId = obuHardwarePrefs.getOrCreateOwnStationId()
|
|
||||||
obuHardwarePrefs.obuHardwareFlow.collectLatest { hardware ->
|
obuHardwarePrefs.obuHardwareFlow.collectLatest { hardware ->
|
||||||
if (hardware != ObuHardware.ESP32_C5) return@collectLatest
|
if (hardware != ObuHardware.ESP32_C5) return@collectLatest
|
||||||
runTransmitLoop()
|
runTransmitLoop()
|
||||||
@@ -111,9 +106,15 @@ class CamTransmitLoop @Inject constructor(
|
|||||||
while (true) {
|
while (true) {
|
||||||
val gnss = latestGnss
|
val gnss = latestGnss
|
||||||
if (gnss != null) {
|
if (gnss != null) {
|
||||||
val cam = PhoneCamBuilder.build(gnss, latestGyroZ, stationId, longitudinalAccel(gnss))
|
// Asked for per CAM rather than once per trip: that is what lets a pseudonym
|
||||||
|
// rotation fall cleanly between two frames instead of inside one.
|
||||||
|
val pseudonym = pseudonymManager.current()
|
||||||
|
// Stamped on GNSS time rather than the phone clock; see GnssTimeSource. Only the
|
||||||
|
// outgoing CAM is: acceleration below still differences wall-clock samples.
|
||||||
|
val fix = gnss.copy(timestamp = GnssTimeSource.correct(gnss.timestamp))
|
||||||
|
val cam = PhoneCamBuilder.build(fix, latestGyroZ, pseudonym.stationId, longitudinalAccel(gnss))
|
||||||
val bytes = codec.encodeCam(cam)
|
val bytes = codec.encodeCam(cam)
|
||||||
usbSerialTransport.sendCamTx(bytes)
|
usbSerialTransport.sendCamTx(bytes, GnPositionVector.fromCam(cam, gnss.accuracyM, pseudonym.mac))
|
||||||
}
|
}
|
||||||
delay((1000.0 / currentRateHz(gnss)).toLong())
|
delay((1000.0 / currentRateHz(gnss)).toLong())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,9 +26,7 @@ import com.hawhamburg.micr0bu.R
|
|||||||
import com.hawhamburg.micr0bu.data.TripRepository
|
import com.hawhamburg.micr0bu.data.TripRepository
|
||||||
import com.hawhamburg.micr0bu.data.cam.CamUseCaseRepository
|
import com.hawhamburg.micr0bu.data.cam.CamUseCaseRepository
|
||||||
import com.hawhamburg.micr0bu.data.db.AppDatabase
|
import com.hawhamburg.micr0bu.data.db.AppDatabase
|
||||||
import com.hawhamburg.micr0bu.domain.detection.DetectionConfig
|
|
||||||
import com.hawhamburg.micr0bu.domain.detection.EventDetector
|
import com.hawhamburg.micr0bu.domain.detection.EventDetector
|
||||||
import com.hawhamburg.micr0bu.domain.detection.EventType
|
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -108,19 +106,10 @@ class TripRecordingService : Service() {
|
|||||||
// V2xMessageEntity's KDoc for why nothing is retained outside of one.
|
// V2xMessageEntity's KDoc for why nothing is retained outside of one.
|
||||||
@Inject lateinit var camUseCaseRepository: CamUseCaseRepository
|
@Inject lateinit var camUseCaseRepository: CamUseCaseRepository
|
||||||
private var v2xLoggingJob: Job? = null
|
private var v2xLoggingJob: Job? = null
|
||||||
private val detector = EventDetector(
|
// These nine thresholds used to be overridden here; they are now the DetectionConfig
|
||||||
DetectionConfig(
|
// defaults, so there is one configuration and the unit tests exercise it. Behaviour is
|
||||||
brakingSpeedDropThreshold = 1.0,
|
// unchanged - see DetectionConfig's KDoc.
|
||||||
brakingAccelStdDevThreshold = 1.8,
|
private val detector = EventDetector()
|
||||||
brakingSustainedFrames = 25,
|
|
||||||
turningGyroMeanThreshold = 0.6,
|
|
||||||
turningBearingChangeThreshold = 15.0,
|
|
||||||
turningSustainedFrames = 30,
|
|
||||||
stoppingSpeedThreshold = 0.3,
|
|
||||||
stoppingFrames = 150,
|
|
||||||
stoppingAccelStdDevThreshold = 0.10,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── Sensor fusion state ───────────────────────────────────────────────────
|
// ── Sensor fusion state ───────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -153,10 +142,11 @@ class TripRecordingService : Service() {
|
|||||||
private val gpsTrackBuilder = StringBuilder("[")
|
private val gpsTrackBuilder = StringBuilder("[")
|
||||||
private var gpsPointCount = 0
|
private var gpsPointCount = 0
|
||||||
|
|
||||||
// Event counts
|
// Number of manoeuvres the detector fired during this trip. The only thing kept about
|
||||||
private var brakingCount = 0
|
// them: it fills the trips.eventCount column, which predates this change and cannot be
|
||||||
private var turningCount = 0
|
// dropped without rebuilding the trips table. See EventDetector's KDoc for why the
|
||||||
private var stoppingCount = 0
|
// detector still runs at all.
|
||||||
|
private var detectedEventCount = 0
|
||||||
|
|
||||||
// ── SensorEventListener ───────────────────────────────────────────────────
|
// ── SensorEventListener ───────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -253,9 +243,7 @@ class TripRecordingService : Service() {
|
|||||||
).also { it.acquire() }
|
).also { it.acquire() }
|
||||||
|
|
||||||
detector.reset()
|
detector.reset()
|
||||||
brakingCount = 0
|
detectedEventCount = 0
|
||||||
turningCount = 0
|
|
||||||
stoppingCount = 0
|
|
||||||
distanceMetres = 0f
|
distanceMetres = 0f
|
||||||
prevLat = Double.NaN
|
prevLat = Double.NaN
|
||||||
prevLon = Double.NaN
|
prevLon = Double.NaN
|
||||||
@@ -273,35 +261,20 @@ class TripRecordingService : Service() {
|
|||||||
isRecording = true,
|
isRecording = true,
|
||||||
currentTripId = currentTripId,
|
currentTripId = currentTripId,
|
||||||
elapsedSeconds = 0L,
|
elapsedSeconds = 0L,
|
||||||
brakingCount = 0,
|
|
||||||
turningCount = 0,
|
|
||||||
stoppingCount = 0,
|
|
||||||
currentSpeedMs = 0f,
|
currentSpeedMs = 0f,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect detector events and persist them
|
// Collect detector events. The CAM transmit-rate policy is their only consumer:
|
||||||
|
// detected manoeuvres are not persisted, exported, or displayed.
|
||||||
serviceScope.launch {
|
serviceScope.launch {
|
||||||
detector.events.collect { event ->
|
detector.events.collect { _ ->
|
||||||
if (currentTripId < 0) return@collect
|
if (currentTripId < 0) return@collect
|
||||||
repository.insertEvent(currentTripId, event)
|
|
||||||
// Bump the CAM transmit rate through the maneuver, not just at detection instant.
|
// Bump the CAM transmit rate through the maneuver, not just at detection instant.
|
||||||
// No-op on the CiT One path (see CamTransmitLoop's KDoc).
|
// No-op on the CiT One path (see CamTransmitLoop's KDoc).
|
||||||
camTransmitLoop.onDetectedEvent()
|
camTransmitLoop.onDetectedEvent()
|
||||||
when (event.type) {
|
detectedEventCount++
|
||||||
EventType.BRAKING -> brakingCount++
|
|
||||||
EventType.TURNING -> turningCount++
|
|
||||||
EventType.STOPPING -> stoppingCount++
|
|
||||||
}
|
|
||||||
TripServiceBus.update {
|
|
||||||
copy(
|
|
||||||
brakingCount = this@TripRecordingService.brakingCount,
|
|
||||||
turningCount = this@TripRecordingService.turningCount,
|
|
||||||
stoppingCount = this@TripRecordingService.stoppingCount,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
updateNotification()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,7 +329,7 @@ class TripRecordingService : Service() {
|
|||||||
v2xLoggingJob = null
|
v2xLoggingJob = null
|
||||||
|
|
||||||
val endTime = System.currentTimeMillis()
|
val endTime = System.currentTimeMillis()
|
||||||
val totalEvents = brakingCount + turningCount + stoppingCount
|
val totalEvents = detectedEventCount
|
||||||
|
|
||||||
// Close GPS track JSON
|
// Close GPS track JSON
|
||||||
gpsTrackBuilder.append("]")
|
gpsTrackBuilder.append("]")
|
||||||
@@ -454,10 +427,7 @@ class TripRecordingService : Service() {
|
|||||||
private fun buildNotification(elapsedSeconds: Long) =
|
private fun buildNotification(elapsedSeconds: Long) =
|
||||||
NotificationCompat.Builder(this, CHANNEL_ID)
|
NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
.setContentTitle("Recording trip")
|
.setContentTitle("Recording trip")
|
||||||
.setContentText(
|
.setContentText("⏱ ${formatElapsed(elapsedSeconds)}")
|
||||||
"⏱ ${formatElapsed(elapsedSeconds)} · " +
|
|
||||||
"🚨 $brakingCount 🔄 $turningCount 🛑 $stoppingCount"
|
|
||||||
)
|
|
||||||
.setSmallIcon(R.mipmap.ic_launcher_foreground)
|
.setSmallIcon(R.mipmap.ic_launcher_foreground)
|
||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
.setOnlyAlertOnce(true)
|
.setOnlyAlertOnce(true)
|
||||||
|
|||||||
@@ -17,9 +17,6 @@ object TripServiceBus {
|
|||||||
val isRecording: Boolean = false,
|
val isRecording: Boolean = false,
|
||||||
val currentTripId: Long = -1L,
|
val currentTripId: Long = -1L,
|
||||||
val elapsedSeconds: Long = 0L,
|
val elapsedSeconds: Long = 0L,
|
||||||
val brakingCount: Int = 0,
|
|
||||||
val turningCount: Int = 0,
|
|
||||||
val stoppingCount: Int = 0,
|
|
||||||
val currentSpeedMs: Float = 0f,
|
val currentSpeedMs: Float = 0f,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -141,46 +141,6 @@ fun RecordingScreen(
|
|||||||
|
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
// ── Event Detection Counters ─────────────────────────────────────────
|
|
||||||
if (state.isRecording || tripServiceState.isRecording) {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.rec_events_detected),
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
modifier = Modifier.align(Alignment.Start),
|
|
||||||
)
|
|
||||||
|
|
||||||
Card(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer),
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
|
||||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
|
||||||
) {
|
|
||||||
EventCountBadge(
|
|
||||||
label = stringResource(R.string.rec_event_braking),
|
|
||||||
count = tripServiceState.brakingCount,
|
|
||||||
color = Color(0xFFFF5252),
|
|
||||||
)
|
|
||||||
EventCountBadge(
|
|
||||||
label = stringResource(R.string.rec_event_turning),
|
|
||||||
count = tripServiceState.turningCount,
|
|
||||||
color = Color(0xFFFFB300),
|
|
||||||
)
|
|
||||||
EventCountBadge(
|
|
||||||
label = stringResource(R.string.rec_event_stopping),
|
|
||||||
count = tripServiceState.stoppingCount,
|
|
||||||
color = Color(0xFF42A5F5),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(Modifier.height(4.dp))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── CSV Session Log shortcut ─────────────────────────────────────────
|
// ── CSV Session Log shortcut ─────────────────────────────────────────
|
||||||
if (!state.isRecording) {
|
if (!state.isRecording) {
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
@@ -228,25 +188,6 @@ fun RecordingScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun EventCountBadge(label: String, count: Int, color: Color) {
|
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
|
||||||
Text(
|
|
||||||
text = count.toString(),
|
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
fontFamily = FontFamily.Monospace,
|
|
||||||
color = color,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(2.dp))
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun StreamRow(label: String, active: Boolean) {
|
private fun StreamRow(label: String, active: Boolean) {
|
||||||
Row(
|
Row(
|
||||||
|
|||||||
@@ -145,7 +145,6 @@ private fun TripCard(
|
|||||||
val durationSec = ((trip.endTime - trip.startTime) / 1000).coerceAtLeast(0)
|
val durationSec = ((trip.endTime - trip.startTime) / 1000).coerceAtLeast(0)
|
||||||
TripStatChip("⏱ ${formatDuration(durationSec)}")
|
TripStatChip("⏱ ${formatDuration(durationSec)}")
|
||||||
TripStatChip("📍 ${formatDistance(trip.distanceMetres)}")
|
TripStatChip("📍 ${formatDistance(trip.distanceMetres)}")
|
||||||
TripStatChip("🚨 ${trip.eventCount} events")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
IconButton(onClick = onOpen) {
|
IconButton(onClick = onOpen) {
|
||||||
|
|||||||
@@ -42,9 +42,7 @@ import androidx.compose.ui.viewinterop.AndroidView
|
|||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
import com.hawhamburg.micr0bu.data.db.DetectedEventEntity
|
|
||||||
import com.hawhamburg.micr0bu.data.db.RecordedTripEntity
|
import com.hawhamburg.micr0bu.data.db.RecordedTripEntity
|
||||||
import com.hawhamburg.micr0bu.viewmodel.TripRecordingViewModel
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.osmdroid.config.Configuration
|
import org.osmdroid.config.Configuration
|
||||||
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
|
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
|
||||||
@@ -61,7 +59,6 @@ import java.util.Locale
|
|||||||
@Composable
|
@Composable
|
||||||
fun TripReviewScreen(
|
fun TripReviewScreen(
|
||||||
trip: RecordedTripEntity,
|
trip: RecordedTripEntity,
|
||||||
viewModel: TripRecordingViewModel,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -70,15 +67,8 @@ fun TripReviewScreen(
|
|||||||
// provider is ready before MapView is constructed in the factory block.
|
// provider is ready before MapView is constructed in the factory block.
|
||||||
initOsmReview(context)
|
initOsmReview(context)
|
||||||
|
|
||||||
LaunchedEffect(trip.id) { viewModel.loadTripEvents(trip.id) }
|
|
||||||
val events by viewModel.selectedTripEvents.collectAsState()
|
|
||||||
|
|
||||||
val gpsPoints = remember(trip.gpsTrackJson) { parseGpsTrack(trip.gpsTrackJson) }
|
val gpsPoints = remember(trip.gpsTrackJson) { parseGpsTrack(trip.gpsTrackJson) }
|
||||||
|
|
||||||
var selectedEvent by remember { mutableStateOf<DetectedEventEntity?>(null) }
|
|
||||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
|
|
||||||
val mapViewRef = remember { mutableStateOf<MapView?>(null) }
|
val mapViewRef = remember { mutableStateOf<MapView?>(null) }
|
||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
|
|
||||||
@@ -108,13 +98,6 @@ fun TripReviewScreen(
|
|||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
Text(
|
|
||||||
"🚨 ${events.count { it.type == "BRAKING" }} " +
|
|
||||||
"🔄 ${events.count { it.type == "TURNING" }} " +
|
|
||||||
"🛑 ${events.count { it.type == "STOPPING" }}",
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Map ───────────────────────────────────────────────────────────────
|
// ── Map ───────────────────────────────────────────────────────────────
|
||||||
@@ -141,33 +124,6 @@ fun TripReviewScreen(
|
|||||||
mv.overlays.add(polyline)
|
mv.overlays.add(polyline)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Event pins
|
|
||||||
events.forEach { event ->
|
|
||||||
val pinColor = when (event.type) {
|
|
||||||
"BRAKING" -> Color(0xFFFF5252)
|
|
||||||
"TURNING" -> Color(0xFFFFB300)
|
|
||||||
"STOPPING" -> Color(0xFF42A5F5)
|
|
||||||
else -> Color.Gray
|
|
||||||
}
|
|
||||||
val marker = Marker(mv).apply {
|
|
||||||
position = GeoPoint(event.latitude, event.longitude)
|
|
||||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
|
||||||
title = "${event.type} (${event.confidence})"
|
|
||||||
setOnMarkerClickListener { _, _ ->
|
|
||||||
selectedEvent = event
|
|
||||||
scope.launch { sheetState.show() }
|
|
||||||
true
|
|
||||||
}
|
|
||||||
// Solid-circle pin in the event color
|
|
||||||
icon = GradientDrawable().apply {
|
|
||||||
shape = GradientDrawable.OVAL
|
|
||||||
setColor(pinColor.toArgb())
|
|
||||||
setSize(32, 32)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
mv.overlays.add(marker)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-fit the camera to the track — deferred via post() so the
|
// Auto-fit the camera to the track — deferred via post() so the
|
||||||
// MapView has been measured before zoomToBoundingBox is called.
|
// MapView has been measured before zoomToBoundingBox is called.
|
||||||
// Calling it with width/height == 0 (before first layout) crashes osmdroid.
|
// Calling it with width/height == 0 (before first layout) crashes osmdroid.
|
||||||
@@ -193,82 +149,6 @@ fun TripReviewScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Event detail bottom sheet ─────────────────────────────────────────────
|
|
||||||
val ev = selectedEvent
|
|
||||||
if (ev != null) {
|
|
||||||
ModalBottomSheet(
|
|
||||||
onDismissRequest = { selectedEvent = null },
|
|
||||||
sheetState = sheetState,
|
|
||||||
dragHandle = { BottomSheetDefaults.DragHandle() },
|
|
||||||
) {
|
|
||||||
EventDetailSheet(event = ev, onDismiss = {
|
|
||||||
scope.launch { sheetState.hide() }.invokeOnCompletion { selectedEvent = null }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Event detail sheet content ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun EventDetailSheet(event: DetectedEventEntity, onDismiss: () -> Unit) {
|
|
||||||
// Created here (not as a top-level static field) so it always uses the
|
|
||||||
// current locale even if the user changes it while the app is running.
|
|
||||||
val sdf = remember { SimpleDateFormat("HH:mm:ss", Locale.getDefault()) }
|
|
||||||
|
|
||||||
val accentColor = when (event.type) {
|
|
||||||
"BRAKING" -> Color(0xFFFF5252)
|
|
||||||
"TURNING" -> Color(0xFFFFB300)
|
|
||||||
"STOPPING" -> Color(0xFF42A5F5)
|
|
||||||
else -> MaterialTheme.colorScheme.primary
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(modifier = Modifier.padding(horizontal = 20.dp).padding(bottom = 32.dp)) {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Text(
|
|
||||||
event.type.replaceFirstChar { it.titlecase() },
|
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = accentColor,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
IconButton(onClick = onDismiss) {
|
|
||||||
Icon(Icons.Default.Close, contentDescription = "Close")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Text(
|
|
||||||
"Confidence: ${event.confidence}",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
sdf.format(Date(event.timestamp)),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(Modifier.height(12.dp))
|
|
||||||
HorizontalDivider()
|
|
||||||
Spacer(Modifier.height(12.dp))
|
|
||||||
|
|
||||||
EventDetailRow("Speed", "%.1f m/s".format(event.speedMps))
|
|
||||||
EventDetailRow("Peak accel", "%.2f m/s²".format(event.peakAccelMagnitude))
|
|
||||||
EventDetailRow("Peak gyro", "%.3f rad/s".format(event.peakGyroMagnitude))
|
|
||||||
EventDetailRow("Duration", "${event.durationMs} ms")
|
|
||||||
EventDetailRow("Location", "%.5f°, %.5f°".format(event.latitude, event.longitude))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun EventDetailRow(label: String, value: String) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
) {
|
|
||||||
Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
||||||
Text(value, style = MaterialTheme.typography.bodyMedium, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Medium)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── GPS track parsing ─────────────────────────────────────────────────────────
|
// ── GPS track parsing ─────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import com.hawhamburg.micr0bu.data.TripRepository
|
import com.hawhamburg.micr0bu.data.TripRepository
|
||||||
import com.hawhamburg.micr0bu.data.shareTripCsv
|
import com.hawhamburg.micr0bu.data.shareTripCsv
|
||||||
import com.hawhamburg.micr0bu.data.db.AppDatabase
|
import com.hawhamburg.micr0bu.data.db.AppDatabase
|
||||||
import com.hawhamburg.micr0bu.data.db.DetectedEventEntity
|
|
||||||
import com.hawhamburg.micr0bu.data.db.RecordedTripEntity
|
import com.hawhamburg.micr0bu.data.db.RecordedTripEntity
|
||||||
import com.hawhamburg.micr0bu.service.TripRecordingService
|
import com.hawhamburg.micr0bu.service.TripRecordingService
|
||||||
import com.hawhamburg.micr0bu.service.TripServiceBus
|
import com.hawhamburg.micr0bu.service.TripServiceBus
|
||||||
@@ -66,20 +65,6 @@ class TripRecordingViewModel(application: Application) : AndroidViewModel(applic
|
|||||||
/** All recorded trips, newest first. */
|
/** All recorded trips, newest first. */
|
||||||
val trips: Flow<List<RecordedTripEntity>> = repository.getAllTrips()
|
val trips: Flow<List<RecordedTripEntity>> = repository.getAllTrips()
|
||||||
|
|
||||||
// ── Trip review ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private val _selectedTripEvents = MutableStateFlow<List<DetectedEventEntity>>(emptyList())
|
|
||||||
val selectedTripEvents: StateFlow<List<DetectedEventEntity>> = _selectedTripEvents.asStateFlow()
|
|
||||||
|
|
||||||
/** Load events for [tripId] into [selectedTripEvents]. */
|
|
||||||
fun loadTripEvents(tripId: Long) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
repository.getEventsForTrip(tripId).collect { events ->
|
|
||||||
_selectedTripEvents.value = events
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Recording control ─────────────────────────────────────────────────────
|
// ── Recording control ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -132,7 +117,6 @@ class TripRecordingViewModel(application: Application) : AndroidViewModel(applic
|
|||||||
shareTripCsv(
|
shareTripCsv(
|
||||||
context = context,
|
context = context,
|
||||||
trip = trip,
|
trip = trip,
|
||||||
events = repository.getEventsForTripOnce(tripId),
|
|
||||||
v2xMessages = repository.getV2xMessagesForTripOnce(tripId),
|
v2xMessages = repository.getV2xMessagesForTripOnce(tripId),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -292,10 +292,6 @@
|
|||||||
<string name="nav_trips">Fahrten</string>
|
<string name="nav_trips">Fahrten</string>
|
||||||
|
|
||||||
<!-- Phase A: Recording screen event counters -->
|
<!-- Phase A: Recording screen event counters -->
|
||||||
<string name="rec_events_detected">Erkannte Ereignisse</string>
|
|
||||||
<string name="rec_event_braking">Bremsen</string>
|
|
||||||
<string name="rec_event_turning">Abbiegen</string>
|
|
||||||
<string name="rec_event_stopping">Anhalten</string>
|
|
||||||
<string name="rec_stream_event_detection">Ereigniserkennung</string>
|
<string name="rec_stream_event_detection">Ereigniserkennung</string>
|
||||||
<string name="rec_open_session_log">CSV-Sitzungsprotokoll</string>
|
<string name="rec_open_session_log">CSV-Sitzungsprotokoll</string>
|
||||||
|
|
||||||
|
|||||||
@@ -305,10 +305,6 @@
|
|||||||
<string name="nav_trips">Trips</string>
|
<string name="nav_trips">Trips</string>
|
||||||
|
|
||||||
<!-- Phase A: Recording screen event counters -->
|
<!-- Phase A: Recording screen event counters -->
|
||||||
<string name="rec_events_detected">Detected Events</string>
|
|
||||||
<string name="rec_event_braking">Braking</string>
|
|
||||||
<string name="rec_event_turning">Turning</string>
|
|
||||||
<string name="rec_event_stopping">Stopping</string>
|
|
||||||
<string name="rec_stream_event_detection">Event Detection</string>
|
<string name="rec_stream_event_detection">Event Detection</string>
|
||||||
<string name="rec_open_session_log">CSV Session Log</string>
|
<string name="rec_open_session_log">CSV Session Log</string>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
package com.hawhamburg.micr0bu
|
||||||
|
|
||||||
|
import com.hawhamburg.micr0bu.data.transport.EspLinkStatus
|
||||||
|
import com.hawhamburg.micr0bu.data.transport.GnPositionVector
|
||||||
|
import com.hawhamburg.micr0bu.domain.asn1.ItsTime
|
||||||
|
import com.hawhamburg.micr0bu.domain.cam.Cam
|
||||||
|
import com.hawhamburg.micr0bu.domain.cam.StationType
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pins the phone side of SERIAL_MSG_CAM_TX_PV: the 24-byte prefix the ESP32 turns into the
|
||||||
|
* GeoNetworking Source Position Vector, and the heartbeat capability bit that decides whether the
|
||||||
|
* phone may send that message at all.
|
||||||
|
*
|
||||||
|
* ## Where the expected bytes come from
|
||||||
|
* Not from this code. They were produced with Python's `struct.pack("<IiihH", ...)` from the
|
||||||
|
* layout documented at SERIAL_MSG_CAM_TX_PV in `serial_link.h`, independently of this encoder, so
|
||||||
|
* an agreement here is not an encoder agreeing with itself.
|
||||||
|
*
|
||||||
|
* That same `struct.pack` call is what the bench harness used on 2026-09-10 to drive an
|
||||||
|
* ESP32-C5 over its native USB port with this message. The CiT One OBU, an independent
|
||||||
|
* GeoNetworking stack, decoded every Source Position Vector field of the resulting
|
||||||
|
* transmissions (station type, PAI, latitude, longitude, speed, heading and timestamp) back to
|
||||||
|
* the values sent. These are bytes a third-party receiver has accepted on air, not only bytes
|
||||||
|
* this app agrees with.
|
||||||
|
*/
|
||||||
|
class CamTxPvSerialTest {
|
||||||
|
|
||||||
|
private fun String.hexToBytes(): ByteArray =
|
||||||
|
chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||||
|
|
||||||
|
private fun ByteArray.u32le(at: Int): Long =
|
||||||
|
(0 until 4).fold(0L) { acc, i -> acc or ((this[at + i].toLong() and 0xFF) shl (8 * i)) }
|
||||||
|
|
||||||
|
// ---- the wire layout -------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `encodes the prefix byte for byte`() {
|
||||||
|
val pv = GnPositionVector(
|
||||||
|
mac = "024d49435230".hexToBytes(),
|
||||||
|
stationType = 2,
|
||||||
|
pai = true,
|
||||||
|
tstMs = 0x12345678L,
|
||||||
|
latTenMicroDeg = 535_543_026,
|
||||||
|
lonTenMicroDeg = 100_226_476,
|
||||||
|
speedCms = 543,
|
||||||
|
headingDeciDeg = 1234,
|
||||||
|
)
|
||||||
|
// 024d49435230 | 02 | 01 | 78563412 | f2bceb1f | ac55f905 | 1f02 | d204
|
||||||
|
assertEquals("024d49435230020178563412f2bceb1fac55f9051f02d204", pv.toSerialPrefix().toHex())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `encodes negative, extreme and flag-clear values`() {
|
||||||
|
// Southern and western hemisphere, full reverse speed, heading at its maximum, PAI clear:
|
||||||
|
// the sign handling that a northern-hemisphere bench test never exercises.
|
||||||
|
val pv = GnPositionVector(
|
||||||
|
mac = "020000000001".hexToBytes(),
|
||||||
|
stationType = 2,
|
||||||
|
pai = false,
|
||||||
|
tstMs = 0xFFFF_FFFFL,
|
||||||
|
latTenMicroDeg = -335_543_026,
|
||||||
|
lonTenMicroDeg = -100_226_476,
|
||||||
|
speedCms = -16384,
|
||||||
|
headingDeciDeg = 3599,
|
||||||
|
)
|
||||||
|
assertEquals("0200000000010200ffffffff0e0500ec54aa06fa00c00f0e", pv.toSerialPrefix().toHex())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the timestamp is reduced modulo 2^32 on the wire`() {
|
||||||
|
// TimestampIts passed 2^32 ms about 49.7 days after its 2004 epoch, so every real value
|
||||||
|
// today is wider than 32 bits and the reduction is the normal case, not an edge case.
|
||||||
|
val pv = vectorAt(tstMs = 716_121_572_779L)
|
||||||
|
assertEquals(3_157_001_643L, pv.toSerialPrefix().u32le(8))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- building it from a CAM ------------------------------------------------------------
|
||||||
|
|
||||||
|
private val cam = Cam(
|
||||||
|
stationId = 1_234_567_890L,
|
||||||
|
stationType = StationType.CYCLIST,
|
||||||
|
latitude = 53.5543026,
|
||||||
|
longitude = 10.0226476,
|
||||||
|
speedMps = 5.43,
|
||||||
|
headingDeg = 123.4,
|
||||||
|
yawRateDps = null,
|
||||||
|
accelerationMps2 = null,
|
||||||
|
timestamp = 1_789_036_772_779L,
|
||||||
|
isOwn = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `fromCam takes the same values the CAM payload carries`() {
|
||||||
|
val pv = GnPositionVector.fromCam(cam, accuracyM = 5f, mac = "024d49435230".hexToBytes())
|
||||||
|
assertEquals(2, pv.stationType)
|
||||||
|
assertEquals(535_543_026, pv.latTenMicroDeg)
|
||||||
|
assertEquals(100_226_476, pv.lonTenMicroDeg)
|
||||||
|
assertEquals(543, pv.speedCms)
|
||||||
|
assertEquals(1234, pv.headingDeciDeg)
|
||||||
|
assertTrue(pv.pai)
|
||||||
|
// The GN TST and the CAM's generationDeltaTime must follow one time rule.
|
||||||
|
assertEquals(ItsTime.timestampIts(cam.timestamp), pv.tstMs)
|
||||||
|
assertEquals(716_121_572_779L, pv.tstMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `speed is clamped to the 15-bit field, never wrapped`() {
|
||||||
|
// A wrapped 15-bit speed flips its sign bit and reads as reversing at speed.
|
||||||
|
assertEquals(16383, GnPositionVector.fromCam(cam.copy(speedMps = 400.0), 5f, mac).speedCms)
|
||||||
|
assertEquals(-16384, GnPositionVector.fromCam(cam.copy(speedMps = -400.0), 5f, mac).speedCms)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `heading wraps into 0 to 3599`() {
|
||||||
|
assertEquals(0, GnPositionVector.fromCam(cam.copy(headingDeg = 360.0), 5f, mac).headingDeciDeg)
|
||||||
|
assertEquals(50, GnPositionVector.fromCam(cam.copy(headingDeg = 725.0), 5f, mac).headingDeciDeg)
|
||||||
|
assertEquals(3590, GnPositionVector.fromCam(cam.copy(headingDeg = -1.0), 5f, mac).headingDeciDeg)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `non-finite speed or heading does not throw`() {
|
||||||
|
val pv = GnPositionVector.fromCam(
|
||||||
|
cam.copy(speedMps = Double.NaN, headingDeg = Double.POSITIVE_INFINITY), 5f, mac,
|
||||||
|
)
|
||||||
|
assertEquals(0, pv.speedCms)
|
||||||
|
assertEquals(0, pv.headingDeciDeg)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `PAI follows the horizontal accuracy`() {
|
||||||
|
assertTrue(GnPositionVector.fromCam(cam, GnPositionVector.PAI_MAX_ACCURACY_M, mac).pai)
|
||||||
|
assertFalse(GnPositionVector.fromCam(cam, 25f, mac).pai)
|
||||||
|
// Android reports 0 when it has no accuracy estimate: unknown is not accurate.
|
||||||
|
assertFalse(GnPositionVector.fromCam(cam, 0f, mac).pai)
|
||||||
|
assertFalse(GnPositionVector.fromCam(cam, null, mac).pai)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(expected = IllegalArgumentException::class)
|
||||||
|
fun `an address that is not six bytes is rejected`() {
|
||||||
|
GnPositionVector.fromCam(cam, 5f, ByteArray(5))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- capability negotiation ------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `firmware that predates the capability byte advertises nothing`() {
|
||||||
|
// Old firmware sends a 7-byte heartbeat. Reading that as "no CAM_TX_PV" is what keeps a
|
||||||
|
// new app on the legacy message, which that firmware still understands.
|
||||||
|
val status = EspLinkStatus.parse("00000000000000".hexToBytes())!!
|
||||||
|
assertEquals(0, status.capabilities)
|
||||||
|
assertFalse(status.supportsCamTxPv)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `firmware that advertises CAM_TX_PV is recognised`() {
|
||||||
|
val status = EspLinkStatus.parse("0000000000000001".hexToBytes())!!
|
||||||
|
assertTrue(status.supportsCamTxPv)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a capability byte without the CAM_TX_PV bit does not enable it`() {
|
||||||
|
assertFalse(EspLinkStatus.parse("0000000000000002".hexToBytes())!!.supportsCamTxPv)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val mac = "024d49435230".hexToBytes()
|
||||||
|
|
||||||
|
private fun vectorAt(tstMs: Long) = GnPositionVector(
|
||||||
|
mac = mac, stationType = 2, pai = false, tstMs = tstMs,
|
||||||
|
latTenMicroDeg = 0, lonTenMicroDeg = 0, speedCms = 0, headingDeciDeg = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun ByteArray.toHex() = joinToString("") { "%02x".format(it) }
|
||||||
|
}
|
||||||
@@ -21,34 +21,34 @@ import kotlin.math.sqrt
|
|||||||
*
|
*
|
||||||
* No Android emulator required — all production classes have zero Android imports.
|
* No Android emulator required — all production classes have zero Android imports.
|
||||||
*
|
*
|
||||||
* The test [config] uses a smaller window and fewer sustained frames than the
|
* The test [config] shortens only the window and the sustained-frame counts, so
|
||||||
* production defaults so tests run in milliseconds without generating thousands
|
* tests run in milliseconds instead of generating thousands of synthetic
|
||||||
* of synthetic samples.
|
* samples. Every *signal* threshold is inherited from [DetectionConfig]'s
|
||||||
|
* defaults, which are the values the app actually runs — the two cannot drift
|
||||||
|
* apart, which they previously did: the service overrode nine of the twelve
|
||||||
|
* parameters and these tests validated the un-overridden ones.
|
||||||
*
|
*
|
||||||
* Accel-std-dev notes
|
* Accel-std-dev notes
|
||||||
* -------------------
|
* -------------------
|
||||||
* A production threshold of 1.2 m/s² requires genuine variability in the window.
|
* The braking accel-std-dev threshold of 1.8 m/s² requires genuine variability
|
||||||
* In the "hard brake" tests we alternate between high and low accel values
|
* in the window. In the "hard brake" tests we alternate between high and low
|
||||||
* (e.g. 3.5 / 0.5), which yields std dev ≈ 1.5 with a 10-sample window.
|
* accel values (4.5 / 0.5), which yields a population std dev of |hi − lo| / 2
|
||||||
|
* = 2.0 in a full window — above the threshold with margin.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
class EventDetectorTest {
|
class EventDetectorTest {
|
||||||
|
|
||||||
/** Tighter config so fewer frames are needed to trigger each event. */
|
/**
|
||||||
|
* Shortens the window and the sustained-frame counts so fewer synthetic frames are
|
||||||
|
* needed per test. Every signal threshold is deliberately left at its default, so
|
||||||
|
* these tests exercise the thresholds the app ships with. Do not restate a signal
|
||||||
|
* threshold here — that is exactly how the two configurations drifted apart before.
|
||||||
|
*/
|
||||||
private val config = DetectionConfig(
|
private val config = DetectionConfig(
|
||||||
windowSize = 10,
|
windowSize = 10,
|
||||||
brakingSustainedFrames = 5,
|
brakingSustainedFrames = 5,
|
||||||
turningSustainedFrames = 8,
|
turningSustainedFrames = 8,
|
||||||
stoppingFrames = 20,
|
stoppingFrames = 20,
|
||||||
// Keep production thresholds for all signal values:
|
|
||||||
brakingSpeedDropThreshold = 0.5,
|
|
||||||
brakingAccelStdDevThreshold = 1.2,
|
|
||||||
brakingHighConfidenceRate = 1.5,
|
|
||||||
turningGyroMeanThreshold = 0.4,
|
|
||||||
turningBearingChangeThreshold = 10.0,
|
|
||||||
turningMinSpeedThreshold = 2.0,
|
|
||||||
stoppingSpeedThreshold = 0.5,
|
|
||||||
stoppingAccelStdDevThreshold = 0.15,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
private lateinit var detector: EventDetector
|
private lateinit var detector: EventDetector
|
||||||
@@ -71,12 +71,12 @@ class EventDetectorTest {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Produces [n] frames with alternating accelMagnitude values of [hi] and [lo],
|
* Produces [n] frames with alternating accelMagnitude values of [hi] and [lo],
|
||||||
* giving a population std dev of |hi - lo| / 2, which exceeds the production
|
* giving a population std dev of |hi - lo| / 2, which exceeds the shipping
|
||||||
* threshold of 1.2 m/s² when hi=3.5 and lo=0.5 (std dev = 1.5).
|
* threshold of 1.8 m/s² when hi=4.5 and lo=0.5 (std dev = 2.0).
|
||||||
*/
|
*/
|
||||||
private fun alternatingAccelFrames(
|
private fun alternatingAccelFrames(
|
||||||
n: Int,
|
n: Int,
|
||||||
hi: Double = 3.5,
|
hi: Double = 4.5,
|
||||||
lo: Double = 0.5,
|
lo: Double = 0.5,
|
||||||
speedMps: Double = 10.0,
|
speedMps: Double = 10.0,
|
||||||
bearingChangeDps: Double = 0.0,
|
bearingChangeDps: Double = 0.0,
|
||||||
@@ -139,12 +139,12 @@ class EventDetectorTest {
|
|||||||
@Test fun `hard brake with large speed drop has HIGH confidence`() = runCollecting { events ->
|
@Test fun `hard brake with large speed drop has HIGH confidence`() = runCollecting { events ->
|
||||||
// Variability established before the drop - see the note in the test above.
|
// Variability established before the drop - see the note in the test above.
|
||||||
alternatingAccelFrames(n = config.windowSize, speedMps = 10.0, timeOffset = 0)
|
alternatingAccelFrames(n = config.windowSize, speedMps = 10.0, timeOffset = 0)
|
||||||
// Drop of 8 m/s > brakingHighConfidenceRate (1.5)
|
// Drop of 8 m/s > brakingHighConfidencePeakDrop (1.5)
|
||||||
alternatingAccelFrames(
|
alternatingAccelFrames(
|
||||||
n = config.brakingSustainedFrames + 5,
|
n = config.brakingSustainedFrames + 5,
|
||||||
hi = 3.5,
|
hi = 4.5,
|
||||||
lo = 0.5,
|
lo = 0.5,
|
||||||
speedMps = 2.0, // drop from 10 → 8 m/s
|
speedMps = 2.0, // drop from 10 → 2 m/s
|
||||||
timeOffset = config.windowSize,
|
timeOffset = config.windowSize,
|
||||||
)
|
)
|
||||||
val braking = events.filter { it.type == EventType.BRAKING }
|
val braking = events.filter { it.type == EventType.BRAKING }
|
||||||
@@ -159,12 +159,16 @@ class EventDetectorTest {
|
|||||||
@Test fun `moderate speed drop has MEDIUM confidence`() = runCollecting { events ->
|
@Test fun `moderate speed drop has MEDIUM confidence`() = runCollecting { events ->
|
||||||
// Variability established before the drop - see `hard brake triggers BRAKING event`.
|
// Variability established before the drop - see `hard brake triggers BRAKING event`.
|
||||||
alternatingAccelFrames(n = config.windowSize, speedMps = 3.0, timeOffset = 0)
|
alternatingAccelFrames(n = config.windowSize, speedMps = 3.0, timeOffset = 0)
|
||||||
// Drop of 0.8 m/s — above speed-drop threshold (0.5) but below high-conf rate (1.5)
|
// Drop of 1.2 m/s — above the speed-drop threshold (1.0) but below the
|
||||||
|
// high-confidence peak drop (1.5), so this must land as MEDIUM. The window
|
||||||
|
// between those two values is narrow at the shipping thresholds, which is
|
||||||
|
// itself worth knowing: MEDIUM braking is only emitted for drops in
|
||||||
|
// (1.0, 1.5] m/s.
|
||||||
alternatingAccelFrames(
|
alternatingAccelFrames(
|
||||||
n = config.brakingSustainedFrames + 5,
|
n = config.brakingSustainedFrames + 5,
|
||||||
hi = 3.5,
|
hi = 4.5,
|
||||||
lo = 0.5,
|
lo = 0.5,
|
||||||
speedMps = 2.2, // drop = 0.8 m/s
|
speedMps = 1.8, // drop = 1.2 m/s
|
||||||
timeOffset = config.windowSize,
|
timeOffset = config.windowSize,
|
||||||
)
|
)
|
||||||
val braking = events.filter { it.type == EventType.BRAKING }
|
val braking = events.filter { it.type == EventType.BRAKING }
|
||||||
@@ -179,9 +183,9 @@ class EventDetectorTest {
|
|||||||
repeat(total) { i ->
|
repeat(total) { i ->
|
||||||
detector.processSample(
|
detector.processSample(
|
||||||
accelMagnitude = 0.3,
|
accelMagnitude = 0.3,
|
||||||
gyroMagnitude = 0.8, // mean → well above 0.4 threshold
|
gyroMagnitude = 0.8, // mean → above the 0.6 threshold
|
||||||
speedMps = 4.0, // above 2 m/s → bearing also checked
|
speedMps = 4.0, // above 2 m/s → bearing also checked
|
||||||
bearingChangeDegPerSec = 15.0, // above 10 °/s → both signals agree
|
bearingChangeDegPerSec = 20.0, // above 15 °/s → both signals agree
|
||||||
latitude = 53.5,
|
latitude = 53.5,
|
||||||
longitude = 10.0,
|
longitude = 10.0,
|
||||||
timestamp = i * 20L,
|
timestamp = i * 20L,
|
||||||
@@ -193,7 +197,7 @@ class EventDetectorTest {
|
|||||||
@Test fun `turning with both signals agreeing gets HIGH confidence`() = runCollecting { events ->
|
@Test fun `turning with both signals agreeing gets HIGH confidence`() = runCollecting { events ->
|
||||||
val total = config.windowSize + config.turningSustainedFrames + 4
|
val total = config.windowSize + config.turningSustainedFrames + 4
|
||||||
repeat(total) { i ->
|
repeat(total) { i ->
|
||||||
detector.processSample(0.3, 0.8, 4.0, 15.0, 53.5, 10.0, i * 20L)
|
detector.processSample(0.3, 0.8, 4.0, 20.0, 53.5, 10.0, i * 20L)
|
||||||
}
|
}
|
||||||
val turning = events.filter { it.type == EventType.TURNING }
|
val turning = events.filter { it.type == EventType.TURNING }
|
||||||
assertTrue(turning.isNotEmpty())
|
assertTrue(turning.isNotEmpty())
|
||||||
@@ -205,9 +209,9 @@ class EventDetectorTest {
|
|||||||
repeat(total) { i ->
|
repeat(total) { i ->
|
||||||
detector.processSample(
|
detector.processSample(
|
||||||
accelMagnitude = 0.2,
|
accelMagnitude = 0.2,
|
||||||
gyroMagnitude = 0.6, // above gyro threshold
|
gyroMagnitude = 0.9, // above the 0.6 gyro threshold
|
||||||
speedMps = 1.0, // below 2 m/s → bearing not enforced
|
speedMps = 1.0, // below 2 m/s → bearing not enforced
|
||||||
bearingChangeDegPerSec = 3.0, // below bearing threshold
|
bearingChangeDegPerSec = 3.0, // below the 15 °/s bearing threshold
|
||||||
latitude = 53.5,
|
latitude = 53.5,
|
||||||
longitude = 10.0,
|
longitude = 10.0,
|
||||||
timestamp = i * 20L,
|
timestamp = i * 20L,
|
||||||
@@ -253,7 +257,7 @@ class EventDetectorTest {
|
|||||||
// Speed stays at zero; occasional accel/gyro spikes from bag jostle
|
// Speed stays at zero; occasional accel/gyro spikes from bag jostle
|
||||||
repeat(50) { i ->
|
repeat(50) { i ->
|
||||||
val accel = if (i % 5 == 0) 1.8 else 0.3 // jitter but mean is below std-dev threshold
|
val accel = if (i % 5 == 0) 1.8 else 0.3 // jitter but mean is below std-dev threshold
|
||||||
val gyro = if (i % 7 == 0) 0.35 else 0.05 // occasional spike but mean stays < 0.4
|
val gyro = if (i % 7 == 0) 0.35 else 0.05 // occasional spike but mean stays < 0.6
|
||||||
detector.processSample(
|
detector.processSample(
|
||||||
accelMagnitude = accel,
|
accelMagnitude = accel,
|
||||||
gyroMagnitude = gyro,
|
gyroMagnitude = gyro,
|
||||||
@@ -265,7 +269,7 @@ class EventDetectorTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
// speed = 0 → no speed drop possible → no BRAKING
|
// speed = 0 → no speed drop possible → no BRAKING
|
||||||
// gyro mean stays below 0.4 (only 1/7 frames spike to 0.35) → no TURNING
|
// gyro mean stays below 0.6 (only 1/7 frames spike to 0.35) → no TURNING
|
||||||
val unwanted = events.filter { it.type == EventType.BRAKING || it.type == EventType.TURNING }
|
val unwanted = events.filter { it.type == EventType.BRAKING || it.type == EventType.TURNING }
|
||||||
assertTrue("Bag movement must not trigger BRAKING or TURNING, got: $events", unwanted.isEmpty())
|
assertTrue("Bag movement must not trigger BRAKING or TURNING, got: $events", unwanted.isEmpty())
|
||||||
}
|
}
|
||||||
@@ -299,13 +303,12 @@ class EventDetectorTest {
|
|||||||
}
|
}
|
||||||
// Second stop episode. Deliberately longer than the first: stopping also requires the
|
// Second stop episode. Deliberately longer than the first: stopping also requires the
|
||||||
// accel std dev to be BELOW a threshold, and the rolling window still holds the five
|
// accel std dev to be BELOW a threshold, and the rolling window still holds the five
|
||||||
// moving samples above. It takes 8 further frames for those to drain out far enough for
|
// moving samples above. At the shipping threshold of 0.10 m/s² even a single 0.5 sample
|
||||||
// the std dev to fall under 0.15, and only then does the counter start. The first episode
|
// left in a 10-sample window gives a std dev of ~0.14, so ALL five have to be evicted
|
||||||
// needs no such allowance because the window begins empty.
|
// before the counter can start - that is a full windowSize of stationary frames. Only
|
||||||
//
|
// then do the 21 qualifying frames the event needs begin to accumulate. The first
|
||||||
// stoppingFrames + 5 was not enough - the second episode reached 17 of the 21 frames it
|
// episode needs no such allowance because the window begins empty.
|
||||||
// needs and silently emitted nothing, which is what made this test fail.
|
repeat(config.stoppingFrames + 20) {
|
||||||
repeat(config.stoppingFrames + 10) {
|
|
||||||
detector.processSample(0.02, 0.01, 0.1, 0.0, 53.5, 10.0, t++ * 20L)
|
detector.processSample(0.02, 0.01, 0.1, 0.0, 53.5, 10.0, t++ * 20L)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.hawhamburg.micr0bu
|
||||||
|
|
||||||
|
import com.hawhamburg.micr0bu.domain.asn1.ItsTime
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pins the arithmetic that moves a transmit timestamp from the phone's wall clock onto GNSS time.
|
||||||
|
*
|
||||||
|
* The cases come from the 2026-09-10 bench session. The sending phone's clock was 1456 s fast
|
||||||
|
* because it had no automatic time source, and every CAM it sent was stamped 24 minutes in the
|
||||||
|
* future. After a manual correction it was 6 s slow. Both have to come out on GNSS time.
|
||||||
|
*/
|
||||||
|
class ItsTimeTest {
|
||||||
|
|
||||||
|
private val gnssNow = 1_789_038_922_000L
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `without a GNSS reading the wall-clock time is used unchanged`() {
|
||||||
|
assertEquals(1_000L, ItsTime.onGnssTime(systemMs = 1_000L, gnssNowMs = null, systemNowMs = 5_000L))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a phone clock running fast is pulled back onto GNSS time`() {
|
||||||
|
val systemNow = gnssNow + 1_456_000L
|
||||||
|
// A fix the wall clock stamped 0.8 s ago. It must still be 0.8 s old afterwards.
|
||||||
|
val fix = systemNow - 800L
|
||||||
|
assertEquals(gnssNow - 800L, ItsTime.onGnssTime(fix, gnssNow, systemNow))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a phone clock running slow is pushed forward onto GNSS time`() {
|
||||||
|
val systemNow = gnssNow - 6_000L
|
||||||
|
assertEquals(gnssNow - 250L, ItsTime.onGnssTime(systemNow - 250L, gnssNow, systemNow))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an accurate phone clock is left where it is`() {
|
||||||
|
assertEquals(gnssNow - 40L, ItsTime.onGnssTime(gnssNow - 40L, gnssNow, gnssNow))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,66 +1,95 @@
|
|||||||
package com.hawhamburg.micr0bu
|
package com.hawhamburg.micr0bu
|
||||||
|
|
||||||
import com.hawhamburg.micr0bu.domain.cam.OwnStationIds
|
import com.hawhamburg.micr0bu.domain.cam.OwnStationIds
|
||||||
|
import com.hawhamburg.micr0bu.domain.cam.OwnStationIds.BENCH_PING
|
||||||
|
import com.hawhamburg.micr0bu.domain.cam.OwnStationIds.BENCH_PING_GRACE_MS
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertNotEquals
|
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pins the rule that decides whether a received CAM is one this phone sent.
|
* Pins the rule that decides whether a received CAM is one this phone sent.
|
||||||
*
|
*
|
||||||
* ## The bug this exists to prevent
|
* ## The bugs this exists to prevent
|
||||||
* The phone transmits under two station IDs: the persisted per-install one used by
|
* Getting it wrong fails in two opposite directions, and each has happened:
|
||||||
* `CamTransmitLoop`, and a fixed bench ID used by `CamPinger` so pings stay identifiable in
|
|
||||||
* captures. The ESP32-C5 receives promiscuously, so both come straight back off the air.
|
|
||||||
*
|
*
|
||||||
* The filter originally checked only the persisted ID. Every bench ping therefore returned as a
|
* - **Too narrow.** An own frame that is not recognised comes back as a remote road user sitting
|
||||||
* remote road user sitting exactly on top of the ego position, moving at the ego's own speed and
|
* exactly on the ego position, and is fed to the detection engine as a collision partner for
|
||||||
* heading, and was fed to the detection engine as a collision partner for itself. Nothing failed
|
* itself. That happened with the bench pinger's separate ID, and pseudonym rotation creates the
|
||||||
* loudly: the app simply raised use case alerts against itself for as long as the pinger ran.
|
* same risk for an ID that has just been retired.
|
||||||
*
|
* - **Too wide.** On 2026-09-10 the bench ID counted as ours on every phone, so a phone watching
|
||||||
* These tests are what should fail if a third transmit path is ever added without teaching this
|
* through the CiT One silently discarded another phone's pings as its own, although it had sent
|
||||||
* rule about it.
|
* none. Nothing appeared on its V2X screen while the broker was full of them.
|
||||||
*/
|
*/
|
||||||
class OwnStationIdsTest {
|
class OwnStationIdsTest {
|
||||||
|
|
||||||
private val persisted = 1_691_338_363L
|
private val current = 1_691_338_363L
|
||||||
|
private val retired = 2_222_222_222L
|
||||||
|
private val ours = setOf(current, retired)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `recognises the persisted transmit id`() {
|
fun `recognises the current transmit id`() {
|
||||||
assertTrue(OwnStationIds.isOwn(persisted, persisted))
|
assertTrue(OwnStationIds.isOwn(current, ours, benchPingIsOurs = false))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `recognises the bench ping id even though it is not the persisted one`() {
|
fun `recognises a recently retired id, so a frame sent just before a rotation is still ours`() {
|
||||||
// The regression. The pinger's id is deliberately different, which is exactly why a
|
assertTrue(OwnStationIds.isOwn(retired, ours, benchPingIsOurs = false))
|
||||||
// filter written around the persisted id alone let every ping through.
|
|
||||||
assertNotEquals(
|
|
||||||
"the bench id is meant to be distinct, or this test proves nothing",
|
|
||||||
persisted,
|
|
||||||
OwnStationIds.BENCH_PING,
|
|
||||||
)
|
|
||||||
assertTrue(OwnStationIds.isOwn(OwnStationIds.BENCH_PING, persisted))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `recognises the bench ping id before the persisted id has loaded`() {
|
fun `another phone's bench ping is shown, not swallowed as our own`() {
|
||||||
// The persisted id is read asynchronously, so it can still be null while the pinger is
|
// The 2026-09-10 regression: this phone is not pinging, so 999999 is someone else.
|
||||||
// already transmitting. The ping must be recognised as ours regardless.
|
assertFalse(OwnStationIds.isOwn(BENCH_PING, ours, benchPingIsOurs = false))
|
||||||
assertTrue(OwnStationIds.isOwn(OwnStationIds.BENCH_PING, null))
|
assertFalse(OwnStationIds.isOwn(BENCH_PING, emptySet(), benchPingIsOurs = false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `our own bench ping is recognised while we are pinging, even before any transmit id loads`() {
|
||||||
|
assertTrue(OwnStationIds.isOwn(BENCH_PING, emptySet(), benchPingIsOurs = true))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `treats a genuine remote station as remote`() {
|
fun `treats a genuine remote station as remote`() {
|
||||||
assertFalse(OwnStationIds.isOwn(2_741_041_966L, persisted))
|
assertFalse(OwnStationIds.isOwn(2_741_041_966L, ours, benchPingIsOurs = true))
|
||||||
assertFalse(OwnStationIds.isOwn(2_741_041_966L, null))
|
assertFalse(OwnStationIds.isOwn(2_741_041_966L, emptySet(), benchPingIsOurs = false))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `station id zero is never ours`() {
|
fun `station id zero is never ours`() {
|
||||||
// 0 is the "not resolved yet" placeholder for the ego identity. Matching on it would
|
// 0 is the "not resolved yet" placeholder for the ego identity. Matching on it would
|
||||||
// swallow real traffic from any station that reported 0.
|
// swallow real traffic from any station that reported 0.
|
||||||
assertFalse(OwnStationIds.isOwn(0L, null))
|
assertFalse(OwnStationIds.isOwn(0L, setOf(0L), benchPingIsOurs = true))
|
||||||
assertFalse(OwnStationIds.isOwn(0L, 0L))
|
}
|
||||||
|
|
||||||
|
// ---- when the bench id is ours ---------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the bench id is ours while the pinger runs`() {
|
||||||
|
assertTrue(OwnStationIds.benchPingIsOurs(pingerActive = true, pingerStoppedAtMs = null, nowMs = 0L))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the bench id is not ours on a phone that never pinged`() {
|
||||||
|
assertFalse(OwnStationIds.benchPingIsOurs(pingerActive = false, pingerStoppedAtMs = null, nowMs = 50_000L))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the bench id stays ours for the grace window after Stop, and not a moment longer`() {
|
||||||
|
val stop = 100_000L
|
||||||
|
assertTrue(OwnStationIds.benchPingIsOurs(false, stop, stop + BENCH_PING_GRACE_MS))
|
||||||
|
assertFalse(OwnStationIds.benchPingIsOurs(false, stop, stop + BENCH_PING_GRACE_MS + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a clock reading before the stop time does not claim the bench id`() {
|
||||||
|
assertFalse(OwnStationIds.benchPingIsOurs(false, pingerStoppedAtMs = 100_000L, nowMs = 99_000L))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the bench MAC is a locally administered unicast address`() {
|
||||||
|
// Bit 1 set, bit 0 clear. A source address must never be a group address.
|
||||||
|
assertEquals(0x02, OwnStationIds.BENCH_PING_MAC[0].toInt() and 0x03)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package com.hawhamburg.micr0bu
|
||||||
|
|
||||||
|
import com.hawhamburg.micr0bu.domain.cam.OwnStationIds
|
||||||
|
import com.hawhamburg.micr0bu.domain.cam.Pseudonym
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNotEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import kotlin.random.Random
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pins what a transmit pseudonym is allowed to look like, and when it rotates.
|
||||||
|
*
|
||||||
|
* The address rules matter on air, not just in the app: the ESP32 writes this MAC straight into
|
||||||
|
* the 802.11 source address. A group (multicast) source address is invalid, and a random address
|
||||||
|
* without the locally-administered bit claims to belong to a real hardware vendor.
|
||||||
|
*/
|
||||||
|
class PseudonymTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rotates every ten minutes`() {
|
||||||
|
assertEquals(10 * 60_000L, Pseudonym.ROTATION_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `expires exactly at the rotation interval, not a millisecond before`() {
|
||||||
|
val p = Pseudonym(stationId = 42L, mac = mac(0x02), createdAtMs = 1_000L)
|
||||||
|
assertFalse(p.isExpired(1_000L + Pseudonym.ROTATION_INTERVAL_MS - 1))
|
||||||
|
assertTrue(p.isExpired(1_000L + Pseudonym.ROTATION_INTERVAL_MS))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a clock that moved back past the creation time forces a rotation`() {
|
||||||
|
// Otherwise a creation time now lying in the future would pin one identity until the
|
||||||
|
// clock caught up, which after a large correction could be hours.
|
||||||
|
val p = Pseudonym(stationId = 42L, mac = mac(0x02), createdAtMs = 1_000L)
|
||||||
|
assertTrue(p.isExpired(999L))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `generated addresses are locally administered unicast, whatever the random bytes`() {
|
||||||
|
repeat(500) { seed ->
|
||||||
|
val first = Pseudonym.generate(0L, Random(seed)).mac[0].toInt()
|
||||||
|
assertEquals("seed $seed: bit 1 set, bit 0 clear", 0x02, first and 0x03)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `generated station ids stay in range`() {
|
||||||
|
repeat(500) { seed ->
|
||||||
|
val id = Pseudonym.generate(0L, Random(seed)).stationId
|
||||||
|
assertTrue("seed $seed: $id", id in 1L until 0xFFFF_FFFEL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `never generates the bench pinger's identity`() {
|
||||||
|
// Scripted so the exclusion loops actually run: the first draw of each is the bench
|
||||||
|
// value, which must be rejected in favour of the second.
|
||||||
|
val random = ScriptedRandom(
|
||||||
|
longs = ArrayDeque(listOf(OwnStationIds.BENCH_PING, 42L)),
|
||||||
|
bytes = ArrayDeque(listOf(OwnStationIds.BENCH_PING_MAC, byteArrayOf(0x13, 1, 2, 3, 4, 5))),
|
||||||
|
)
|
||||||
|
val p = Pseudonym.generate(0L, random)
|
||||||
|
assertEquals(42L, p.stationId)
|
||||||
|
assertEquals("0x13 with the group bit cleared and the local bit set", 0x12, p.mac[0].toInt() and 0xFF)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a rotation replaces the station id and the address together`() {
|
||||||
|
val a = Pseudonym.generate(0L, Random(1))
|
||||||
|
val b = Pseudonym.generate(Pseudonym.ROTATION_INTERVAL_MS, Random(2))
|
||||||
|
assertNotEquals(a.stationId, b.stationId)
|
||||||
|
assertFalse(a.mac.contentEquals(b.mac))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `equality compares the address bytes, not the array instance`() {
|
||||||
|
assertEquals(
|
||||||
|
Pseudonym(7L, mac(0x02), 5L),
|
||||||
|
Pseudonym(7L, mac(0x02), 5L),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mac(first: Int) = byteArrayOf(first.toByte(), 0x11, 0x22, 0x33, 0x44, 0x55)
|
||||||
|
|
||||||
|
private class ScriptedRandom(
|
||||||
|
private val longs: ArrayDeque<Long>,
|
||||||
|
private val bytes: ArrayDeque<ByteArray>,
|
||||||
|
) : Random() {
|
||||||
|
override fun nextBits(bitCount: Int): Int = error("not used by Pseudonym.generate")
|
||||||
|
override fun nextLong(from: Long, until: Long): Long = longs.removeFirst()
|
||||||
|
override fun nextBytes(size: Int): ByteArray = bytes.removeFirst().copyOf()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -139,8 +139,8 @@ share no code. The requirement is satisfied twice, by different means.
|
|||||||
| 11.1 | Orientation-Independent Sensor Strategy | **Done** | `SensorRepository.kt` (magnitude-based) | — |
|
| 11.1 | Orientation-Independent Sensor Strategy | **Done** | `SensorRepository.kt` (magnitude-based) | — |
|
||||||
| 11.2 | Running Standard Deviation Event Detector | **Done** | `EventDetector.kt`, `RunningStats.kt` | **18 unit tests, 0 failures** |
|
| 11.2 | Running Standard Deviation Event Detector | **Done** | `EventDetector.kt`, `RunningStats.kt` | **18 unit tests, 0 failures** |
|
||||||
| 11.3 | Trip Recording Architecture | **Done** | `TripRepository.kt`, `TripRecordingService.kt` *(cited)* | — |
|
| 11.3 | Trip Recording Architecture | **Done** | `TripRepository.kt`, `TripRecordingService.kt` *(cited)* | — |
|
||||||
| 11.4 | Data Model | **Done** | `data/db/` Room entities *(cited)* | — |
|
| 11.4 | Data Model | **Partial — scope reduced** | `data/db/` Room entities *(cited)* | `detected_events` dropped in schema v5, see scope note |
|
||||||
| 11.5 | New UI Elements for Phase A | **Done** | `TripHistoryScreen.kt`, `TripReviewScreen.kt` | — |
|
| 11.5 | New UI Elements for Phase A | **Partial — scope reduced** | `TripHistoryScreen.kt`, `TripReviewScreen.kt` | event pins/counters removed by decision, see note |
|
||||||
| 11.6 | Phase A Success Criteria | **Partial** | — | needs a real ride; see Open Items |
|
| 11.6 | Phase A Success Criteria | **Partial** | — | needs a real ride; see Open Items |
|
||||||
|
|
||||||
**Correction note (11.2).** Four `EventDetectorTest` cases had been failing since the initial commit.
|
**Correction note (11.2).** Four `EventDetectorTest` cases had been failing since the initial commit.
|
||||||
@@ -149,6 +149,26 @@ described stimuli the detector cannot physically see, because they ignored the s
|
|||||||
rolling standard-deviation window. Tests corrected, assertions unchanged, detector untouched. This is
|
rolling standard-deviation window. Tests corrected, assertions unchanged, detector untouched. This is
|
||||||
worth reporting — it is a finding about test design, not a defect.
|
worth reporting — it is a finding about test design, not a defect.
|
||||||
|
|
||||||
|
**Scope note (11.5).** The event-detection UI — the live per-type counters on the recording screen,
|
||||||
|
the coloured event pins and detail sheet on the trip review map, and the event count on the trip
|
||||||
|
history card — was removed deliberately. A count of the rider's own braking events is not a goal of
|
||||||
|
this project. The detector itself still runs: it is the input to the CAM transmit-rate policy
|
||||||
|
(§ 13), which raises the beacon rate from 1 Hz to the elevated rate for five seconds after a
|
||||||
|
detected manoeuvre. That is now its only effect: the `detected_events` table was dropped in schema
|
||||||
|
v5 and the per-event rows removed from the trip CSV, so a detected manoeuvre is consumed and
|
||||||
|
discarded. `trips.eventCount` is kept as a single integer per ride, since dropping a SQLite column
|
||||||
|
means recreating the table.
|
||||||
|
|
||||||
|
**Defect note (11.2).** Two defects found while documenting the detector were fixed on 2026-09-07.
|
||||||
|
The nine threshold overrides in `TripRecordingService`'s constructor were promoted to
|
||||||
|
`DetectionConfig`'s defaults and the override deleted, so there is one configuration and
|
||||||
|
`EventDetectorTest` exercises the shipping thresholds rather than the superseded Phase A ones;
|
||||||
|
detector sensitivity is unchanged, and the synthetic stimuli were re-derived because several no
|
||||||
|
longer cleared the stricter real thresholds. `brakingHighConfidenceRate` was renamed
|
||||||
|
`brakingHighConfidencePeakDrop`: it was documented as a rate but has always been compared against
|
||||||
|
the peak cumulative speed drop. The name was corrected rather than the comparison, so detector
|
||||||
|
output is unchanged and the confidence assertions remain valid evidence.
|
||||||
|
|
||||||
## 12. Future Architecture & Open Design Questions
|
## 12. Future Architecture & Open Design Questions
|
||||||
|
|
||||||
| § | Title | Status | Notes |
|
| § | Title | Status | Notes |
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
+33
-22
@@ -1,9 +1,23 @@
|
|||||||
#include "geonet.h"
|
#include "geonet.h"
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
|
|
||||||
|
// GeoNetworking is big-endian throughout, unlike this project's serial framing.
|
||||||
|
static void put_be16(uint8_t **p, uint16_t v)
|
||||||
|
{
|
||||||
|
*(*p)++ = (uint8_t)(v >> 8);
|
||||||
|
*(*p)++ = (uint8_t)(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void put_be32(uint8_t **p, uint32_t v)
|
||||||
|
{
|
||||||
|
*(*p)++ = (uint8_t)(v >> 24);
|
||||||
|
*(*p)++ = (uint8_t)(v >> 16);
|
||||||
|
*(*p)++ = (uint8_t)(v >> 8);
|
||||||
|
*(*p)++ = (uint8_t)(v);
|
||||||
|
}
|
||||||
|
|
||||||
int geonet_wrap_shb(const uint8_t *its_payload, int its_len,
|
int geonet_wrap_shb(const uint8_t *its_payload, int its_len,
|
||||||
const uint8_t mac[6], uint8_t station_type,
|
const gn_lpv_t *lpv,
|
||||||
int32_t latitude_tenmicrodeg, int32_t longitude_tenmicrodeg,
|
|
||||||
uint16_t btp_dest_port,
|
uint16_t btp_dest_port,
|
||||||
uint8_t *out, size_t out_len)
|
uint8_t *out, size_t out_len)
|
||||||
{
|
{
|
||||||
@@ -50,27 +64,24 @@ int geonet_wrap_shb(const uint8_t *its_payload, int its_len,
|
|||||||
// defined to BE the link-layer (802.11) address - so this must match
|
// defined to BE the link-layer (802.11) address - so this must match
|
||||||
// the source address dot11p_build_frame uses, not just "look similar."
|
// the source address dot11p_build_frame uses, not just "look similar."
|
||||||
uint8_t gn_addr[8];
|
uint8_t gn_addr[8];
|
||||||
gn_addr[0] = (uint8_t)((0 << 7) | ((station_type & 0x1F) << 2)); // M=0, ST=station_type, top 2 reserved bits=0
|
gn_addr[0] = (uint8_t)((0 << 7) | ((lpv->station_type & 0x1F) << 2)); // M=0, ST=station_type, top 2 reserved bits=0
|
||||||
gn_addr[1] = 0x00; // remaining 8 reserved bits
|
gn_addr[1] = 0x00; // remaining 8 reserved bits
|
||||||
memcpy(&gn_addr[2], mac, 6); // MID = link-layer address
|
memcpy(&gn_addr[2], lpv->mac, 6); // MID = link-layer address
|
||||||
memcpy(p, gn_addr, 8); p += 8;
|
memcpy(p, gn_addr, 8); p += 8;
|
||||||
// Timestamp (4 bytes, ms since 2004-01-01 mod 2^32) - placeholder 0,
|
// TST (4 bytes): when the position below was acquired, ms, TimestampIts mod 2^32.
|
||||||
// same caveat as detectionTime in denm.c.
|
put_be32(&p, lpv->tst_ms);
|
||||||
memset(p, 0, 4); p += 4;
|
// Latitude/Longitude (4+4 bytes, signed, 1/10 microdegree) - fixed-width binary fields, not
|
||||||
// Latitude/Longitude (4+4 bytes, signed, big-endian, 1/10 microdegree) -
|
// UPER bit-packed like the CAM payload's own position.
|
||||||
// fixed-width binary fields, not UPER bit-packed.
|
put_be32(&p, (uint32_t)lpv->lat_tenmicrodeg);
|
||||||
uint32_t lat_u = (uint32_t)latitude_tenmicrodeg;
|
put_be32(&p, (uint32_t)lpv->lon_tenmicrodeg);
|
||||||
*p++ = (uint8_t)(lat_u >> 24); *p++ = (uint8_t)(lat_u >> 16);
|
// PAI (1 bit) + Speed (15 bits, signed, 0.01 m/s). Clamped, not masked: a 15-bit value that
|
||||||
*p++ = (uint8_t)(lat_u >> 8); *p++ = (uint8_t)(lat_u);
|
// overflows wraps its sign bit and reads as travelling backwards at speed.
|
||||||
uint32_t lon_u = (uint32_t)longitude_tenmicrodeg;
|
int32_t speed = lpv->speed_cms;
|
||||||
*p++ = (uint8_t)(lon_u >> 24); *p++ = (uint8_t)(lon_u >> 16);
|
if (speed > 16383) speed = 16383;
|
||||||
*p++ = (uint8_t)(lon_u >> 8); *p++ = (uint8_t)(lon_u);
|
if (speed < -16384) speed = -16384;
|
||||||
// PAI(1 bit) + Speed(15 bits), packed into 2 bytes: 0 = PAI false,
|
put_be16(&p, (uint16_t)(((lpv->pai ? 1u : 0u) << 15) | ((uint16_t)speed & 0x7FFFu)));
|
||||||
// speed 0 - which is actually correct semantics for a STATIONARY
|
// Heading (16 bits, 0.1 degree from north, clockwise, 0..3599).
|
||||||
// vehicle beacon, not just a placeholder.
|
put_be16(&p, (uint16_t)(lpv->heading_decideg % 3600u));
|
||||||
*p++ = 0x00; *p++ = 0x00;
|
|
||||||
// Heading (16 bits, 0.1 degree units): 0 = due north / unavailable
|
|
||||||
*p++ = 0x00; *p++ = 0x00;
|
|
||||||
// Reserved (4 bytes) - clause 9.8.4: the SHB extended header is the 24-byte Source Position
|
// Reserved (4 bytes) - clause 9.8.4: the SHB extended header is the 24-byte Source Position
|
||||||
// Vector FOLLOWED BY a 4-byte reserved field (media-dependent data), 28 bytes in total. These
|
// Vector FOLLOWED BY a 4-byte reserved field (media-dependent data), 28 bytes in total. These
|
||||||
// four bytes were missing, which is why a standards-compliant receiver read our CAM payload's
|
// four bytes were missing, which is why a standards-compliant receiver read our CAM payload's
|
||||||
@@ -84,7 +95,7 @@ int geonet_wrap_shb(const uint8_t *its_payload, int its_len,
|
|||||||
*p++ = (uint8_t)(btp_dest_port & 0xFF);
|
*p++ = (uint8_t)(btp_dest_port & 0xFF);
|
||||||
*p++ = 0x00; *p++ = 0x00; // destination port info, unused for BTP-B
|
*p++ = 0x00; *p++ = 0x00; // destination port info, unused for BTP-B
|
||||||
|
|
||||||
// ---- ITS payload (DENM UPER bytes) ----
|
// ---- ITS payload (CAM UPER bytes from the phone) ----
|
||||||
memcpy(p, its_payload, its_len);
|
memcpy(p, its_payload, its_len);
|
||||||
p += its_len;
|
p += its_len;
|
||||||
|
|
||||||
|
|||||||
+43
-29
@@ -1,43 +1,57 @@
|
|||||||
#ifndef GEONET_H
|
#ifndef GEONET_H
|
||||||
#define GEONET_H
|
#define GEONET_H
|
||||||
|
#include <stdbool.h>
|
||||||
#include <stdint.h>
|
#include <stdint.h>
|
||||||
#include <stddef.h>
|
#include <stddef.h>
|
||||||
|
|
||||||
// Wraps an ITS application payload (e.g. from denm_encode) with a minimal
|
// The variable content of a GeoNetworking Long Position Vector (ETSI EN 302 636-4-1 clause
|
||||||
// GeoNetworking Basic Header + Common Header + Single-Hop-Broadcast
|
// 9.5.2): who the sender is and where it was. This is the Source Position Vector every
|
||||||
// extended header (HeaderType=TSB(5), HeaderSubtype=SINGLE_HOP(0), per
|
// GeoNetworking packet from this firmware carries.
|
||||||
// ETSI EN 302 636-4-1 table 9), then prepends a BTP-B header addressed to
|
|
||||||
// the DENM service port (2002).
|
|
||||||
//
|
//
|
||||||
// `mac` is the 6-byte pseudonym/link-layer address - pass the SAME address
|
// Every field here used to be a compile-time constant: the bench coordinates, speed 0, heading 0,
|
||||||
// you hand to dot11p_build_frame's src address, since GN_ADDR's MID field
|
// timestamp 0, passengerCar, and one fixed MAC. The phone never told the firmware where it was,
|
||||||
// (the last 6 bytes of the 8-byte GN_ADDR) is defined to BE that
|
// so the GN layer described a stationary car parked at the bench while the CAM inside it
|
||||||
// link-layer address (EN 302 636-4-1 clause 9.5.1). `station_type` is the
|
// described a moving cyclist somewhere else. The phone now supplies these values with each frame
|
||||||
// 5-bit ITS-S type from the same clause (5 = passengerCar) and gets packed
|
// (SERIAL_MSG_CAM_TX_PV in serial_link.h) and this firmware only lays them out on the wire.
|
||||||
// into GN_ADDR alongside the address.
|
typedef struct {
|
||||||
|
// Pseudonym. Written into GN_ADDR's MID field here AND, by dot11p_build_frame, into the
|
||||||
|
// 802.11 source address. Clause 9.5.1 defines the MID as the link-layer address, so the two
|
||||||
|
// must be the same six bytes; taking both from this one field is what keeps them identical
|
||||||
|
// when the pseudonym rotates.
|
||||||
|
uint8_t mac[6];
|
||||||
|
// ITS-S type, TS 102 894-2 StationType (2 = cyclist). Only the low 5 bits fit in GN_ADDR.
|
||||||
|
uint8_t station_type;
|
||||||
|
// Position Accuracy Indicator.
|
||||||
|
bool pai;
|
||||||
|
// TST: the moment lat/lon were acquired, in ms, as TimestampIts modulo 2^32.
|
||||||
|
uint32_t tst_ms;
|
||||||
|
// 1/10 microdegree, signed.
|
||||||
|
int32_t lat_tenmicrodeg;
|
||||||
|
int32_t lon_tenmicrodeg;
|
||||||
|
// 0.01 m/s. The wire field is 15-bit signed, so this is clamped to -16384..16383 on encode.
|
||||||
|
int16_t speed_cms;
|
||||||
|
// 0.1 degree from north, clockwise. Wrapped into 0..3599 on encode.
|
||||||
|
uint16_t heading_decideg;
|
||||||
|
} gn_lpv_t;
|
||||||
|
|
||||||
|
// Wraps an ITS application payload (the CAM UPER bytes the phone built) in a GeoNetworking Basic
|
||||||
|
// Header + Common Header + Single-Hop-Broadcast extended header (HeaderType=TSB(5),
|
||||||
|
// HeaderSubtype=SINGLE_HOP(0), EN 302 636-4-1 table 9), then a BTP-B header addressed to
|
||||||
|
// `btp_dest_port`.
|
||||||
//
|
//
|
||||||
// `latitude_tenmicrodeg`/`longitude_tenmicrodeg` go into the Source Long
|
// Single-hop broadcast is the correct packet type for CAM, which ETSI defines as never forwarded,
|
||||||
// Position Vector (clause 9.5.2) as plain 32-bit signed big-endian fields -
|
// so it has no destination area and no sequence number. A future DENM transmit path would need
|
||||||
// NOT UPER bit-packed like the DENM payload's position fields, this is a
|
// GeoBroadcast (HeaderType=4) instead, which this function does not build.
|
||||||
// fixed-width binary protocol. Pass the SAME values you gave denm_encode's
|
|
||||||
// eventPosition, so the GN-layer position and the DENM's own claimed
|
|
||||||
// position agree.
|
|
||||||
//
|
//
|
||||||
// Deliberate simplification: real DENM dissemination normally uses
|
// `lpv` supplies the Source Position Vector. Hand the SAME lpv->mac to dot11p_build_frame as its
|
||||||
// GeoBroadcast (GBC, HeaderType=4) so RSUs/OBUs can forward it across an
|
// source address, or the GN and 802.11 layers will name two different senders.
|
||||||
// area - that needs a sequence number + geo-area fields this skeleton
|
|
||||||
// doesn't build yet. Single-hop broadcast is simpler and is the
|
|
||||||
// best-tested decode path in the receiver firmware you already have
|
|
||||||
// working (same extended header shape as CAM). Fine for a single-vehicle
|
|
||||||
// beacon; revisit if you need real multi-hop forwarding later.
|
|
||||||
//
|
//
|
||||||
// `btp_dest_port` is the BTP-B destination port for the service being carried
|
// `btp_dest_port` is the BTP-B destination port (ETSI TS 103 248): 2001 = CAM, 2002 = DENM,
|
||||||
// (ETSI TS 103 248): 2001 = CAM, 2002 = DENM, 2003 = MAPEM, 2004 = SPATEM, ...
|
// 2003 = MAPEM, 2004 = SPATEM.
|
||||||
//
|
//
|
||||||
// Returns bytes written, or -1 if out buffer too small.
|
// Returns bytes written, or -1 if the out buffer is too small.
|
||||||
int geonet_wrap_shb(const uint8_t *its_payload, int its_len,
|
int geonet_wrap_shb(const uint8_t *its_payload, int its_len,
|
||||||
const uint8_t mac[6], uint8_t station_type,
|
const gn_lpv_t *lpv,
|
||||||
int32_t latitude_tenmicrodeg, int32_t longitude_tenmicrodeg,
|
|
||||||
uint16_t btp_dest_port,
|
uint16_t btp_dest_port,
|
||||||
uint8_t *out, size_t out_len);
|
uint8_t *out, size_t out_len);
|
||||||
|
|
||||||
|
|||||||
+76
-20
@@ -21,7 +21,9 @@
|
|||||||
static const char *TAG = "obu-tx";
|
static const char *TAG = "obu-tx";
|
||||||
|
|
||||||
// Phase 03: CAM is no longer built on this chip. The phone fuses its own GNSS+IMU, UPER-encodes
|
// Phase 03: CAM is no longer built on this chip. The phone fuses its own GNSS+IMU, UPER-encodes
|
||||||
// CAM itself, and hands the finished bytes down over serial_link (SERIAL_MSG_CAM_TX) - this
|
// CAM itself, and hands the finished bytes down over serial_link (SERIAL_MSG_CAM_TX_PV, together
|
||||||
|
// with the GeoNetworking position vector to send them under; plain SERIAL_MSG_CAM_TX from an app
|
||||||
|
// that predates it) - this
|
||||||
// firmware's job on transmit shrinks to "GeoNetworking/BTP-wrap + 802.11-wrap + key the PA the
|
// firmware's job on transmit shrinks to "GeoNetworking/BTP-wrap + 802.11-wrap + key the PA the
|
||||||
// instant a CAM arrives." There is no on-chip transmit timer anymore; the phone's send cadence
|
// instant a CAM arrives." There is no on-chip transmit timer anymore; the phone's send cadence
|
||||||
// (1 Hz baseline, faster near intersections/events - all decided app-side) IS the air cadence.
|
// (1 Hz baseline, faster near intersections/events - all decided app-side) IS the air cadence.
|
||||||
@@ -41,26 +43,25 @@ static const char *TAG = "obu-tx";
|
|||||||
#define TX_FREQ_MHZ 5900
|
#define TX_FREQ_MHZ 5900
|
||||||
|
|
||||||
// ---- CAM beacon profile (used for the GeoNetworking layer only now - see below) ----
|
// ---- CAM beacon profile (used for the GeoNetworking layer only now - see below) ----
|
||||||
#define STATION_TYPE 5 // passengerCar (TS 102 894-2 StationType) - matches gn_addr's ST field
|
#define STATION_TYPE 2 // cyclist (TS 102 894-2 StationType), legacy CAM_TX path only - see legacy_lpv()
|
||||||
#define BTP_PORT_CAM 2001 // BTP-B destination port for CAM (ETSI TS 103 248)
|
#define BTP_PORT_CAM 2001 // BTP-B destination port for CAM (ETSI TS 103 248)
|
||||||
|
|
||||||
// Bench location, hardcoded since there's no GNSS module wired in yet and the unit is genuinely
|
// Bench location, 53°33'16.8"N 10°01'20.6"E, in 1/10-microdegree units. Used only by the legacy
|
||||||
// stationary here: 53°33'16.8"N 10°01'20.6"E, in 1/10-microdegree units. Used ONLY for the
|
// SERIAL_MSG_CAM_TX path (see legacy_lpv), which carries no position of its own. A current app
|
||||||
// GeoNetworking Source Long Position Vector now (geonet_wrap_shb's own claimed position) - the
|
// sends SERIAL_MSG_CAM_TX_PV instead, and the GN Source Position Vector then comes from the
|
||||||
// CAM payload's own referencePosition comes from the phone's real GNSS and can legitimately
|
// phone's real fix, the same one the CAM payload's own referencePosition is built from.
|
||||||
// differ from this bench placeholder until the GN layer is also given a real position source.
|
|
||||||
// TODO: feed this from the phone too (e.g. a lightweight position update piggybacked on
|
|
||||||
// SERIAL_MSG_CAM_TX, or a new small message type) instead of a fixed bench location.
|
|
||||||
#define BENCH_LATITUDE_TENMICRODEG 535546667
|
#define BENCH_LATITUDE_TENMICRODEG 535546667
|
||||||
#define BENCH_LONGITUDE_TENMICRODEG 100223889
|
#define BENCH_LONGITUDE_TENMICRODEG 100223889
|
||||||
|
|
||||||
// Single source of truth for the pseudonym/link-layer address: used both as
|
// Link-layer address for the legacy SERIAL_MSG_CAM_TX path only. Locally-administered bit set
|
||||||
// the 802.11 source MAC (Addr2) and as GN_ADDR's MID field, since the GN
|
// (0x02), per normal MAC convention.
|
||||||
// spec defines those as being the same address. Locally-administered bit
|
//
|
||||||
// set (0x02) per normal MAC convention. Fixed/non-rotating for now - real
|
// This reverses the Phase 03 decision that the pseudonym is owned entirely by this firmware. That
|
||||||
// stacks rotate this every 5-15 min for privacy. Owned entirely by this firmware (not the
|
// was simplest while the address never changed, but a pseudonym only protects anyone if the
|
||||||
// phone) per the Phase 03 design decision - simplest given the phone never needs to know it.
|
// 802.11 address, the GN_ADDR MID and the CAM's stationID all change together, and the phone owns
|
||||||
static const uint8_t pseudonym_mac[6] = {0x02, 0x00, 0x00, 0x00, 0x00, 0x01};
|
// the stationID. One identity needs one owner, so with CAM_TX_PV the phone sends the address with
|
||||||
|
// every frame and rotates it, and this constant is only what the legacy path falls back to.
|
||||||
|
static const uint8_t LEGACY_MAC[6] = {0x02, 0x00, 0x00, 0x00, 0x00, 0x01};
|
||||||
|
|
||||||
// Undocumented libphy.a calls that push the radio into 802.11p OCB mode on
|
// Undocumented libphy.a calls that push the radio into 802.11p OCB mode on
|
||||||
// the 5.9 GHz ITS-G5 band. See docs/04-transmit-setup.md for source + what
|
// the 5.9 GHz ITS-G5 band. See docs/04-transmit-setup.md for source + what
|
||||||
@@ -77,6 +78,7 @@ extern void phy_change_channel(int freq_mhz, int bw_mode, int sec_chan_offset, i
|
|||||||
typedef struct {
|
typedef struct {
|
||||||
uint8_t data[SERIAL_LINK_MAX_PAYLOAD];
|
uint8_t data[SERIAL_LINK_MAX_PAYLOAD];
|
||||||
int len;
|
int len;
|
||||||
|
gn_lpv_t lpv; // the Source Position Vector this CAM goes out under
|
||||||
} cam_tx_item_t;
|
} cam_tx_item_t;
|
||||||
|
|
||||||
static QueueHandle_t s_tx_queue;
|
static QueueHandle_t s_tx_queue;
|
||||||
@@ -87,6 +89,23 @@ static QueueHandle_t s_tx_queue;
|
|||||||
// tx_radio_task below, off the UART parsing path entirely. xQueueSend with 0 timeout: if the
|
// tx_radio_task below, off the UART parsing path entirely. xQueueSend with 0 timeout: if the
|
||||||
// radio task is somehow behind, drop this CAM rather than stall UART frame parsing - the next
|
// radio task is somehow behind, drop this CAM rather than stall UART frame parsing - the next
|
||||||
// one is only ~1s (or less, at elevated rate) away regardless.
|
// one is only ~1s (or less, at elevated rate) away regardless.
|
||||||
|
// Source Position Vector for the legacy SERIAL_MSG_CAM_TX path, which carries no position of its
|
||||||
|
// own. Everything here describes the bench, not the rider: a fixed point, standing still, at an
|
||||||
|
// unknown time, under a fixed address. That is exactly why the phone now sends CAM_TX_PV. Kept so
|
||||||
|
// an app that predates it still transmits what it always did, except that the station type now
|
||||||
|
// says cyclist to agree with the CAM inside.
|
||||||
|
static void legacy_lpv(gn_lpv_t *lpv)
|
||||||
|
{
|
||||||
|
memcpy(lpv->mac, LEGACY_MAC, sizeof(lpv->mac));
|
||||||
|
lpv->station_type = STATION_TYPE;
|
||||||
|
lpv->pai = false;
|
||||||
|
lpv->tst_ms = 0;
|
||||||
|
lpv->lat_tenmicrodeg = BENCH_LATITUDE_TENMICRODEG;
|
||||||
|
lpv->lon_tenmicrodeg = BENCH_LONGITUDE_TENMICRODEG;
|
||||||
|
lpv->speed_cms = 0;
|
||||||
|
lpv->heading_decideg = 0;
|
||||||
|
}
|
||||||
|
|
||||||
static void on_cam_tx_from_phone(const uint8_t *cam_uper, int cam_len)
|
static void on_cam_tx_from_phone(const uint8_t *cam_uper, int cam_len)
|
||||||
{
|
{
|
||||||
if (cam_len <= 0 || cam_len > SERIAL_LINK_MAX_PAYLOAD) {
|
if (cam_len <= 0 || cam_len > SERIAL_LINK_MAX_PAYLOAD) {
|
||||||
@@ -96,6 +115,42 @@ static void on_cam_tx_from_phone(const uint8_t *cam_uper, int cam_len)
|
|||||||
cam_tx_item_t item;
|
cam_tx_item_t item;
|
||||||
item.len = cam_len;
|
item.len = cam_len;
|
||||||
memcpy(item.data, cam_uper, (size_t)cam_len);
|
memcpy(item.data, cam_uper, (size_t)cam_len);
|
||||||
|
legacy_lpv(&item.lpv);
|
||||||
|
if (xQueueSend(s_tx_queue, &item, 0) != pdTRUE) {
|
||||||
|
ESP_LOGW(TAG, "tx queue full, dropping CAM from phone");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static uint16_t le16(const uint8_t *p)
|
||||||
|
{
|
||||||
|
return (uint16_t)(p[0] | (p[1] << 8));
|
||||||
|
}
|
||||||
|
|
||||||
|
static uint32_t le32(const uint8_t *p)
|
||||||
|
{
|
||||||
|
return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24);
|
||||||
|
}
|
||||||
|
|
||||||
|
// SERIAL_MSG_CAM_TX_PV: the phone's CAM plus the position vector to send it under. The prefix
|
||||||
|
// layout is documented at SERIAL_MSG_CAM_TX_PV in serial_link.h. Same speed constraint as
|
||||||
|
// on_cam_tx_from_phone: decode, queue, return.
|
||||||
|
static void on_cam_tx_pv_from_phone(const uint8_t *prefix, const uint8_t *cam_uper, int cam_len)
|
||||||
|
{
|
||||||
|
if (cam_len <= 0 || cam_len > SERIAL_LINK_MAX_PAYLOAD) {
|
||||||
|
ESP_LOGW(TAG, "on_cam_tx_pv_from_phone: bad length %d", cam_len);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cam_tx_item_t item;
|
||||||
|
item.len = cam_len;
|
||||||
|
memcpy(item.data, cam_uper, (size_t)cam_len);
|
||||||
|
memcpy(item.lpv.mac, prefix, sizeof(item.lpv.mac));
|
||||||
|
item.lpv.station_type = prefix[6];
|
||||||
|
item.lpv.pai = (prefix[7] & 0x01) != 0;
|
||||||
|
item.lpv.tst_ms = le32(prefix + 8);
|
||||||
|
item.lpv.lat_tenmicrodeg = (int32_t)le32(prefix + 12);
|
||||||
|
item.lpv.lon_tenmicrodeg = (int32_t)le32(prefix + 16);
|
||||||
|
item.lpv.speed_cms = (int16_t)le16(prefix + 20);
|
||||||
|
item.lpv.heading_decideg = le16(prefix + 22);
|
||||||
if (xQueueSend(s_tx_queue, &item, 0) != pdTRUE) {
|
if (xQueueSend(s_tx_queue, &item, 0) != pdTRUE) {
|
||||||
ESP_LOGW(TAG, "tx queue full, dropping CAM from phone");
|
ESP_LOGW(TAG, "tx queue full, dropping CAM from phone");
|
||||||
}
|
}
|
||||||
@@ -116,8 +171,7 @@ static void tx_radio_task(void *arg)
|
|||||||
// singleton, created once in app_main. Both wrap functions bounds-check against the size
|
// singleton, created once in app_main. Both wrap functions bounds-check against the size
|
||||||
// passed in and return <= 0 on overflow, so an oversized CAM is rejected, not written past.
|
// passed in and return <= 0 on overflow, so an oversized CAM is rejected, not written past.
|
||||||
static uint8_t gn_payload[SERIAL_LINK_MAX_PAYLOAD + 64];
|
static uint8_t gn_payload[SERIAL_LINK_MAX_PAYLOAD + 64];
|
||||||
int gn_len = geonet_wrap_shb(item.data, item.len, pseudonym_mac, STATION_TYPE,
|
int gn_len = geonet_wrap_shb(item.data, item.len, &item.lpv,
|
||||||
BENCH_LATITUDE_TENMICRODEG, BENCH_LONGITUDE_TENMICRODEG,
|
|
||||||
BTP_PORT_CAM, gn_payload, sizeof(gn_payload));
|
BTP_PORT_CAM, gn_payload, sizeof(gn_payload));
|
||||||
if (gn_len <= 0) {
|
if (gn_len <= 0) {
|
||||||
ESP_LOGW(TAG, "geonet_wrap_shb failed (cam_len=%d)", item.len);
|
ESP_LOGW(TAG, "geonet_wrap_shb failed (cam_len=%d)", item.len);
|
||||||
@@ -125,7 +179,9 @@ static void tx_radio_task(void *arg)
|
|||||||
}
|
}
|
||||||
|
|
||||||
static uint8_t frame[SERIAL_LINK_MAX_PAYLOAD + 192];
|
static uint8_t frame[SERIAL_LINK_MAX_PAYLOAD + 192];
|
||||||
int frame_len = dot11p_build_frame(gn_payload, gn_len, pseudonym_mac, frame,
|
// Source address from the same lpv the GN header was built from, so the 802.11 and
|
||||||
|
// GeoNetworking layers always name the same sender, including across a pseudonym change.
|
||||||
|
int frame_len = dot11p_build_frame(gn_payload, gn_len, item.lpv.mac, frame,
|
||||||
sizeof(frame), false);
|
sizeof(frame), false);
|
||||||
if (frame_len <= 0) {
|
if (frame_len <= 0) {
|
||||||
ESP_LOGW(TAG, "dot11p_build_frame failed (gn_len=%d)", gn_len);
|
ESP_LOGW(TAG, "dot11p_build_frame failed (gn_len=%d)", gn_len);
|
||||||
@@ -339,7 +395,7 @@ void app_main(void)
|
|||||||
|
|
||||||
xTaskCreate(tx_radio_task, "tx_radio", 4096, NULL, 6, NULL);
|
xTaskCreate(tx_radio_task, "tx_radio", 4096, NULL, 6, NULL);
|
||||||
xTaskCreate(rx_forward_task, "rx_forward", 4096, NULL, 5, NULL);
|
xTaskCreate(rx_forward_task, "rx_forward", 4096, NULL, 5, NULL);
|
||||||
serial_link_init(on_cam_tx_from_phone);
|
serial_link_init(on_cam_tx_from_phone, on_cam_tx_pv_from_phone);
|
||||||
|
|
||||||
ESP_LOGW(TAG, "OCB @ %d MHz - TX/RX armed, driven by serial_link (no on-chip TX timer)",
|
ESP_LOGW(TAG, "OCB @ %d MHz - TX/RX armed, driven by serial_link (no on-chip TX timer)",
|
||||||
TX_FREQ_MHZ);
|
TX_FREQ_MHZ);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ static const char *TAG = "serial_link";
|
|||||||
#define SYNC1 0x55
|
#define SYNC1 0x55
|
||||||
|
|
||||||
static serial_link_cam_tx_cb_t s_on_cam_tx;
|
static serial_link_cam_tx_cb_t s_on_cam_tx;
|
||||||
|
static serial_link_cam_tx_pv_cb_t s_on_cam_tx_pv;
|
||||||
|
|
||||||
// ---- Counters reported to the phone in every heartbeat (see SERIAL_MSG_STATUS in the header).
|
// ---- Counters reported to the phone in every heartbeat (see SERIAL_MSG_STATUS in the header).
|
||||||
// Saturating rather than wrapping: "65535 drops" reads as "lots and still going", whereas a wrap
|
// Saturating rather than wrapping: "65535 drops" reads as "lots and still going", whereas a wrap
|
||||||
@@ -157,9 +158,9 @@ bool serial_link_send_v2x_rx(uint16_t btp_dest_port, int8_t rssi,
|
|||||||
|
|
||||||
bool serial_link_send_status(uint8_t status)
|
bool serial_link_send_status(uint8_t status)
|
||||||
{
|
{
|
||||||
// [status:1][oversize_drops:2 LE][tx_failures:2 LE][rx_crc_errors:2 LE] - keep in lockstep
|
// [status:1][oversize_drops:2 LE][tx_failures:2 LE][rx_crc_errors:2 LE][capabilities:1] -
|
||||||
// with EspLinkStatus.parse() in the app's SerialFrame.kt.
|
// keep in lockstep with EspLinkStatus.parse() in the app's SerialFrame.kt.
|
||||||
uint8_t payload[7];
|
uint8_t payload[8];
|
||||||
payload[0] = status;
|
payload[0] = status;
|
||||||
payload[1] = (uint8_t)(s_oversize_drops & 0xFF);
|
payload[1] = (uint8_t)(s_oversize_drops & 0xFF);
|
||||||
payload[2] = (uint8_t)((s_oversize_drops >> 8) & 0xFF);
|
payload[2] = (uint8_t)((s_oversize_drops >> 8) & 0xFF);
|
||||||
@@ -167,6 +168,9 @@ bool serial_link_send_status(uint8_t status)
|
|||||||
payload[4] = (uint8_t)((s_tx_failures >> 8) & 0xFF);
|
payload[4] = (uint8_t)((s_tx_failures >> 8) & 0xFF);
|
||||||
payload[5] = (uint8_t)(s_rx_crc_errors & 0xFF);
|
payload[5] = (uint8_t)(s_rx_crc_errors & 0xFF);
|
||||||
payload[6] = (uint8_t)((s_rx_crc_errors >> 8) & 0xFF);
|
payload[6] = (uint8_t)((s_rx_crc_errors >> 8) & 0xFF);
|
||||||
|
// What this firmware accepts. The app reads it to decide whether it may send CAM_TX_PV, which
|
||||||
|
// is what lets a new app keep working against firmware that predates that message.
|
||||||
|
payload[7] = SERIAL_CAP_CAM_TX_PV;
|
||||||
return send_frame(SERIAL_MSG_STATUS, payload, sizeof(payload));
|
return send_frame(SERIAL_MSG_STATUS, payload, sizeof(payload));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,9 +266,19 @@ static void rx_task(void *arg)
|
|||||||
uint16_t crc_calc = crc16_ccitt_false(crc_buf, (size_t)(3 + len));
|
uint16_t crc_calc = crc16_ccitt_false(crc_buf, (size_t)(3 + len));
|
||||||
|
|
||||||
if (crc_calc == crc_recv) {
|
if (crc_calc == crc_recv) {
|
||||||
if (type == SERIAL_MSG_CAM_TX && s_on_cam_tx) {
|
if (type == SERIAL_MSG_CAM_TX) {
|
||||||
s_on_cam_tx(payload, len);
|
if (s_on_cam_tx) s_on_cam_tx(payload, len);
|
||||||
} else if (type != SERIAL_MSG_CAM_TX) {
|
} else if (type == SERIAL_MSG_CAM_TX_PV) {
|
||||||
|
// A frame that is all prefix has nothing to transmit.
|
||||||
|
if (len > SERIAL_CAM_TX_PV_PREFIX_LEN) {
|
||||||
|
if (s_on_cam_tx_pv) {
|
||||||
|
s_on_cam_tx_pv(payload, payload + SERIAL_CAM_TX_PV_PREFIX_LEN,
|
||||||
|
len - SERIAL_CAM_TX_PV_PREFIX_LEN);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ESP_LOGW(TAG, "rx: CAM_TX_PV of %u bytes carries no CAM, ignoring", len);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
ESP_LOGW(TAG, "rx: unexpected frame type 0x%02x from phone, ignoring", type);
|
ESP_LOGW(TAG, "rx: unexpected frame type 0x%02x from phone, ignoring", type);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -279,9 +293,11 @@ static void rx_task(void *arg)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void serial_link_init(serial_link_cam_tx_cb_t on_cam_tx)
|
void serial_link_init(serial_link_cam_tx_cb_t on_cam_tx,
|
||||||
|
serial_link_cam_tx_pv_cb_t on_cam_tx_pv)
|
||||||
{
|
{
|
||||||
s_on_cam_tx = on_cam_tx;
|
s_on_cam_tx = on_cam_tx;
|
||||||
|
s_on_cam_tx_pv = on_cam_tx_pv;
|
||||||
|
|
||||||
s_tx_mutex = xSemaphoreCreateMutex();
|
s_tx_mutex = xSemaphoreCreateMutex();
|
||||||
if (!s_tx_mutex) {
|
if (!s_tx_mutex) {
|
||||||
|
|||||||
@@ -49,9 +49,11 @@
|
|||||||
// message's own ItsPduHeader.stationID is the meaningful identifier.
|
// message's own ItsPduHeader.stationID is the meaningful identifier.
|
||||||
// SERIAL_MSG_STATUS (0x03), ESP32 -> phone: heartbeat + counters, sent at 1 Hz so the phone can
|
// SERIAL_MSG_STATUS (0x03), ESP32 -> phone: heartbeat + counters, sent at 1 Hz so the phone can
|
||||||
// distinguish "link idle" from "link dead" independent of CAM traffic (the app's watchdog in
|
// distinguish "link idle" from "link dead" independent of CAM traffic (the app's watchdog in
|
||||||
// UsbSerialTransport.kt declares the link dead after 3 missed beats). Payload is 7 bytes:
|
// UsbSerialTransport.kt declares the link dead after 3 missed beats). Payload is 8 bytes:
|
||||||
// [status:1][oversize_drops:2 LE][tx_failures:2 LE][rx_crc_errors:2 LE]
|
// [status:1][oversize_drops:2 LE][tx_failures:2 LE][rx_crc_errors:2 LE][capabilities:1]
|
||||||
// status 0 = ok. The counters are free-running totals since boot, saturating at 0xFFFF.
|
// status 0 = ok. The counters are free-running totals since boot, saturating at 0xFFFF.
|
||||||
|
// capabilities is a bitmask of the SERIAL_CAP_* flags below. It was appended as byte 7 rather
|
||||||
|
// than inserted, so an app that predates it, and reads only the first 7 bytes, is unaffected.
|
||||||
// They exist because the alternative - ESP_LOGW on the flashing port - is invisible to the
|
// They exist because the alternative - ESP_LOGW on the flashing port - is invisible to the
|
||||||
// phone, which is the only thing watching during a bench session. Mirrored by EspLinkStatus
|
// phone, which is the only thing watching during a bench session. Mirrored by EspLinkStatus
|
||||||
// in the app's SerialFrame.kt.
|
// in the app's SerialFrame.kt.
|
||||||
@@ -59,10 +61,40 @@
|
|||||||
#define SERIAL_MSG_CAM_RX 0x02
|
#define SERIAL_MSG_CAM_RX 0x02
|
||||||
#define SERIAL_MSG_STATUS 0x03
|
#define SERIAL_MSG_STATUS 0x03
|
||||||
#define SERIAL_MSG_V2X_RX 0x04
|
#define SERIAL_MSG_V2X_RX 0x04
|
||||||
|
#define SERIAL_MSG_CAM_TX_PV 0x05
|
||||||
|
|
||||||
// Size of the V2X_RX prefix documented above. Must match the app's SerialFrame.kt.
|
// Size of the V2X_RX prefix documented above. Must match the app's SerialFrame.kt.
|
||||||
#define SERIAL_V2X_RX_PREFIX_LEN 14
|
#define SERIAL_V2X_RX_PREFIX_LEN 14
|
||||||
|
|
||||||
|
// SERIAL_MSG_CAM_TX_PV (0x05), phone -> ESP32: a CAM together with the GeoNetworking Source
|
||||||
|
// Position Vector to transmit it under. Payload is a fixed 24-byte prefix, then the CAM UPER:
|
||||||
|
//
|
||||||
|
// [0..5] mac 6 bytes pseudonym: the 802.11 source address AND the GN_ADDR MID
|
||||||
|
// [6] station_type uint8 TS 102 894-2 StationType (2 = cyclist)
|
||||||
|
// [7] flags uint8 bit0: PAI, position accuracy indicator
|
||||||
|
// [8..11] tst uint32 LE ms at which lat/lon were acquired, TimestampIts mod 2^32
|
||||||
|
// [12..15] lat int32 LE 1/10 microdegree
|
||||||
|
// [16..19] lon int32 LE 1/10 microdegree
|
||||||
|
// [20..21] speed int16 LE 0.01 m/s
|
||||||
|
// [22..23] heading uint16 LE 0.1 degree from north, clockwise, 0..3599
|
||||||
|
// [24..] CAM UPER bytes
|
||||||
|
//
|
||||||
|
// Little-endian like the rest of this framing; geonet.c converts to GeoNetworking's big-endian.
|
||||||
|
// Every prefix field is something the phone already has when it builds the CAM, and none of it
|
||||||
|
// can be known on this chip, which has no GNSS and no clock source on the OCB channel. Before
|
||||||
|
// this message existed the GN header carried fixed placeholders instead (see main.c).
|
||||||
|
//
|
||||||
|
// A new type rather than a redefined CAM_TX, so app and firmware can be updated independently:
|
||||||
|
// - old app, new firmware: the app sends CAM_TX, which is handled exactly as before.
|
||||||
|
// - new app, old firmware: the app sends CAM_TX_PV only once the heartbeat advertises
|
||||||
|
// SERIAL_CAP_CAM_TX_PV, and an old heartbeat carries no such bit, so it stays on CAM_TX.
|
||||||
|
// Redefining CAM_TX would instead have double-wrapped every frame in one of those combinations
|
||||||
|
// and sent one with no GN header in the other, silently, since neither side checks versions.
|
||||||
|
#define SERIAL_CAM_TX_PV_PREFIX_LEN 24
|
||||||
|
|
||||||
|
// Capability bits, carried in byte 7 of the SERIAL_MSG_STATUS payload.
|
||||||
|
#define SERIAL_CAP_CAM_TX_PV 0x01
|
||||||
|
|
||||||
// USB Serial/JTAG has no baud rate or GPIO pins to configure - it's a fixed on-chip USB device
|
// USB Serial/JTAG has no baud rate or GPIO pins to configure - it's a fixed on-chip USB device
|
||||||
// controller wired directly to the native USB-C port's D+/D- lines in silicon. RX/TX buffer
|
// controller wired directly to the native USB-C port's D+/D- lines in silicon. RX/TX buffer
|
||||||
// sizes for usb_serial_jtag_driver_install() (see serial_link.c) are sized generously relative
|
// sizes for usb_serial_jtag_driver_install() (see serial_link.c) are sized generously relative
|
||||||
@@ -83,16 +115,25 @@
|
|||||||
// Raised from 160 to 512: 160 was reasoned from cam.c's 96-byte encode buffer, which only ever
|
// Raised from 160 to 512: 160 was reasoned from cam.c's 96-byte encode buffer, which only ever
|
||||||
// described OUR OWN minimal CAM. A third-party CAM off the air carrying a path-history or
|
// described OUR OWN minimal CAM. A third-party CAM off the air carrying a path-history or
|
||||||
// special-vehicle container comfortably exceeds it, and those stations would then never reach the
|
// special-vehicle container comfortably exceeds it, and those stations would then never reach the
|
||||||
// phone at all. 512 clears any realistic CAM; the real upstream ceiling on the RX path is
|
// phone at all. 512 clears any realistic CAM. Our own CAM is 43 bytes of UPER.
|
||||||
// rx_item_t.data (400 bytes) in main.c, so nothing larger can get here anyway.
|
//
|
||||||
|
// This, not the radio side, is the ceiling on the RX path. main.c captures up to RX_FRAME_MAX_LEN
|
||||||
|
// (800) bytes per frame, sized for the CiT One's 528-byte DENM, so a larger ITS payload
|
||||||
|
// does arrive here. serial_link_send_v2x_rx() then drops anything above this minus its 14-byte
|
||||||
|
// prefix and counts it in the heartbeat's oversize-drop counter.
|
||||||
#define SERIAL_LINK_MAX_PAYLOAD 512
|
#define SERIAL_LINK_MAX_PAYLOAD 512
|
||||||
|
|
||||||
// Initializes the USB Serial/JTAG driver and its background RX-framing and 1 Hz heartbeat tasks.
|
// Initializes the USB Serial/JTAG driver and its background RX-framing and 1 Hz heartbeat tasks.
|
||||||
// Call once from app_main, after nvs/event loop init. `on_cam_tx` is invoked (from the RX task's
|
// Call once from app_main, after nvs/event loop init. Both callbacks run in the RX task's context,
|
||||||
// context - keep it fast, it blocks the next frame's parsing) whenever a complete, checksummed
|
// so keep them fast: they block the next frame's parsing.
|
||||||
// SERIAL_MSG_CAM_TX frame arrives from the phone.
|
// on_cam_tx a complete, checksummed SERIAL_MSG_CAM_TX frame: bare CAM UPER.
|
||||||
|
// on_cam_tx_pv a complete, checksummed SERIAL_MSG_CAM_TX_PV frame, already checked to carry at
|
||||||
|
// least one CAM byte after its prefix: the 24-byte prefix, then the CAM UPER.
|
||||||
typedef void (*serial_link_cam_tx_cb_t)(const uint8_t *cam_uper, int cam_len);
|
typedef void (*serial_link_cam_tx_cb_t)(const uint8_t *cam_uper, int cam_len);
|
||||||
void serial_link_init(serial_link_cam_tx_cb_t on_cam_tx);
|
typedef void (*serial_link_cam_tx_pv_cb_t)(const uint8_t *prefix,
|
||||||
|
const uint8_t *cam_uper, int cam_len);
|
||||||
|
void serial_link_init(serial_link_cam_tx_cb_t on_cam_tx,
|
||||||
|
serial_link_cam_tx_pv_cb_t on_cam_tx_pv);
|
||||||
|
|
||||||
// Sends a SERIAL_MSG_V2X_RX frame: the metadata prefix plus the UPER bytes gn_unwrap.c extracted
|
// Sends a SERIAL_MSG_V2X_RX frame: the metadata prefix plus the UPER bytes gn_unwrap.c extracted
|
||||||
// from an over-the-air frame. Pass has_geo_area=false and zeroes for the area fields when the
|
// from an over-the-air frame. Pass has_geo_area=false and zeroes for the area fields when the
|
||||||
|
|||||||
Reference in New Issue
Block a user