Compare commits

..
5 Commits
Author SHA1 Message Date
Ashin Walpola 83153a0971 Send each CAM with its position vector, a rotating pseudonym and GNSS time
The app side of the firmware's CAM_TX_PV message. Until now the phone
handed the ESP32 bare CAM bytes, so the GeoNetworking header around them
could only carry the firmware's bench placeholders.

GnPositionVector.fromCam builds the Source Position Vector from the same
Cam the UPER is encoded from, so the two layers cannot disagree about
where the rider is. Position is rounded exactly as CamUperCodec rounds
it, heading wraps into 0..3599, and non-finite values become 0. PAI is
set when Android's horizontal accuracy is at most 24.7 m, the 40 m
itsGnPaiInterval/2 threshold converted from a 95% to a 68% confidence
radius. UsbSerialTransport.sendCamTx sends 0x05 once the heartbeat
advertises the capability and 0x01 otherwise, so this build still
transmits against older firmware, and logs which path it is on.

Pseudonyms. The station ID used to be created once per install and never
changed, under a MAC that never changed either, so every CAM this phone
ever sent was linkable to every other. PseudonymManager now owns the
station ID and the MAC as one identity and replaces both together every
10 minutes, or immediately if the clock goes backwards. Both are
persisted in a single edit, so a crash cannot leave them mismatched.
MACs are locally administered unicast and can never equal the bench
ping's. CamTransmitLoop takes the current pseudonym per CAM, and the two
most recently retired IDs still count as ours, so a frame sent just
before a rotation is not taken for a stranger.

GNSS time. On 2026-09-10 the bench phone's clock was 24 minutes fast:
with no SIM and no internet time it had no automatic time source, and
every CAM went out stamped in the future. GnssTimeSource moves transmit
timestamps onto SystemClock.currentGnssTimeClock() and falls back to the
wall clock without a fix, logging which one is in use and the measured
error. ItsTime is now the single rule for both the CAM's
generationDeltaTime and the GN TST. Receive paths stay on the wall clock
so everything they stamp remains comparable.

The bench pinger keeps its fixed station 999999 and a fixed MAC, so a
ping stays recognisable in a capture. 999999 now counts as ours only
while this phone's pinger runs and for 5 s after it stops. The previous
rule treated it as ours unconditionally, which hid another phone's pings
on the same bench.

Leap seconds are an open question, recorded in ItsTime: TimestampIts may
be TAI-based, which would put it 5 s higher. 85 tests, 0 failures.
2026-09-10 14:47:30 +02:00
Ashin Walpola 3eeccfb268 Send CAMs under the phone's position vector, not bench placeholders
Every field of the GeoNetworking Source Position Vector this firmware sent
was a compile-time constant: the bench coordinates, speed 0, heading 0,
TST 0, station type passengerCar and one fixed MAC. The CAM inside
described a moving cyclist while the GN header around it described a car
parked at the bench.

SERIAL_MSG_CAM_TX_PV (0x05) puts a 24-byte prefix ahead of the CAM UPER:
MAC, station type, PAI, TST, latitude, longitude, speed and heading, all
values the phone already has when it builds the CAM and none of which
this chip can know. geonet_wrap_shb now takes them as a gn_lpv_t, and
tx_radio_task hands the same MAC to dot11p_build_frame, so the 802.11
source address and the GN_ADDR MID stay one address across a pseudonym
change. Speed is clamped rather than masked, since an overflowing 15-bit
value flips its sign bit and reads as travelling backwards.

This reverses the Phase 03 decision that the firmware owns the
pseudonym. A pseudonym only protects anyone if the MAC, the GN_ADDR and
the CAM's stationID change together, and the phone owns the stationID.

The heartbeat gains a capability byte (payload[7], bit0 = CAM_TX_PV),
appended so an app reading the first 7 bytes is unaffected. The app sends
0x05 only once it sees that bit, so app and firmware can be updated in
either order. CAM_TX (0x01) is still handled and falls back to the bench
values, with the station type corrected to cyclist to match the CAM.

