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.
Two cards below the status cards, each shown only when there is
something to show and each opening the V2X screen when tapped.
Hazard: cause name, distance, and a count of the others behind it,
ranked closest first. A hazard whose distance cannot be resolved,
because there is no fix yet, sorts last rather than being dropped.
Traffic light: the intersection changing soonest, its leading phase
with a countdown, and every signal group as a colour-coded chip. The
countdown runs on its own 500 ms clock rather than on SPATEM arrivals,
so it cannot freeze mid-count and keep claiming a light is about to
change after the RSU stops transmitting.
Signals are ranked by time-to-change rather than by distance because
SPATEM carries no position at all. Placing an intersection needs MAPEM
geometry, which nothing on air is currently sending.
Also fixes bottom-nav taps. One rule now applies to every tab: a tap
lands on that tab's own screen, popping back to it when it is still on
the stack so the gesture behaves exactly like Back or a back swipe.
saveState/restoreState are gone, since on this flat graph a restored
back stack brought back the sub-screen the rider was on instead of the
tab root, which is the opposite of what the tap asked for. Tabs also
now stay lit on the screens that belong to them.
Bench test on 2026-08-25 against live RSU and CiT One traffic found that 611 RSU
CAMs decoded correctly and none of them were ever displayed. Excluding RSUs from
UseCaseDetectionEngine - correct, since a permanently stationary station at a
fixed point trips the stopped-vehicle use case for as long as it is in range -
also removed them from the map and station list, because remotePositions is the
engine's own map.
RSUs are now tracked in a separate rsuStations flow and merged with the engine's
road users for display only. Expiry is clock-driven for the same reason as
hazards and signals: an RSU going out of range simply stops transmitting, and no
further emission would arrive to recompute the list. Cleared on link-down
alongside engine.reset(), so a stale RSU cannot outlive an unplug.
The kinematics line is suppressed for them. An RSU's CAM uses
rsuContainerHighFrequency, which carries no kinematics at all, so the zeroes in
the model are placeholders - printing "0.0 km/h - heading 0" would assert a
stationary vehicle pointing due north.
Also logs the ESP32's STATUS heartbeat counters whenever one changes. They
previously reached only the CAM Pinger card, so a bench run captured through
logcat had no record of whether the firmware dropped anything. Logged on change
rather than per beat: the interesting event is a drop appearing, and a
once-per-second line would bury it.
Test report in 05-obu-bench-test-2026-08-25.md.
UI:
- Top bar shows a USB glyph reflecting the serial link on the ESP32-C5 path,
instead of a Wi-Fi glyph driven by an MQTT state that is permanently
disconnected there.
- Recording screen's OBU stream row follows whichever transport the selected
hardware uses. It previously read as offline throughout a recording that was
actively beaconing CAMs.
- Live map uses distinct markers: a centred dot for own position (a fact about
the viewer, not a tracked object) and a teardrop pin for remote stations,
tinted by alert severity. Both were osmdroid's identical default pin before,
and severity required tapping a marker to read its label.
- Sensor Monitor moves out of the bottom nav to Settings > Developer. A live
phone-sensor feed is a bench tool; the Dashboard already reports GNSS/IMU
health. Screen and route are unchanged, just not in the rider's way.
Detection engine on the ESP32 path:
- Seed our own StationID from the persisted value CamTransmitLoop transmits.
It was null here (the CiT One learns it from v2x/rx/obu_gnss, which doesn't
exist on this path), so the self-heard-TX filter never fired: the ESP32 runs
promiscuous for raw TX to work at all, hears our own CAMs back off the air,
and they were tracked as a remote station - a ghost vehicle on top of the ego
position, fed to the engine as a collision partner for itself.
RSSI:
- The firmware has always sent per-frame RSSI in byte 0 of every CAM_RX frame;
the app discarded it. Now carried on Cam, persisted per V2X message, shown per
station in the received-CAM list, and exported. Null for own CAMs and the
whole CiT One path, neither of which has a measurement.
Trips, CSV and export (schema v3 -> v4):
- trips.sessionId links a trip to the CSV session recorded alongside it. The two
are written by independent subsystems that the Recording button happens to
start together; without the link, deleting a trip orphaned its CSV forever.
Timestamp matching was rejected - close recordings would delete the wrong file.
- Deleting a trip now removes the CSV file AND its sessions row, so it stops
appearing in the Session Log pointing at nothing.
- Per-trip combined CSV export: GPS track, detected events, V2X messages (with
RSSI) and the raw sensor samples in one file, keyed by a leading type column.
One file rather than a zip of tables because the point of the export is
correlating those streams, and splitting them pushes the join downstream.
- Export takes the Activity context. Sharing from the ViewModel's Application
context threw AndroidRuntimeException on startActivity - this crashed on the
first tap of the share button.
Trips recorded before v4 have a null sessionId, so their exports omit raw sensor
rows and their CSVs still need clearing by hand once.
CurvatureCalculationMode is the one extensible ENUMERATED in CAM:
ENUMERATED {yawRateUsed(0), yawRateNotUsed(1), unavailable(2), ...}
UPER encodes an extensible ENUMERATED as an extension bit followed by the root
index - 1 + 2 = 3 bits. All three of our encoders wrote only the 2-bit index,
shifting yawRate and the entire low-frequency container one bit early for any
standards-compliant receiver.
It went unnoticed because every end of this project shared the mistake: the
Kotlin codec was ported bit-for-bit from cam.c, so phone and ESP32 agreed
perfectly with each other and with nothing else. Confirmed against the ETSI
ASN.1 in the C-ITS-Parser checkout, where rasn marks this type - and only this
type - #[non_exhaustive].
Fixed in all three copies of the encoder (app CamUperCodec.kt,
obu-firmware/main/cam.c, obu-cam-transmistter/main/cam.c) plus the decoder,
which now rejects rather than misreads a set extension bit. Frame size is
unchanged at 43 bytes. Transmitter reflashed and the phone decodes its CAMs.
Also in this change:
- serial_link: skip send_frame entirely when no USB host is attached, and raise
the tx mutex timeout above the worst-case hold. With the phone unplugged every
write blocked its full timeout while holding the lock, so forwarded CAM_RX
traffic starved the 1 Hz heartbeat - observed as "tx mutex timeout, dropping
frame" on the console, and it would have tripped the phone's link watchdog.
Verified gone on hardware.
- Log decoded and failed CAMs in CamUseCaseRepository. "The app shows nothing"
had two indistinguishable causes; a silent `?: return` made this bug much
harder to find than it needed to be.
- Remove the ESP32 send-only/send-and-receive toggle. Reception can't be
disabled in firmware (raw TX only works while promiscuous), so it was an
app-side filter pretending to be a radio control.
- V2X monitor follows the serial link state on the ESP32 path instead of MQTT,
which is permanently disconnected there; CAM intake is gated on the link being
up, and engine state is cleared when it drops.
- About screen: 0.5.0, Phase 03.
- Track obu-cam-transmistter, the bench CAM transmitter. Its cam.c is compiled
(unlike obu-firmware's reference copy) and must stay bit-identical to the other
two - this commit is what that coupling costs when it's broken.
- Document the two-toolchain split: this project builds on IDF 5.5.4, obu-firmware
on the pinned 6.1. Exporting both in one shell fails confusingly.
Enumeration:
- Merge the library's stock probe table instead of replacing it, so adding
Espressif 0x303A/0x1001 doesn't drop every other supported device
- Select the ESP32-C5 by VID/PID rather than list position
- Log USB interface descriptors to distinguish CDC data from the JTAG interface
Lifecycle:
- Don't close the shared port in MqttViewModel.onCleared() - the foreground
recording service outlives the ViewModel and would beacon into a dead port
- Handle ACTION_USB_DEVICE_DETACHED so the UI stops reporting a stale link
- Implement the STATUS heartbeat on both sides (1 Hz) plus a phone-side watchdog
- Surface write failures and firmware drop counters on the CAM Pinger card
Protocol:
- Assert DTR/RTS on open (unverified on hardware - see FLASHING.md step 5)
- Raise SERIAL_LINK_MAX_PAYLOAD 160 -> 512 on both sides; real third-party CAMs
exceed 160 and were being silently dropped at the resync branch
- Move the enlarged buffers off task stacks; serialize send_frame with a mutex
Firmware and app must be updated together - a 512/160 mismatch fails silently.
- Firmware: rewrite obu-firmware TX loop to be serial-driven (no on-chip timer), add promiscuous RX + GeoNetworking/BTP unwrap (gn_unwrap.c), add binary UART framing to the phone (serial_link.c/.h). Drop local cam_encode() - CAM is now built on the phone.
- Kotlin: byte-exact UPER CAM encoder/decoder ported from cam.c (BitWriter/BitReader/CamUperCodec), matching SerialFrame codec, real UsbSerialTransport (usb-serial-for-android), CamTransmitLoop (1Hz base rate, event/geofence boost, ESP32-C5-only), wired into CamUseCaseRepository for RX and TripRecordingService for TX.
- Add V2X message retention: persist all CAM (own+remote) to Room while recording, drop otherwise (DB v2 -> v3 migration).
- Add jitpack repo + usb-serial-for-android dependency.
Fixes: UsbSerialTransport now uses SerialInputOutputManager.start()/stop() (this lib version manages its own thread internally) instead of manual Runnable/Thread submission, which didn't compile.