Verified on air from the COM10 test board, decoded independently by the
CiT One's gnHeader: 24 of 24 CAM_TX_PV frames matched the sent position
vector field by field, and so did the CAM station ID. The legacy path
delivered 23 of 24 frames with no field mismatches. Flashed on the COM3
OBU and its boot log is clean.

Also corrects the SERIAL_LINK_MAX_PAYLOAD comment, which still named the
400-byte receive capture buffer as the ceiling on the RX path. That
buffer is 800 bytes now, so the serial link is the ceiling, and larger
payloads are dropped and counted there.
2026-09-10 14:47:30 +02:00
Ashin Walpola 5ec3619cbe Stop retaining detected manoeuvres; the CAM rate bump is their only consumer
The detector runs to raise the CAM transmit rate through a manoeuvre. Nothing
else read its output once the UI was removed, so keeping the rows was storing
data with no reader on the chance it would one day be analysed.

Drops the detected_events table in schema v5, deletes DetectedEventEntity and
the DAO and repository methods behind it, removes the insertEvent call from the
recording service, and removes the per-event rows and their five columns
(event_type, confidence, peak_accel, peak_gyro, duration_ms) from the trip CSV
along with the events parameter threaded through buildTripCsv and shareTripCsv.
A detected manoeuvre now lives for the length of one onDetectedEvent call.

MIGRATION_1_2 still creates the table: a v1 install upgrades 1-2-3-4-5 and so
creates it before v5 drops it. Removing it from the earlier migration would
break that path for anyone who has not upgraded yet.

trips.eventCount is kept. Dropping a SQLite column means recreating the table
and copying every recorded ride across, which is real risk for one unused
integer; the service still writes an accurate count and the CSV header still
reports it. It is the only thing left about detected manoeuvres.

This closes off the route to the false-positive measurement that 11.3 flags as
missing, so 11.3 now says that outright rather than pointing at an export that
no longer carries the data. Docs 11.3/11.4, the user guide, the README and the
traceability matrix updated to match. 55 tests, 0 failures.
2026-09-08 16:29:02 +02:00
Ashin Walpola 1ad123a6f8 Make the event detector a CAM rate input, not a ride-stats readout
The detector's only live consumer is the CAM transmit-rate policy: every
emitted event calls CamTransmitLoop.onDetectedEvent, raising the beacon
rate from 1 Hz to the elevated rate for five seconds so nearby stations
get denser updates through a manoeuvre. Counting one's own braking events
is not a goal of this project, so the display is gone and the detector
stays: the live per-type counters and their notification text, the event
pins and detail sheet on the trip review map, and the event chip on the
history card. Events are still persisted and exported to CSV, which is
the only route to the tuning measurement section 11.3 says is missing.

Fix two defects found while documenting the detector.

TripRecordingService overrode nine of DetectionConfig's twelve parameters
in its constructor, so the tests validated the Phase A defaults while the
phone ran something materially less sensitive. The tuned values are now
the defaults and the override is deleted; the numbers moved location, not
value, so detector sensitivity is unchanged. EventDetectorTest now sets
only windowSize and the sustained-frame counts and inherits every signal
threshold, which cannot drift again. That was not a free change and makes
the same point from the other side: at the real thresholds the old stimuli
triggered nothing. Accel alternating 3.5/0.5 gives a std dev of 1.5 and
never clears 1.8, and the moderate-braking case used a 0.8 m/s drop that
never clears 1.0. Those stimuli are re-derived against the real values.

brakingHighConfidenceRate was documented as a rate but has always been
compared against the peak cumulative drop from the onset speed, which
grows with episode length, so HIGH was assigned more readily than the name
implied. Renamed to brakingHighConfidencePeakDrop rather than changing the
comparison: "lost more than 1.5 m/s in one episode" is coherent, whereas a
rate off a 1 Hz speed signal sampled at 50 Hz spikes on a near-zero
divisor early in an episode. Output is unchanged, so the existing
confidence assertions stay evidence instead of being re-baselined.

Docs 11.3/11.4 updated in place, including the correction of a claim that
detected events do not reach the V2X side; the rate-bump path already
existed when that was written. 55 tests, 0 failures.
2026-09-08 16:20:14 +02:00
Ashin Walpola 83ccf335bb Document the event detector's specification and trigger conditions
Section 11 described how the detector works and the test-design finding from
2026-08-25, but carried no threshold values, no trigger conditions and no
emission semantics. That was inconsistent with section 10.4, which tabulates
all nineteen UseCaseDetectionConfig parameters for the V2X side. Adds 11.3 and
11.4 to close the gap.

11.3 tabulates all twelve DetectionConfig parameters in three columns, because
three different configurations exist and they do not agree. DetectionConfig's
KDoc says its defaults match the Phase A specification; TripRecordingService
overrides nine of the twelve when it constructs the detector, every one of them
in the direction of lower sensitivity. The shipping detector is not the
specified detector, and that was recorded nowhere outside a constructor.

11.4 gives the input rates, the qualifying condition for each of the three
event types, when each emits, and how confidence is assigned. It also explains
why braking compares against a reference speed latched at onset rather than a
per-frame delta: GNSS updates at 1 Hz against a 50 Hz detector, so a per-frame
delta is non-zero on one frame in fifty and could never coincide with a
25-frame sustain requirement. That is the same sampling lag that made four
tests unsatisfiable, seen from the implementation side.

Two discrepancies found while writing this are recorded rather than fixed,
since fixing either changes behaviour and belongs in its own change:

- EventDetectorTest states it keeps production thresholds for all signal
  values. The values it keeps are the DetectionConfig defaults, not the ones
  TripRecordingService runs. All 18 tests validate a configuration that never
  executes on a phone. The logic under test is shared, so they remain valid
  logic tests; they are not evidence about the shipped system.
- brakingHighConfidenceRate is documented as a rate in m/s per GNSS update but
  is compared against the peak cumulative drop from the onset reference, which
  is not a rate and grows with episode length. HIGH confidence is therefore
  assigned more readily than the name implies.

Also notes the emission asymmetry: turning and stopping emit once per episode,
braking re-arms and re-fires roughly every half second at the shipping values.

Edited in place through the existing package rather than regenerated, so Word's
own parts and the manual edits from 312f094 survive. All sixteen package parts
verified present afterwards, section order unchanged, all nine image
placeholders intact.
2026-09-07 16:42:58 +02:00
43 changed files with 1382 additions and 644 deletions
+2 -2
View File
@@ -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),
) )
} }
-4
View File
@@ -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>
-4
View File
@@ -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()
}
}
+22 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+23 -7
View File
@@ -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) {
+53 -12
View File
@@ -49,20 +49,52 @@
// message's own ItsPduHeader.stationID is the meaningful identifier. // message's own ItsPduHeader.stationID is the meaningful identifier.
// SERIAL_MSG_STATUS (0x03), ESP32 -> phone: heartbeat + counters, sent at 1 Hz so the phone can // SERIAL_MSG_STATUS (0x03), ESP32 -> phone: heartbeat + counters, sent at 1 Hz so the phone can
// distinguish "link idle" from "link dead" independent of CAM traffic (the app's watchdog in // distinguish "link idle" from "link dead" independent of CAM traffic (the app's watchdog in
// UsbSerialTransport.kt declares the link dead after 3 missed beats). Payload is 7 bytes: // UsbSerialTransport.kt declares the link dead after 3 missed beats). Payload is 8 bytes:
// [status:1][oversize_drops:2 LE][tx_failures:2 LE][rx_crc_errors:2 LE] // [status:1][oversize_drops:2 LE][tx_failures:2 LE][rx_crc_errors:2 LE][capabilities:1]
// status 0 = ok. The counters are free-running totals since boot, saturating at 0xFFFF. // status 0 = ok. The counters are free-running totals since boot, saturating at 0xFFFF.
// capabilities is a bitmask of the SERIAL_CAP_* flags below. It was appended as byte 7 rather
// than inserted, so an app that predates it, and reads only the first 7 bytes, is unaffected.
// They exist because the alternative - ESP_LOGW on the flashing port - is invisible to the // They exist because the alternative - ESP_LOGW on the flashing port - is invisible to the
// phone, which is the only thing watching during a bench session. Mirrored by EspLinkStatus // phone, which is the only thing watching during a bench session. Mirrored by EspLinkStatus
// in the app's SerialFrame.kt. // in the app's SerialFrame.kt.
#define SERIAL_MSG_CAM_TX 0x01 #define SERIAL_MSG_CAM_TX 0x01
#define SERIAL_MSG_CAM_RX 0x02 #define SERIAL_MSG_CAM_RX 0x02
#define SERIAL_MSG_STATUS 0x03 #define SERIAL_MSG_STATUS 0x03
#define SERIAL_MSG_V2X_RX 0x04 #define SERIAL_MSG_V2X_RX 0x04
#define SERIAL_MSG_CAM_TX_PV 0x05
// Size of the V2X_RX prefix documented above. Must match the app's SerialFrame.kt. // Size of the V2X_RX prefix documented above. Must match the app's SerialFrame.kt.
#define SERIAL_V2X_RX_PREFIX_LEN 14 #define SERIAL_V2X_RX_PREFIX_LEN 14
// SERIAL_MSG_CAM_TX_PV (0x05), phone -> ESP32: a CAM together with the GeoNetworking Source
// Position Vector to transmit it under. Payload is a fixed 24-byte prefix, then the CAM UPER:
//
// [0..5] mac 6 bytes pseudonym: the 802.11 source address AND the GN_ADDR MID
// [6] station_type uint8 TS 102 894-2 StationType (2 = cyclist)
// [7] flags uint8 bit0: PAI, position accuracy indicator
// [8..11] tst uint32 LE ms at which lat/lon were acquired, TimestampIts mod 2^32
// [12..15] lat int32 LE 1/10 microdegree
// [16..19] lon int32 LE 1/10 microdegree
// [20..21] speed int16 LE 0.01 m/s
// [22..23] heading uint16 LE 0.1 degree from north, clockwise, 0..3599
// [24..] CAM UPER bytes
//
// Little-endian like the rest of this framing; geonet.c converts to GeoNetworking's big-endian.
// Every prefix field is something the phone already has when it builds the CAM, and none of it
// can be known on this chip, which has no GNSS and no clock source on the OCB channel. Before
// this message existed the GN header carried fixed placeholders instead (see main.c).
//
// A new type rather than a redefined CAM_TX, so app and firmware can be updated independently:
// - old app, new firmware: the app sends CAM_TX, which is handled exactly as before.
// - new app, old firmware: the app sends CAM_TX_PV only once the heartbeat advertises
// SERIAL_CAP_CAM_TX_PV, and an old heartbeat carries no such bit, so it stays on CAM_TX.
// Redefining CAM_TX would instead have double-wrapped every frame in one of those combinations
// and sent one with no GN header in the other, silently, since neither side checks versions.
#define SERIAL_CAM_TX_PV_PREFIX_LEN 24
// Capability bits, carried in byte 7 of the SERIAL_MSG_STATUS payload.
#define SERIAL_CAP_CAM_TX_PV 0x01
// USB Serial/JTAG has no baud rate or GPIO pins to configure - it's a fixed on-chip USB device // USB Serial/JTAG has no baud rate or GPIO pins to configure - it's a fixed on-chip USB device
// controller wired directly to the native USB-C port's D+/D- lines in silicon. RX/TX buffer // controller wired directly to the native USB-C port's D+/D- lines in silicon. RX/TX buffer
// sizes for usb_serial_jtag_driver_install() (see serial_link.c) are sized generously relative // sizes for usb_serial_jtag_driver_install() (see serial_link.c) are sized generously relative
@@ -83,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