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

The rest of the colleague's tree (their own VAM firmware, PKI tooling,
station-link Python tools, the V2X2MAP bridge) stays out of this repository
and gitignored; nothing is pushed to their repository. NOTES.md, docs/06,
TODO.md and the pcap verifier's usage line point at the new location.
2026-09-24 10:56:05 +02:00
Ashin Walpola 2f60623e18 Document the signed-ITS/VAM/BLE work and how it was verified
docs/06-signed-its-vam-ble.md: who does what between phone and ESP32-C5
(signing lives on the board), the link protocol, recovery paths (USB
heartbeat watchdog, BLE supervision timeout and auto-reconnect, board-reset
reconfiguration, app restart), the demo PKI, and what is still open.

obu-firmware/test/verify_signed_pcap.py checks the IEEE 1609.2 signatures in
a pcap with asn1tools and OpenSSL, independent of the firmware. On a capture
of the CAM pinger (2026-09-23) all 12 signed CAMs verify under the demo
ticket, whose chain verifies too. The CiT One receives the same CAMs but its
MQTT interface exposes no security information, so it cannot confirm the
signature itself. The V2X2MAP bridge on COM10 now verifies against the demo
chain as well (change in the colleague's repository); signed CAMs and VAMs
show as verified.

TODO.md: bench checks confirmed so far ticked; open are BLE/ITS-G5
coexistence, time_regression over a longer stationary run, and board reset
recovery over BLE.
2026-09-23 17:28:14 +02:00
Ashin Walpola a08494b56a Drive the ESP32-C5 station over USB or BLE, send CAM or VAM, signed or not
The app now speaks the station-link protocol of the new obu-firmware.
Esp32Link picks the transport from Settings (UsbSerialTransport or the new
BleLinkTransport), tells the previous firmware from the new one by its
heartbeat, and runs the session: STATION_CONFIGURE with the current pseudonym
MAC (which also starts the board's radio), CREDENTIALS_PROVISION of the
bundled demo chain when the board has no ticket, then per message a
POTI_UPDATE and a BTP_DATA_REQUEST. Received messages still arrive as
V2X_RX frames, so the receive side is unchanged. A board on the previous
firmware keeps working for CAM over USB.

Settings > Connection > ESP32-C5: link USB-C or Bluetooth, transmit CAM or
VAM, "Sign outgoing messages" (on by default). The connection card, top bar
and dashboard show the link in use, the pairing passkey and signing counters.

- VAM: VamUperCodec (TS 103 300-3 V2.3.1, bytes checked against asn1tools)
  and VamGenerationRules (clause 6.4, Tables 16/17).
- BLE: the firmware's GATT layout (service 0000C175-...), MTU 517, pairing
  and encryption settled before any other operation (short timeouts during
  pairing made it loop), backoff between attempts, reasons on the card.
- Clock: a PoTi goes to the board once per new fix and never moves the
  board's clock backwards except for a real correction (>= 60 s); stale and
  wobbling fix times made the board answer time_regression and restart its
  stack every few seconds. GnssTimeSource keeps the last measured phone-clock
  error while GNSS time drops out indoors: the bench phone is 14 minutes fast,
  and falling back to it made every transmitted timestamp jump by that much.
- assets/demo-chain.vcr: throwaway, not EU-registered demo chain generated
  2026-09-23 (AT B80B49387A4C12EB, psid 36 and 638). Its private key ships
  with the app on purpose; receivers verifying against the EU trust list
  drop what it signs.
- Bluetooth permissions requested at start-up on Android 12+.

StationLinkTest pins the codec to bytes from the colleague's Python
implementation (microbu_link/messages.py). 103 unit tests pass.
2026-09-23 17:28:05 +02:00
Ashin Walpola d2fd222a62 Sign ITS messages on the ESP32-C5 with vanetza-idf, over USB or BLE
obu-firmware is now a port of the colleague's standalone VRU station
(microbu-esp32c5/firmware, kept beside this repository and gitignored): the
vanetza-idf C-ITS stack with the TS 103 097 security entity, credentials in
NVS, the station-link v1 protocol over the native USB port (frame type 0x10
in the existing 0xAA55 framing) and over a BLE GATT peripheral, and its
ITS-G5 radio adapter. The phone still builds CAM and VAM; the board adds
GeoNetworking/BTP and signs with the provisioned authorization ticket. The
private key never leaves the board. Builds with ESP-IDF 6.0.2 only, which
vanetza-idf pins for the radio's private driver ABI. The previous C firmware
stays on disk unbuilt; a full-flash backup of the bench board is kept in
firmware-backups/ (gitignored).

Changed against the colleague's firmware, marked MicrOBU: in the sources:
- Reception unchanged for the app. vanetza-idf drops what it cannot verify
  (unsigned traffic, every RSU), so each captured frame also goes through the
  previous gn_unwrap.c and reaches the phone as link opcode V2X_RX (0x85),
  whose body is the old SERIAL_MSG_V2X_RX payload.
- Unsigned transmission still possible, with the previous geonet.c header;
  the phone chooses per message.
- Console on UART0 (CH343 port); the native USB port carries only link frames.
- BLE advertising pauses while the USB link is in use: BLE and ITS-G5 share
  one RF front end.
- NVS 80 KB (app at 0x20000). At 24 KB, with Wi-Fi settings the previous
  firmware left behind, the BLE bond could not be stored and the phone had to
  pair on every connection.
- Bench fixes: the radio queue is drained before the first PoTi (no RX and
  ~177 queue drops before); the station loop waited pdMS_TO_TICKS(5) = 0
  ticks at 100 Hz and starved the idle task; the 2.4 KB RX capture buffer is
  off the Wi-Fi task stack; BLE notifications longer than the MTU are dropped
  instead of cut short, MTU 517; serial writes are skipped with no USB host.
- Manual country policy and TX-power read-back from the previous radio setup;
  logs for BLE encryption changes and the number of stored bonds.

Verified on the bench board (COM3) with the phone over USB and BLE: CAM and
VAM, signed and unsigned, go out; reception of the sim car and the RSU's
CAM/SPATEM/MAPEM continues; the board survives app restarts and reconnects.
See docs/06-signed-its-vam-ble.md.
2026-09-23 17:27:54 +02:00
Ashin Walpola 7285fa19b7 Count and surface RX-queue drops on the ESP32-C5's promiscuous path
wifi_promisc_rx_cb() fed s_rx_queue with a 0-timeout xQueueSend() and never
checked whether it succeeded, so a burst of captured frames arriving faster
than rx_forward_task could drain them vanished with no counter anywhere -
none of oversizeDrops/txFailures/rxCrcErrors caught it. Added a rxQueueDrops
counter, threaded it through the STATUS heartbeat as a new trailing uint16
(old firmware/app on either side still parse fine), and surfaced it on the
CAM Pinger card.

Confirmed on the bench: flashed to the production OBU (COM3) and installed
the matching app build on the phone, then watched the counter over logcat
against obu-cam-transmistter's ~3.3 Hz beacon - it is real (0 -> 89 -> 90
across two sessions) but bursty around connect/reconnect rather than a
continuous overflow under steady single-station traffic.
2026-09-22 14:45:17 +02:00
Ashin Walpola 21e01499d8 Drive the bench CAM beacon round a street loop in St. Georg
The bench transmitter sent a parked car: one fixed position, speed 0,
no heading, a CAM every second. It now simulates a car driving a loop
through six waypoints around Berliner Tor on the real streets, which
makes it a moving target for the app's map and the use case detection
without taking a car out.

The route is generated, not hand-traced. tools/make_route.py asks the
OSRM demo server for a driving route through the waypoints and back to
the first, thins the 371 street points to 103 (none more than 1.5 m off
the line), and writes main/route_points.h. It also saves OSRM's answer
(--offline rebuilds from it) and a map page to check the route before
flashing. Each waypoint is sent with the direction towards the next one:
without it, points on divided roads such as Beim Strohhause snapped to
the opposite carriageway and the loop came out at 8.4 km of U-turns.
With it the loop is 5.2 km, still including two turn-round detours
that OSRM needs to reach the waypoints legally (Borgfelder Strasse /
Anckelmannsplatz, and Nagelsweg / Norderstrasse / Repsoldstrasse).
Route data (c) OpenStreetMap contributors, ODbL.

main/route.c moves the car along the points. It cruises at 50 km/h and
limits each bend to the speed that keeps sideways acceleration at
2 m/s^2, so a junction turn is taken at about 15 km/h and a gentle curve
barely slows it; braking (2 m/s^2) and acceleration (1.5 m/s^2) are
planned across as many points as a bend needs. A simulated lap on the
host is 5.16 km in 7.7 min, averaging 40 km/h.

CAMs now follow the EN 302 637-2 generation rules instead of a fixed
1 Hz: checked every 100 ms, sent on a heading change over 4 degrees, a
move over 4 m, a speed change over 0.5 m/s, or after 1 s - about 3 Hz
at 50 km/h. generationDeltaTime is milliseconds since boot. The
GeoNetworking source position vector now carries the same speed and
heading as the CAM instead of zeros.

NOTES.md gains build and flash steps (including reading a board's app
descriptor first, since both firmwares name their image
obu_firmware.bin) and a section on the simulated drive. The pointer to
docs/04-transmit-setup.md is corrected: that file is not in the repo.

Flashed to the COM8 board and checked on its console: it starts driving
on power-up and sends CAMs with changing position, speed and heading.
Not yet received over the air.
2026-09-16 14:21:44 +02:00
Ashin Walpola 01204a2c22 Give the V2X live map its own screen, and a traffic light per SPATEM
The map was a third view mode inside the V2X Monitor's topic pane, below
the use case alert panel and the DENM/CAM TX cards. On a phone that left
it about a third of the display tall, which is not enough to see where
anything is relative to anything else - the one thing a map is for. It
is now its own destination, V2xMapScreen on route v2x_map, reached from
a map button in that screen's header. The button sits in the header
rather than the view-mode row so it is also reachable from the message
detail pane and does not move as the available modes change with the
selected hardware. The status bar and bottom nav are hidden on this
route; the screen carries its own floating back button, and system back
still works. Both hardware paths get the same screen: everything drawn
comes from CamUseCaseRepository, which already merges the CiT One's MQTT
feed and the ESP32-C5's serial feed into one set of flows.

With the map gone from the toggle row, the row offers a single choice on
the ESP32-C5 path - there is no broker there and `topics` is always
empty - so it is hidden entirely in that mode.

SPATEM markers. Hazards already drew as a warning triangle; signalised
intersections did not draw at all. They now draw as a traffic light with
one lamp lit. Two things are worth knowing, because neither is forced by
the data:

- SPATEM carries signal state but no geometry, which is MAPEM's job and
  MAPEM is not decoded. The only position available is the sending RSU's
  own CAM, so the light is drawn there, and that RSU is drawn once - as
  the light, not as a CAM pin with a light on top of it. An intersection
  whose sender has not been heard over CAM cannot be placed; the map
  says how many rather than dropping them silently.
- Which lamp lights follows the rule DashboardScreen's SignalCard
  already uses, the signal group changing soonest speaking for the
  intersection, so the same intersection reads the same way in both
  places instead of inventing a second convention.

Four drawables rather than one tinted at runtime: setTint recolours
every path in a vector, so a single shared asset would turn the whole
light one flat colour and stop it reading as a traffic light.

Marker reuse. Every incoming message recomposes the map, and the update
block cleared the overlay list and rebuilt every Marker, decoding and
mutating a fresh Drawable per marker - at up to 10 Hz per station. It
also called animateTo(own) on every update, restarting the pan animation
before it could finish. Drawables are now loaded once per alert level
and phase and shared (osmdroid sets the icon's bounds on each draw, so
one instance across markers is safe), Markers are cached by key, and the
overlay list is only reordered, which moves references without
allocating. Following uses setCenter, keeping animateTo for the one move
worth seeing: the rider asking for follow back.

Follow-own now hands over to the rider on the first touch and returns
via the location button, which lights up while following. Before this
the map could not be panned at all while traffic was flowing, since the
next CAM dragged the viewport back.

Also on the map view: tiles scaled to DPI, the floating +/- buttons off
(they sit where the thumb lands and duplicate pinch), a zoom range, and
more tile threads so a pan that exposes a screenful of new tiles is not
served two at a time.

Compiles and the unit tests pass. None of it has been seen with live
traffic; TODO.md lists the on-device checks under "Waiting on hardware",
including which of the HAW RSUs send CAM alongside SPATEM.
2026-09-15 17:23:01 +02:00
Ashin Walpola 0f06cdb339 Merge branch 'main' of gitlab.rzbt.haw-hamburg.de:urban-mobility-lab/microbu/microbuapp
# Please enter a commit message to explain why this merge is necessary,
# especially if it merges an updated upstream into a topic branch.
#
# Lines starting with '#' will be ignored, and an empty message aborts
# the commit.
2026-09-14 12:56:30 +02:00
Ashin Walpola 75d6d3b85c Receive signed ITS messages and forward each at its declared length
Signed packets. A GeoNetworking Basic Header NextHeader of 2 means a
TS 103 097 (IEEE 1609.2) envelope follows, with the Common Header
inside it. gn_unwrap_its rejected all of these, and most real traffic is
signed: the 2026-08-17 capture holds 157 signed frames from 15 source
MACs against 2 unsecured stations. It now opens a COER-encoded
signedData, or a bare unsecuredData, and parses the inner packet as
before. The inner packet comes first inside tbsData, so the certificate
and signature are never parsed, and the signature is not verified - the
firmware has no trust store. Such messages reach the phone with the new
V2X_RX flags bit1, signed but not verified. The app reads only bit0 and
is unaffected until it learns the flag. Encrypted payloads, nested
signing and the legacy v1.2.1 envelope are still rejected. All 157
recorded signed frames have the layout this reads, in all three COER
length forms, and asn1tools decodes every envelope to the same inner
packet.

Payload bounds. Every frame recorded through the ESP32-C5's promiscuous
RX, about 15 000 of them, ends in 8 bytes that are not part of the
802.11 frame and not a valid FCS. obu-firmware reads frames through the
same API and took the rest of the frame as the message, so it forwarded
those 8 bytes to the phone after every message. UPER decoders stop where
the message ends, so nothing visibly broke, but the bytes cost serial
bandwidth and 8 bytes of the DENM's headroom, and they stayed attached
wherever raw payloads were stored or passed on. The payload is now
exactly what the Common Header's payload-length field declares, which is
also what separates a signed message from its signature.

A frame longer than main.c's 800-byte capture buffer is now reported as
truncated instead of being forwarded cut off, and counted as an oversize
drop through the new serial_link_note_oversize_drop, as it was when the
cut-off frame failed serial_link's size check.

Host tests in obu-firmware/test/host build the firmware sources
unmodified with MSYS2 gcc; `make` runs all three.
- test_chain: frames from the firmware's TX code checked byte by byte
  against EN 302 636-4-1 and parsed back, including hand-built signed
  frames, the payload-length rule, the RX trailer, and every truncation
  length against a no-access guard page. 1731 checks, 0 failures.
- test_replay and check_replay.py: all 15 145 recorded frames through
  gn_unwrap_its, cut to 800 bytes as on the board, and re-derived
  independently in Python with the envelope decoded by asn1tools. They
  agree on every record; 15 131 accepted, 157 of them signed. 11 043 of
  the 11 106 distinct messages re-encode byte-identically. The other 63
  fail the same way with the old 8 bytes put back, so the boundary is
  not the cause: 5 are our own CAMs from before the 2026-08-20
  yawRateConfidence fix, and the rest, from other stations, are a
  follow-up in TODO.md.
- fuzz_gn_unwrap: random edits of every recorded frame, each run against
  the guard page. 50 000 000 iterations, no crash.

obu-firmware/test/pcap_gn_tally.py tallies GeoNetworking header fields
per station over captures; it is how the other stations' lifetimes were
measured. TODO.md collects what is still open, including the on-air
check for this change: it builds on IDF 6.1 but has not been flashed.
2026-09-11 20:19:40 +02:00
Ashin Walpola 1baae2c5f6 Encode yawRateConfidence in 4 bits in the firmware CAM encoders
YawRateConfidence has nine enumerands, degSec-000-01(0) to
unavailable(8) (cdd_1_3_1_1.asn), so UPER needs 4 bits and
"unavailable" is 8. Both firmware copies of cam.c wrote 3 bits with
value 7, which is also the wrong symbol (outOfRange), and every field
after it shifted by one bit. The app's CamUperCodec fixed the same line
on 2026-08-20; these two copies were missed.

obu-cam-transmistter compiles its cam.c, so a board running that bench
beacon sent CAMs no standards-compliant station could decode.
obu-firmware's copy is reference only - it is not in SRCS, since the
phone encodes the CAM - and is kept in step because the app's encoder
was ported from it. The production OBU runs obu-firmware and was never
affected.

Every other field width was compared against CamUperCodec.kt and
matches. Checked with asn1tools against asn1/cam_1_4_1.asn and
cdd_1_3_1_1.asn: the CAM both fixed copies emit decodes with every
expected value and re-encodes byte-identically, while the version before
this change fails on yawRateConfidence. obu-cam-transmistter builds on
IDF 5.5.4.
2026-09-11 20:19:40 +02:00
Ashin Walpola 8871708a98 Send the GeoNetworking lifetime as 1 s, not 3200 s
geonet_wrap_shb wrote lifetime 0x83, commented as about 60 s. The field
holds the multiplier in its upper six bits and the base in the lower two
(50 ms, 1 s, 10 s, 100 s), so 0x83 is 32 x 100 s = 3200 s. That is over
the 600 s itsGnMaxPacketLifetime a sender may use at all; vanetza
refuses to send such a packet. The byte arrived with the Phase 03 commit
as a placeholder and was never checked against the encoding.

For a single-hop CAM this is non-compliance rather than a functional
fault: nothing stores or forwards an SHB packet, so no receiver acts on
the value, and no dropped CAM was ever traced to it.

0x05 (1 x 1 s) is what every other station in
its-g5-receiver-firmware/recordings sends its CAMs with; the recorded
GeoBroadcast DENMs use 0x79 (30 s). Changed in obu-cam-transmistter's
copy as well. Both firmwares build (IDF 6.1 and 5.5.4), and the
disassembled geonet_wrap_shb of each stores 0x05. Not yet seen on air:
the production OBU still runs the 2026-09-10 build, and the on-air
check is listed in TODO.md.
2026-09-11 20:19:39 +02:00
Ashin Walpola d7043bb04d Ignore the vanetza checkout and Python bytecode caches
vanetza is a clone of the open-source ETSI C-ITS stack, kept beside the
project as a reference in the same way as C-ITS-Parser. It is read, not
built, and it carries its own .git, so a plain `git add .` would have
picked it up as an embedded repository.

__pycache__/ appears when obu-firmware/test/host/check_replay.py is
imported rather than run.
2026-09-11 20:19:39 +02:00
niklasdathe@web e908f7fae1 Move architecture files into docs 2026-09-10 15:17:28 +02:00
Niklas Dathe 8f397eea20 Add drawio architecture diagram 2026-09-10 15:12:53 +02:00
Niklas Dathe cc395771ea First draft of architecture that was decided on at the Workshop (09.09.2026) 2026-09-10 14:55:45 +02:00
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
Ashin Walpola 034ef22336 Decode raw v2x/rx on the CiT One path, and stop tracking our own CAM pings
The Use Case app's v2x-uca/output/json topics are a rate-limited and
lossy view: traffic the OBU's radio actually heard, the ESP32's CAM
pinger among it, never reached the app. The raw v2x/rx topics carry
everything, as RecvV2XMessage protobuf with the ITS-G5 PDU in one bytes
field (CI-CiT MQTT API section 2.4).

RecvV2xMessage is a minimal protobuf wire-format reader for the three
fields needed: btpHeader type and destination port, the GeoNetworking
destination-area radius, and the payload. Hand-written for the same
reason the ASN.1 codecs are, rather than adding protoc and the protobuf
Gradle plugin and vendoring a third-party .proto into this repository.
Field numbers are pinned by a byte fixture written out by hand from the
encoding rules, not generated by our own encoder.

Raw payloads now travel as bytes rather than String. The previous UTF-8
round trip replaced every byte that is not valid UTF-8, leaving a
payload that still looked plausible in a log and decoded to nothing.

CAM, DENM and SPATEM from both transports now meet in shared handlers,
so everything downstream is transport-agnostic. SPATEM works on the CiT
One path for the first time, and DENM gains its relevance radius there.
Where both sources describe the same event the decoded one wins: remote
CAMs from the processed topic are suppressed while the raw topic is
live, and DENMs dedup on ETSI's actionID with the decoded list last.
The processed topics stay subscribed as a fallback for an OBU whose
configuration does not publish the raw ones.

Two defects found while testing this:

CamPinger transmits under a fixed bench station id, deliberately
distinct from the persisted one, but the self-heard filter only knew
the persisted id. Every ping therefore came back through the ESP32's
promiscuous receive as a remote road user sitting exactly on top of the
ego position, moving at the ego's own speed and heading, and was handed
to the detection engine as a collision partner for itself. The rule now
lives in OwnStationIds, covers both ids, and has tests, so a third
transmit path cannot reintroduce the same gap quietly.

Self-heard frames are now counted and reported on the pinger card
instead of being discarded. That round trip is the only direct evidence
the serial link, the ESP32's transmit path and its receive path all
work, which is what the bench pinger exists to demonstrate.

Also: the stationType warning banner no longer shows in ESP32-C5 mode.
It reads a value from the CiT One's obu_gnss topic, which that hardware
never publishes, so it stayed on screen reporting on an OBU that was no
longer in use.
2026-09-02 15:25:31 +02:00
Ashin Walpola ebe1c9edfd Dashboard: surface the nearest hazard and the next signal change
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.
2026-09-02 15:25:05 +02:00
Ashin Walpola 312f094909 Save the Word-edited copies of both documents
Both files were opened and edited in Word and are re-saved here as the
authoritative versions. They supersede the generated originals from 16998bf.

The files grew by roughly 9 to 11 KB, which is Word repackaging them: its own
settings part, embedded font references and per-run revision identifiers, none
of which the generator wrote. The technical document also lost two empty
paragraphs, which is Word trimming trailing empties.

Extracted text is byte-for-byte unchanged in the user guide and identical in
word count in the technical document, and both still carry their full section
structure and all twenty image placeholders. Neither file contains an embedded
image yet, so the placeholders are all still waiting on artwork.

Word is now the source of truth for these two files. There is no longer a
script that can regenerate them without discarding whatever was changed here,
so edit them in Word rather than rebuilding.
2026-09-01 15:10:09 +02:00
Ashin Walpola b6a687982d Update the README for the ESP32-C5 path and the two documents
The README still described the project as it was before Phase 03. Several
claims had become actively wrong rather than merely dated, and the last one
is the kind a reviewer would catch:

- "no ASN.1 encoding in the app" - the app hand-encodes and decodes CAM,
  DENM and SPATEM bit by bit on the ESP32-C5 path. domain/asn1/ is now the
  highest-risk code in the project, and the README denied it existed.
- The phase table listed Phase 03 as Bluetooth BLE. Phase 03 is the
  ESP32-C5; Bluetooth is not implemented and the requirements leave it open.
- "communicates with the OBU exclusively via the consider it MQTT API v6"
  is true of one of the two hardware paths.
- The feature list claimed MAP and CPM display. There is no MAPEM decoder
  and nothing in the tree supports CPM, so both claims are dropped rather
  than carried forward.
- DENM transmission was listed as a headline feature with no indication that
  it is a manual antenna and range test tool, CiT One only, and deliberately
  never triggered by a detected event or a use case alert. That decoupling is
  the project's central architectural rule and the README implied the
  opposite.
- The architecture tree predated domain/asn1, domain/usecase, domain/cam,
  data/cam, the serial transport, obu-firmware/ and asn1/.

Added: the project goal, the two hardware paths and the point where they
converge, a verification section, a documentation index, and a status
section.

The convergence point is worth stating in the README rather than only in the
technical document, because it is what makes the second OBU a drop-in rather
than a fork: both paths normalise into the domain Cam type at
CamUseCaseRepository, and everything above it is shared and
transport-agnostic.

The status section says plainly that requirement 11.6 is not met, that
messages are not signed, and that the detection thresholds are untuned
estimates. Someone arriving at this repository should learn that from the
front page rather than from page forty of a Word document.
2026-09-01 15:07:57 +02:00
Ashin Walpola 3da60d3a99 Ignore Office lock files
Word creates ~$<name>.docx beside a document while it is open and removes it on
close. docs/~$crOBU-User-Guide.docx was showing as untracked while the user guide
was being edited, which is exactly the kind of transient file a git add -A sweeps
in by accident.
2026-08-26 17:13:50 +02:00
Ashin Walpola 081347f31b Tapping the active bottom-nav tab returns to that tab's root screen
Tapping Settings while on Settings > Connection appeared to do nothing. The tab
navigated to its own route, but restoreState = true then restored that tab's
saved back stack, putting the sub-screen straight back on top. The only way out
was the back button or a back swipe.

When the tap targets the tab already in use and the current destination is deeper
inside it, pop back to the tab's own screen instead of navigating. Only entries
above the tab root are removed, so Back and back-swipe behave exactly as before -
both routes out of a sub-screen now work.

The tab also stayed unhighlighted while any sub-screen was open, because selected
compared the current route for equality with the tab's route. Ownership is now
derived from the existing route naming convention, so settings/connection belongs
to Settings and trip_review/{tripId} belongs to Trips. A new settings/* screen is
picked up automatically; a sub-screen named outside its tab's prefix would need a
line in ownsRoute.
2026-08-26 17:12:46 +02:00
Ashin Walpola 16998bf478 Add the user guide and technical documentation as Word documents
Two documents, wiki-style so they import cleanly: short titled sections,
tables over prose where the content is comparative, and cross-references
between sections rather than a narrative that has to be read start to end.

MicrOBU-User-Guide.docx is for riders. Features, setup for both hardware
paths, what each screen shows, what the five alerts mean in plain language,
troubleshooting. No ASN.1, no BTP ports, no bit widths anywhere in it. The
alert descriptions and the link states are taken from values/strings.xml so
the guide and the interface use the same words.

MicrOBU-Technical-Documentation.docx is for supervisors and stakeholders.
Architecture, the message path end to end, the serial protocol, the codecs
and how they are verified, the full requirements matrix, the bench results,
and the decisions. Section 15 is fourteen decisions written as chosen /
alternative / reasoning / cost accepted, because the alternative is the part
a supervisor asks about and it was previously recorded only in commit
messages.

Nothing is re-derived. Every measurement is cited from
05-obu-bench-test-2026-08-25.md, 04-transmit-setup.md, asn1/README.md or
the commit history. Where a claim has no evidence it is marked as unverified
rather than asserted:

- 11.6 Phase A success criteria is stated as not met, in its own subsection.
  The bench proves reception; it cannot prove the use case with two moving
  stations because nothing on the bench moves. This is the central claim of
  the project and it needs a real ride.
- The tx_custom.c bypass is described as the least defensible component in
  the system and load bearing, with the reverse-engineered struct layouts
  and the skipped sanity checking spelled out.
- The 5900 MHz transmit story is marked a mitigation for a hypothesis, not a
  diagnosis, and the isolation test that would settle it is named as not run.
- The detection thresholds are presented as untuned engineering estimates in
  both documents, since presenting them as validated is the easiest and most
  damaging overstatement available here.

Twenty image placeholders, none of them filled. Each is a shaded block
carrying a caption and a "Must show" line. For the three screenshots that
exist the line names the bench session and section they came from; for the
four diagrams that do not exist yet it is a full drawing spec, so the two
hardware paths, the message path, the frame layout and the verification loop
can be drawn from the document without re-reading the source.

references.bib collects the standards as BibTeX for a later publication:
EN 302 637-2/3, TS 103 301, SAE J2735, TS 102 894-2, EN 302 636-4-1 and
-5-1, TS 103 248, TS 103 097, IEEE 802.11 OCB, plus the C2C-CC white paper
and the vendored parser provenance.

Also tracks two documents the new ones cite that had never been committed:
docs/01-requirements-traceability.md and 04-transmit-setup.md. Section 18.3
lists them as repository sources, which would have been a dangling reference
otherwise.

Not included, deliberately: the two consider it PDFs cited in section 18.2.
They are third-party vendor documentation and redistributing them is a
licensing decision, not a documentation one.

Still missing: the German user guide. values-de/strings.xml already fixes the
terminology for it.
2026-08-26 16:03:11 +02:00
Ashin Walpola cc35994e68 Fix four EventDetectorTest cases that never passed
These have been red since the initial commit. All four failed for the same
reason, and in every case the test was wrong rather than the detector.

EventDetector requires two conditions to hold on the SAME frame, and one of them
is a rolling-window statistic that takes time to respond. The tests ignored that
lag, so they described situations the detector cannot see - and could not have
seen at any point in its history.

Braking (3 tests). Each filled the accel window with a CONSTANT value, then
stepped the GPS speed down. The speed drop is therefore true on exactly one
frame, and on that frame the accel std dev is still ~0.75 against a 1.2
threshold, because the window is full of the constant. By the time the window
recovers (frame 4), prevSpeedMps has caught up and the drop is 0. The two
conditions never coincide and no BRAKING is possible. Constant accelerometer
output right up to the instant of a brake is not physical either: the IMU is
sampled continuously while GPS speed lags, so the shaking precedes the reported
drop. The tests now establish that variability during the cruise phase.

Stopping. The second episode ran for stoppingFrames + 5. But stopping requires
the accel std dev to be BELOW a threshold, and the window still held the five
moving samples from the acceleration burst between episodes. Those take 8 frames
to drain far enough for the std dev to fall under 0.15, leaving 17 of the 21
frames the event needs. The first episode is unaffected because the window starts
empty. The episode is now long enough to cover the settling time.

Verified by simulating the detector's exact arithmetic against both the old and
new inputs before touching the file: the old profiles produce no events, the new
ones produce BRAKING/HIGH, BRAKING/HIGH, BRAKING/MEDIUM and two STOPPING.

No production code changed - the detector behaves consistently and defensibly.
Assertions are unchanged; only the stimulus is now something a bicycle could
actually produce. Suite is 41 tests, 0 failures.
2026-08-25 15:21:34 +02:00
Ashin Walpola 04b0076b8b Move the RX capture buffer off the WiFi driver's callback stack
rx_item_t is ~800 bytes at RX_FRAME_MAX_LEN, and wifi_promisc_rx_cb declared one
as a local. That callback runs on the WiFi driver's own task, already several
frames deep in the driver's call chain, on a stack of roughly 3.5 KB
(CONFIG_ESP_WIFI_TASK_STACK_SIZE, left at its default). Putting a fifth of that
stack into a single local is a stack-overflow risk that only appears under real
traffic - in front of an RSU rather than on the bench - and would present as a
random panic rather than anything pointing at its cause.

Both instances are now static: one in the callback, one in rx_forward_task. Safe
because each is touched by exactly one task, so there is no re-entrancy to guard
against; the same reasoning serial_link.c already uses for its static send
buffers. xQueueSend copies the struct out before returning, so reusing the
callback's buffer on the next frame is fine.

Firmware-only, no protocol change, so it does not require a matching app install.

Re-verified against live traffic after flashing: 1094 frames over 125 s with zero
decode failures, USB errors, detaches, crashes or mutex timeouts. SPATEM capture
rose from 3.20/s to 3.98/s against a theoretical maximum of 4.00/s, which is the
direction relieving stack pressure would produce, though RF geometry moves
between runs and this is not proof.

Report updated with T9, the accepted 512-byte ceiling, and the decision to drop
Phase B: the intersection use case is CAM-driven and needs none of it.
2026-08-25 15:13:23 +02:00
Ashin Walpola d0701ccea4 Show roadside units in the station list; log ESP32 drop counters
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.
2026-08-25 14:37:46 +02:00
Ashin Walpola b2b57fa39e Vendor the ASN.1 modules the codecs are verified against; untrack IDE churn
asn1/
Three tests assert exact bytes - CamEncodeGoldenTest, DenmAirReceiveTest and
SpatemUperCodecTest - and their expected values came from asn1tools compiled
against ETSI modules that existed only as an untracked working copy on one
machine. A golden-byte fixture nobody else can regenerate is a fixture nobody
can safely touch, so the modules are now in the repo.

Only the seven .asn files those tests need are copied, 576 KB of a 4.2 MB
checkout; the upstream Rust parser is not used by this project at all. Verified
sufficient in isolation: copied into an empty directory, all three specs compile
and reproduce the committed golden CAM bytes byte-identically.

Source is consider it GmbH's C-ITS-Parser (github.com/consider-it/C-ITS-Parser)
at f457426, MIT licensed - LICENSE is retained alongside as that requires. The
schemas themselves are ETSI's standard definitions; upstream's contribution is
assembling them into a compilable set. asn1/README.md records the provenance,
which module pairs with which message, and the rule that matters: never
regenerate a golden fixture from this project's own encoder, because sharing a
mistake between encoder and decoder is exactly the failure these files exist to
catch.

Doc references in the codecs and tests now point at asn1/ instead of the
untracked checkout, and C-ITS-Parser/ is gitignored so the working copy beside
the project is never picked up.

Untracked local state
- .idea/deploymentTargetSelector.xml rewrites itself on every deploy, so it has
  been showing as modified in essentially every commit. Along with
  deviceManager.xml, appInsightsSettings.xml and studiobot.xml it is per-machine
  state, not project configuration.
- obu-firmware/sdkconfig.old is ESP-IDF build output - it is the previous
  sdkconfig, rewritten on every build. sdkconfig.defaults remains tracked, since
  that is the configuration actually chosen.

All five stay on disk; only the tracking is removed. Also ignores
.claude/settings.local.json, which is per-machine, while leaving the skills
beside it committable as project knowledge.
2026-08-21 14:28:57 +02:00
Ashin Walpola eb6150260b Fix NPE crash on the V2X map when messages arrive during teardown
osmdroid's MapView.onDetach() permanently tears the view down: afterwards its
MapViewRepository holds a null MapView, so constructing a Marker against it
throws NullPointerException from inside InfoWindow's constructor.

It was being called from a DisposableEffect keyed on the lifecycle owner, which
disposes independently of the AndroidView that owns the map. The update block
could therefore still run against an already-detached MapView and rebuild its
markers:

  java.lang.NullPointerException: Attempt to invoke virtual method
    'MapViewRepository MapView.getRepository()' on a null object reference
      at org.osmdroid.views.overlay.Marker.<init>(Marker.java:116)
      at V2xLiveMapViewKt...(V2xLiveMapView.kt:119)

The crash is dated 2026-08-17 18:19, ten minutes after DENM reception went live
on the device. The defect was always there, but every incoming message
recomposes this view, so going from occasional updates to one per second made
the window easy to land in - and SPATEM at ~2 Hz makes it easier still.

Moves the teardown to AndroidView's onRelease, which is the callback that means
"this View is gone" and after which Compose guarantees no further update.
2026-08-20 16:31:41 +02:00
Ashin Walpola a5ad3dcc5d SPATEM receive, RSU CAM decode, and two ASN.1 encoding fixes
SPATEM over the air
- gn_unwrap.c accepts BTP-B port 2004 alongside 2001/2002. The serial protocol
  already carries the port in its V2X_RX prefix, so nothing else changed there.
  Note the crossover that makes this easy to get wrong: SPATEM is port 2004 but
  messageID 4, while MAPEM is port 2003 and messageID 5.
- SpatemUperCodec decodes SPAT down to per-signal-group phase and timing. The
  bit layout was validated by replaying 79,042 real SPATEMs - the whole
  2026-03-18 drive across 7+ RSUs plus the bench trigger - against asn1tools
  using the ETSI modules. All 79,042 matched on every field, none hit an
  unsupported branch. Two traps are pinned by tests: TimeChangeDetails is the
  one SEQUENCE here that is NOT extensible (5 optional bits, no extension bit),
  and maneuverAssistList cannot be skipped when present - it is variable-length,
  so it has to be walked to find where the next movement starts.
- The V2X list shows one row per intersection with each signal group coloured by
  phase and a countdown where the RSU supplies timing. TimeMark wraps hourly, so
  the countdown corrects for it; without that it reads hugely negative once an
  hour, precisely when someone is watching it.
- Entries expire after 15 s, much shorter than DENM's window: a traffic light
  that stopped updating is not "still green".

  Size caveat, deliberately deferred: SERIAL_LINK_MAX_PAYLOAD is still 512, so a
  SPATEM over ~498 bytes is counted as an oversize drop. The bench RSU sends 58
  bytes and is unaffected, but real road RSUs measured 555 median / 1243 max, so
  roughly 70% would not arrive. Raising the cap also requires enlarging
  RX_FRAME_MAX_LEN and moving rx_item_t off the WiFi driver's callback stack,
  where it would otherwise overflow.

RSU CAM decode
- HighFrequencyContainer is a CHOICE, and a roadside unit picks
  rsuContainerHighFrequency, which carries no kinematics at all. The decoder
  bailed on that branch, so every RSU CAM was dropped - including the bench RSU,
  which sends CAM and SPATEM from the same station id. It now decodes for
  position and stationType.
- RSU CAMs are kept out of UseCaseDetectionEngine. They arrive as a permanently
  stationary station at a fixed point, which is exactly the shape the
  stopped-vehicle and intersection-movement use cases match, and would raise a
  standing false alert for as long as the RSU was in range.

CAM transmit: yawRateConfidence
- YawRateConfidence has nine enumerands (0..8), so UPER needs 4 bits and
  "unavailable" is 8. The encoder wrote 3 bits with value 7 - one bit short and
  the wrong symbol - shifting every field after yawRate for any standards-strict
  receiver. The decoder read 3 bits too, so phone and ESP32 agreed with each
  other and with nothing else.
- This is the third instance of that exact failure mode in this project, after
  CurvatureCalculationMode and the GeoNetworking reserved bytes. A round-trip
  test through our own decoder structurally cannot catch it, so CamEncodeGolden
  Test asserts the bytes asn1tools produces instead: it decoded this encoder's
  output and re-encoded it byte-identically. Confirmed on air afterwards - 26 of
  our own CAMs captured back off the OBU's receiver, all 26 accepted, where the
  same decoder rejected them before.

DENM
- Hazards now expire 60 s after their last repetition. This needs a clock, not
  just a filter: both source flows only emit when a DENM arrives, so a sender
  that drives away or loses power would never trigger a recompute and its hazard
  would stay on screen indefinitely.
- The MQTT path was dropping every DENM for two independent reasons, both found
  by checking the payload against CI-CiT-MQTT_API_Documentation-v6 listing 2.6
  rather than guessing: the station id key is originatingStationId, and
  eventPosition IS a GeoJSON Point rather than an object containing one. Also
  parses termination (presence is the signal), sequenceNumber, stationType and
  the RFC3339 detectionTime. Note roadSideUnit is 15, not 12 - the enumeration
  has a gap after tram(11).

V2X screen
- The decoded CAM/DENM list now renders on the CiT One path too; it was gated to
  the ESP32-C5 path and CiT One fell through to the raw MQTT topic list. Those
  topics move to their own tab, hidden on the ESP32-C5 path where there is no
  broker.

Testing
- Adds org.json as a test-only dependency: the android.jar stub throws
  "not mocked" on every JSONObject call, which made the MQTT payload parsers
  untestable off-device.
- 23 V2X tests pass. EventDetectorTest's 4 failures are pre-existing and
  untouched by this change.
2026-08-20 15:47:14 +02:00
Ashin Walpola 0ccb867228 DENM over-the-air receive on the ESP32-C5 path
The firmware forwarded CAM only: gn_unwrap_cam accepted single-hop broadcast
(HT=5) and BTP port 2001, so every DENM was dropped before it reached the phone.
Real OBUs disseminate DENM by GeoBroadcast (HT=4), whose 44-byte extended header
also carries the hazard's relevance area - materially more useful on a map than
the sender's own position, since a sender may be relaying for someone else.

Firmware
- gn_unwrap_cam -> gn_unwrap_its: accepts GeoBroadcast alongside TSB/SHB, and
  BTP ports 2001 and 2002, extracting the GeoBroadcast destination area. Both
  extended-header lengths were measured against live air capture rather than
  read off a spec table. Secured packets (Basic Header NextHeader=2) are
  rejected rather than misparsed.
- SERIAL_MSG_CAM_RX (0x02) superseded by SERIAL_MSG_V2X_RX (0x04): a 14-byte
  prefix carrying BTP port, RSSI and the destination area. Adding MAPEM later
  needs a decoder on the phone but no protocol change. 0x02 stays reserved so
  the numbering is not silently reused.
- Promiscuous RX capture buffer 400 -> 800 bytes. A real GeoBroadcast DENM is
  around 500 bytes on air and was being truncated mid-payload, which no amount
  of correct unwrapping downstream could have recovered from.
- geonet_wrap_shb, both firmwares: the SHB extended header is 28 bytes, not 24.
  The Source Position Vector is followed by a 4-byte reserved field; without it
  a standards-strict receiver reads the CAM payload's first two bytes as the BTP
  destination port.

App
- DenmUperCodec: UPER decoder for the ManagementContainer and the
  SituationContainer's eventType. ValidityDuration is 17 bits, not 16, and
  ManagementContainer, SituationContainer and CauseCode each carry their own
  extension bit - a single wrong bit made a real frame read causeCode 47
  instead of 94.
- DenmEvent gains actionID (originatingStationID + sequenceNumber), stationType,
  termination, detectionTime, relevance radius and RSSI. Dedup keys on actionID
  where available, so a termination lands on the event it ends instead of
  creating a second pin.
- denmEvents merges the MQTT and over-the-air sources and drops terminated
  events. The V2X list view now shows hazards above the CAM stations; it
  previously took no DENM parameter at all, so hazards reached the map but never
  the list.
- DenmParser: the Use Case API sends causeCode as a string enum, so reading it
  as an Int always yielded null.

Testing
- DenmAirReceiveTest covers the V2X_RX prefix and the decoder using real frames
  from a live capture as fixtures. Expected values were cross-checked against
  the ETSI ASN.1 modules via asn1tools, which agreed on all 1885 decodable
  DENMs across the capture set, every field including detectionTime.
- Verified on hardware: a CiT One HLN-SV DENM decodes as cause 94/0 with a
  1000 m relevance radius at 1 Hz alongside CAM, with no decode failures and no
  unexpected BTP ports.

Also replaces em dashes with hyphens throughout the user-facing strings,
including the German translation.
2026-08-17 18:42:48 +02:00
Ashin Walpola f1770e11dd ESP32 link indicators, trip export with V2X + RSSI, trip/CSV cleanup
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.
2026-08-11 15:47:54 +02:00
Ashin Walpola f507a8a9fd Fix UPER encoding of CurvatureCalculationMode; verified on hardware
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.
2026-08-11 14:50:35 +02:00
Ashin Walpola b91eb460dc Phase 03: CAM decode coverage, real sensor data in TX, V2X monitor for ESP32 path
CAM codec:
- Stop rejecting CAMs carrying a specialVehicleContainer. It is declared last in
  CamParameters, after everything this decoder reads, so buses / emergency
  vehicles / road-works vehicles now decode for position and kinematics instead
  of being dropped outright
- Drop the lowFrequencyContainer parse - it extracted nothing into Cam, and its
  reads were only correct when no high-frequency optionals were present
- Document why the 7 optional-presence bits are consumed but not acted on: UPER
  writes a SEQUENCE's presence bitmap up front but each field's value in
  declaration order, and all seven are declared after yawRate
- Field widths and container ordering verified against the ETSI ASN.1 sources in
  the C-ITS-Parser checkout, not from memory

Transmit path:
- Own StationID is now a persisted random 32-bit value instead of a hardcoded 0.
  Receivers key on StationID to track a station across CAMs, so every unit
  broadcasting 0 made two MicrOBUs indistinguishable - including to this app's
  own detection engine
- Populate longitudinalAcceleration from successive GNSS speed samples. Not from
  the accelerometer: CAM wants signed along-track acceleration, and the raw
  sensor is device-frame with gravity in it. Null outside a usable sample gap
  rather than a fabricated value
- CAM pinger builds from live GNSS/IMU via PhoneCamBuilder instead of beaconing a
  hardcoded bench coordinate with speed and heading pinned to zero, so it now
  exercises the sensor pipeline and not just the wire. Sends nothing without a
  fix, and reports that rather than sitting at "Sent: 0"

V2X monitor:
- Received-CAM pane for the ESP32-C5 path, replacing the MQTT topic list that is
  permanently empty there. One row per station rather than per message - CAMs
  arrive at 1-10 Hz per station, so the pane is bounded by road users nearby, not
  by traffic rate. Nearest first, tinted by active alert level
- DENM hazard pins on the live map as a warning triangle, drawn above vehicle
  markers. CiT One path only: the ESP32 firmware forwards BTP-B port 2001 (CAM)
  and drops port 2002 before it reaches the phone

DenmParser uses tolerant field-name matching - the Use Case API's DENM JSON
schema is not yet confirmed against real payloads.
2026-08-10 14:09:18 +02:00
9939 changed files with 1594548 additions and 2709 deletions
+46
View File
@@ -17,3 +17,49 @@ local.properties
*.jks *.jks
*.keystore *.keystore
secrets.properties secrets.properties
# --- local IDE state, not project configuration -------------------------------
# Which device you last deployed to, your emulator list, and Studio's assistant
# settings are per-machine. deploymentTargetSelector.xml in particular rewrites
# itself on every run, so tracking it means a dirty tree after every deploy.
/.idea/deploymentTargetSelector.xml
/.idea/deviceManager.xml
/.idea/appInsightsSettings.xml
/.idea/studiobot.xml
# Python bytecode caches, e.g. from importing obu-firmware/test/host/check_replay.py.
__pycache__/
# ESP-IDF rewrites sdkconfig on every build and keeps the previous one here.
# sdkconfig.defaults is the real, intentional configuration; these two are output.
sdkconfig.old
# Claude Code local settings (permissions, per-machine). The skills alongside it
# are project knowledge and may be committed deliberately.
.claude/settings.local.json
# Third-party working copies kept beside the project, not part of it. The ASN.1
# modules this project actually needs are vendored under asn1/ instead.
/C-ITS-Parser/
/vanetza/
# Office lock files. Word/Excel create these beside a document while it is open
# and remove them on close, so they are transient and machine-local.
~$*
# Full-flash images read back off the bench boards before reflashing them (16 MB each).
# Restore with: esptool --chip esp32c5 -p COM<N> write-flash 0 <image>
/firmware-backups/
# ESP-IDF component manager downloads (espressif/esp-boost for obu-firmware's vanetza-idf), ~125 MB.
# dependencies.lock beside the project pins them and is committed; this is its cache.
managed_components/
# The colleague's standalone ESP32-C5 VRU station (its own repository, HAW GitLab
# urban-mobility-lab/microbu/microbu-esp32c5). Kept beside the project on the lab laptop, for
# reference and because the V2X2MAP bridge runs from its tools/, but not part of this repository:
# obu-firmware only needs its vanetza-idf, which is copied to obu-firmware/external/vanetza-idf.
/microbu-esp32c5/
# draw.io keeps a backup beside an open diagram.
*.drawio.bkp
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AppInsightsSettings">
<option name="selectedTabId" value="Android vitals" />
</component>
</project>
-31
View File
@@ -1,31 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-08-05T15:07:39.343414600Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=56211FDAP0015L" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection>
<targets>
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=56211FDAP0015L" />
</handle>
</Target>
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\Ashin\.android\avd\Pixel_7_Pro.avd" />
</handle>
</Target>
</targets>
</DialogSelection>
</SelectionState>
</selectionStates>
</component>
</project>
-13
View File
@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DeviceTable">
<option name="columnSorters">
<list>
<ColumnSorterState>
<option name="column" value="Name" />
<option name="order" value="ASCENDING" />
</ColumnSorterState>
</list>
</option>
</component>
</project>
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="StudioBotProjectSettings">
<option name="shareContext" value="OptedOut" />
</component>
</project>
+280
View File
@@ -0,0 +1,280 @@
# Step 4 - Build, flash, and validate the HLN-SV DENM transmitter
## Radio-config fix (2026-07-15) - read this first
Symptom: nothing on air. Proof it was a radio-config problem, not a
frame-format problem: the 2026-07-15 sniffer capture (`recordings/
its5_20260715_131448.pcap`) contains 13,584 ITS-G5 frames on 5.9 GHz but
**zero** from our source MAC `02:00:00:00:00:01`. A sniffer records a
station's frames even if the payload is malformed, so the frame was never
leaving the radio - the DENM/GeoNet/802.11 encoding was never the issue.
Three stacking causes, all in `main.c`'s Wi-Fi init, now fixed:
1. **Plain STA, no promiscuous, power-save on.** ESP-IDF only actually emits
raw frames when the MAC is in promiscuous mode or associated to an AP, and
default STA power-save sleeps the radio between beacons and drops outbound
frames. The *working sniffer* runs promiscuous - the OBU didn't. Fixed:
`esp_wifi_set_ps(WIFI_PS_NONE)` + `esp_wifi_set_promiscuous(true)` after
`esp_wifi_start()`.
2. **Never entered 5 GHz band mode.** The stuck `esp_wifi_get_channel()
primary=1` was the dual-band C5 still in 2.4 GHz band mode, so
`esp_wifi_set_channel(<5G channel>)` failed silently and the HMAC TX path
keyed the 2.4 GHz PHY on ch1 - inaudible to a 5.9 GHz sniffer. Fixed:
`esp_wifi_set_band_mode(WIFI_BAND_MODE_5G_ONLY)` before start. Channel prime
changed from 140 (5700 MHz) to **177 (5885 MHz)** - see cause 3.
3. **5900 MHz is out of the C5's spec range.** The datasheet 5 GHz range is
**5180-5885 MHz**; our target 5900 MHz (ITS-G5 G5-CCH) is 15 MHz above it.
RX tolerates 15 MHz over (that's why the sniffer works at 5900); TX may be
PA-calibration-gated at an uncalibrated frequency. Priming the driver to
channel 177 (5885, the top legal channel) keeps TX on the closest real
power table before `phy_change_channel(5900,...)` nudges the LO.
### If it STILL shows nothing after these fixes - the 5900 MHz isolation test
Causes 1 and 2 are high-confidence. Cause 3 is the remaining unknown, so
isolate it before touching anything else. Temporarily change **both** ends to
a channel inside the C5's spec range and see if the OBU appears:
- In `main.c`, change the target frequency from `5900` to `5885` in *both*
`phy_change_channel()` calls (boot + per-TX), matching the channel-177 prime.
- Put your sniffer on 5885 MHz too.
- Ground GPIO4 and watch the dashboard/PCAP for source MAC
`02:00:00:00:00:01`.
If the OBU now appears at 5885 but not at 5900: TX genuinely can't key the PA
at the out-of-spec 5900 MHz, and no amount of frame-format work will change
that - you'd need a chip whose 5 GHz range covers the ITS band, or to accept
operating one channel down. If it appears at neither, the problem is still in
the TX-enable path (recheck that the three calls above returned ESP_OK in the
boot log), not the frequency.
Also note: **nothing transmits until GPIO4 is grounded** (active-low hazard
input). If you were bench-testing without grounding GPIO4, the TX path was
never even entered - ground it (jumper GPIO4 to GND) before concluding
anything.
---
Profile: **HLN-SV** (aftermarket stationary recovery vehicle) - causeCode 94
(stationaryVehicle), subCauseCode 0, transmits only while the hazard-light
GPIO reads active. No location/alacarte containers, no optional fields.
Code lives in `obu-firmware/main/`: `main.c` (entry, PHY hack, GPIO, TX loop),
`denm.c/.h` (ASN.1 UPER encoding), `geonet.c/.h` (GeoNetworking + BTP-B
wrapping), `dot11p.c/.h` (802.11 OCB frame assembly), `tx_custom.c/.h` (the
actual raw-frame transmit path - see "The TX bypass" below).
## The TX bypass (tx_custom.c)
`esp_wifi_80211_tx()` - ESP-IDF's public raw-frame API - rejects QoS Data
frames outright (`esp_err 258` / "unsupport QoS frame type"), and there's no
supported way to override that from application code (a linker-level symbol
override attempt, `main/wifi_patches.c`, is kept around only for history -
confirmed not to work).
`tx_custom.c` was pulled from
[opentrafficmap/its-g5-receiver-firmware_txenabled](https://codeberg.org/opentrafficmap/its-g5-receiver-firmware_txenabled)
- a TX-enabled fork of the exact receiver firmware (V2X2MAP) already used on
the RX side of this project, same authors, same chip. Instead of trying to
disable the gate, it skips the code path that contains it entirely: it calls
`ic_ebuf_alloc()` and `ieee80211_post_hmac_tx()` - undocumented internal
driver functions - directly, submitting straight to the MAC. `main.c` now
calls `esp_wifi_80211_tx_custom()` instead of `esp_wifi_80211_tx()`, with the
one proven-working parameter set (`WIFI_PHY_MODE_11A`, `WIFI_PHY_RATE_12M`,
`WIFI_BAND_5G`, `WIFI_BW20`) copied from the only call site in the upstream
repo (`mqtt.cpp`, triggered by an incoming MQTT message there - ours is
triggered by the GPIO4 hazard-light logic instead).
**What this unlocks**: `dot11p.c` is back to real QoS Data frames (subtype 8)
since the gate that forced the non-QoS downgrade no longer applies. Frame
size is now 117 bytes (was 115 with non-QoS Data's 2-byte-shorter header) -
expect `sent (117 bytes)` in the log now, not 115.
**What's NOT yet independently verified** - things worth checking as you go:
- Whether `ieee80211_post_hmac_tx`, `ic_ebuf_alloc`, `ic_get_default_sched`,
`g_osi_funcs_p`, and `g_wifi_global_lock` actually exist as symbols in
*your* IDF version's `libnet80211.a`/`libpp.a` for esp32c5 - we only
previously confirmed `ieee80211_raw_frame_sanity_check` exists via `nm`,
not these. If the build fails to link with `undefined reference`, this is
the first thing to check - same `nm` approach as the `phy_11p_set` section
below, just against these symbol names.
- `tx_custom.c`'s internal struct layouts (`x_eb_txdesc_t`, `x_ebuf_t`) are
reverse-engineered from the closed WiFi driver, pinned only by a `sizeof()`
assert - that catches a total-size mismatch across IDF versions but not a
field-order mismatch that happens to keep the same total size. If your IDF
version differs meaningfully from whatever the upstream repo has pinned
(check their `esp-idf` git submodule commit vs. `idf.py --version`), this
could compile and link cleanly but write to the wrong internal offsets.
Worth keeping in mind as a possible explanation if you get a crash/hang
right when TX fires rather than a clean error.
- Same "skips ALL sanity checking" risk as the old override attempt: a
malformed frame from a bug anywhere in `denm.c`/`geonet.c`/`dot11p.c` could
now behave worse (crash, silent corruption) than a clean rejection.
- `esp_wifi_set_channel(140, ...)` was added in `main.c` right before the
`phy_11p_set`/`phy_change_channel` pair, copied from upstream's
`cmd_sniffer.c` (their own comment on it: "not sure if strictly needed").
Motivation: our own `esp_wifi_get_channel()` diagnostic was reporting a
stuck `primary=1` regardless of what the PHY hack was told, consistent
with the driver's channel bookkeeping never being touched by anything it
tracks. This is a "worth trying," not a confirmed fix - watch whether
`esp_wifi_get_channel()`'s reported value changes at all now.
## Confidence levels - read this before debugging blind
Updated after pulling the actual specs (EN 302 636-4-1, EN 302 636-5-1) and
the real ASN.1 modules from forge.etsi.org (EN 302 637-3, TS 102 894-2 CDD) -
this isn't guesswork anymore for the parts listed as "verified" below.
- **GeoNetworking Basic/Common/SHB headers, BTP-B header** (`geonet.c`):
verified field-by-field against EN 302 636-4-1. This caught three real
bugs in the previous version: wrong header type (was encoded as
GeoUnicast, HT=2 - now correctly TSB/SINGLE_HOP, HT=5/HST=0), wrong
payload-length calculation (was including the 24-byte extended header,
which it shouldn't), and GN_ADDR being an arbitrary byte string instead of
its actual structure (M-flag + 5-bit station type + reserved + 48-bit
MID = the same link-layer address used in the 802.11 header).
- **BTP-B header** (`geonet.c`): verified against EN 302 636-5-1 - unchanged
from before, structure was already correct.
- **802.11 header, LLC/SNAP** (`dot11p.c`): back to real QoS Data (subtype 8,
26-byte header with a QoS Control field), matching actual ITS-G5 hardware.
This required abandoning `esp_wifi_80211_tx()` entirely in favor of
`esp_wifi_80211_tx_custom()` (`tx_custom.c`) - see "The TX bypass" above for
the full story and the list of things about it that aren't independently
verified yet for our exact toolchain.
- **DENM ASN.1 UPER payload** (`denm.c`): verified against the real ASN.1
modules (DENM-PDU-Descriptions.asn, ITS-Container.asn). This caught real
bugs too: `ManagementContainer`, `SituationContainer`, and the inner
`CauseCode` SEQUENCE are all declared with a trailing `...` (extensible),
each of which needs its own leading extension bit that the previous
version omitted entirely; `SituationContainer`'s optional-presence bits
were encoded in the wrong position (at the end instead of the start); and
three field widths were wrong (latitude is 31 bits not 32, the two
position-confidence fields and orientation are 12 bits not 16, altitude
value is 20 bits not 24). All fixed now, with the exact ASN.1 type and
constraint range cited in comments next to each field.
- **Known-missing, by design, not bugs**: `detectionTime`/`referenceTime`/GN
timestamp are hardcoded to 0 (no RTC/NTP wired up - will decode as
2004-01-01), and `latitude`/`longitude` are hardcoded to 0 (no GNSS wired
up). Both are called out with `TODO` comments in the source.
- **Privacy pseudonym**: the source MAC/GN_ADDR MID is a fixed placeholder,
not rotated. Fine for bench testing; real stacks rotate this every 5-15 min.
- **Unsecured** (no IEEE 1609.2 signing) - matches "no additional
parameters"/easiest, and your sniffer already handles unsecured frames fine
(that's how it decodes RSU SPATEM/MAPEM today).
- **Still a deliberate simplification, not a bug**: single-hop broadcast
(TSB/SINGLE_HOP) instead of GeoBroadcast. Real DENM dissemination
typically uses GeoBroadcast so RSUs/OBUs can forward it across an area -
upgrading to that needs a sequence number + circular geo-area fields in
the GN extended header that this skeleton doesn't build. Fine for a
single-vehicle beacon; revisit if you need multi-hop forwarding.
## Build
```powershell
. $env:IDF_PATH\export.ps1 # or use the "ESP-IDF PowerShell" Start Menu shortcut instead
cd C:\Users\Ashin\Documents\micrOBU_workspace\v2x-obu-esp32c5\obu-firmware
idf.py set-target esp32c5
idf.py build
```
### If the linker fails on `phy_11p_set` / `phy_change_channel`
These are undocumented, reverse-engineered symbols pulled straight from
`libphy.a` - not a public API, so exact names/signatures can shift between
ESP-IDF versions. If you get `undefined reference`, check what's actually
exported for your IDF version:
```powershell
riscv32-esp-elf-nm $env:IDF_PATH\components\esp_phy\lib\esp32c5\libphy.a | Select-String -Pattern "11p|change_channel"
```
Adjust the `extern` declarations at the top of `main.c` to match whatever you
find.
## Flash
```powershell
idf.py -p COM5 -b 921600 flash monitor
```
You should see in the log:
```
OCB mode requested @ 5900 MHz - HLN-SV DENM beacon armed, waiting on GPIO4
```
Nothing transmits yet - GPIO4 is pulled up (inactive) until you ground it.
## Wire up the hazard-light input
For bench testing: a jumper wire or push button between GPIO4 and GND is
enough (active-low - grounding it = "hazard lights on" = beacon active).
For the real thing later: tap whatever signal your recovery vehicle's hazard
switch drives (through a level shifter / opto-isolator if it's 12V vehicle
wiring - don't feed vehicle voltage directly into a GPIO).
## Event lifecycle (added after cross-checking the Cohda UCA MQTT schema)
The Cohda "Use Case App" schema (`v2x-uca/output/json/denm`) documents a
`termination` key: "present if the DENM is cancelled or negated." The
firmware now implements this properly:
- `actionID` (station ID + sequence number) is assigned once per event, on
the rising edge of the hazard-light GPIO, and stays constant across every
repeat of that same event - it does **not** increment every second like an
earlier version of this code did.
- On the falling edge (hazard lights go off), exactly one DENM is sent with
`termination = isCancellation`, referencing that same actionID, then
transmission stops until the next rising edge.
Expect your sniffer to show the same station+sequence number repeating for
the duration of the event, then one final frame with a termination flag.
## Validate against your own sniffer
This is the important part, since a few of the payload bit widths are
best-effort: with the transmitter running (GPIO4 grounded) and your Phase 1
sniffer board powered on nearby, check the V2X2MAP dashboard.
Expect to see:
- A new station appear, sending DENM (and only DENM - this skeleton doesn't
send CAM).
- `causeCode` decoding to **stationaryVehicle**, `subCauseCode` **0**.
- `stationType` decoding to **passengerCar**.
If instead you get "unknown message type," garbage station type, or the
dashboard just doesn't show anything: capture a PCAP from the dashboard's
record button and open it in Wireshark - compare byte-by-byte against a real
captured DENM (like the one from your Cohda OBU) to see exactly where the
two diverge. That's a much faster debug loop than staring at the C code.
## Validate against the real RSU
Your own sniffer receiving the frame only tells you the RF/PHY side works -
it doesn't tell you whether a standards-strict stack (Cohda) will accept it.
With the transmitter running and both your sniffer and the Cohda-based RSU
mqtt monitor watching:
- If **both** see it, and it decodes as a real DENM (not "Type ?"): done,
move on to the "once this round-trips cleanly" list below.
- If your sniffer sees it but doesn't classify it, or the RSU doesn't see it
at all: worth first ruling out the receiver's own reliability (checked
separately - the receiver has shown signs of hanging independent of the
transmitter) before concluding anything about frame format.
- If **neither** sees it: still worth re-checking `esp_wifi_get_channel()`'s
reported value (logged before every TX) - if it's still stuck regardless of
the new `esp_wifi_set_channel(140, ...)` call, that's a stronger signal
the PHY genuinely isn't moving to 5900MHz, independent of frame format.
Note the receiver firmware (V2X2MAP) never faces the frame-type problem this
project spent a while on: receiving never calls `esp_wifi_80211_tx()`, so
there was never a gate on that side to work around. The `phy_11p_set`/
`phy_change_channel` PHY setup in `main.c` already mirrors what the receiver
does for getting onto 5.9GHz OCB mode.
## Once this round-trips cleanly
Next reasonable steps, in rough order: wire in real GNSS (replaces the 0/0
lat-long and the GN timestamp), wire in SNTP or GNSS-derived UTC time
(replaces detectionTime/referenceTime), then decide whether you actually
need GeoBroadcast/multi-hop forwarding instead of SHB, then - only if you
need it - look at IEEE 1609.2 signing.
+290
View File
@@ -0,0 +1,290 @@
# ESP32-C5 OBU firmware — bench test against live ITS-G5 traffic
**Date:** 2026-08-25
**Firmware:** `obu-firmware` @ commit `b2b57fa`, flashed to COM3 (CH343 UART bridge, 921600 baud)
**App:** `app-debug.apk`, installed 14:33:45, Pixel 9 Pro on the C5's native USB-C port
**Verdict:** the receive path works against all three live message types with zero failures.
Two blocking items remain before real-world use, both known and both outside what this bench can
exercise. See [Readiness](#readiness).
## Test environment
| role | device | notes |
|---|---|---|
| Device under test | ESP32-C5 OBU, COM3 | our firmware; phone attached to its native USB port |
| Traffic source | RSU, broker `192.168.3.202` | transmits SPATEM + CAM |
| Traffic source | CiT One OBU, broker `192.168.3.201` | transmits DENM + CAM |
| Independent witness | both brokers' `v2x/rx/*` topics | each hears the *other* device |
Both brokers publish raw UPER (protobuf-wrapped, field 3), so their counts are directly comparable
with what the OBU forwarded. That is what makes this a cross-check rather than a self-report: the
OBU's output is measured against two receivers that share none of its code.
Station IDs observed today (`3983312873` RSU, `3257224191` CiT One) differ from those seen this
morning (`968482441`, `2880458775`). **Station IDs rotate**, so nothing may treat one as a durable
identity for a physical unit.
## T1 — Message type coverage and rate
305-second continuous capture, phone logcat.
| type | frames | rate | stations |
|---|---|---|---|
| CAM | 1244 | 4.08/s | 2 |
| SPATEM | 978 | 3.20/s | 1 |
| DENM | 646 | 2.12/s | 3 |
| **total** | **2868** | **9.40/s** | |
Per station, with received signal strength:
| type | station | frames | RSSI min/median/max |
|---|---|---|---|
| SPATEM | 3983312873 | 978 | −65 / −60 / −48 dBm |
| CAM | 3257224191 | 633 | −58 / −52 / −48 dBm |
| DENM | 3257224191 | 616 | −65 / −58 / −48 dBm |
| CAM | 3983312873 | 611 | −65 / −62 / −60 dBm |
| DENM | 2908440021 | 20 | −64 / −61 / −53 dBm |
| DENM | 1220851972 | 10 | −63 / −52 / −49 dBm |
All three types decoded concurrently, from both transmitters plus two additional DENM sources that
happened to be on air. **PASS.**
## T2 — Decode integrity
Over the same window:
| check | result |
|---|---|
| Decode failures (CAM / DENM / SPATEM) | **0** |
| Unexpected BTP ports from firmware | **0** |
| USB I/O errors | **0** |
| Device detach events | **0** |
| Fatal exceptions | **0** |
Zero decode failures across 2868 frames. Since the decoders return null rather than guessing
whenever an extension bit or unsupported optional appears, a zero here means every frame matched
the bit layouts exactly — not that failures were being swallowed. **PASS.**
## T3 — Cross-check against independent receivers
60-second simultaneous capture from both brokers, counting the same UPER the OBU sees.
| stream | independent witness | our OBU |
|---|---|---|
| RSU SPATEM (3983312873) | 2.00/s | 3.20/s |
| RSU CAM (3983312873) | 2.00/s | 2.00/s |
| CiT One CAM (3257224191) | 2.05/s | 2.08/s |
| CiT One DENM (3257224191) | 1.00/s | 2.02/s |
CAM matches on both transmitters. SPATEM and DENM read high — explained in T4, not a defect.
## T4 — Duplicate transmission (finding, not a fault)
The DENM and SPATEM discrepancies above are **real duplicate transmissions**, not double-counting
in our firmware. Inter-arrival analysis of the captured stream:
| type | gaps < 150 ms | median of those | identical content, different RSSI |
|---|---|---|---|
| SPATEM | 50% (485/977) | 7 ms | **480 / 485** |
| DENM | 50% (308/615) | 5 ms | **282 / 308** |
| CAM | 2% (11/632) | 100 ms | 1 / 11 |
Each SPATEM and DENM goes out **twice, ~5–7 ms apart, with different RSSI** — two antennas. CAM is
sent once. This also reconciles the broker figures: the CiT One reports one copy on `v2x/rx/*` and
the other on `v2x/rx-red/*` ("red" = redundant), and only the primary was counted.
Our firmware is a promiscuous receiver, so forwarding both copies is correct behaviour. The app
deduplicates downstream — DENM on ETSI actionID, SPATEM on intersection key — so the UI shows one
entry per event. The cost is serial bandwidth: **38% of the bytes carried are duplicate copies.**
## T5 — Serial link load
Measured over the 305 s window, using UPER sizes taken from the brokers:
- **9.40 frames/s, ~1550 B/s (12.4 kbit/s)**
- Largest frame: DENM at 402 B UPER → 416 B payload (81% of the 512 B cap, 96 B headroom)
- On the wire that frame is 423 B, 41% of the 1024 B TX ring
- Suppressing duplicate copies would cut this to ~961 B/s (7.7 kbit/s)
No frame in this session exceeded the payload cap.
## T6 — Firmware drop counters
Read directly off the app at 14:45, after the capture:
```
ESP32: tx fail 0 · oversize 0 · crc err 0
```
These are free-running totals **since firmware boot**, so all three being zero covers the whole
session, not just the test window — no oversize drops, no `esp_wifi_80211_tx` failures, no CRC
errors at any point since the C5 was flashed. **PASS.**
The app also now logs these counters whenever one changes (added for this test; they previously
reached only the UI), so a future bench run captured through logcat records drops as they happen.
## T7 — End-to-end UI verification
Screenshot at 14:45 confirms the full chain reaches the display:
| element | shown |
|---|---|
| Link state | CONNECTED |
| Hazard (DENM) | `stationaryVehicle · station 3257224191`, 30 m, 1000 m radius, −63 dBm |
| Signals (SPATEM) | `Intersection -1/23 · station 3983312873`, SG1 red / SG2 amber, −63 dBm |
| Station (CAM) | `Station 3257224191 · Car`, 1.4 km/h, heading 19°, 25 m, −50 dBm |
Signal-group colouring, the DENM relevance radius from the GeoNetworking header, and per-station
RSSI all render correctly.
### Finding: the RSU's CAM decodes but is never displayed
The station list reads **"1 station(s) in range"** — only the CiT One (`3257224191`). The RSU
(`3983312873`) is absent, despite **611 of its CAMs decoding successfully** during the capture.
Cause: RSU CAMs are deliberately excluded from `UseCaseDetectionEngine` (a permanently stationary
station at a fixed point otherwise trips the stopped-vehicle use case continuously) — but
`remoteCamPositions`, which feeds both the station list and the map, is populated *by that engine*.
So the exclusion removes them from the display as well as from detection.
The RSU is not entirely invisible: it appears in the SPAT section as the intersection's station. But
its CAM-reported position is dropped on the floor. This is a defect introduced with the RSU CAM
decode fix earlier today, not a firmware problem — the firmware forwarded all 611 correctly.
**Fixed and re-verified the same session.** RSU CAMs are now tracked in a separate
`rsuStations` flow in the repository, merged with the engine's road users for display only, with a
15 s staleness window and a clear on link-down. Screenshot at 15:01 confirms:
```
2 station(s) in range - latest CAM per station
Station 2199514753 · Car 0.8 km/h · heading 192° 28 m -52 dBm
Station 440624502 · Roadside unit roadside unit - no kinematics reported
46 m -61 dBm
```
The kinematics line is suppressed for RSUs: their CAM carries none, so the zeroes in the model are
placeholders and printing "0.0 km/h · heading 0" would assert a stationary vehicle facing north.
The same station also drives the SPAT row, so the two views agree.
## T8 — Station ID rotation
Station IDs rotated **twice within one session**:
| time | RSU | CiT One |
|---|---|---|
| ~09:00 | 968482441 | 2880458775 |
| 14:45 | 3983312873 | 3257224191 |
| 15:01 | 440624502 | 2199514753 |
That is a rotation inside 16 minutes. Consequences for the app, none of them currently handled:
- **DENM dedup keys on ETSI actionID**, which contains the originating station ID. A hazard that
outlives a rotation will appear as a second, independent pin rather than an update of the first.
Both then persist until the 60 s TTL expires them.
- **The station list and map key on station ID**, so a rotation shows the same physical vehicle
twice for up to the 15 s window.
- **SPATEM is unaffected**, because it dedups on the intersection reference (`region/id`), which is
a property of the junction rather than the sender. That is the right key and it survives rotation.
Nothing here is a firmware issue, and pseudonym rotation is the intended privacy behaviour of the
transmitters. But any future logic that assumes a station ID identifies a physical unit over time
will be wrong.
## T9 — Stack fix and re-verification
`rx_item_t` was moved off both task stacks (`static` in the promiscuous callback and in
`rx_forward_task`), firmware reflashed, and the campaign re-run:
| | before fix (305 s) | after fix (125 s) |
|---|---|---|
| Total | 9.40/s | 8.73/s |
| CAM | 4.08/s | 4.11/s |
| SPATEM | 3.20/s | **3.98/s** |
| DENM | 2.12/s | 0.64/s |
| Decode failures / IO errors / crashes | 0 | 0 |
| Mutex timeouts | — | 0 |
SPATEM capture rose from ~80% to ~100% of the theoretical 4.00/s (2 Hz × two antennas). Not
attributable to the fix with confidence — RF geometry moves between runs — but it is the direction
stack pressure relief would produce, and worth re-checking on the next run. The DENM drop is the
CiT One's trigger being intermittent, not a receive problem.
### Finding: no automatic reconnect after re-enumeration
Reflashing resets the C5, which re-enumerates its USB device. The app did **not** recover: it went
to `Connection error - check the cable and native USB-C port, then try again` and stayed there until
Connect was tapped manually, followed by a fresh USB permission grant.
This matters more for the intersection use case than SPATEM does. On a bike, a jostled cable that
re-enumerates leaves the link dead until the rider notices and taps a button — a silent loss of the
CAM stream the use case runs on. The permission grant is a genuine one-time consent and cannot be
automated, but retrying automatically when a matching device is already attached would cover the
common case.
## Readiness
### Working
- All three received message types decode correctly from live over-the-air traffic
- Concurrent multi-station, multi-type reception with no interference between streams
- Sustained 5-minute run with no link drop, no I/O error, no crash
- RSSI plausible and discriminating between transmitters (−48 to −65 dBm at bench distance)
- No frame exceeded the serial payload cap under this traffic mix
### Scope decision (2026-08-25): Phase B dropped
Raising the payload cap was considered and **deliberately rejected**. The project goal is V2X
communication with at least one white-paper use case — incoming car at an intersection — working on
the ESP32. That use case is `IMA-B`/`IMA-S`, which `UseCaseDetectionEngine` drives entirely from CAM
kinematics; the engine contains **zero references to SPATEM or MAPEM**. Everything the goal needs
fits the current cap with margin: CAM 26–211 B, DENM 402 B, bench SPATEM 58 B, against a 498 B
budget.
Phase B would buy only road-RSU SPATEM/MAPEM — the add-on, not the goal — while putting a measured,
zero-failure chain at risk. The one component of it that *reduces* risk, moving `rx_item_t` off the
WiFi callback stack, was done separately (T9).
### Known ceiling, accepted
1. **Serial payload cap (512 B).** The bench RSU sends 58-byte SPATEMs, but the 2026-03-18 drive
measured real road RSUs at 555 B median and 1243 B max — **roughly 70% would be dropped as
oversize**. Raising `SERIAL_LINK_MAX_PAYLOAD` and `RX_FRAME_MAX_LEN` to ~1536 is required, and
forces item 2.
2. ~~**`rx_item_t` on the WiFi driver's callback stack**~~ — **fixed 2026-08-25**, see T9.
3. **DENM headroom is 96 B.** DENM matters to this project in a way SPATEM does not, and at 402 B
it is the closest message to the cap. A DENM carrying more optional containers than the CiT One's
HLN-SV currently sends would be silently dropped and counted as oversize. The `oversize` counter
on the CAM Pinger card is the thing to check if hazards ever stop appearing.
### Defect found and fixed during this test
- **RSU CAM positions were decoded but never displayed** (see T7). App-side; the firmware forwarded
all 611 correctly. Fixed and re-verified in the same session.
### Open, found by this test
- **Station ID rotation** (see T8) fragments DENM and CAM identity across a rotation. Not yet
handled.
### Untested here
- ~~**Link recovery**~~ — exercised by the reflash in T9: it does **not** auto-recover. See T9.
- **Sustained load at road rates.** This bench ran at 9.4 frames/s. The drive data implies 24–32
frames/s with frames 3× larger, where the TX-mutex interaction (400 ms worst-case hold vs the
1 Hz heartbeat and the phone's 3-beat dead-link timeout) becomes the thing to watch.
- **The link's actual ceiling**, which has never been saturated and so is unmeasured.
- **MAPEM** — nothing on air is transmitting it; no decoder written.
- **Secured messages** — 75 frames with GN `NextHeader=2` appeared in earlier pcaps; these are
rejected by design. The bench runs with `ItsGnSecurity = 0`.
### Recommendation
Ready for continued bench and short-range field work as it stands. **Not ready for a road drive
past real RSUs** until items 1 and 2 land, because the failure there is silent: oversize SPATEMs are
counted and dropped, so the symptom is "the intersection never appears" rather than an error.
## Reproducing
Capture: `adb logcat -d` filtered on `CamUseCaseRepo` while subscribed to `v2x/rx/#` on both
brokers. Decode cross-checks use `asn1tools` with the modules in `asn1/` — see `asn1/README.md`.
+107 -29
View File
@@ -2,78 +2,156 @@
Android companion app for the micrOBU; a compact V2X on-board unit developed by HAW Hamburg and consider it GmbH for vulnerable road users (cyclists, e-bike riders, pedestrians). Android companion app for the micrOBU; a compact V2X on-board unit developed by HAW Hamburg and consider it GmbH for vulnerable road users (cyclists, e-bike riders, pedestrians).
The app serves as the HMI for the micrOBU hardware, handling V2X message display, sensor data collection, trip recording, and OBU communication over USB-C, Wi-Fi (dev), and Bluetooth (upcoming). The app is the HMI for the OBU hardware and, on one of the two supported hardware paths, the entire V2X protocol stack. It handles V2X message display, use case detection, sensor collection, trip recording, and OBU communication over USB-C.
**Platform:** Android (Kotlin) · **Min SDK:** 29 (Android 10) · **Target SDK:** 36 **Platform:** Android (Kotlin) / **Min SDK:** 29 (Android 10) / **Target SDK:** 36 / **Version:** 0.5.0 (Phase 03)
## Project goal
Demonstrate V2X communication with at least one C2C-CC bicycle safety use case working on the ESP32-C5, specifically intersection movement assist: a car approaching an intersection on a path that conflicts with the rider's.
That use case is driven entirely from the periodic position and kinematics vehicles broadcast. It needs no traffic light state and no intersection lane geometry, which is why several scope decisions in this repository look deliberately narrow.
## Two hardware paths
The project began against the consider it CiT One and later added the ESP32-C5 as a second option. Both paths are supported at runtime and selected by the rider in Settings > OBU Hardware.
| | consider it CiT One | ESP32-C5 |
|---|---|---|
| What it is | Complete V2X on-board unit | Development board acting as a plain radio |
| Transport | IP over USB tethering, MQTT | Framed binary protocol over USB CDC serial |
| Reaches the phone as | Processed JSON | Raw ASN.1 UPER bytes |
| V2X stack lives | On the OBU | On the phone, except the radio and GeoNetworking |
| Own CAM generated by | The OBU, autonomously | The phone, transmitted on the phone's clock |
| DENM trigger | Available (manual test tool) | Not available |
| Needs a network | Yes, internally | No |
Both paths converge at `CamUseCaseRepository`, which normalises whatever arrived into the domain `Cam` type. Everything above that point, including the entire use case detection engine and all UI, is shared and transport-agnostic. That is what makes the ESP32-C5 a drop-in second OBU rather than a fork of the application.
## What it does ## What it does
**Real-time V2X monitoring**; subscribes to the OBU's MQTT broker and displays live CAM, DENM, SPAT, MAP, and CPM messages grouped by topic with pretty-printed JSON and TX/RX badges. **Real-time V2X monitoring**; live CAM, DENM and SPATEM with a station list, active hazards, live signal phase, and a map view. On the CiT One path the topic viewer additionally shows whatever the broker publishes, grouped by topic with pretty-printed JSON and TX/RX badges.
**DENM transmission**; triggers DENM use cases (e.g. stationary vehicle warning `hln-sv`) on the OBU via the consider it Use Case API (`v2x-uca/input/denmtrg`) with a single tap. **CAM-based use case detection**; correlates the rider's own state with a short per-station history of received CAMs to evaluate five C2C-CC bicycle safety use cases (IMA-B, IMA-S, RTW-B, LTW-B, SMVA/BCW-B) and raises alerts under the three-tier Info / Awareness / Warning model. None of these use cases generates a DENM.
**Sensor monitoring**; live readout of phone GNSS, accelerometer, gyroscope, magnetometer, and barometer alongside OBU GNSS for cross-reference. **Phone-generated CAM**; on the ESP32-C5 path the app builds a CAM from live GNSS and IMU, UPER-encodes it, and pushes it down the serial link for the board to broadcast over ITS-G5.
**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. **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 review**; past trips displayed on an OpenStreetMap layer with detected events overlaid as coloured pins. Tap any pin for event details. **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.
**CSV export**; every sensor sample written to a timestamped CSV in real time during a session. Shareable via the standard Android share sheet. **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.
## Architecture ## Architecture
MVVM with Repository pattern throughout. Jetpack Compose for all UI (no XML layouts). Hilt for dependency injection. MVVM with Repository pattern throughout. Jetpack Compose for all UI (no XML layouts). Hilt for dependency injection.
``` ```
ui/screens/ Compose screens (Dashboard, V2X Monitor, Sensors, Recording, Trip History, Settings…) ui/screens/ Compose screens (Dashboard, Record, Trips, V2X Monitor, Settings...)
ui/navigation/ Navigation graph and bottom nav bar ui/navigation/ Navigation graph and bottom nav bar
viewmodel/ MqttViewModel, SensorViewModel, TripRecordingViewModel viewmodel/ MqttViewModel, SensorViewModel, TripRecordingViewModel
data/mqtt/ MQTT repository, Paho client, exponential-backoff reconnection data/mqtt/ MQTT repository, Paho client, exponential-backoff reconnection
data/transport/ USB tethering detection and gateway IP resolution data/transport/ UsbSerialTransport, SerialFrame, UsbNetworkDetector, ObuHardware
data/db/ Room database (sessions, trips, detected events) data/cam/ CamUseCaseRepository; where both hardware paths converge
data/ SensorRepository, TripRepository, CsvExporter data/db/ Room database (sessions, trips, detected events, V2X messages)
data/ SensorRepository, TripRepository, CsvExporter, TripExporter
domain/asn1/ BitReader/BitWriter and the CAM, DENM and SPATEM UPER codecs
domain/usecase/ UseCaseDetectionEngine, UseCaseDetectionConfig, AlertLevel, GeoMath
domain/detection/ EventDetector, RunningStats sliding window (orientation-independent) domain/detection/ EventDetector, RunningStats sliding window (orientation-independent)
service/ TripRecordingService (foreground service) domain/cam/ Cam, CamParser, PhoneCamBuilder, CamTransmitConfig
service/ TripRecordingService, CamTransmitLoop, CamPinger
obu-firmware/ ESP32-C5 firmware (serial link, GeoNetworking, 802.11 OCB, raw TX)
asn1/ Vendored ETSI ASN.1 modules the codecs are verified against
``` ```
The `domain/` packages contain no Android imports. That is what makes the 41-test JVM suite possible without an emulator or instrumentation.
## Connectivity ## Connectivity
The app uses a phased transport strategy. The MQTT client, topic subscriptions, and all UI are identical across transports; only the underlying network path changes. USB-C on both hardware paths. Bluetooth is **not implemented** and remains an open question in the requirements.
| Phase | Transport | Status | | Transport | Path | Status |
|---|---|----------| |---|---|---|
| Phase 01 | Wi-Fi | Complete | | USB-C tethering (IP + MQTT) | CiT One | Active |
| Phase 02 | USB-C tethering | Active | | USB-C serial (framed binary) | ESP32-C5 | Active |
| Phase 03 | Bluetooth BLE | Future | | Wi-Fi | CiT One | Developer builds only |
| Bluetooth | Either | Not implemented |
The MQTT broker runs on the OBU hardware (Mosquitto 2.0.11, port 1883). In Phase 02, Android USB tethering exposes the OBU as a virtual Ethernet interface at `192.168.42.x`. The app auto-detects the gateway IP on plug-in. On the CiT One path the MQTT broker runs on the OBU (Mosquitto 2.0.11, port 1883); Android USB tethering exposes it as a virtual Ethernet interface at `192.168.42.x` and the app auto-detects the gateway IP on plug-in. On the ESP32-C5 path there is no network layer at all: a private framed protocol runs over the board's native USB-C port as a CDC-ACM device.
## Verification
The app hand-encodes and decodes ETSI messages bit by bit on the ESP32-C5 path, which is the highest-risk code in the project. Round-trip tests through the project's own codecs structurally cannot catch a shared mistake about a field's bit width, and this project shipped exactly that bug three times (`CurvatureCalculationMode`, the GeoNetworking reserved bytes, `yawRateConfidence`). Phone and ESP32 agreed with each other and with nothing else.
Verification therefore uses an independent oracle: `asn1tools` compiled from the ETSI modules vendored in `asn1/`.
- **Golden-byte fixtures** assert exact encoder output, with expected values produced by the oracle rather than by this encoder.
- **Bulk replay** compares every field over real captures: 79,042 SPATEMs and 1,885 DENMs, zero mismatches.
- **Off-air confirmation**: 26 of this project's own CAMs captured back by an independent receiver, all accepted.
Never regenerate a golden fixture from this project's own encoder output. See `asn1/README.md`.
## Documentation
| Document | Audience |
|---|---|
| [docs/MicrOBU-User-Guide.docx](docs/MicrOBU-User-Guide.docx) | Riders. Setup, screens, what the alerts mean, troubleshooting |
| [docs/MicrOBU-Technical-Documentation.docx](docs/MicrOBU-Technical-Documentation.docx) | Supervisors and stakeholders. Architecture, message path, verification, results, decisions |
| [docs/01-requirements-traceability.md](docs/01-requirements-traceability.md) | Requirements chapters 0 to 13 mapped to implementation and evidence |
| [05-obu-bench-test-2026-08-25.md](05-obu-bench-test-2026-08-25.md) | Bench campaign T1 to T9, measured results |
| [04-transmit-setup.md](04-transmit-setup.md) | Transmitter bring-up, radio configuration diagnosis, the TX bypass |
| [obu-firmware/FLASHING.md](obu-firmware/FLASHING.md) | Toolchain setup, flashing, phone-to-board bring-up checklist |
| [asn1/README.md](asn1/README.md) | ASN.1 module provenance and the fixture regeneration rule |
| [docs/references.bib](docs/references.bib) | Standards references as BibTeX |
## Key dependencies ## Key dependencies
| Library | Purpose | | Library | Purpose |
|---|---| |---|---|
| Jetpack Compose + Material3 | UI | | Jetpack Compose + Material3 | UI |
| Eclipse Paho MQTT | OBU communication | | Eclipse Paho MQTT | CiT One path communication |
| usb-serial-for-android | ESP32-C5 path communication (custom probe table for Espressif VID/PID) |
| Room | Local database | | Room | Local database |
| Hilt | Dependency injection | | Hilt | Dependency injection |
| OSMDroid | Trip review map | | OSMDroid | Trip review and live V2X map |
| DataStore | Settings persistence | | DataStore | Settings persistence |
| FusedLocationProviderClient | GNSS | | FusedLocationProviderClient | GNSS |
## Getting started ## Getting started
1. Open in Android Studio (Hedgehog or newer). 1. Open in Android Studio and build the `app` module. Gradle 8.10.2.
2. Connect a device running Android 10+ (API 29). 2. Connect a device running Android 10+ (API 29).
3. Build and run the `app` module. 3. Choose your hardware in Settings > OBU Hardware.
4. For Phase 02 testing: plug the phone into the OBU via USB-C, enable USB tethering on the phone, and the app will detect the interface and connect automatically. Broker IP can be overridden manually in Settings → Connection.
5. For standalone trip recording: no OBU required. Go to the Record tab and tap Record.
The Wi-Fi transport (Phase 01 broker at `192.168.3.202`) remains available in developer builds and can be toggled in Settings → Developer. **CiT One path.** Plug the phone into the OBU via USB-C, enable USB tethering on the phone, and the app detects the interface and connects automatically. Broker IP can be overridden in Settings > Connection.
**ESP32-C5 path.** Flash `obu-firmware/` (see `obu-firmware/FLASHING.md`), then plug the phone into the board's **native** USB-C port, not the UART bridge port used for flashing. Tap Connect and grant the USB permission. Use the CAM Pinger on the V2X screen to verify the link and radio without starting a trip.
**Standalone trip recording.** No OBU required. Go to the Record tab and tap REC.
The Wi-Fi transport (broker at `192.168.3.202`) remains available in developer builds via Settings > Developer.
Firmware and app must be flashed and installed together: `SERIAL_LINK_MAX_PAYLOAD` is 512 on both sides and a mismatch silently rejects every large frame.
## Status and known limitations
Bench verified against live ITS-G5 traffic on 2026-08-25: 2868 frames over 305 seconds, zero decode failures, zero USB errors, zero crashes. Not yet road validated.
- **Requirement 11.6, Phase A success criteria, is not met.** The bench proves reception. It cannot prove the use case behaves correctly with two genuinely moving stations, because nothing on the bench moves. This is the main open evidence gap for the project's central claim.
- **No message signing.** ETSI TS 103 097 is out of scope. Secured frames are rejected rather than mis-parsed.
- **Detection thresholds are untuned engineering estimates**, not calibrated against real intersection data.
- **512-byte serial payload cap.** Roughly 70% of real road RSU SPATEMs would be dropped as oversize. Accepted deliberately: the intersection use case is CAM-driven and needs none of it.
- **No automatic reconnect** after USB re-enumeration; requires a manual Connect.
- **Station IDs rotate**, so they cannot identify a physical unit over time.
- **No backend, no login.** Everything is on-device.
- **No MAPEM decoder**, so signal groups cannot yet be associated with the rider's lane.
## Project context ## Project context
The micrOBU project is funded under the ZIM program (BMWK) and targets micromobility users in Hamburg. The companion app offloads processing from the compact OBU hardware to the smartphone; GNSS fusion, event detection, and future antenna coordination all run on the phone to keep the OBU lightweight and power-efficient. The micrOBU project is funded under the ZIM program (BMWK) and targets micromobility users in Hamburg. The companion app offloads processing from the compact OBU hardware to the smartphone; GNSS fusion, event detection, and on the ESP32-C5 path the full ASN.1 encoding and decoding all run on the phone to keep the OBU lightweight and power-efficient.
V2X communication uses ITS-G5 (IEEE 802.11p / DSRC) at 5.9 GHz. The app communicates with the OBU exclusively via the consider it MQTT API v6 (processed JSON messages); no ASN.1 encoding in the app. V2X communication uses ITS-G5 (IEEE 802.11p) at 5.9 GHz. On the CiT One path the app communicates via the consider it MQTT API v6 (processed JSON). On the ESP32-C5 path the app performs its own ASN.1 UPER encoding and decoding against the ETSI modules vendored in `asn1/`.
**Owner:** HAW Hamburg **Owner:** HAW Hamburg
+365
View File
@@ -0,0 +1,365 @@
# TODO
Engineering to-do list. The reviewer-facing open items live in
`docs/01-requirements-traceability.md` ("Open items"); this file is the working list behind them.
## Waiting on hardware
### Signed-TX firmware (vanetza-idf port), VAM and BLE: first on-air checks (added 2026-09-23)
obu-firmware is now a port of the colleague's `microbu-esp32c5` station (vanetza-idf, TS 103 097
signing, station-link protocol, BLE GATT), built with **ESP-IDF 6.0.2**. See `obu-firmware/NOTES.md`.
The previous firmware is backed up in `firmware-backups/` (restore command in its README.txt).
The app speaks the new protocol over USB or BLE and still falls back to the old frames against the
old firmware.
Done without hardware: IDF 6.0.2 build clean (39 % app partition free); host suite (`make` in
`obu-firmware/test/host`) passes unchanged; app unit tests 103/103, including the VAM encoder
against asn1tools, the station-link codec against the colleague's Python `messages.py`, and the VAM
generation rules. Flashed to **COM3** 2026-09-23 (hash verified); boot log: IDF v6.0.2,
`BLE advertising started as 'micrOBU-4AFA'`, station task ready. The radio stays off until the app
configures the station. New app build installed on the Pixel 9 Pro (adb, `install -r`).
First phone session (user, 2026-09-23): BLE works and CAMs go out. Three faults, fixed and
reflashed/reinstalled the same day:
1. No RX until the CAM pinger ran, with ~177 RX-queue drops: the colleague's `Station::tick()`
returned before draining the radio until the first PoTi had set the clock. Now drained always.
2. "refused a request: time_regression": the loops re-send the latest fix every tick; a stale fix
timestamp read as the clock going back > 1 s, and each time the board rebuilt its stack.
`Esp32Link` now sends a PoTi only for a newer fix (or a >= 60 s real clock correction).
3. BLE reconnect loop: GATT operations with a 5 s timeout ran during Android's pairing, cut it off
and restarted it on every attempt. Encryption/pairing is now settled first (60 s), retries back
off to 30 s, and every failure reason is logged and shown; the board logs encryption changes.
Second session (user, 2026-09-23): USB, RX and signing work; BLE still prompted every time and
never connected; time_regression every ~8 s. Found and fixed, reflashed (full flash, NVS erased):
4. The board never stored a bond: NVS (24 KB, the colleague's 4 MB-board layout) was full, mostly
Wi-Fi settings the previous firmware left behind, and NimBLE's bond write failed. NVS is now
80 KB (app moved to 0x20000) and was erased; boot logs `N bonded phone(s) in NVS`.
5. The station loop waited `pdMS_TO_TICKS(5)` = 0 ticks at 100 Hz, so it spun on the single core
(task watchdog: IDLE starved). Now waits at least one tick.
6. The phone clock is ~14 min fast; GnssTimeSource fell back to it whenever GNSS time blinked out
indoors, so every transmitted timestamp (CAM generationDeltaTime too) jumped 14 min back and
forth. It now keeps the last measured error.
Watch COM3 (`idf.py -p COM3 monitor`, or `readlog.py`-style with DTR/RTS low) during these; it only
resets the board, the phone is on the other port.
- [x] **USB session.** Settings > Connection > ESP32-C5: link USB-C, transmit CAM, signing on.
Phone on the native port, Connect. Expected: the card shows "Provisioning the demo credentials"
once, then Connected and `Signing on · tickets 1 · signed N` with N rising while recording.
COM3: `radio on channel 180, transmit and receive`, `tx power: … dBm`,
`credentials provisioned: 1 roots, 1 authorities, 1 tickets`, and no `radio refused a frame`.
Confirmed by the user 2026-09-23: connects, signing works.
- [x] **Reception intact.** Same session, sim car (COM8) beaconing: its CAMs (station 195936478) on
the V2X map at ~3 Hz as before. Then put a DENM and a SPATEM on air: both show up (they come
through the raw V2X_RX path; the vanetza stack drops them because they are not demo-signed).
Confirmed 2026-09-23: sim car and the RSU's CAM/SPATEM/MAPEM arrive; DENM not yet re-tested.
- [x] **Signed CAM on air** (2026-09-23, CAM pinger over BLE, signing on). Recorded 25 s through
the V2X2MAP bridge's `/api/record` (`micrOBU_workspace/v2x-obu-esp32c5/signed-cam-check.pcap`)
and checked with the new `obu-firmware/test/verify_signed_pcap.py` (asn1tools + OpenSSL, no
vanetza code): 12/12 secured CAMs, station 999999, psid 36, signer = full certificate of the
demo AT `B80B49387A4C12EB`, **all signatures valid**, COER canonical, and the bundle's chain
(AT <- AA <- root) verifies. The CiT One (192.168.40.201) also receives them (~1 Hz on
`v2x/rx/cam`), i.e. a third-party stack unwraps our 1609.2 envelope; its MQTT API exposes no
security fields, and it forwards unsigned and unknown-root messages alike, so it cannot say
whether it verified them. The same capture showed generationTime wobbling by seconds, with
`time_regression` still firing: fixed in Esp32Link (the PoTi never moves the micrOBU's clock
back except for a >= 60 s correction). Re-check: no "restarted its stack" lines in logcat.
- [x] **Unsigned toggle.** Signing off: the same capture shows next header 1 (common header), as
the previous firmware sent. The card's `signed` count stops rising.
Confirmed 2026-09-23: unsigned pinger CAMs show on V2X2MAP as unsigned.
- [x] **Signed VAM on V2X2MAP.** Since 2026-09-23 17:16 the COM10 bridge is the colleague's
v2x2map-0.3.0 from source with a new `verify.py` and `--trust demo-chain.vcr` (launcher:
`micrOBU_workspace/v2x-obu-esp32c5/start-v2x2map-signed.bat`, replacing its-g5-bridge.exe).
Signed CAMs from the pinger already show "signature verified" live. Switch to VAM with signing
on: the VAM must be decoded (cyclist, position) and show "signature verified" too.
Confirmed by the user 2026-09-23: signed VAMs decode and verify.
- [x] **VAM.** Transmit VAM: BTP port 2018, psid 638; with `tools/wireshark/psid-vru.lua` from the colleague's microbu-esp32c5 repository
Wireshark decodes the VAM (stationType cyclist, bicyclist profile in every ~2 s VAM). Rate:
≥1 per 5 s standing still, about one per GNSS fix while riding.
Covered by the V2X2MAP check above (decoded VAM, psid 638); Wireshark not needed.
- [x] **RX without recording.** Connect only (no recording, no pinger): sim-car CAMs appear and
the RX-queue drop counter stays at 0 or near it.
Confirmed 2026-09-23: messages come in on connect alone.
- [ ] **No time_regression.** Record for a few minutes standing still indoors: no "refused" line on
the card, and COM3 never logs `ITS time moved back`.
- [x] **BLE after the NVS fix.** First forget micrOBU-4AFA in Android's Bluetooth settings (the
phone still holds the bond the board lost). Then Connect, passkey 123456 once; a second
Connect after an app restart must not prompt again, and COM3's next boot must say
`1 bonded phone(s) in NVS`.
Confirmed 2026-09-23: pairs once, reconnects after an app restart.
- [x] **BLE.** Link Bluetooth, unplug USB, Connect. Android asks to pair with micrOBU-4AFA:
passkey 123456. Expected: Connected, CAMs keep going, sim-car CAMs keep arriving.
While USB is plugged in and in use, the phone's BLE scan must not see micrOBU-4AFA
(COM3: `USB link in use: BLE advertising paused`). If it loops again, the card now says why;
"refused this phone's stored pairing" means forget micrOBU-4AFA in Android and pair again.
COM3 shows `encryption change status=...` for the board's side.
Confirmed 2026-09-23: CAMs and VAMs out, reception in, over BLE.
- [ ] **BLE/ITS-G5 coexistence (the unmeasured risk from the hardware review).** With BLE
connected, count the sim car's CAMs received per minute and ours at the sniffer; compare
with the same over USB. A clear drop, or reception stopping altogether, means the coex
arbiter takes the radio off 5900 MHz (our channel is set behind the driver's back with
`phy_change_channel`). Then BLE cannot be used while receiving, or needs a longer connection
interval.
- [ ] **Board reset recovery over BLE.** Press RST mid-session: the app reconnects by itself and
reconfigures on the first STATUS saying `not configured`, with no manual Connect. (Over USB a
reset re-enumerates the port and needs a manual Connect, as before.)
### Confirm the RX queue drop counter explains the bench-session frame drops / map flicker (added 2026-09-22)
Investigated the user's report of "OBU mode keeps dropping a few frames" and "v2x screen comes
and goes" while bench-testing against `obu-cam-transmistter`. Found a real, previously invisible
drop path: `obu-firmware/main/main.c`'s `wifi_promisc_rx_cb()` calls `xQueueSend(s_rx_queue, ...,
0)` (queue depth 8) without checking the return value, so a burst of promiscuously-captured
frames arriving faster than `rx_forward_task` can drain them (each drain can legitimately block up
to ~400ms under USB/UART contention) silently vanishes. None of the existing `EspLinkStatus`
counters (`oversizeDrops`/`txFailures`/`rxCrcErrors`) caught this class of drop.
This plausibly also explains the map symptom: `UseCaseDetectionEngine.pruneStale()` drops a remote
station's marker after `staleRemoteMs` (3 s) with no CAM update. Measured 2026-09-22 via
`tools/cit_one_rx_watch.py --host 192.168.40.201` against `obu-cam-transmistter`'s bench beacon
(stationID 195936478 / 0x0BADC0DE): **75 CAMs in 25 s, ~3 Hz**, not the 1 Hz this note assumed
earlier — faster than assumed means more promiscuous captures per second and a shorter fuse on
`staleRemoteMs`, both of which make the queue-overflow theory more likely, not less.
Fixed to be **visible**, not yet fixed to **not drop**: added a `rxQueueDrops` counter, checked
`xQueueSend`'s return value (`main.c`), wired it through the STATUS heartbeat as a new trailing
`uint16` field (`serial_link.c/.h`, `SerialFrame.kt`'s `EspLinkStatus`), and surfaced it on the
CAM Pinger card (`MqttTopicViewerScreen.kt`, string `mqtt_cam_pinger_fw_counters`). Host build
untouched (serial_link.c/main.c aren't in the host test's standard-headers-only set); IDF build
verification is the remaining pre-flash check. Deliberately did NOT bump `s_rx_queue`'s depth from
8 — no real burst-size data yet, and guessing a bigger number against an unmeasured memory budget
is exactly the kind of assumption [[microbu-hw-review]] flags as needing verification first, not
capacity that's cheap to reason your way into.
Needs: a phone attached to the production OBU's native USB port, watching the CAM Pinger card,
while `obu-cam-transmistter` (or real traffic) beacons.
- [x] `idf.py build` succeeds (obu-firmware, IDF 6.1) — clean, both changed files compiled with no
warnings, 17% flash free.
- [x] Reflashed the production OBU on **COM3** 2026-09-22 (hash verified). Boot log confirms the
new build (`21e0149-dirty`, compiled Sep 22 2026 14:14:09), clean boot, OCB @ 5900 MHz
TX/RX armed, `serial_link up ... 1 Hz heartbeat`, no panic. Incidentally answers part of the
"measure the OBU's actual transmit power" item below: this boot logged
`tx power: 72 quarter-dBm = 18.00 dBm (20.00 requested)` — the driver **is** clamping below
the requested 20 dBm at 5900 MHz, as that item suspected but had not measured.
- [x] 25 s of steady-state console (no phone attached, `obu-cam-transmistter` beaconing nearby):
silent — no crash, no `oversize`/`rx queue full`/`crc` warnings. Inconclusive on its own
(successful forwards aren't logged, and nothing was attached to trigger the ~400 ms UART
stalls the theory needs), but at least rules out a crash-on-boot regression.
- [x] Confirmed the wider bench RF path independently via the CiT One OBU broker
(`py -3.11 tools/cit_one_rx_watch.py --host 192.168.40.201`): heard `obu-cam-transmistter`'s
beacon cleanly, 75/25 s, GN source `14:00:02:00:00:00:00:01`, position in the expected
St. Georg route area. This is a *different* receiver from the production OBU though — it
shows the beacon is genuinely on air, not that COM3 forwards every one of it without drops.
- [x] **Confirmed on real hardware, 2026-09-22.** Installed the updated debug APK (previous build
on the phone was from 2026-09-15, predating this fix entirely) on the Pixel 9 Pro (adb over
Wi-Fi), relaunched against the freshly-reflashed COM3, and read `rx queue drop` via `adb
logcat -s UsbSerialTransport`. The counter mechanism works end-to-end and **the bug is
real**: `rxQueueDrops` was 0 at the last flash (14:22), read as 89 at first reconnect
(14:48, ~26 min later), and 90 at a second reconnect (14:52). No `oversizeDrops`,
`txFailures`, or `rxCrcErrors` moved at all, and zero `decode FAILED` lines — this queue is
the only place frames are going missing.
Nuance: over a clean ~4.5 min window in between (14:48→14:52) with `obu-cam-transmistter`
actively beaconing at a measured **~3.33 Hz** (matches the CiT One's 75/25 s independently)
and 490+ CAMs decoding cleanly with steady cadence and no gaps, the counter did **not**
move — it only ticked at connect/reconnect moments. So this is a low-rate, bursty drop (matches
the user's own "a few frames" framing), not a continuous overflow under steady single-station
traffic; it may be specific to WiFi/PHY activity around association or reconnect rather than
raw beacon rate. Worth a longer, quieter-boot capture before sizing a `s_rx_queue` bump.
Did **not** independently confirm the map-flicker connection this session — that needs eyes
on the app's V2X screen while watching this same counter live, not just logcat.
### On-device check of the full-screen V2X live map (added 2026-09-15)
The live map moved out of the V2X Monitor's view-mode row into its own full-screen destination
(`V2xMapScreen`, route `v2x_map`), reached from the map button in that screen's header. Markers are
now cached and reused across updates instead of being rebuilt on every incoming message, and
SPATEM intersections are drawn as traffic lights at the position of the RSU's own CAM. All of that
compiles and the unit tests pass, but none of it has been seen with live traffic.
Needs: the phone with the app, plus a CAM/DENM/SPATEM source - either the CiT One, or the OBU
ESP32-C5 with a second board or a real RSU transmitting.
- [ ] Both hardware modes: tap the map button, confirm the map fills the screen (no status bar, no
bottom nav) and the back button returns to the V2X Monitor.
- [ ] Panning stays smooth while CAMs are arriving - this is what the marker reuse is for. Compare
against the old behaviour if it still judders.
- [ ] Touching the map stops it recentring; the location FAB resumes follow and lights up.
- [ ] A DENM shows the warning triangle, and a SPATEM intersection shows a traffic light with the
lamp matching the Dashboard's SignalCard for the same intersection.
- [ ] Near a real RSU: confirm the RSU is drawn once, as a traffic light, not as a CAM pin with a
light on top of it. If the RSU sends SPATEM but no CAM, the "signals not shown" note should
appear instead - worth knowing which of the two the HAW RSUs actually do.
### Over-the-air check of the GN lifetime fix (added 2026-09-11)
`geonet.c` now writes GN lifetime `0x05` (1 s) instead of `0x83`, which decoded to 3200 s. Changed
in both `obu-firmware` and `obu-cam-transmistter`. Both still build (IDF 6.1 / 5.5.4), and the
compiled `geonet_wrap_shb` stores the new byte, but it has not been seen on air yet. Nothing else
reads this byte (`gn_unwrap.c` ignores it, the app never sees GN headers), so the app does not
need updating alongside the firmware.
Needs: the phone with the app, the OBU ESP32-C5, and a **second** ESP32-C5 running
`its-g5-receiver-firmware` to capture with.
- [ ] Flash `obu-firmware` (see `obu-firmware/FLASHING.md`).
- [ ] Connect the phone, let it send CAMs, and confirm the CAM Pinger's `tx fail` counter stays 0.
- [ ] Capture with the receiver into `its-g5-receiver-firmware/recordings/`.
- [ ] Run `python obu-firmware/test/pcap_gn_tally.py its-g5-receiver-firmware/recordings/<capture>.pcap`.
The rows for the phone's pseudonym MACs must show SHB, port 2001, lifetime `0x05`, exactly
like every other station's CAMs.
- [ ] While the phone is connected: real-station CAMs/DENMs still reach the app (RX path unchanged).
Partial check possible with one board and no phone: flash it, `idf.py -p COMx monitor`, and look
for `OCB @ 5900 MHz - TX/RX armed`. That proves the new build boots and brings the radio up, not
that it transmits correctly.
### Measure the OBU's actual transmit power (added 2026-09-14)
Nothing in this project has ever measured it. `main.c` asks for 20 dBm
(`esp_wifi_set_max_tx_power(80)`, 0.25 dBm units) and the build's ceiling is the same
(`CONFIG_ESP_PHY_MAX_TX_POWER=20`), but a request is a ceiling, not a guarantee: the driver clamps
it to its own calibrated table, and 5900 MHz is above the range this chip is rated for, so the
table actually in use is channel 177's. The firmware now reads the value back and logs it at boot,
which records what the driver admits to, not what leaves the antenna.
- [ ] Flash and `idf.py -p COM3 monitor`, then note the `tx power:` line. A value below 80 means
the driver clamped the request, which the code alone cannot tell you.
- [ ] Relative check with the second ESP32-C5 on `its-g5-receiver-firmware`: capture at a measured
distance in a straight line, read the RSSI the receive path already reports, and record
distance and RSSI together. This gives a comparable number between builds and antennas,
which is what matters for range work, without any lab equipment.
- [ ] Only a spectrum analyser or a calibrated reference receiver gives real radiated power. Worth
it only if the range result looks wrong, or if the thesis needs an absolute figure.
For context: ETSI allows up to 33 dBm EIRP on the ITS band, and production OBUs sit around
20 to 23 dBm, so the requested figure is in the right region if the PA really keys it there.
### obu-cam-transmistter yawRateConfidence fix (added 2026-09-11)
Its `cam.c` (compiled into that firmware) wrote `yawRateConfidence` as 3 bits / 7 instead of
4 bits / unavailable(8), the bug the app fixed on 2026-08-20. Fixed in it and in obu-firmware's
reference copy; asn1tools now decodes the CAM and re-encodes it byte-identically, and it builds on
IDF 5.5.4. No board runs this firmware right now (the production OBU runs obu-firmware), so this
only matters if it is flashed again:
- [ ] After flashing it: capture, run `pcap_gn_tally.py`, and decode the CAM payload with
asn1tools (`py -3.11`, modules in `asn1/`).
### Signed-message reception and exact payloads (added 2026-09-11)
obu-firmware's `gn_unwrap.c` now unwraps TS 103 097 signed packets (signature not verified,
reported as V2X_RX flags bit1) and cuts every message to the length its header declares, dropping
the 8 bytes the chip's RX appends to each frame, which were forwarded to the phone until now.
Verified on the host (`obu-firmware/test/host`: chain, replay of all recordings against asn1tools,
50M-iteration fuzz) and built on IDF 6.1, but not flashed: the production OBU still runs the
2026-09-10 build. Needs the OBU with this build, the phone, and signed traffic - real vehicles or
RSUs, since the bench CiT One sends unsigned. A second ESP32 running the receiver firmware is
optional, but shows what was on air at the time.
- [ ] Flash obu-firmware (this also carries the GN lifetime fix above).
- [ ] Near signed traffic: signed CAMs/DENMs appear in the app, and a simultaneous capture shows
them on air (`pcap_gn_tally.py` lists them as `secured`).
- [ ] Unsigned bench traffic still decodes in the app as before (messages now arrive 8 bytes
shorter).
- [ ] The heartbeat's oversize counter still counts over-long messages (e.g. road SPATEMs).
### CiT One custom CAM injection over `v2x/tx/v2/cam` (added 2026-09-14)
The haw-002 unit now runs the special firmware: Cohda's own CAM transmission disabled, and a
V2X-Gateway build that accepts a `SendV2XMessage` (schemas.consider-innovation.de/its-s/
v2x_interface.proto) carrying a UPER CAM on `v2x/tx/v2/cam`. `tools/cit_one_cam_tx.py` builds
and publishes those from a PC; its `--self-test` passes offline, proving only that the bytes
match `CamEncodeGoldenTest.kt` and that the protobuf wrapper round-trips. Nothing about what
the OBU does with them is established.
Reach the broker over Wi-Fi or Ethernet for now - the USB-peripheral-mode link needs the phone
to be USB host on a `172.25.1.0/24` interface with no DHCP server, which Android cannot
configure from inside an app.
Needs: the CiT One haw-002 on the same network as a PC, and a second ESP32-C5 running
`its-g5-receiver-firmware` sniffing G5CC (`-c 5900`) to capture with.
Bench run 2026-09-14, PC -> haw-002 (192.168.3.201), captured on the RSU (192.168.3.202,
**not** .2.202 - that address does not route). `tools/cit_one_rx_watch.py` decodes what a unit
hears. Result: the injection path works end to end, with one blocker found.
- [x] Publishes without the broker refusing the topic. 1.00 Hz, confirmed by subscribing to
`v2x/tx/v2/cam` on the OBU itself.
- [x] The RSU hears our CAMs on air, 1.00 Hz, matching what we publish.
- [x] `ItsPduHeader` **is** expected in the payload - we send it included and it decodes.
- [x] BTP destination port 2001. GN source address `08:00:26:93:92:01:91:dc`, the OBU's.
- [x] **Our CAM content goes out intact**: position, speed (417), heading (639), width (7) and
length (18) arrive byte-exact. The gateway does not touch the content.
- [x] **The gateway overwrites `stationID`** with the OBU's own (999999 -> 4033890855, which
matches `own_info.stationID` on `v2x/rx/obu_gnss`). This is what the "OBU owns identity"
decision wants, so `--follow-obu-identity` is not needed on this unit.
- [x] ~~BLOCKER: Cohda's own CAM is still transmitting.~~ Fixed 2026-09-14 by disabling CAM in
a second conf file: the RSU now hears only our stream, 0 CAMs with the stack's
unavailable dimensions over 30 s. Note the stack restart gave the unit a new identity
(stationID 4033890855 -> 2553426533, GN source `08:00:26:...` -> `08:00:a2:...`), which is
expected under `ItsGnLocalAddrConfMethod = 2` (anonymous, random at boot).
- [x] Re-checked: 41 published / 41 heard over 40 s, 1.02 Hz both ends, inter-arrival a steady
1.0 s. 100% delivery, no gateway rate limiting. An earlier 0.40 Hz sample was the stack
still settling after the restart and did not persist.
Two topics the v6 API does not document, found by subscribing to `#` on haw-002:
- `v2x/loopback/cam` - a `RecvV2XMessage` (btpHeader.type=2) carrying each CAM the unit
transmits, 1:1 with what we publish and **after** the gateway's stationID rewrite. This is the
TX confirmation we were going to ask consider it for: it makes "did my CAM go out, and under
which identity" answerable on the transmitting unit alone, without an RSU or a second ESP32.
- `v2x/rx/obuinfo` at 10 Hz - the protobuf `OwnStationInfo` (binary twin of `obu_gnss`;
field 2 decodes to the same stationID, field 10 to the same heading). Output only, so it is
not the content-feed input we speculated about.
- [ ] Wire `v2x/loopback/cam` into `cit_one_rx_watch.py` as a local TX check.
- [ ] Sanity-check the rate: `--rate 4` should produce 4 CAMs/s on air, since `ItsDCCEnabled = 0`
on this unit.
## Set up host testing
- [x] Install MSYS2 UCRT64 gcc (done 2026-09-11: gcc 16.2.0, GNU Make 4.4.1; chosen over WSL,
vanetza is not going to be built). Setup and the PATH gotcha: `obu-firmware/test/host/README.md`.
- [x] Host round-trip test `obu-firmware/test/host/test_chain.c` (`geonet_wrap_shb` ->
`dot11p_build_frame` -> `gn_unwrap_its`, byte-checked against the standard). Done
2026-09-11: 491 checks, 0 failed. Run `make` in that folder before flashing any firmware fix.
- [x] Replay of the recorded captures (`test_replay.c` + `check_replay.py`, independent asn1tools
check). Done 2026-09-11: C and Python agree on all 15 145 records.
- [x] Mutation fuzzer `fuzz_gn_unwrap.c`, inputs against a no-access guard page. Done 2026-09-11:
50 000 000 iterations, no crash. `make` runs a 2 000 000-iteration pass every time.
## Firmware ideas from the vanetza review (2026-09-11, not started)
Suggested order after the host tests exist:
- [x] **Read secured packets (GN NextHeader=2) without verifying them.** Done 2026-09-11 in
`gn_unwrap.c`, host-verified; flagged to the phone as V2X_RX flags bit1. On-air check under
"Waiting on hardware".
- [ ] **Forward the full GeoBroadcast area**: shape (circle/rectangle/ellipse), DistanceB, angle,
appended to the V2X_RX prefix behind a capability bit. Port vanetza's `geonet/areas.cpp`
`inside_or_at_border` to the app, which currently treats every area as a circle.
- [ ] **RX filtering before the serial link**: duplicate detection for GBC (last 8 sequence numbers
per source, as vanetza does), drop our own frames, reject GN version != 1.
- [ ] **Read the DCC-MCO field** (the 4 "reserved" bytes of an SHB header): neighbours' channel
busy ratio for free.
- [ ] **Minimum TX gap in firmware** as a DCC safety net (vanetza reactive table: 60 ms relaxed ...
460 ms restrictive), with a CBR estimate in the heartbeat.
- [ ] **Generic V2X_TX message** (BTP port, SHB/GBC, traffic class, lifetime, area) so the phone can
send DENM and VAM without reflashing. Consider QoS Data frames: vanetza's Cohda receive path
drops non-QoS ones.
Dropped: building vanetza as a GN/BTP oracle. Real captures (`pcap_gn_tally.py`), the host
round-trip test and `asn1tools` for UPER cover what it would have checked.
## Follow-ups found 2026-09-11
- [ ] **App: show the signed flag.** `V2xRxFrame.parse` in `SerialFrame.kt` only reads bit0 of
the flags byte; read bit1 (signed, not verified) and show it where messages are listed.
- [ ] **Messages that do not decode with asn1tools.** In the recordings, 56 from the CiT One
(`aa:f8:76:7d:bd:ad`: 54 CAMs of 245 bytes, 2 DENMs of 402 bytes) and one 218-byte CAM from
`6e:94:03:1b:05:26` fail against `cam_1_4_1`/`denm_1_3_1` + `cdd_1_3_1_1`, with or without the
old trailing bytes. A newer module version on the sender, or a sender bug; check what the
app's decoders make of them (`check_replay.py` lists the records).
+3
View File
@@ -80,5 +80,8 @@ dependencies {
// ── Unit tests (JVM, no emulator required) ──────────────────────────────── // ── Unit tests (JVM, no emulator required) ────────────────────────────────
testImplementation(libs.junit) testImplementation(libs.junit)
// Real org.json for JVM unit tests: the android.jar stub throws "not mocked" on every
// JSONObject call, which would make the MQTT payload parsers untestable off-device.
testImplementation(libs.json)
testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.kotlinx.coroutines.test)
} }
+11 -1
View File
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Networking (MQTT / tile downloads) --> <!-- Networking (MQTT / tile downloads) -->
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
@@ -10,6 +11,15 @@
<!-- Location --> <!-- Location -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<!-- BLE link to the ESP32-C5 (BleLinkTransport). Android 12+ asks for scan/connect at runtime;
older versions use the legacy pair plus location, which is requested anyway. -->
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation"
tools:targetApi="s" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<!-- Phase A: foreground service (trip recording) --> <!-- Phase A: foreground service (trip recording) -->
Binary file not shown.
@@ -26,10 +26,12 @@ import androidx.core.view.WindowCompat
import androidx.navigation.NavType import androidx.navigation.NavType
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument import androidx.navigation.navArgument
import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
import com.hawhamburg.micr0bu.data.transport.UsbSerialState import com.hawhamburg.micr0bu.data.transport.Esp32LinkState
import com.hawhamburg.micr0bu.data.transport.Esp32Transport
import com.hawhamburg.micr0bu.data.transport.ObuHardware import com.hawhamburg.micr0bu.data.transport.ObuHardware
import com.hawhamburg.micr0bu.ui.components.StatusTopBar import com.hawhamburg.micr0bu.ui.components.StatusTopBar
import com.hawhamburg.micr0bu.ui.navigation.BottomNavBar import com.hawhamburg.micr0bu.ui.navigation.BottomNavBar
@@ -40,6 +42,7 @@ import com.hawhamburg.micr0bu.ui.screens.MqttTopicViewerScreen
import com.hawhamburg.micr0bu.ui.screens.RecordingScreen import com.hawhamburg.micr0bu.ui.screens.RecordingScreen
import com.hawhamburg.micr0bu.ui.screens.SensorScreen import com.hawhamburg.micr0bu.ui.screens.SensorScreen
import com.hawhamburg.micr0bu.ui.screens.SessionLogScreen import com.hawhamburg.micr0bu.ui.screens.SessionLogScreen
import com.hawhamburg.micr0bu.ui.screens.V2xMapScreen
import com.hawhamburg.micr0bu.ui.screens.MapScreen import com.hawhamburg.micr0bu.ui.screens.MapScreen
import com.hawhamburg.micr0bu.ui.screens.TripHistoryScreen import com.hawhamburg.micr0bu.ui.screens.TripHistoryScreen
import com.hawhamburg.micr0bu.ui.screens.TripReviewScreen import com.hawhamburg.micr0bu.ui.screens.TripReviewScreen
@@ -86,7 +89,16 @@ class MainActivity : AppCompatActivity() {
val showBatteryOptPrompt by tripViewModel.showBatteryOptPrompt.collectAsState() val showBatteryOptPrompt by tripViewModel.showBatteryOptPrompt.collectAsState()
val useCaseEnabledMap by mqttViewModel.useCaseEnabledMap.collectAsState() val useCaseEnabledMap by mqttViewModel.useCaseEnabledMap.collectAsState()
val obuHardware by mqttViewModel.obuHardware.collectAsState() val obuHardware by mqttViewModel.obuHardware.collectAsState()
val usbSerialState by mqttViewModel.usbSerialState.collectAsState() val esp32LinkState by mqttViewModel.esp32LinkState.collectAsState()
val esp32Transport by mqttViewModel.esp32Transport.collectAsState()
val outgoingMessage by mqttViewModel.outgoingMessage.collectAsState()
val signOutgoing by mqttViewModel.signOutgoing.collectAsState()
// Received hazards and live signal state, for the Dashboard's V2X summary cards.
// Both flows already expire their own entries on a clock, so nothing here has to
// decide when a hazard or a traffic light has gone stale.
val denmEvents by mqttViewModel.denmEvents.collectAsState()
val spatIntersections by mqttViewModel.spatIntersections.collectAsState()
val ownCamPosition by mqttViewModel.ownCamPosition.collectAsState()
MicrOBUTheme(darkTheme = state.darkTheme) { MicrOBUTheme(darkTheme = state.darkTheme) {
val view = LocalView.current val view = LocalView.current
@@ -98,6 +110,13 @@ class MainActivity : AppCompatActivity() {
} }
val navController = rememberNavController() val navController = rememberNavController()
// The V2X live map is a full-bleed destination: the app's own chrome would eat a
// third of the display on the one screen whose entire job is showing where things
// are relative to each other. It carries its own floating back button, and system
// back still works, so nothing becomes unreachable.
val currentBackStackEntry by navController.currentBackStackEntryAsState()
val isFullBleed = currentBackStackEntry?.destination?.route == Screen.V2xMap.route
val locationLauncher = rememberLauncherForActivityResult( val locationLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions() ActivityResultContracts.RequestMultiplePermissions()
) { permissions -> ) { permissions ->
@@ -113,11 +132,17 @@ class MainActivity : AppCompatActivity() {
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.startImuStreams() viewModel.startImuStreams()
// Bluetooth scan/connect ride along with location (Android 12+): the ESP32-C5
// can be reached over BLE, and a denial only matters if that is selected, where
// BleLinkTransport then says so instead of silently finding nothing.
val bluetooth = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
arrayOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT)
} else emptyArray()
locationLauncher.launch( locationLauncher.launch(
arrayOf( arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION,
) ) + bluetooth
) )
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
notificationLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) notificationLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
@@ -126,14 +151,17 @@ class MainActivity : AppCompatActivity() {
Scaffold( Scaffold(
topBar = { topBar = {
if (!isFullBleed) {
StatusTopBar( StatusTopBar(
state = state, state = state,
mqttConnectionState = mqttConnectionState, mqttConnectionState = mqttConnectionState,
isEsp32 = obuHardware == ObuHardware.ESP32_C5, isEsp32 = obuHardware == ObuHardware.ESP32_C5,
usbSerialState = usbSerialState, esp32LinkState = esp32LinkState,
esp32Bluetooth = esp32Transport == Esp32Transport.BLE,
) )
}
}, },
bottomBar = { BottomNavBar(navController) }, bottomBar = { if (!isFullBleed) BottomNavBar(navController) },
) { innerPadding -> ) { innerPadding ->
NavHost( NavHost(
navController = navController, navController = navController,
@@ -146,24 +174,34 @@ class MainActivity : AppCompatActivity() {
mqttConnectionState = mqttConnectionState, mqttConnectionState = mqttConnectionState,
activeTransport = activeTransport, activeTransport = activeTransport,
obuHardware = obuHardware, obuHardware = obuHardware,
usbSerialState = usbSerialState, esp32LinkState = esp32LinkState,
esp32Bluetooth = esp32Transport == Esp32Transport.BLE,
usbCableConnected = usbConnected, usbCableConnected = usbConnected,
obuStationTypeWarning = obuStationTypeWarning, obuStationTypeWarning = obuStationTypeWarning,
obuStationType = obuStationType, obuStationType = obuStationType,
hazards = denmEvents,
signals = spatIntersections,
ownPosition = ownCamPosition,
onNavigateToConnection = { navController.navigate(Screen.Connection.route) }, onNavigateToConnection = { navController.navigate(Screen.Connection.route) },
onNavigateToSensors = { onNavigateToSensors = {
navController.navigate(Screen.Sensors.route) { navController.navigate(Screen.Sensors.route) {
popUpTo(Screen.Dashboard.route) { saveState = true } popUpTo(Screen.Dashboard.route)
launchSingleTop = true launchSingleTop = true
restoreState = true
} }
}, },
onNavigateToMap = { navController.navigate(Screen.Map.route) }, onNavigateToMap = { navController.navigate(Screen.Map.route) },
onNavigateToRecord = { onNavigateToRecord = {
navController.navigate(Screen.Record.route) { navController.navigate(Screen.Record.route) {
popUpTo(Screen.Dashboard.route) { saveState = true } popUpTo(Screen.Dashboard.route)
launchSingleTop = true
}
},
// Same options the bottom bar uses, so arriving at V2X from a
// Dashboard card leaves the same back stack as tapping the tab.
onNavigateToV2x = {
navController.navigate(Screen.MqttViewer.route) {
popUpTo(Screen.Dashboard.route)
launchSingleTop = true launchSingleTop = true
restoreState = true
} }
}, },
) )
@@ -176,7 +214,7 @@ class MainActivity : AppCompatActivity() {
state = state, state = state,
mqttConnectionState = mqttConnectionState, mqttConnectionState = mqttConnectionState,
obuConnected = if (obuHardware == ObuHardware.ESP32_C5) obuConnected = if (obuHardware == ObuHardware.ESP32_C5)
usbSerialState == UsbSerialState.CONNECTED esp32LinkState == Esp32LinkState.CONNECTED
else else
mqttConnectionState == MqttConnectionState.CONNECTED, mqttConnectionState == MqttConnectionState.CONNECTED,
tripServiceState = tripServiceState, tripServiceState = tripServiceState,
@@ -233,15 +271,24 @@ 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,
)
} }
} }
composable(Screen.MqttViewer.route) { composable(Screen.MqttViewer.route) {
MqttTopicViewerScreen(viewModel = mqttViewModel) MqttTopicViewerScreen(
viewModel = mqttViewModel,
onOpenMap = { navController.navigate(Screen.V2xMap.route) },
)
}
composable(Screen.V2xMap.route) {
// Activity-scoped instance, like Connection below: a hiltViewModel()
// here would be scoped to this NavBackStackEntry and torn down on the
// way back out, taking the shared transport with it.
V2xMapScreen(
viewModel = mqttViewModel,
onBack = { navController.popBackStack() },
)
} }
composable(Screen.Settings.route) { composable(Screen.Settings.route) {
SettingsScreen( SettingsScreen(
@@ -269,6 +316,12 @@ class MainActivity : AppCompatActivity() {
onMqttPrefsChange = mqttViewModel::updatePrefs, onMqttPrefsChange = mqttViewModel::updatePrefs,
obuHardware = obuHardware, obuHardware = obuHardware,
onObuHardwareChange = mqttViewModel::setObuHardware, onObuHardwareChange = mqttViewModel::setObuHardware,
esp32Transport = esp32Transport,
onEsp32TransportChange = mqttViewModel::setEsp32Transport,
outgoingMessage = outgoingMessage,
onOutgoingMessageChange = mqttViewModel::setOutgoingMessage,
signOutgoing = signOutgoing,
onSignOutgoingChange = mqttViewModel::setSignOutgoing,
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack() },
) )
} }
@@ -324,7 +377,7 @@ class MainActivity : AppCompatActivity() {
// hiltViewModel() — that would create a separate instance scoped to // hiltViewModel() — that would create a separate instance scoped to
// this NavBackStackEntry, whose onCleared() (fired the moment you // this NavBackStackEntry, whose onCleared() (fired the moment you
// navigate away) would disconnect the shared UsbSerialTransport out // navigate away) would disconnect the shared UsbSerialTransport out
// from under every other screen still using it. // from under every other screen still using.
ConnectionSetupScreen(viewModel = mqttViewModel) ConnectionSetupScreen(viewModel = mqttViewModel)
} }
composable(Screen.Map.route) { composable(Screen.Map.route) {
@@ -51,7 +51,7 @@ suspend fun shareSessionCsv(context: Context, session: RecordingSession) {
val intent = Intent(Intent.ACTION_SEND).apply { val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/csv" type = "text/csv"
putExtra(Intent.EXTRA_STREAM, uri) putExtra(Intent.EXTRA_STREAM, uri)
putExtra(Intent.EXTRA_SUBJECT, "MicrOBU Session Export — $fileName") putExtra(Intent.EXTRA_SUBJECT, "MicrOBU Session Export - $fileName")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
} }
context.startActivity(Intent.createChooser(intent, "Export session")) context.startActivity(Intent.createChooser(intent, "Export session"))
@@ -82,7 +82,7 @@ suspend fun saveSessionCsvToUri(context: Context, session: RecordingSession, uri
*/ */
fun buildSessionCsv(s: RecordingSession): String = buildString { fun buildSessionCsv(s: RecordingSession): String = buildString {
appendLine("# MicrOBU Session Export") appendLine("# MicrOBU Session Export")
appendLine("# Generated by MicrOBU v0.2.0 — HAW Hamburg / Project MicrOBU") appendLine("# Generated by MicrOBU v0.2.0 - HAW Hamburg / Project MicrOBU")
appendLine("# Session ID,${s.id}") appendLine("# Session ID,${s.id}")
appendLine("# Start,${iso.format(Date(s.startTime))}") appendLine("# Start,${iso.format(Date(s.startTime))}")
appendLine("# End,${iso.format(Date(s.endTime))}") appendLine("# End,${iso.format(Date(s.endTime))}")
@@ -0,0 +1,76 @@
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,
* the last error measured is kept, because the wall clock's error changes slowly while GNSS time
* comes and goes indoors. Only before any GNSS time since the app started is the wall clock used
* unchanged.
*
* Keeping it matters: with the bench phone 14 minutes fast (2026-09-23), dropping back to the raw
* wall clock whenever GNSS time blinked out made every transmitted timestamp jump 14 minutes back
* and forth, and the micrOBU restarted its stack at each jump back (time_regression).
*
* 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
/** GNSS time minus wall clock at the last reading of both; null until GNSS time was first seen. */
@Volatile private var lastErrorMs: Long? = 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
}
if (gnssNow != null) lastErrorMs = gnssNow - systemNow
noteSource(gnssNow, systemNow)
return ItsTime.onGnssTime(systemMs, lastErrorMs?.let { systemNow + it }, 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 if (lastErrorMs != null) {
Log.i(TAG, "GNSS time unavailable, keeping the last measured phone clock error of " +
"${"%+.1f".format(-lastErrorMs!! / 1000.0)} s")
} 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) }
@@ -127,7 +115,7 @@ suspend fun shareTripCsv(
val intent = Intent(Intent.ACTION_SEND).apply { val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/csv" type = "text/csv"
putExtra(Intent.EXTRA_STREAM, uri) putExtra(Intent.EXTRA_STREAM, uri)
putExtra(Intent.EXTRA_SUBJECT, "MicrOBU Trip Export — $fileName") putExtra(Intent.EXTRA_SUBJECT, "MicrOBU Trip Export - $fileName")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
} }
context.startActivity(Intent.createChooser(intent, "Export trip")) context.startActivity(Intent.createChooser(intent, "Export trip"))
@@ -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
@@ -7,19 +7,32 @@ import com.hawhamburg.micr0bu.data.SensorRepository
import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
import com.hawhamburg.micr0bu.data.mqtt.MqttRepository import com.hawhamburg.micr0bu.data.mqtt.MqttRepository
import com.hawhamburg.micr0bu.data.mqtt.ObuHardwarePreferences import com.hawhamburg.micr0bu.data.mqtt.ObuHardwarePreferences
import com.hawhamburg.micr0bu.data.mqtt.RAW_CAM_TOPIC
import com.hawhamburg.micr0bu.data.mqtt.RAW_DENM_TOPIC
import com.hawhamburg.micr0bu.data.mqtt.RAW_SPATEM_TOPIC
import com.hawhamburg.micr0bu.data.mqtt.RecvV2xMessage
import com.hawhamburg.micr0bu.data.mqtt.UseCaseAlertPreferences import com.hawhamburg.micr0bu.data.mqtt.UseCaseAlertPreferences
import com.hawhamburg.micr0bu.data.transport.ObuHardware import com.hawhamburg.micr0bu.data.transport.ObuHardware
import com.hawhamburg.micr0bu.data.transport.BtpPort
import com.hawhamburg.micr0bu.data.transport.SerialFrameType import com.hawhamburg.micr0bu.data.transport.SerialFrameType
import com.hawhamburg.micr0bu.data.transport.UsbSerialState import com.hawhamburg.micr0bu.data.transport.V2xRxFrame
import com.hawhamburg.micr0bu.data.transport.UsbSerialTransport import com.hawhamburg.micr0bu.data.transport.Esp32LinkState
import com.hawhamburg.micr0bu.data.transport.Esp32Link
import com.hawhamburg.micr0bu.domain.asn1.DenmUperCodec
import com.hawhamburg.micr0bu.domain.asn1.RealAsn1UperCodec import com.hawhamburg.micr0bu.domain.asn1.RealAsn1UperCodec
import com.hawhamburg.micr0bu.domain.asn1.SpatemUperCodec
import com.hawhamburg.micr0bu.domain.cam.Cam import com.hawhamburg.micr0bu.domain.cam.Cam
import com.hawhamburg.micr0bu.domain.cam.CamParser import com.hawhamburg.micr0bu.domain.cam.CamParser
import com.hawhamburg.micr0bu.domain.cam.ObuGnssParser import com.hawhamburg.micr0bu.domain.cam.ObuGnssParser
import com.hawhamburg.micr0bu.domain.cam.OwnStationIds
import com.hawhamburg.micr0bu.domain.cam.OwnTxLoopback
import com.hawhamburg.micr0bu.domain.cam.StationType import com.hawhamburg.micr0bu.domain.cam.StationType
import com.hawhamburg.micr0bu.domain.denm.DenmEvent
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
@@ -34,6 +47,7 @@ import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -49,6 +63,16 @@ private const val PRUNE_INTERVAL_MS = 1_000L
// missed updates, not just normal jitter between samples. // missed updates, not just normal jitter between samples.
private const val OBU_GNSS_STALE_MS = 2_500L private const val OBU_GNSS_STALE_MS = 2_500L
/**
* How long a raw `v2x/rx/cam` message keeps the Use Case app's CAM topic suppressed.
*
* The two topics carry the same traffic, but `v2x-uca/output/json/cam` is rate-limited and drops
* messages, so while the raw topic is arriving there is nothing the processed one can add. A few
* seconds is many missed repetitions at CAM rates, so this only lapses if the raw topic really
* has stopped, which is what makes the fallback automatic on an OBU that does not publish it.
*/
private const val RAW_PREFERRED_WINDOW_MS = 5_000L
/** /**
* Bridges the raw MQTT CAM stream (plus the ego's own obu_gnss/phone GNSS state) to * Bridges the raw MQTT CAM stream (plus the ego's own obu_gnss/phone GNSS state) to
* [UseCaseDetectionEngine] and exposes the resulting CAM-based Use Case Alerts to the UI * [UseCaseDetectionEngine] and exposes the resulting CAM-based Use Case Alerts to the UI
@@ -66,15 +90,23 @@ private const val OBU_GNSS_STALE_MS = 2_500L
* A singleton so detection keeps running (and alert state survives) even while no screen is * A singleton so detection keeps running (and alert state survives) even while no screen is
* collecting it — same rationale as [MqttRepository]'s per-topic message log. * collecting it — same rationale as [MqttRepository]'s per-topic message log.
* *
* No DENM is generated or consumed anywhere in this class. * **Two decode sources, one funnel.** UPER arrives either from the ESP32-C5 serial link or, on
* the CiT One path, from the raw `v2x/rx` protobuf topics ([RecvV2xMessage]). Both end up in
* the same handlers, so everything downstream is transport-agnostic. The CiT One's processed
* `v2x-uca/output/json` topics remain a fallback for an OBU that does not publish the raw ones.
*
* DENM is decoded from both (see [decodedDenm]) but deliberately kept out of
* [UseCaseDetectionEngine] — that engine reasons about moving road users from CAM kinematics.
*/ */
@Singleton @Singleton
class CamUseCaseRepository @Inject constructor( class CamUseCaseRepository @Inject constructor(
private val mqttRepository: MqttRepository, private val mqttRepository: MqttRepository,
private val prefs: UseCaseAlertPreferences, private val prefs: UseCaseAlertPreferences,
private val usbSerialTransport: UsbSerialTransport, private val esp32Link: Esp32Link,
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)
@@ -91,6 +123,9 @@ class CamUseCaseRepository @Inject constructor(
@Volatile private var lastOwnStationType: Int = StationType.CYCLIST @Volatile private var lastOwnStationType: Int = StationType.CYCLIST
@Volatile private var lastObuGnssTimestamp: Long = 0L @Volatile private var lastObuGnssTimestamp: Long = 0L
/** When a raw `v2x/rx/cam` message last arrived, for [rawCamPreferred]. */
@Volatile private var lastRawCamMs: Long = 0L
/** Per-use-case enable/disable toggles (Settings > Use Case Alerts). */ /** Per-use-case enable/disable toggles (Settings > Use Case Alerts). */
val enabledMap: StateFlow<Map<UseCaseType, Boolean>> = prefs.enabledMapFlow.stateIn( val enabledMap: StateFlow<Map<UseCaseType, Boolean>> = prefs.enabledMapFlow.stateIn(
scope, SharingStarted.Eagerly, UseCaseType.entries.associateWith { true }, scope, SharingStarted.Eagerly, UseCaseType.entries.associateWith { true },
@@ -121,6 +156,55 @@ class CamUseCaseRepository @Inject constructor(
*/ */
val processedCam: SharedFlow<Cam> = _processedCam.asSharedFlow() val processedCam: SharedFlow<Cam> = _processedCam.asSharedFlow()
private val _rsuStations = MutableStateFlow<Map<Long, Cam>>(emptyMap())
/**
* Latest CAM per roadside unit heard over the air.
*
* Separate from [remotePositions] because an RSU is infrastructure, not a road user: it has no
* kinematics, sits at a fixed point forever, and would trip the stopped-vehicle and
* intersection-movement use cases for as long as it is in range. It still belongs on the map
* and in the station list, which is what this flow is for. Consumers should apply their own
* staleness window - nothing prunes this map except a link drop.
*/
val rsuStations: StateFlow<Map<Long, Cam>> = _rsuStations.asStateFlow()
private val _ownTxLoopback = MutableStateFlow<OwnTxLoopback?>(null)
/**
* Our own transmissions heard back off the air, or null until one is.
*
* These frames are dropped from the detection engine, correctly, since the phone is not a
* road user to itself. But dropping them silently threw away the one thing that proves the
* whole radio loop works: the frame went out over serial, the ESP32 transmitted it, and the
* ESP32 received it again. That is precisely what the bench pinger exists to demonstrate, so
* it is counted here and reported rather than discarded.
*
* ESP32-C5 path in practice. The CiT One does not normally hear its own transmissions.
*/
val ownTxLoopback: StateFlow<OwnTxLoopback?> = _ownTxLoopback.asStateFlow()
/** Clears the loopback tally. Called when a fresh pinger run starts, so the count is per run. */
fun resetOwnTxLoopback() { _ownTxLoopback.value = null }
private val _decodedSpat = MutableSharedFlow<SpatEvent>(replay = 16, extraBufferCapacity = 32)
/**
* SPATEMs decoded from UPER, from either hardware path: the ESP32-C5 serial link or the CiT
* One's `v2x/rx/spatem` topic. Replayed so a screen opened mid-stream sees the current signal
* state immediately rather than waiting up to half a second for the next repetition.
*
* The CiT One's own `v2x-uca/output/json/spat` topic is not a source here. It was never
* parsed, so before the raw topic was wired up this path produced no signal state at all.
*/
val decodedSpat: SharedFlow<SpatEvent> = _decodedSpat.asSharedFlow()
private val _decodedDenm = MutableSharedFlow<DenmEvent>(replay = 32, extraBufferCapacity = 32)
/**
* DENMs decoded from UPER, from either hardware path: the ESP32-C5 serial link or the CiT
* One's `v2x/rx/denm` topic. `replay` so a screen opened after a hazard was first heard still
* sees it - DENMs repeat at ~1 Hz but a subscriber that missed the last repetition shouldn't
* have to wait for the next.
*/
val decodedDenm: SharedFlow<DenmEvent> = _decodedDenm.asSharedFlow()
init { init {
scope.launch { scope.launch {
mqttRepository.messages.collect { msg -> mqttRepository.messages.collect { msg ->
@@ -131,6 +215,37 @@ class CamUseCaseRepository @Inject constructor(
} }
} }
// CiT One raw path: every message the OBU's radio heard, as protobuf, decoded here with
// the same codecs the serial path uses. This is what makes the CiT One see traffic the
// Use Case app filtered out, the ESP32-C5's CAM pinger among it, and it is the only
// source of SPATEM on this hardware.
scope.launch {
mqttRepository.rawV2x.collect { raw ->
val envelope = RecvV2xMessage.parse(raw.bytes)
if (envelope == null) {
Log.w(TAG, "rawV2x: unparseable RecvV2XMessage on ${raw.topic}, " +
"${raw.bytes.size} bytes - first bytes: ${raw.bytes.toHexPreview()}")
return@collect
}
when (raw.topic) {
RAW_CAM_TOPIC -> {
lastRawCamMs = raw.timestamp
handleCamUper(envelope.payload, rssiDbm = null, source = "mqtt")
}
// The GeoBroadcast radius comes off the GeoNetworking header the same way it
// does on the serial path, so a hazard's relevance area survives here too.
RAW_DENM_TOPIC -> handleDenmUper(
uper = envelope.payload,
rssiDbm = null,
relevanceRadiusM = envelope.destAreaRadiusM,
source = "mqtt",
)
RAW_SPATEM_TOPIC -> handleSpatUper(envelope.payload, rssiDbm = null, source = "mqtt")
else -> Log.w(TAG, "rawV2x: unexpected topic ${raw.topic}")
}
}
}
// Phone GNSS fallback — only applied when obu_gnss has gone stale (see class KDoc). // Phone GNSS fallback — only applied when obu_gnss has gone stale (see class KDoc).
// Retries in a loop: this singleton can be created before the user grants location // Retries in a loop: this singleton can be created before the user grants location
// permission (requested at app startup), so a single subscription attempt isn't // permission (requested at app startup), so a single subscription attempt isn't
@@ -170,10 +285,18 @@ class CamUseCaseRepository @Inject constructor(
// this only ever sees CAM UPER bytes. No-op stream on the CiT One path (the transport // this only ever sees CAM UPER bytes. No-op stream on the CiT One path (the transport
// just never emits CAM_RX frames if nothing's plugged in over serial). // just never emits CAM_RX frames if nothing's plugged in over serial).
scope.launch { scope.launch {
usbSerialTransport.incomingFrames.collect { frame -> esp32Link.incomingFrames.collect { frame ->
if (frame.type != SerialFrameType.CAM_RX) return@collect if (frame.type != SerialFrameType.V2X_RX) return@collect
if (usbSerialTransport.state.value != UsbSerialState.CONNECTED) return@collect if (esp32Link.state.value != Esp32LinkState.CONNECTED) return@collect
handleCamFromSerial(frame.payload) val v2x = V2xRxFrame.parse(frame.payload) ?: return@collect
when (v2x.btpPort) {
BtpPort.CAM -> handleCamFromSerial(v2x)
BtpPort.DENM -> handleDenmFromSerial(v2x)
BtpPort.SPATEM -> handleSpatFromSerial(v2x)
// The firmware only forwards ports it was told to accept, so anything else
// means the two sides have drifted out of sync.
else -> Log.w(TAG, "unexpected BTP port ${v2x.btpPort} from firmware")
}
} }
} }
@@ -181,18 +304,19 @@ class CamUseCaseRepository @Inject constructor(
// last-seen positions and their alerts linger on the map and in the use-case panel after // last-seen positions and their alerts linger on the map and in the use-case panel after
// an unplug, which reads as live traffic - the worst kind of stale on a safety display. // an unplug, which reads as live traffic - the worst kind of stale on a safety display.
scope.launch { scope.launch {
usbSerialTransport.state.collect { state -> esp32Link.state.collect { state ->
// ESP32-only: on the CiT One path this transport is permanently DISCONNECTED and // ESP32-only: on the CiT One path this transport is permanently DISCONNECTED and
// resetting here would wipe perfectly good MQTT-derived state. // resetting here would wipe perfectly good MQTT-derived state.
if (currentHardware == ObuHardware.ESP32_C5 && state != UsbSerialState.CONNECTED) { if (currentHardware == ObuHardware.ESP32_C5 && state != Esp32LinkState.CONNECTED) {
engine.reset() engine.reset()
_rsuStations.value = emptyMap()
} }
} }
} }
// 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
@@ -200,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
} }
} }
} }
@@ -213,8 +340,22 @@ class CamUseCaseRepository @Inject constructor(
scope.launch { prefs.setEnabled(type, enabled) } scope.launch { prefs.setEnabled(type, enabled) }
} }
/** True if [stationId] matches the ego OBU's own station ID (for OWN/REMOTE UI badges). */ /**
fun isOwnStationId(stationId: Long): Boolean = stationId != 0L && stationId == _ownStationId.value * True if [stationId] is one this phone transmits under, so a frame heard back off the air is
* recognised as our own rather than tracked as another road user. Also drives the OWN/REMOTE
* badges in the raw message list.
*
* The rule lives in [OwnStationIds], which explains which ids count and what goes wrong when
* 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 =
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
@@ -257,16 +398,32 @@ class CamUseCaseRepository @Inject constructor(
_processedCam.tryEmit(ego) _processedCam.tryEmit(ego)
} }
/** True while `v2x/rx/cam` is arriving, in which case the processed CAM topic adds nothing. */
private fun rawCamPreferred(now: Long): Boolean =
lastRawCamMs != 0L && now - lastRawCamMs <= RAW_PREFERRED_WINDOW_MS
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
// This phone's own bench ping, relayed back by the CiT One's radio: not a road user, and not
// ego state either, since it is built from the same phone GNSS the engine already has.
// 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
// are unavailable/stale. // are unavailable/stale. Deliberately still processed while the raw topic is live:
// v2x/rx/cam is a receive topic and never carries the ego station's own CAM, so
// suppressing this would remove the fallback without anything replacing it.
engine.onOwnCam(cam) engine.onOwnCam(cam)
} else { _processedCam.tryEmit(cam)
engine.onRemoteCam(cam) return
} }
// A remote CAM the raw topic has already delivered, in fuller form and without the Use
// Case app's rate limiting. Dropping it here rather than letting both reach the engine
// keeps one station from being fed by two sources at two different rates.
if (rawCamPreferred(timestamp)) return
engine.onRemoteCam(cam)
_processedCam.tryEmit(cam) _processedCam.tryEmit(cam)
} }
@@ -281,29 +438,141 @@ class CamUseCaseRepository @Inject constructor(
* its own just-transmitted frame (promiscuous capture of a local TX). Guarded the same way * its own just-transmitted frame (promiscuous capture of a local TX). Guarded the same way
* the MQTT path guards against reprocessing "own" CAM: compare against [_ownStationId]. * the MQTT path guards against reprocessing "own" CAM: compare against [_ownStationId].
*/ */
private fun handleCamFromSerial(payload: ByteArray) { private fun handleCamFromSerial(v2x: V2xRxFrame) =
if (payload.isEmpty()) return handleCamUper(v2x.uper, v2x.rssiDbm, source = "serial")
val rssiDbm = payload[0].toInt() // signed dBm from the firmware's promiscuous RX metadata
val camBytes = payload.copyOfRange(1, payload.size) // payload[0] is RSSI, not part of the CAM /** Shared by both transports: [rssiDbm] is null on the MQTT path, which does not report it. */
val cam = camCodec.decodeCam(camBytes, System.currentTimeMillis())?.copy(rssiDbm = rssiDbm) private fun handleCamUper(uper: ByteArray, rssiDbm: Int?, source: String) {
val v2x = UperSource(uper, rssiDbm, source)
val cam = camCodec.decodeCam(v2x.uper, System.currentTimeMillis())?.copy(rssiDbm = v2x.rssiDbm)
if (cam == null) { if (cam == null) {
// Logged, not silently dropped: "the app shows nothing" has two completely different // Logged, not silently dropped: "the app shows nothing" has two completely different
// causes - frames not arriving at all, versus arriving and failing to decode - and // causes - frames not arriving at all, versus arriving and failing to decode - and
// without this line they're indistinguishable from the outside. rssi is signed. // without this line they're indistinguishable from the outside.
Log.w( Log.w(
TAG, TAG,
"handleCamFromSerial: decode FAILED for ${camBytes.size}-byte CAM " + "handleCamUper[${v2x.source}]: decode FAILED for ${v2x.uper.size}-byte CAM " +
"(rssi=$rssiDbm dBm) - first bytes: ${camBytes.toHexPreview()}", "(rssi=${v2x.rssiDbm} dBm) - first bytes: ${v2x.uper.toHexPreview()}",
) )
return return
} }
Log.d(TAG, "handleCamFromSerial: decoded station=${cam.stationId} " + Log.d(TAG, "handleCamUper[${v2x.source}]: decoded station=${cam.stationId} " +
"lat=${cam.latitude} lon=${cam.longitude} speed=${cam.speedMps} rssi=$rssiDbm dBm") "lat=${cam.latitude} lon=${cam.longitude} speed=${cam.speedMps} rssi=${v2x.rssiDbm} dBm")
if (_ownStationId.value != null && cam.stationId == _ownStationId.value) return // self-heard TX if (isOwnStationId(cam.stationId)) {
// Ours, on either station id. Kept out of the engine, but counted: this is the
// round trip completing, and it is the only direct evidence the radio path works.
_ownTxLoopback.update { prev ->
OwnTxLoopback(
frames = (prev?.frames ?: 0) + 1,
// Hold the last known reading rather than overwriting it with null on a
// transport that does not report RSSI, so the figure does not blink away.
lastRssiDbm = v2x.rssiDbm ?: prev?.lastRssiDbm,
lastHeardMs = System.currentTimeMillis(),
)
}
return
}
// Roadside units are infrastructure, not road users. Their CAM carries no kinematics (see
// CamUperCodec's rsuContainerHighFrequency branch), so it reaches here as a permanently
// stationary station at a fixed point - which is precisely the shape the stopped-vehicle
// and intersection-movement use cases look for. Feeding it to the engine would raise a
// standing false alert for as long as the RSU is in range.
if (cam.stationType == StationType.ROAD_SIDE_UNIT) {
// Tracked here rather than in the engine, so an RSU still shows on the map and in the
// station list without being evaluated for alerts. Keeping it out of the engine
// entirely - as the first version of this did - also removed it from the display,
// because remotePositions is the engine's map: 611 RSU CAMs decoded during the
// 2026-08-25 bench run and none of them were ever shown.
_rsuStations.value = _rsuStations.value + (cam.stationId to cam)
_processedCam.tryEmit(cam)
return
}
engine.onRemoteCam(cam) engine.onRemoteCam(cam)
_processedCam.tryEmit(cam) _processedCam.tryEmit(cam)
} }
/**
* A DENM heard over the air. Deliberately NOT fed to [UseCaseDetectionEngine] - that engine
* reasons about moving road users from CAM kinematics, and a static hazard is a different kind
* of thing. DENMs go to the map and the message list only.
*/
private fun handleDenmFromSerial(v2x: V2xRxFrame) = handleDenmUper(
uper = v2x.uper,
rssiDbm = v2x.rssiDbm,
relevanceRadiusM = v2x.geoArea?.radiusMeters,
source = "serial",
)
private fun handleDenmUper(
uper: ByteArray,
rssiDbm: Int?,
relevanceRadiusM: Int?,
source: String,
) {
val v2x = UperSource(uper, rssiDbm, source)
val denm = DenmUperCodec.decode(
bytes = v2x.uper,
receivedAtEpochMs = System.currentTimeMillis(),
rssiDbm = v2x.rssiDbm,
relevanceRadiusM = relevanceRadiusM,
)
if (denm == null) {
Log.w(
TAG,
"handleDenmUper[${v2x.source}]: decode FAILED for ${v2x.uper.size}-byte DENM " +
"(rssi=${v2x.rssiDbm} dBm) - first bytes: ${v2x.uper.toHexPreview()}",
)
return
}
Log.d(TAG, "handleDenmUper[${v2x.source}]: decoded station=${denm.stationId}/${denm.sequenceNumber} " +
"cause=${denm.causeCode}/${denm.subCauseCode} lat=${denm.latitude} lon=${denm.longitude} " +
"radius=${denm.relevanceRadiusM}m termination=${denm.isTermination} rssi=${v2x.rssiDbm} dBm")
_decodedDenm.tryEmit(denm)
}
/**
* A SPATEM heard over the air: the live signal phase for one or more intersections.
*
* Like DENM, this is deliberately kept out of [UseCaseDetectionEngine] - a traffic light is
* not a moving road user, and the CAM-based use cases reason about kinematics.
*
* Note the firmware drops any SPATEM whose UPER exceeds the 512-byte serial payload cap and
* counts it as an oversize drop, so on real road RSUs (median 555 bytes) most will not arrive
* until that cap is raised. The bench trigger's ~58-byte messages are unaffected.
*/
private fun handleSpatFromSerial(v2x: V2xRxFrame) =
handleSpatUper(v2x.uper, v2x.rssiDbm, source = "serial")
private fun handleSpatUper(uper: ByteArray, rssiDbm: Int?, source: String) {
val v2x = UperSource(uper, rssiDbm, source)
val spat = SpatemUperCodec.decode(
bytes = v2x.uper,
receivedAtEpochMs = System.currentTimeMillis(),
rssiDbm = v2x.rssiDbm,
)
if (spat == null) {
Log.w(
TAG,
"handleSpatUper[${v2x.source}]: decode FAILED for ${v2x.uper.size}-byte SPATEM " +
"(rssi=${v2x.rssiDbm} dBm) - first bytes: ${v2x.uper.toHexPreview()}",
)
return
}
Log.d(TAG, "handleSpatUper[${v2x.source}]: decoded station=${spat.stationId} " +
"intersections=${spat.intersections.joinToString { it.key }} " +
"movements=${spat.intersections.sumOf { it.movements.size }} rssi=${v2x.rssiDbm} dBm")
_decodedSpat.tryEmit(spat)
}
/**
* The bits of a received frame the decoders and their log lines need, independent of whether
* it came off the serial link or an MQTT topic. [rssiDbm] is null on the MQTT path: the
* RecvV2XMessage envelope does not carry signal strength.
*/
private data class UperSource(val uper: ByteArray, val rssiDbm: Int?, val source: String)
private fun ByteArray.toHexPreview(limit: Int = 16): String = private fun ByteArray.toHexPreview(limit: Int = 16): String =
take(limit).joinToString(" ") { "%02x".format(it) } + if (size > limit) " ..." else "" take(limit).joinToString(" ") { "%02x".format(it) } + if (size > limit) " ..." else ""
} }
@@ -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)
@@ -43,6 +43,13 @@ private val SUBSCRIBED_TOPICS = listOf(
"sys/state/heartbeat", "sys/state/heartbeat",
"sys/state/cellular", "sys/state/cellular",
"v2x/rx/obu_gnss", "v2x/rx/obu_gnss",
// Everything the radio heard, as RecvV2XMessage protobuf (API section 2.4). Preferred over
// the v2x-uca topics below, which are a rate-limited and lossy view of the same traffic.
RAW_CAM_TOPIC,
RAW_DENM_TOPIC,
RAW_SPATEM_TOPIC,
// Kept subscribed as a fallback for an OBU whose product configuration does not publish the
// raw topics, and because the Use Case app is still the only source of its own alert output.
"v2x-uca/output/json/cam", "v2x-uca/output/json/cam",
"v2x-uca/output/json/denm", "v2x-uca/output/json/denm",
"v2x-uca/output/json/spat", "v2x-uca/output/json/spat",
@@ -50,6 +57,31 @@ private val SUBSCRIBED_TOPICS = listOf(
"v2x-uca/output/json/cpm", "v2x-uca/output/json/cpm",
) )
/** Raw received-V2X topics, carrying protobuf rather than JSON. See [RecvV2xMessage]. */
const val RAW_CAM_TOPIC = "v2x/rx/cam"
const val RAW_DENM_TOPIC = "v2x/rx/denm"
const val RAW_SPATEM_TOPIC = "v2x/rx/spatem"
private val RAW_V2X_TOPICS = setOf(RAW_CAM_TOPIC, RAW_DENM_TOPIC, RAW_SPATEM_TOPIC)
/**
* A message straight off a `v2x/rx` topic, before the protobuf envelope is opened.
*
* Carried as bytes, not [MqttMessage]: that type holds a String, and putting protobuf through
* a UTF-8 round trip replaces every byte that is not valid UTF-8 with U+FFFD. The payload
* survives looking plausible in a log and decodes to nothing.
*/
data class RawV2xMqttMessage(val topic: String, val bytes: ByteArray, val timestamp: Long) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is RawV2xMqttMessage) return false
return topic == other.topic && timestamp == other.timestamp && bytes.contentEquals(other.bytes)
}
override fun hashCode(): Int =
31 * (31 * topic.hashCode() + timestamp.hashCode()) + bytes.contentHashCode()
}
@Singleton @Singleton
class MqttRepository @Inject constructor( class MqttRepository @Inject constructor(
private val prefs: MqttPreferences, private val prefs: MqttPreferences,
@@ -69,6 +101,21 @@ class MqttRepository @Inject constructor(
) )
val messages: SharedFlow<MqttMessage> = _messages.asSharedFlow() val messages: SharedFlow<MqttMessage> = _messages.asSharedFlow()
// Same buffering rationale as [_messages], with more headroom: this stream carries every CAM
// the radio hears rather than the Use Case app's thinned-out selection, which at a busy
// intersection is a considerably higher rate.
private val _rawV2x = MutableSharedFlow<RawV2xMqttMessage>(
replay = 0,
extraBufferCapacity = 512,
)
/**
* Undecoded `v2x/rx` protobuf messages. Consumed by
* [com.hawhamburg.micr0bu.data.cam.CamUseCaseRepository], which opens the envelope and runs
* the UPER decoders over the payload, exactly as it does for the ESP32-C5 serial path.
*/
val rawV2x: SharedFlow<RawV2xMqttMessage> = _rawV2x.asSharedFlow()
// Per-topic message log, kept here (singleton) so it survives even when no screen is // Per-topic message log, kept here (singleton) so it survives even when no screen is
// collecting — e.g. DENM TX messages emitted by TripRecordingService while the V2X // collecting — e.g. DENM TX messages emitted by TripRecordingService while the V2X
// Monitor screen isn't open. // Monitor screen isn't open.
@@ -194,6 +241,12 @@ class MqttRepository @Inject constructor(
) )
} }
/** A one-line, printable stand-in for a binary payload, for the raw topic log. */
private fun describeBinary(bytes: ByteArray, limit: Int = 24): String {
val hex = bytes.take(limit).joinToString(" ") { "%02x".format(it) }
return "${bytes.size} bytes protobuf: $hex" + if (bytes.size > limit) " ..." else ""
}
/** /**
* Record a message into both the live [messages] stream (for screens currently open) * Record a message into both the live [messages] stream (for screens currently open)
* and the persistent [topicMessages] log (survives even when no screen is collecting). * and the persistent [topicMessages] log (survives even when no screen is collecting).
@@ -301,11 +354,26 @@ class MqttRepository @Inject constructor(
override fun connectionLost(cause: Throwable?) { lostSignal.complete(cause) } override fun connectionLost(cause: Throwable?) { lostSignal.complete(cause) }
override fun messageArrived(topic: String, message: PahoMqttMessage) { override fun messageArrived(topic: String, message: PahoMqttMessage) {
val now = System.currentTimeMillis()
if (topic in RAW_V2X_TOPICS) {
// Binary. The bytes go to the decoders untouched; the topic log gets a hex
// preview instead, because decoding these to a String would show the operator
// a screenful of replacement characters and imply the data was corrupt.
_rawV2x.tryEmit(RawV2xMqttMessage(topic, message.payload, now))
recordMessage(
MqttMessage(
topic = topic,
payload = describeBinary(message.payload),
timestamp = now,
)
)
return
}
recordMessage( recordMessage(
MqttMessage( MqttMessage(
topic = topic, topic = topic,
payload = message.payload.toString(Charsets.UTF_8), payload = message.payload.toString(Charsets.UTF_8),
timestamp = System.currentTimeMillis(), timestamp = now,
) )
) )
} }
@@ -1,17 +1,21 @@
package com.hawhamburg.micr0bu.data.mqtt package com.hawhamburg.micr0bu.data.mqtt
import android.content.Context import android.content.Context
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.longPreferencesKey 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.Esp32Transport
import com.hawhamburg.micr0bu.data.transport.ObuHardware import com.hawhamburg.micr0bu.data.transport.ObuHardware
import com.hawhamburg.micr0bu.data.transport.OutgoingMessage
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 +30,15 @@ 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")
// ESP32-C5 only.
val ESP32_TRANSPORT = stringPreferencesKey("esp32_transport")
val OUTGOING_MESSAGE = stringPreferencesKey("outgoing_message")
val SIGN_OUTGOING = booleanPreferencesKey("sign_outgoing")
} }
val obuHardwareFlow: Flow<ObuHardware> = context.obuHardwareDataStore.data.map { prefs -> val obuHardwareFlow: Flow<ObuHardware> = context.obuHardwareDataStore.data.map { prefs ->
@@ -37,31 +49,67 @@ 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. */ /** How the phone reaches the ESP32-C5: its native USB-C port (default) or BLE. */
val ownStationIdFlow: Flow<Long?> = context.obuHardwareDataStore.data.map { prefs -> val esp32TransportFlow: Flow<Esp32Transport> = context.obuHardwareDataStore.data.map { prefs ->
prefs[Keys.OWN_STATION_ID] Esp32Transport.entries.firstOrNull { it.id == prefs[Keys.ESP32_TRANSPORT] } ?: Esp32Transport.USB
}
suspend fun setEsp32Transport(transport: Esp32Transport) {
context.obuHardwareDataStore.edit { prefs -> prefs[Keys.ESP32_TRANSPORT] = transport.id }
}
/** What the ESP32-C5 path transmits while recording: CAM (default) or VAM. */
val outgoingMessageFlow: Flow<OutgoingMessage> = context.obuHardwareDataStore.data.map { prefs ->
OutgoingMessage.entries.firstOrNull { it.id == prefs[Keys.OUTGOING_MESSAGE] } ?: OutgoingMessage.CAM
}
suspend fun setOutgoingMessage(message: OutgoingMessage) {
context.obuHardwareDataStore.edit { prefs -> prefs[Keys.OUTGOING_MESSAGE] = message.id }
} }
/** /**
* Returns this device's own CAM StationID, generating and persisting a random one on first * Whether outgoing messages are signed (TS 103 097, demo PKI). Default on. Off sends them
* call. * unsigned exactly as the previous firmware did, which verifying receivers may prefer to a
* * signature they cannot chain to the EU trust list.
* Replaces the previous hardcoded 0: receivers key on StationID to track a station across
* successive CAMs, so every MicrOBU broadcasting 0 makes two units in the same area
* 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 signOutgoingFlow: Flow<Boolean> = context.obuHardwareDataStore.data.map { prefs ->
val prefs = context.obuHardwareDataStore.edit { p -> prefs[Keys.SIGN_OUTGOING] ?: true
if (p[Keys.OWN_STATION_ID] == null) { }
p[Keys.OWN_STATION_ID] = Random.nextLong(1L, 0xFFFF_FFFEL)
suspend fun setSignOutgoing(sign: Boolean) {
context.obuHardwareDataStore.edit { prefs -> prefs[Keys.SIGN_OUTGOING] = sign }
}
/**
* The transmit pseudonym last saved by [savePseudonym], or null if there is none.
*
* 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. */
suspend fun savePseudonym(pseudonym: Pseudonym) {
context.obuHardwareDataStore.edit { p ->
p[Keys.OWN_STATION_ID] = pseudonym.stationId
p[Keys.OWN_MAC] = pseudonym.mac.joinToString("") { "%02x".format(it) }
p[Keys.OWN_PSEUDONYM_CREATED_MS] = pseudonym.createdAtMs
} }
} }
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()
} }
@@ -0,0 +1,240 @@
package com.hawhamburg.micr0bu.data.mqtt
/**
* The CiT One's raw received-V2X envelope, as published on the `v2x/rx` MQTT topics.
*
* These topics carry a `RecvV2XMessage` protobuf (CI-CiT MQTT API section 2.4), not JSON: the
* ITS-G5 PDU sits in one bytes field, and the GeoNetworking and BTP headers the stack stripped
* off travel alongside it. That is the CiT One's counterpart to the ESP32-C5 path's
* [com.hawhamburg.micr0bu.data.transport.V2xRxFrame], and it exists for the same reason: the
* app decodes the UPER itself instead of accepting somebody else's summary.
*
* **Why this rather than the Use Case app's JSON.** `v2x-uca/output/json` is a processed,
* rate-limited view. It drops messages, and what it does publish has already been reduced to
* the fields the Use Case app cared about. `v2x/rx` is everything the radio actually heard.
*
* **Why a hand-written reader.** Only three of this envelope's fields are used, protobuf's wire
* format is trivial to walk, and the alternative is adding protoc and the protobuf Gradle plugin
* to an Android build plus vendoring a third-party `.proto` into this repository. The same
* argument the ASN.1 codecs in `domain/asn1/` are built on applies here.
*
* Field numbers below come from consider it's `v2x_interface.proto`, V2X RX protocol v2.4.2.
* They are wire-format constants: changing them silently mis-parses every message, so they are
* pinned by `RecvV2xMessageTest` against a byte fixture rather than left to inspection.
*/
data class RecvV2xMessage(
/**
* `btpHeader.type`, the stack's own idea of which PDU this is: DENM 1, CAM 2, SPATEM 4,
* MAPEM 5. Null when the sender omitted the header. Advisory only, since every decoder
* re-checks the messageID in the ItsPduHeader itself.
*/
val pduType: Int?,
/** `btpHeader.destinationPort`: 2001 CAM, 2002 DENM, 2003 MAPEM, 2004 SPATEM. */
val destinationPort: Int?,
/**
* `gnHeader.dest.area.distA`, metres: the radius of the GeoBroadcast destination area, so
* how far the sender meant its message to apply. Only DENM normally carries one. This is the
* MQTT path's equivalent of the serial prefix's
* [com.hawhamburg.micr0bu.data.transport.V2xRxFrame.GeoArea.radiusMeters].
*/
val destAreaRadiusM: Int?,
/** The ITS-G5 PDU as UPER, ItsPduHeader included. Empty when the field was absent. */
val payload: ByteArray,
) {
// Generated equals/hashCode would compare the payload array by identity, which makes two
// decodes of the same bytes unequal and quietly breaks any test or set that holds these.
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is RecvV2xMessage) return false
return pduType == other.pduType &&
destinationPort == other.destinationPort &&
destAreaRadiusM == other.destAreaRadiusM &&
payload.contentEquals(other.payload)
}
override fun hashCode(): Int {
var result = pduType ?: 0
result = 31 * result + (destinationPort ?: 0)
result = 31 * result + (destAreaRadiusM ?: 0)
result = 31 * result + payload.contentHashCode()
return result
}
companion object {
// RecvV2XMessage
private const val F_BTP_HEADER = 1
private const val F_GN_HEADER = 2
private const val F_PAYLOAD = 3
// BasicTransportProtocolHeader
private const val F_BTP_TYPE = 1
private const val F_BTP_DEST_PORT = 2
// GeoNetworkingHeader
private const val F_GN_DEST = 8
// GNDestination
private const val F_DEST_AREA = 1
// GeoNetworkingArea
private const val F_AREA_DIST_A = 3
/**
* Parses an MQTT payload from a `v2x/rx` topic, or null if it is not a readable
* `RecvV2XMessage` or carries no PDU.
*
* Unknown fields are skipped rather than treated as errors, which is what protobuf
* requires and what keeps this working if consider it adds fields in a later revision.
*/
fun parse(bytes: ByteArray): RecvV2xMessage? {
var pduType: Int? = null
var destPort: Int? = null
var radius: Int? = null
var payload: ByteArray? = null
val reader = ProtoReader(bytes)
while (reader.hasNext()) {
val tag = reader.readTag() ?: return null
when {
tag.field == F_PAYLOAD && tag.wireType == WIRE_LENGTH_DELIMITED ->
payload = reader.readBytes() ?: return null
tag.field == F_BTP_HEADER && tag.wireType == WIRE_LENGTH_DELIMITED -> {
val sub = reader.readBytes() ?: return null
val btp = ProtoReader(sub)
while (btp.hasNext()) {
val t = btp.readTag() ?: return null
when {
t.field == F_BTP_TYPE && t.wireType == WIRE_VARINT ->
pduType = btp.readVarint()?.toInt() ?: return null
t.field == F_BTP_DEST_PORT && t.wireType == WIRE_VARINT ->
destPort = btp.readVarint()?.toInt() ?: return null
else -> if (!btp.skip(t.wireType)) return null
}
}
}
tag.field == F_GN_HEADER && tag.wireType == WIRE_LENGTH_DELIMITED -> {
val sub = reader.readBytes() ?: return null
radius = readDestAreaRadius(sub)
}
else -> if (!reader.skip(tag.wireType)) return null
}
}
// A message with no payload has nothing to decode. Returning it anyway would push an
// empty byte array into the ASN.1 decoders for them to reject one layer later.
val pdu = payload ?: return null
if (pdu.isEmpty()) return null
return RecvV2xMessage(
pduType = pduType,
destinationPort = destPort,
destAreaRadiusM = radius,
payload = pdu,
)
}
/** GeoNetworkingHeader.dest.area.distA, walking two levels down. Null at any break. */
private fun readDestAreaRadius(gnHeader: ByteArray): Int? {
val dest = nestedField(gnHeader, F_GN_DEST) ?: return null
val area = nestedField(dest, F_DEST_AREA) ?: return null
val reader = ProtoReader(area)
while (reader.hasNext()) {
val tag = reader.readTag() ?: return null
if (tag.field == F_AREA_DIST_A && tag.wireType == WIRE_VARINT) {
return reader.readVarint()?.toInt()
}
if (!reader.skip(tag.wireType)) return null
}
return null
}
/** The bytes of the first length-delimited field numbered [field], or null. */
private fun nestedField(bytes: ByteArray, field: Int): ByteArray? {
val reader = ProtoReader(bytes)
while (reader.hasNext()) {
val tag = reader.readTag() ?: return null
if (tag.field == field && tag.wireType == WIRE_LENGTH_DELIMITED) {
return reader.readBytes()
}
if (!reader.skip(tag.wireType)) return null
}
return null
}
}
}
private const val WIRE_VARINT = 0
private const val WIRE_FIXED64 = 1
private const val WIRE_LENGTH_DELIMITED = 2
private const val WIRE_FIXED32 = 5
private data class ProtoTag(val field: Int, val wireType: Int)
/**
* A minimal protobuf wire-format reader: enough to walk a message, read varints and
* length-delimited fields, and skip everything else.
*
* Every read returns null instead of throwing on a malformed or truncated buffer. These bytes
* arrive off a network topic and a decoder that throws on bad input is a decoder that takes the
* MQTT callback thread down with it.
*/
private class ProtoReader(private val buf: ByteArray) {
private var pos = 0
fun hasNext(): Boolean = pos < buf.size
fun readTag(): ProtoTag? {
val raw = readVarint() ?: return null
val field = (raw ushr 3).toInt()
val wireType = (raw and 0x7L).toInt()
if (field <= 0) return null
return ProtoTag(field, wireType)
}
/**
* Reads a base-128 varint. Capped at ten bytes: that is the longest a 64-bit value can be,
* and without the cap a run of 0x80 bytes would walk the reader off the end of the buffer.
*/
fun readVarint(): Long? {
var result = 0L
var shift = 0
while (shift < 64) {
if (pos >= buf.size) return null
val b = buf[pos++].toInt()
result = result or ((b and 0x7F).toLong() shl shift)
if (b and 0x80 == 0) return result
shift += 7
}
return null
}
fun readBytes(): ByteArray? {
val len = readVarint()?.toInt() ?: return null
if (len < 0 || pos + len > buf.size) return null
val out = buf.copyOfRange(pos, pos + len)
pos += len
return out
}
/** Advances past a field of [wireType]. False if the type is unknown or the buffer is short. */
fun skip(wireType: Int): Boolean = when (wireType) {
WIRE_VARINT -> readVarint() != null
WIRE_FIXED64 -> advance(8)
WIRE_LENGTH_DELIMITED -> readBytes() != null
WIRE_FIXED32 -> advance(4)
else -> false // groups (3, 4) are not used by this schema
}
private fun advance(n: Int): Boolean {
if (pos + n > buf.size) return false
pos += n
return true
}
}
@@ -0,0 +1,466 @@
package com.hawhamburg.micr0bu.data.transport
import android.Manifest
import android.annotation.SuppressLint
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCallback
import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothGattDescriptor
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothProfile
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.os.Build
import android.util.Log
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeoutOrNull
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
private const val TAG = "BleLinkTransport"
/**
* BLE GATT central for the micrOBU's station link: the Android counterpart of the firmware's
* `obu-firmware/main/simple_ble.cpp` (from the colleague's microbu-esp32c5) and of their Python
* `microbu_link/ble_transport.py`, which this follows step for step.
*
* ## The GATT layout (what the firmware actually implements)
* The station-link README describes a Nordic-UART-shaped service with fragmentation. The firmware
* does something else, and the firmware is what counts here: a custom service
* `0000C175-BA5E-4C17-8000-00805F9B34FB` with one characteristic per primitive, and each GATT value
* is one complete link message, never fragmented. Requests are written with response to the
* characteristic of their opcode ([writeTarget]); replies, STATUS and V2X_RX arrive as
* notifications. A message may be up to 512 octets, so the ATT MTU must be raised to 517 first:
* the firmware refuses to notify a message that does not fit rather than send it cut short.
*
* ## Pairing
* Every characteristic needs an encrypted, authenticated link. The firmware uses LE Secure
* Connections with a fixed passkey, [PASSKEY] (DisplayOnly). Android shows its own pairing dialog
* the first time; the user types the passkey there, and the bond is kept on both sides. The
* passkey is public, so this gives encryption but no protection against an active attacker
* during that first pairing; acceptable for the demo PKI this carries.
*
* ## RF
* BLE shares the C5's single RF front end with 5.9 GHz ITS-G5. The firmware stops advertising
* while the USB link is in use; whether an active BLE connection disturbs ITS-G5 has not been
* measured yet (TODO.md, "Waiting on hardware").
*
* Like [UsbSerialTransport], an app-scoped singleton: only an explicit disconnect or the process
* dying closes it, never a screen or ViewModel going away.
*/
@Singleton
class BleLinkTransport @Inject constructor(
@ApplicationContext private val context: Context,
) {
companion object {
const val PASSKEY = "123456"
const val NAME_PREFIX = "micrOBU"
private val SERVICE: UUID = UUID.fromString("0000c175-ba5e-4c17-8000-00805f9b34fb")
private val BTP_REQUEST: UUID = UUID.fromString("0000c176-ba5e-4c17-8000-00805f9b34fb")
private val BTP_INDICATION: UUID = UUID.fromString("0000c177-ba5e-4c17-8000-00805f9b34fb")
private val POTI: UUID = UUID.fromString("0000c178-ba5e-4c17-8000-00805f9b34fb")
/** Read-encrypted, returns nothing useful: only used to find out whether the link is secure. */
private val STATUS_CHAR: UUID = UUID.fromString("0000c179-ba5e-4c17-8000-00805f9b34fb")
private val ID_EVENT: UUID = UUID.fromString("0000c17a-ba5e-4c17-8000-00805f9b34fb")
private val CONFIG: UUID = UUID.fromString("0000c17b-ba5e-4c17-8000-00805f9b34fb")
private val RESULT: UUID = UUID.fromString("0000c17c-ba5e-4c17-8000-00805f9b34fb")
private val CCCD: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
private const val REQUESTED_MTU = 517
private const val SCAN_TIMEOUT_MS = 15_000L
/** Long enough for the user to find and type the passkey in the system dialog. */
private const val BOND_TIMEOUT_MS = 60_000L
private const val GATT_OP_TIMEOUT_MS = 5_000L
/** After a working link dropped: try again soon. */
private const val RECONNECT_DELAY_MS = 1_000L
/** After failed attempts: 2, 4, 8, 16, then every 30 s, so a broken pairing does not spin. */
private const val RETRY_BASE_MS = 2_000L
private const val RETRY_MAX_MS = 30_000L
/** ATT status codes Android reports when the link lacks the encryption a characteristic needs. */
private val AUTH_FAILURES = setOf(5, 8, 15, 137)
/** Which characteristic a phone -> micrOBU message is written to, by opcode (as ble_transport.py). */
fun writeTarget(opcode: Int): UUID = when (opcode) {
LinkOpcode.BTP_DATA_REQUEST -> BTP_REQUEST
LinkOpcode.POTI_UPDATE -> POTI
0x08 /* SF_IDCHANGE_EVENT_RESPONSE */ -> ID_EVENT
else -> CONFIG
}
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val bluetoothManager = context.getSystemService(BluetoothManager::class.java)
private val _state = MutableStateFlow(Esp32LinkState.DISCONNECTED)
val state: StateFlow<Esp32LinkState> = _state.asStateFlow()
/** Why the last attempt failed, or what the user has to do (e.g. type the passkey); null when fine. */
private val _detail = MutableStateFlow<String?>(null)
val detail: StateFlow<String?> = _detail.asStateFlow()
private val _incoming = MutableSharedFlow<ByteArray>(extraBufferCapacity = 256)
/** Every link message the micrOBU notifies, one GATT value each. */
val incoming: SharedFlow<ByteArray> = _incoming.asSharedFlow()
/** Name of the connected micrOBU, e.g. "micrOBU-4AF8". */
@Volatile var deviceName: String? = null
private set
@Volatile private var gatt: BluetoothGatt? = null
/** Whether the current GATT connection is up, as the last connection-state callback said. */
@Volatile private var linkUp = false
@Volatile private var wanted = false
private var sessionJob: Job? = null
/** One GATT operation at a time: Android drops a second one issued before the first completes. */
private val gattMutex = Mutex()
@Volatile private var pendingOp: CompletableDeferred<Int>? = null
@Volatile private var connected: CompletableDeferred<Boolean>? = null
@Volatile private var mtuDone: CompletableDeferred<Int>? = null
@Volatile private var servicesDone: CompletableDeferred<Boolean>? = null
fun hasPermissions(): Boolean {
val needed = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
listOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT)
} else {
listOf(Manifest.permission.ACCESS_FINE_LOCATION)
}
return needed.all { context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED }
}
/** Scans for (or reuses the bond with) a micrOBU, pairs if needed, and opens the link. No-op if already under way. */
fun connect() {
if (sessionJob?.isActive == true) return
wanted = true
sessionJob = scope.launch {
var failures = 0
while (wanted) {
val ok = try {
session()
} catch (e: CancellationException) {
throw e // disconnect(): not a failure to report
} catch (e: Exception) {
fail("BLE session failed: ${e.message}")
}
closeGatt()
if (!wanted) break
failures = if (ok) 0 else failures + 1
_state.value = if (ok) Esp32LinkState.DEVICE_ATTACHED else Esp32LinkState.ERROR
delay(if (ok) RECONNECT_DELAY_MS
else minOf(RETRY_MAX_MS, RETRY_BASE_MS shl (failures - 1).coerceAtMost(4)))
}
_state.value = Esp32LinkState.DISCONNECTED
}
}
fun disconnect() {
wanted = false
sessionJob?.cancel()
sessionJob = null
closeGatt()
_state.value = Esp32LinkState.DISCONNECTED
_detail.value = null
}
/**
* Writes one complete link message to the characteristic of its opcode, with response.
* Suspends until the micrOBU acknowledged the write; false when not connected or it failed.
*/
@SuppressLint("MissingPermission")
suspend fun send(message: ByteArray): Boolean {
val g = gatt ?: return false
if (_state.value != Esp32LinkState.CONNECTED || message.isEmpty()) return false
val characteristic = g.getService(SERVICE)?.getCharacteristic(writeTarget(message[0].toInt() and 0xFF))
?: return false
return gattOp {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
g.writeCharacteristic(characteristic, message, BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT) ==
BluetoothGatt.GATT_SUCCESS
} else {
@Suppress("DEPRECATION")
characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
@Suppress("DEPRECATION")
characteristic.value = message
@Suppress("DEPRECATION")
g.writeCharacteristic(characteristic)
}
} == BluetoothGatt.GATT_SUCCESS
}
/** One connection lifetime. Returns true if it reached CONNECTED before ending. */
@SuppressLint("MissingPermission")
private suspend fun session(): Boolean {
if (!hasPermissions()) {
wanted = false
return fail("Bluetooth permission not granted (Android Settings > Apps > MicrOBU > Permissions)")
}
val adapter = bluetoothManager?.adapter
if (adapter == null || !adapter.isEnabled) return fail("Bluetooth is off")
_state.value = Esp32LinkState.DEVICE_ATTACHED
// A bonded micrOBU is reused without scanning: its address is stable (public address), and
// this is what makes a reconnect after a dropout fast.
val device = adapter.bondedDevices.firstOrNull { it.name?.startsWith(NAME_PREFIX) == true }
?: scan() ?: return fail("No micrOBU advertising nearby (is the phone on its USB port?)")
deviceName = device.name
_detail.value = null
Log.i(TAG, "connecting to ${device.name} ${device.address} (bond state ${device.bondState})")
connected = CompletableDeferred()
gatt = device.connectGatt(context, false, callback, BluetoothDevice.TRANSPORT_LE)
if (withTimeoutOrNull(GATT_OP_TIMEOUT_MS * 2) { connected!!.await() } != true) {
return fail("Could not connect to ${device.name}")
}
val g = gatt ?: return false
// Services first: discovery needs no encryption, and the encryption probe below needs the
// STATUS characteristic.
servicesDone = CompletableDeferred()
g.discoverServices()
if (withTimeoutOrNull(GATT_OP_TIMEOUT_MS) { servicesDone!!.await() } != true) {
return fail("Service discovery on ${device.name} timed out")
}
if (g.getService(SERVICE) == null) {
return fail("${device.name} does not offer the station-link service (old firmware?)")
}
// Encryption before anything else. Every characteristic needs an encrypted, authenticated
// link; the board asks for security as soon as a phone connects. A phone it has a bond with
// encrypts with the stored key. Otherwise Android pairs, with its passkey dialog, which takes
// as long as the user takes. A GATT operation with a short timeout during that cuts the
// pairing off, the link drops, and the next attempt starts pairing again: a loop.
val wasBonded = device.bondState == BluetoothDevice.BOND_BONDED
if (!awaitEncryption(g, device)) {
if (!linkUp) return fail("${device.name} dropped the link while pairing")
return fail(
if (wasBonded) "${device.name} refused this phone's stored pairing. In Android's Bluetooth " +
"settings, forget ${device.name}, then Connect again (passkey $PASSKEY)."
else "Pairing with ${device.name} failed or timed out (passkey $PASSKEY)"
)
}
_state.value = Esp32LinkState.DEVICE_ATTACHED
_detail.value = null
mtuDone = CompletableDeferred()
g.requestMtu(REQUESTED_MTU)
val mtu = withTimeoutOrNull(GATT_OP_TIMEOUT_MS) { mtuDone!!.await() } ?: 23
Log.i(TAG, "ATT MTU $mtu")
if (mtu < LINK_MAX_MESSAGE + 3) {
// The board drops a notification that does not fit rather than truncate it (simple_ble.cpp).
Log.w(TAG, "MTU $mtu is below ${LINK_MAX_MESSAGE + 3}: large V2X_RX messages will not arrive")
}
for (uuid in listOf(RESULT, BTP_INDICATION, ID_EVENT)) {
if (!enableNotifications(g, uuid)) return fail("Could not subscribe to ${device.name} notifications")
}
_state.value = Esp32LinkState.CONNECTED
Log.i(TAG, "BLE station link ready: ${device.name}")
// Wait until the link drops (callback completes `connected` anew with false).
val dropped = CompletableDeferred<Boolean>()
connected = dropped
dropped.await()
Log.w(TAG, "BLE link to ${device.name} lost")
return true
}
@SuppressLint("MissingPermission")
private suspend fun scan(): BluetoothDevice? {
val scanner = bluetoothManager?.adapter?.bluetoothLeScanner ?: return null
val found = CompletableDeferred<BluetoothDevice>()
val scanCallback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
val name = result.scanRecord?.deviceName ?: result.device.name
val offersService = result.scanRecord?.serviceUuids?.any { it.uuid == SERVICE } == true
if (offersService || name?.startsWith(NAME_PREFIX) == true) found.complete(result.device)
}
override fun onScanFailed(errorCode: Int) {
Log.w(TAG, "BLE scan failed: $errorCode")
}
}
val settings = ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build()
scanner.startScan(null, settings, scanCallback)
return try {
withTimeoutOrNull(SCAN_TIMEOUT_MS) { found.await() }
} finally {
runCatching { scanner.stopScan(scanCallback) }
}
}
/**
* Returns once the link is encrypted, pairing first if needed; false if that fails or the user
* does not finish within [BOND_TIMEOUT_MS].
*
* The probe is a read of the STATUS characteristic, which needs an encrypted and authenticated
* link, as the colleague's Python transport does. Android answers a read the link is not
* secure enough for by encrypting, or by pairing and showing the passkey dialog, and then
* retries the read itself. While it pairs, the card says which passkey to type.
*/
@SuppressLint("MissingPermission")
private suspend fun awaitEncryption(g: BluetoothGatt, device: BluetoothDevice): Boolean {
val status = g.getService(SERVICE)?.getCharacteristic(STATUS_CHAR) ?: return false
val receiver = object : BroadcastReceiver() {
override fun onReceive(ctx: Context, intent: Intent) {
val changed = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE, BluetoothDevice::class.java)
} else {
@Suppress("DEPRECATION") intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)
}
if (changed?.address != device.address) return
val state = intent.getIntExtra(BluetoothDevice.EXTRA_BOND_STATE, BluetoothDevice.ERROR)
Log.i(TAG, "bond state of ${device.name}: $state")
if (state == BluetoothDevice.BOND_BONDING) {
_state.value = Esp32LinkState.PERMISSION_REQUESTED
_detail.value = "Pair with ${device.name}: enter passkey $PASSKEY"
}
}
}
register(receiver)
try {
if (device.bondState == BluetoothDevice.BOND_BONDING) {
_state.value = Esp32LinkState.PERMISSION_REQUESTED
_detail.value = "Pair with ${device.name}: enter passkey $PASSKEY"
}
// Up to two reads: the first can come back with an authentication error at the moment
// pairing completes, before Android's own retry.
repeat(2) { attempt ->
val result = gattOp(BOND_TIMEOUT_MS) { g.readCharacteristic(status) }
Log.i(TAG, "encryption probe ${attempt + 1}: status $result, bond state ${device.bondState}")
if (result == BluetoothGatt.GATT_SUCCESS) return true
if (result !in AUTH_FAILURES) return false
}
return false
} finally {
runCatching { context.unregisterReceiver(receiver) }
}
}
private fun register(receiver: BroadcastReceiver) {
val filter = IntentFilter(BluetoothDevice.ACTION_BOND_STATE_CHANGED)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.registerReceiver(receiver, filter, Context.RECEIVER_EXPORTED)
} else {
@Suppress("UnspecifiedRegisterReceiverFlag") context.registerReceiver(receiver, filter)
}
}
/** Logs [reason], shows it on the connection card, and ends the attempt. */
private fun fail(reason: String): Boolean {
Log.w(TAG, reason)
_detail.value = reason
return false
}
@SuppressLint("MissingPermission")
private suspend fun enableNotifications(g: BluetoothGatt, uuid: UUID): Boolean {
val characteristic = g.getService(SERVICE)?.getCharacteristic(uuid) ?: return false
if (!g.setCharacteristicNotification(characteristic, true)) return false
val cccd = characteristic.getDescriptor(CCCD) ?: return false
val value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
return gattOp {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
g.writeDescriptor(cccd, value) == BluetoothGatt.GATT_SUCCESS
} else {
@Suppress("DEPRECATION") cccd.value = value
@Suppress("DEPRECATION") g.writeDescriptor(cccd)
}
} == BluetoothGatt.GATT_SUCCESS
}
/** Starts one GATT operation and waits for its callback's status; -1 if it could not start or timed out. */
private suspend fun gattOp(timeoutMs: Long = GATT_OP_TIMEOUT_MS, start: () -> Boolean): Int = gattMutex.withLock {
val op = CompletableDeferred<Int>()
pendingOp = op
if (!start()) {
pendingOp = null
return@withLock -1
}
withTimeoutOrNull(timeoutMs) { op.await() } ?: -1
}
@SuppressLint("MissingPermission")
private fun closeGatt() {
gatt?.let { runCatching { it.disconnect(); it.close() } }
gatt = null
pendingOp?.complete(-1)
connected?.complete(false)
}
private val callback = object : BluetoothGattCallback() {
override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) {
Log.i(TAG, "connection state $newState (status $status)")
when (newState) {
BluetoothProfile.STATE_CONNECTED -> {
linkUp = true
connected?.complete(true)
}
BluetoothProfile.STATE_DISCONNECTED -> {
linkUp = false
connected?.complete(false)
pendingOp?.complete(-1)
if (_state.value == Esp32LinkState.CONNECTED) _state.value = Esp32LinkState.ERROR
}
}
}
override fun onMtuChanged(g: BluetoothGatt, mtu: Int, status: Int) {
mtuDone?.complete(mtu)
}
override fun onServicesDiscovered(g: BluetoothGatt, status: Int) {
servicesDone?.complete(status == BluetoothGatt.GATT_SUCCESS)
}
override fun onDescriptorWrite(g: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
pendingOp?.complete(status)
}
override fun onCharacteristicWrite(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) {
pendingOp?.complete(status)
}
// API 33+ calls this overload; older versions the deprecated one below.
override fun onCharacteristicRead(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic,
value: ByteArray, status: Int) {
pendingOp?.complete(status)
}
@Deprecated("Deprecated in API 33")
override fun onCharacteristicRead(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) {
pendingOp?.complete(status)
}
// API 33+ delivers the value here and no longer calls the deprecated overload below.
override fun onCharacteristicChanged(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray) {
_incoming.tryEmit(value.copyOf())
}
@Deprecated("Deprecated in API 33")
override fun onCharacteristicChanged(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
@Suppress("DEPRECATION")
characteristic.value?.let { _incoming.tryEmit(it.copyOf()) }
}
}
}
@@ -0,0 +1,422 @@
package com.hawhamburg.micr0bu.data.transport
import android.content.Context
import android.os.SystemClock
import android.util.Log
import com.hawhamburg.micr0bu.data.cam.PseudonymManager
import com.hawhamburg.micr0bu.data.mqtt.ObuHardwarePreferences
import com.hawhamburg.micr0bu.domain.asn1.ItsTime
import com.hawhamburg.micr0bu.domain.cam.StationType
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.math.roundToInt
/** Connection lifecycle of the ESP32-C5 link, over either transport. */
enum class Esp32LinkState { DISCONNECTED, DEVICE_ATTACHED, PERMISSION_REQUESTED, CONNECTED, ERROR }
/** Which physical link the phone uses to reach the ESP32-C5 (Settings). */
enum class Esp32Transport(val id: String) { USB("usb"), BLE("ble") }
/** What the phone transmits while a trip records (Settings). */
enum class OutgoingMessage(val id: String) { CAM("cam"), VAM("vam") }
/**
* What the board on the other end speaks. The phone cannot ask, so it listens: the previous
* obu-firmware sends a [SerialFrameType.STATUS] heartbeat, the current one a station-link STATUS.
*/
enum class Esp32Protocol { UNKNOWN, LEGACY_SERIAL, STATION_LINK }
/** One ITS message for the air, with what the micrOBU needs to know about the sender. */
class OutgoingIts(
val kind: OutgoingMessage,
val uper: ByteArray,
/** Pseudonym MAC, station type and position; its [GnPositionVector.tstMs] is the fix time. */
val positionVector: GnPositionVector,
/** Android horizontal accuracy, metres (68 %); null when unknown. */
val accuracyM: Float?,
val signed: Boolean,
)
/**
* The one entry point the app uses to talk to the ESP32-C5: picks USB ([UsbSerialTransport]) or
* BLE ([BleLinkTransport]) from the setting, works out which firmware protocol is on the other end,
* and runs the station-link session the current firmware needs.
*
* ## Station-link session (obu-firmware since 2026-09-23)
* The firmware keeps no state the phone depends on, except what it stores itself (credentials in
* NVS), so the phone sets it up each time it sees it unconfigured:
* 1. STATION_CONFIGURE, with the current pseudonym MAC as the GN address and 802.11 source. This
* also starts the radio, which until then neither transmits nor receives.
* 2. If the answer reports no authorization ticket, CREDENTIALS_PROVISION of the demo bundle in
* `assets/demo-chain.vcr` (a disposable chain, not EU-registered: receivers that verify against
* the EU trust list will drop what it signs). The firmware keeps it in NVS from then on.
* 3. Per message: POTI_UPDATE (the fix, which also sets the micrOBU's ITS clock for the signature
* time), then BTP_DATA_REQUEST, secured or unsecured per the "Sign outgoing messages" setting.
* A pseudonym change reconfigures with the new MAC before the next message goes out. A STATUS
* saying "not configured" (the board reset) starts again at 1.
*
* ## Legacy firmware
* A board still on the previous obu-firmware (0xAA55 frames 0x01-0x05, no signing, USB only)
* keeps working for CAM exactly as before. VAM needs the current firmware.
*
* Everything received is surfaced the old way, as [DecodedFrame]s of [SerialFrameType.V2X_RX], so
* the receive side of the app did not change.
*/
@Singleton
class Esp32Link @Inject constructor(
@ApplicationContext private val context: Context,
private val usb: UsbSerialTransport,
private val ble: BleLinkTransport,
private val prefs: ObuHardwarePreferences,
private val pseudonymManager: PseudonymManager,
) {
companion object {
private const val TAG = "Esp32Link"
private const val REPLY_TIMEOUT_MS = 3_000L
/** Applying a bundle verifies the chain and rebuilds the stack on the C5: seconds, not ms. */
private const val PROVISION_TIMEOUT_MS = 15_000L
private const val SEGMENT_SIZE = 240
private const val DEMO_BUNDLE_ASSET = "demo-chain.vcr"
/**
* A PoTi this far behind the last one sent is a real correction of the phone's clock (e.g.
* GNSS time taking over from a wrong system clock), not a repeated fix; it goes through
* and the micrOBU restarts its stack at the new time once.
*/
private const val CLOCK_STEP_BACK_MS = 60_000L
/** How long a refusal stays on the connection card. */
private const val DETAIL_HOLD_MS = 10_000L
const val BTP_PORT_CAM = 2001
const val BTP_PORT_VAM = 2018
const val ITS_AID_CAM = 36L
const val ITS_AID_VAM = 638L
/** CAM SSP version 1, no special-vehicle permissions: what the demo ticket grants for ITS-AID 36. */
val SSP_CAM = byteArrayOf(0x01, 0x00, 0x00)
/** VRU SSP as the demo ticket grants for ITS-AID 638 (same as the colleague's VBS). */
val SSP_VAM = byteArrayOf(0x01)
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val transport: StateFlow<Esp32Transport> =
prefs.esp32TransportFlow.stateIn(scope, SharingStarted.Eagerly, Esp32Transport.USB)
val state: StateFlow<Esp32LinkState> = combine(transport, usb.state, ble.state) { t, u, b ->
if (t == Esp32Transport.USB) u else b
}.stateIn(scope, SharingStarted.Eagerly, Esp32LinkState.DISCONNECTED)
private val _protocol = MutableStateFlow(Esp32Protocol.UNKNOWN)
val protocol: StateFlow<Esp32Protocol> = _protocol.asStateFlow()
private val _incomingFrames = MutableSharedFlow<DecodedFrame>(extraBufferCapacity = 256)
/** Received ITS messages ([SerialFrameType.V2X_RX]) and, from legacy firmware, its heartbeats. */
val incomingFrames: SharedFlow<DecodedFrame> = _incomingFrames.asSharedFlow()
private val _linkStatus = MutableStateFlow<EspLinkStatus?>(null)
/** Heartbeat counters in the old shape, from either firmware; null before the first one. */
val linkStatus: StateFlow<EspLinkStatus?> = _linkStatus.asStateFlow()
private val _stationStatus = MutableStateFlow<StationStatus?>(null)
/** Full station-link STATUS (signing counters, tickets); null with legacy firmware. */
val stationStatus: StateFlow<StationStatus?> = _stationStatus.asStateFlow()
private val _detail = MutableStateFlow<String?>(null)
/** One line for the UI about the session: pairing, provisioning, or why it is stuck. */
val detail: StateFlow<String?> = _detail.asStateFlow()
private val _consecutiveWriteFailures = MutableStateFlow(0)
val consecutiveWriteFailures: StateFlow<Int> = _consecutiveWriteFailures.asStateFlow()
private val _refusedRequests = MutableStateFlow(0)
/** BTP_DATA_REQUESTs the micrOBU answered with anything but accepted (e.g. no ticket). */
val refusedRequests: StateFlow<Int> = _refusedRequests.asStateFlow()
private val sequence = AtomicInteger(0)
private val pending = ConcurrentHashMap<Int, CompletableDeferred<LinkResult>>()
private val sessionMutex = Mutex()
/** The STATION_CONFIGURE the micrOBU is known to run, or null when it has to be sent again. */
@Volatile private var configured: StationConfigure? = null
@Volatile private var lastRefusalLogMs = 0L
/**
* Timestamp of the last POTI_UPDATE sent in this session, or null when the micrOBU's ITS clock
* has to be set again (new session, board reset).
*/
@Volatile private var lastPotiMs: Long? = null
/** [SystemClock.elapsedRealtime] when [lastPotiMs] was sent: with it, where the micrOBU's clock stands now. */
@Volatile private var lastPotiElapsedMs = 0L
/** The fix time (before any clamping) of the last PoTi sent, to send each fix only once. */
@Volatile private var lastPotiFixMs: Long? = null
init {
scope.launch { usb.incomingFrames.collect { onUsbFrame(it) } }
scope.launch { ble.incoming.collect { onLinkMessage(it) } }
scope.launch { usb.linkStatus.collect { if (it != null) _linkStatus.value = it } }
// A new connection, on either transport, starts a new session.
scope.launch {
state.collect { s ->
if (s != Esp32LinkState.CONNECTED) {
configured = null; lastPotiMs = null; lastPotiFixMs = null
_protocol.value = Esp32Protocol.UNKNOWN
_stationStatus.value = null
_linkStatus.value = null
pending.values.forEach { it.cancel() }
pending.clear()
} else if (transport.value == Esp32Transport.BLE) {
_protocol.value = Esp32Protocol.STATION_LINK // BLE exists only on the current firmware
scope.launch { ensureConfigured(null) }
}
}
}
scope.launch { ble.detail.collect { if (transport.value == Esp32Transport.BLE) _detail.value = it } }
// Switching transport in Settings closes the other one; connecting stays a user action.
scope.launch {
transport.collect { t ->
if (t == Esp32Transport.USB) ble.disconnect() else usb.disconnect()
_detail.value = null
}
}
}
fun connect() {
if (transport.value == Esp32Transport.USB) usb.connect() else ble.connect()
}
fun disconnect() {
usb.disconnect()
ble.disconnect()
}
/**
* Hands one message to the micrOBU for transmission. False when it could not be handed over
* (no link, legacy firmware asked for a VAM, session setup failed); the next message retries.
* Acceptance by the micrOBU is not awaited: a refusal shows up in [refusedRequests].
*/
suspend fun send(its: OutgoingIts): Boolean = withContext(Dispatchers.IO) {
val ok = when (_protocol.value) {
Esp32Protocol.LEGACY_SERIAL -> {
if (its.kind == OutgoingMessage.CAM) {
usb.sendCamTx(its.uper, its.positionVector)
} else {
noteRefusal("VAM needs the current obu-firmware; this board runs the previous one")
false
}
}
Esp32Protocol.STATION_LINK -> sendStationLink(its)
Esp32Protocol.UNKNOWN -> false // no heartbeat yet: nothing to address
}
if (ok) _consecutiveWriteFailures.value = 0 else _consecutiveWriteFailures.value++
ok
}
private suspend fun sendStationLink(its: OutgoingIts): Boolean {
val pv = its.positionVector
if (!ensureConfigured(StationConfigure(stationType = pv.stationType, mid = pv.mac))) return false
// The micrOBU's ITS clock must never be sent backwards: past 1 s it answers
// time_regression and rebuilds its whole stack. Two things tried to. The transmit loops
// re-send the latest GNSS fix every tick while fused location pauses, so an old fix time
// arrived again and again while the micrOBU's clock ran on. And the GNSS-corrected fix
// times themselves wobble by seconds indoors (measured 2026-09-23: -2.1 s, +4.9 s between
// consecutive CAMs). So a fix goes over once, and its timestamp is never below where the
// micrOBU's clock stands now, except for a real correction of the phone clock.
val poti = potiFor(its)
if (poti.timestampMs != lastPotiFixMs) {
val last = lastPotiMs
val microbuNow = last?.let { it + (SystemClock.elapsedRealtime() - lastPotiElapsedMs) }
val timestamp = when {
microbuNow == null -> poti.timestampMs
poti.timestampMs < microbuNow - CLOCK_STEP_BACK_MS -> poti.timestampMs
else -> maxOf(poti.timestampMs, microbuNow)
}
if (!write(LinkOpcode.POTI_UPDATE, poti.copy(timestampMs = timestamp).encode())) return false
lastPotiFixMs = poti.timestampMs
lastPotiMs = timestamp
lastPotiElapsedMs = SystemClock.elapsedRealtime()
}
val request = BtpDataRequest(
destinationPort = if (its.kind == OutgoingMessage.CAM) BTP_PORT_CAM else BTP_PORT_VAM,
itsAid = if (its.kind == OutgoingMessage.CAM) ITS_AID_CAM else ITS_AID_VAM,
securityProfile = if (its.signed) LinkSecurityProfile.SECURED else LinkSecurityProfile.UNSECURED,
permissions = if (its.kind == OutgoingMessage.CAM) SSP_CAM else SSP_VAM,
flSdu = its.uper,
)
return write(LinkOpcode.BTP_DATA_REQUEST, request.encode())
}
/**
* Makes sure the micrOBU runs [wanted] (or, when null, any configuration: used right after a
* BLE connect or a board reset, to start its receiver before the first message goes out).
*/
private suspend fun ensureConfigured(wanted: StationConfigure?): Boolean = sessionMutex.withLock {
val current = configured
if (current != null && (wanted == null || current == wanted)) return@withLock true
val config = wanted ?: StationConfigure(stationType = StationType.CYCLIST, mid = pseudonymManager.current().mac)
_detail.value = "Configuring the micrOBU"
val result = request(LinkOpcode.STATION_CONFIGURE, config.encode(), REPLY_TIMEOUT_MS)
if (result == null || !result.accepted) {
_detail.value = "micrOBU did not accept the configuration (${result?.let { LinkResultCode.name(it.code) } ?: "no reply"})"
return@withLock false
}
val info = StationInfo.decode(result.detail)
Log.i(TAG, "station configured: $info")
if (info == null || !info.credentialsLoaded || info.tickets == 0) {
if (!provisionDemoCredentials()) return@withLock false
}
configured = config
_detail.value = null
true
}
private suspend fun provisionDemoCredentials(): Boolean {
_detail.value = "Provisioning the demo credentials"
val bundle = runCatching { context.assets.open(DEMO_BUNDLE_ASSET).use { it.readBytes() } }.getOrElse {
_detail.value = "Demo credential bundle missing from the app"
return false
}
var offset = 0
while (offset < bundle.size) {
val segment = bundle.copyOfRange(offset, minOf(bundle.size, offset + SEGMENT_SIZE))
val last = offset + segment.size == bundle.size
val result = request(LinkOpcode.CREDENTIALS_PROVISION, credentialsSegment(bundle.size, offset, segment),
if (last) PROVISION_TIMEOUT_MS else REPLY_TIMEOUT_MS)
if (result == null || !result.accepted) {
_detail.value = "micrOBU refused the demo credentials (${result?.let { LinkResultCode.name(it.code) } ?: "no reply"})"
return false
}
if (last) {
val d = result.detail
Log.i(TAG, "demo credentials provisioned: " +
if (d.size == 3) "${d[0]} root(s), ${d[1]} authorit(ies), ${d[2]} ticket(s)" else "no report")
}
offset += segment.size
}
return true
}
private fun potiFor(its: OutgoingIts): PotiUpdate {
val pv = its.positionVector
// Semi-axes of the 95 % ellipse from Android's 68 % radius (circular error), as GnPositionVector's PAI bound.
val semiCm = its.accuracyM?.takeIf { it > 0f && it.isFinite() }
?.let { (it * 1.62f * 100).roundToInt().coerceAtMost(65_535) } ?: 0
return PotiUpdate(
timestampMs = fullTimestampIts(pv.tstMs),
latTenMicroDeg = pv.latTenMicroDeg,
lonTenMicroDeg = pv.lonTenMicroDeg,
semiMajorCm = semiCm,
semiMinorCm = semiCm,
speedCms = pv.speedCms.coerceAtLeast(0),
headingDeciDeg = pv.headingDeciDeg,
pai = pv.pai,
)
}
/** [GnPositionVector.tstMs] is already the full TimestampIts; guard against a reduced one anyway. */
private fun fullTimestampIts(tstMs: Long): Long {
if (tstMs > 0xFFFF_FFFFL) return tstMs
val now = ItsTime.timestampIts(System.currentTimeMillis())
return now - ((now - tstMs) and 0xFFFF_FFFFL)
}
private suspend fun request(opcode: Int, body: ByteArray, timeoutMs: Long): LinkResult? {
val seq = nextSequence()
val reply = CompletableDeferred<LinkResult>()
pending[seq] = reply
return try {
if (!writeMessage(LinkMessage(opcode, seq, body).encode())) null
else withTimeoutOrNull(timeoutMs) { reply.await() }
} finally {
pending.remove(seq)
}
}
private suspend fun write(opcode: Int, body: ByteArray): Boolean =
writeMessage(LinkMessage(opcode, nextSequence(), body).encode())
private suspend fun writeMessage(message: ByteArray): Boolean =
if (transport.value == Esp32Transport.USB) usb.sendFrame(SERIAL_FRAME_LINK, message)
else ble.send(message)
private fun nextSequence(): Int = sequence.incrementAndGet() and 0xFFFF
private fun onUsbFrame(frame: DecodedFrame) {
when (frame.type) {
SerialFrameType.STATUS -> _protocol.value = Esp32Protocol.LEGACY_SERIAL
SerialFrameType.V2X_RX -> _incomingFrames.tryEmit(frame)
SERIAL_FRAME_LINK -> onLinkMessage(frame.payload)
}
}
private fun onLinkMessage(octets: ByteArray) {
val message = LinkMessage.decode(octets) ?: return
when (message.opcode) {
LinkOpcode.V2X_RX -> _incomingFrames.tryEmit(DecodedFrame(SerialFrameType.V2X_RX, message.body))
LinkOpcode.RESULT -> {
val result = LinkResult.decode(message.body) ?: return
val waiting = pending.remove(message.sequence)
if (waiting != null) {
waiting.complete(result)
} else if (result.code == LinkResultCode.TIME_REGRESSION) {
// Only sent for a deliberate clock correction (see CLOCK_STEP_BACK_MS): the
// micrOBU accepted the new time and restarted its stack. Not a refusal.
Log.i(TAG, "micrOBU followed a step back of the phone's clock and restarted its stack")
} else if (!result.accepted) {
// A POTI_UPDATE or BTP_DATA_REQUEST the micrOBU refused (they are not awaited).
_refusedRequests.value++
if (result.code == LinkResultCode.NOT_CONFIGURED) { configured = null; lastPotiMs = null; lastPotiFixMs = null }
noteRefusal("micrOBU refused a request: ${LinkResultCode.name(result.code)}")
}
}
LinkOpcode.STATUS -> {
val status = StationStatus.decode(message.body) ?: return
val first = _protocol.value != Esp32Protocol.STATION_LINK
_protocol.value = Esp32Protocol.STATION_LINK
_stationStatus.value = status
_linkStatus.value = EspLinkStatus(
status = 0,
oversizeDrops = 0,
txFailures = status.radioFailed.coerceAtMost(0xFFFF).toInt(),
rxCrcErrors = status.linkCrcErrors.coerceAtMost(0xFFFF).toInt(),
rxQueueDrops = status.radioDropped.coerceAtMost(0xFFFF).toInt(),
)
// Board reset (or first contact): configure now, so its receiver runs even before
// the first message is sent.
if (!status.configured) { configured = null; lastPotiMs = null; lastPotiFixMs = null }
if (first || !status.configured) scope.launch { ensureConfigured(null) }
}
}
}
private fun noteRefusal(line: String) {
val now = System.currentTimeMillis()
if (now - lastRefusalLogMs < 5_000) return
lastRefusalLogMs = now
Log.w(TAG, line)
_detail.value = line
// A refusal is news, not a state: it leaves the card again unless something replaced it.
scope.launch {
delay(DETAIL_HOLD_MS)
_detail.compareAndSet(line, null)
}
}
}
@@ -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
@@ -14,13 +19,24 @@ object SerialFrameType {
/** Phone -> ESP32: raw CAM UPER bytes to GeoNetworking-wrap and transmit immediately. */ /** Phone -> ESP32: raw CAM UPER bytes to GeoNetworking-wrap and transmit immediately. */
const val CAM_TX: Int = 0x01 const val CAM_TX: Int = 0x01
/** ESP32 -> phone: payload is `[rssi: 1 signed][CAM UPER bytes...]`, already stripped of /** Superseded by [V2X_RX]; the firmware no longer sends this. Kept so the number isn't reused. */
* 802.11/LLC-SNAP/GeoNetworking/BTP-B framing by the firmware's `gn_unwrap.c`. */
const val CAM_RX: Int = 0x02 const val CAM_RX: Int = 0x02
/** ESP32 -> phone: any received ITS message — see [V2xRxFrame] for the payload layout. */
const val V2X_RX: Int = 0x04
/** 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
} }
/** /**
@@ -41,8 +57,9 @@ const val SERIAL_LINK_MAX_PAYLOAD = 512
/** /**
* Decoded [SerialFrameType.STATUS] payload: `[status:1][oversizeDrops:2 LE][txFailures:2 LE] * Decoded [SerialFrameType.STATUS] payload: `[status:1][oversizeDrops:2 LE][txFailures:2 LE]
* [rxCrcErrors:2 LE]` (7 bytes). Counters are free-running totals since firmware boot and * [rxCrcErrors:2 LE][capabilities:1][rxQueueDrops:2 LE]` (10 bytes; the last two fields are an
* saturate at 0xFFFF rather than wrapping. * optional tail — see [capabilities] and [rxQueueDrops]). Counters are free-running totals since
* firmware boot and saturate at 0xFFFF rather than wrapping.
* *
* Exists so the phone can tell "link alive, no traffic" from "link dead", and so firmware-side * Exists so the phone can tell "link alive, no traffic" from "link dead", and so firmware-side
* drops — which otherwise only reach `ESP_LOGW` on the flashing port that the phone isn't * drops — which otherwise only reach `ESP_LOGW` on the flashing port that the phone isn't
@@ -57,10 +74,31 @@ 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,
/**
* Promiscuously-captured frames the firmware's `wifi_promisc_rx_cb` had to drop because its
* RX queue (8 deep) was still full of frames `rx_forward_task` hadn't finished forwarding —
* bytes 8-9 of the payload. 0 for firmware that predates this field (payload of 7 or 8 bytes),
* which is the honest answer: such firmware drops these frames identically, it just never
* counted them. A nonzero, growing value here — as opposed to [oversizeDrops] — points at
* bursty RX outrunning the forward task rather than any one frame being too large.
*/
val rxQueueDrops: 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
@@ -70,6 +108,8 @@ 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,
rxQueueDrops = if (payload.size >= 10) u16(8) else 0,
) )
} }
} }
@@ -93,6 +133,182 @@ object Crc16CcittFalse {
} }
} }
/** BTP-B destination ports (ETSI TS 103 248) the firmware forwards. */
object BtpPort {
const val CAM = 2001
const val DENM = 2002
/**
* Watch the crossover: SPATEM is BTP port **2004** but ItsPduHeader messageID **4**, while
* MAPEM is port 2003 and messageID 5. The two numbering schemes are unrelated, and swapping
* them routes messages to the wrong decoder.
*/
const val SPATEM = 2004
}
/**
* Decoded [SerialFrameType.V2X_RX] payload: a 14-byte little-endian prefix followed by the UPER
* message. Must stay in lockstep with `serial_link.h`'s `SERIAL_V2X_RX_PREFIX_LEN` and the layout
* documented there.
*
* Deliberately generic — [btpPort] says what [uper] is, so adding MAPEM or SPATEM later needs a
* decoder here and one accepted port in the firmware's `gn_unwrap.c`, but no protocol change.
*/
data class V2xRxFrame(
/** 2001 = CAM, 2002 = DENM. See [BtpPort]. */
val btpPort: Int,
/** Received signal strength, dBm, from the firmware's promiscuous RX metadata. */
val rssiDbm: Int,
/**
* GeoBroadcast destination area, or null when the source frame was single-hop broadcast and
* carried none. For a DENM this is the hazard's relevance circle — "applies within
* [GeoArea.radiusMeters] of this point" — which is more useful on a map than the sender's own
* position, since the sender may be relaying for someone else.
*/
val geoArea: GeoArea?,
/** The raw UPER message bytes. */
val uper: ByteArray,
) {
data class GeoArea(val latitude: Double, val longitude: Double, val radiusMeters: Int)
companion object {
const val PREFIX_SIZE = 14
/** Returns null if [payload] is too short to be a well-formed V2X_RX payload. */
fun parse(payload: ByteArray): V2xRxFrame? {
if (payload.size <= PREFIX_SIZE) return null
fun u8(i: Int) = payload[i].toInt() and 0xFF
fun u16(i: Int) = u8(i) or (u8(i + 1) shl 8)
fun i32(i: Int) = u8(i) or (u8(i + 1) shl 8) or (u8(i + 2) shl 16) or (u8(i + 3) shl 24)
val hasArea = (u8(3) and 0x01) != 0
return V2xRxFrame(
btpPort = u16(0),
rssiDbm = payload[2].toInt(), // signed
geoArea = if (hasArea) {
GeoArea(
// GeoNetworking carries these in 1/10 microdegree.
latitude = i32(4) / 1e7,
longitude = i32(8) / 1e7,
radiusMeters = u16(12),
)
} else null,
uper = payload.copyOfRange(PREFIX_SIZE, payload.size),
)
}
}
}
/**
* 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 {
@@ -0,0 +1,274 @@
package com.hawhamburg.micr0bu.data.transport
/**
* Phone side of the station-link message layer, version 1: the protocol of the colleague's
* vanetza-idf ESP32-C5 firmware (microbu-esp32c5/station-link/README.md), which obu-firmware runs
* since 2026-09-23. Kotlin counterpart of `obu-firmware/main/link_protocol.hpp` and of the
* colleague's Python `microbu_link/messages.py`; the unit test pins these encoders to bytes that
* Python module produced.
*
* Transport independent: over USB each message is the payload of one serial frame of type
* [SERIAL_FRAME_LINK] (same 0xAA55 framing as before, see [SerialFrameEncoder]); over BLE each
* message is one GATT value (see [BleLinkTransport]).
*
* Message: `[opcode:1][flags:1][sequence:2 LE][body]`, at most [LINK_MAX_MESSAGE] octets, all
* integers little-endian. The phone numbers its requests; the firmware answers with a RESULT
* carrying the same sequence.
*
* Only what this app uses is implemented: configure, PoTi, BTP-DATA.request, credential
* provisioning, RESULT, STATUS, and the MicrOBU extension [LinkOpcode.V2X_RX]. The SF-SAP
* identifier-change primitives are not used: the app owns its pseudonym (see
* [com.hawhamburg.micr0bu.data.cam.PseudonymManager]) and reconfigures the station on a change.
*/
const val SERIAL_FRAME_LINK = 0x10
const val LINK_MAX_MESSAGE = 512
const val LINK_HEADER_SIZE = 4
object LinkOpcode {
const val STATION_CONFIGURE = 0x01
const val POTI_UPDATE = 0x02
const val BTP_DATA_REQUEST = 0x03
const val CREDENTIALS_PROVISION = 0x04
const val CREDENTIALS_ERASE = 0x05
const val STATUS_REQUEST = 0x0C
const val RESULT = 0x80
const val BTP_DATA_INDICATION = 0x81
const val STATUS = 0x84
/** MicrOBU extension: body is exactly the old [SerialFrameType.V2X_RX] payload ([V2xRxFrame]). */
const val V2X_RX = 0x85
}
/** RESULT codes: vanetza_idf::Result first, then the link's own. */
object LinkResultCode {
const val ACCEPTED = 0
const val TIME_REGRESSION = 7
const val NOT_CONFIGURED = 0x12
fun name(code: Int): String = when (code) {
0 -> "accepted"; 1 -> "invalid_argument"; 2 -> "unsupported"; 3 -> "wrong_entry_point"
4 -> "security_unavailable"; 5 -> "resource_limit"; 6 -> "rejected"; 7 -> "time_regression"
8 -> "identity_change_pending"; 0x10 -> "unknown_opcode"; 0x11 -> "malformed"
0x12 -> "not_configured"; 0x13 -> "busy"; 0x14 -> "no_credentials"
else -> "code_$code"
}
}
/** GN security profile of a BTP-DATA.request (TS 103 300-3 Table 4). */
object LinkSecurityProfile {
const val STATION_DEFAULT = 0
const val UNSECURED = 1
const val SECURED = 2
}
class LinkMessage(val opcode: Int, val sequence: Int, val body: ByteArray, val flags: Int = 0) {
fun encode(): ByteArray {
require(LINK_HEADER_SIZE + body.size <= LINK_MAX_MESSAGE) {
"link message 0x%02x too long: %d".format(opcode, LINK_HEADER_SIZE + body.size)
}
return byteArrayOf(opcode.toByte(), flags.toByte(), sequence.toByte(), (sequence shr 8).toByte()) + body
}
companion object {
fun decode(octets: ByteArray): LinkMessage? {
if (octets.size < LINK_HEADER_SIZE || octets.size > LINK_MAX_MESSAGE) return null
val sequence = (octets[2].toInt() and 0xFF) or ((octets[3].toInt() and 0xFF) shl 8)
return LinkMessage(octets[0].toInt() and 0xFF, sequence,
octets.copyOfRange(LINK_HEADER_SIZE, octets.size), octets[1].toInt() and 0xFF)
}
}
}
internal class LinkWriter {
private val out = java.io.ByteArrayOutputStream()
fun u8(v: Int) = apply { out.write(v and 0xFF) }
fun u16(v: Int) = apply { u8(v); u8(v shr 8) }
fun u32(v: Long) = apply { for (i in 0 until 4) u8((v ushr (8 * i)).toInt()) }
fun i32(v: Int) = u32(v.toLong())
fun u64(v: Long) = apply { for (i in 0 until 8) u8((v ushr (8 * i)).toInt()) }
fun bytes(b: ByteArray) = apply { out.write(b) }
fun toByteArray(): ByteArray = out.toByteArray()
}
/**
* STATION_CONFIGURE body. (Re)creates the GeoNetworking stack and security entity on the micrOBU.
* [mid] is the pseudonym MAC: with [addressConfiguration] 0 (AUTO) it becomes both the GN_ADDR MID
* and the 802.11 source address, as with the old CAM_TX_PV prefix.
*/
data class StationConfigure(
val stationType: Int,
val mid: ByteArray,
val security: Int = 1,
val addressConfiguration: Int = 0,
val beaconing: Int = 0,
val channelNumber: Int = 180,
val transmitPowerDbm: Int = 20,
/** 0 off, 1 receive only, 2 transmit and receive. */
val radio: Int = 2,
/** Raw GN traffic class octet: TC-ID 2, as the previous firmware's geonet.c. */
val defaultTrafficClass: Int = 2,
/** Raw GN lifetime octet: 1 s. */
val defaultLifetime: Int = 0x05,
) {
init { require(mid.size == 6) }
fun encode(): ByteArray = LinkWriter()
.u8(stationType).u8(security).u8(addressConfiguration).bytes(mid).u8(beaconing)
.u16(channelNumber).u8(transmitPowerDbm).u8(radio).u8(defaultTrafficClass).u8(defaultLifetime)
.toByteArray()
override fun equals(other: Any?): Boolean = other is StationConfigure && encode().contentEquals(other.encode())
override fun hashCode(): Int = encode().contentHashCode()
}
/** RESULT detail of STATION_CONFIGURE. */
data class StationInfo(val credentialsLoaded: Boolean, val tickets: Int) {
companion object {
fun decode(detail: ByteArray): StationInfo? =
if (detail.size != 18) null
else StationInfo(detail[16].toInt() != 0, detail[17].toInt() and 0xFF)
}
}
/**
* POTI_UPDATE body (EN 302 890-2 minimum data set). [timestampMs] is TimestampIts under
* [com.hawhamburg.micr0bu.domain.asn1.ItsTime]'s convention; it also sets the micrOBU's ITS clock,
* which the security entity stamps into every signed message's generationTime.
*/
data class PotiUpdate(
val timestampMs: Long,
val latTenMicroDeg: Int,
val lonTenMicroDeg: Int,
val semiMajorCm: Int = 0,
val semiMinorCm: Int = 0,
val orientationDeciDeg: Int = 0,
val altitudeCm: Int? = null,
val speedCms: Int? = null,
val headingDeciDeg: Int? = null,
val pai: Boolean = false,
) {
fun encode(): ByteArray {
val flags = (if (altitudeCm != null) 1 else 0) or (if (speedCms != null) 2 else 0) or
(if (headingDeciDeg != null) 4 else 0) or (if (pai) 8 else 0)
return LinkWriter().u64(timestampMs).i32(latTenMicroDeg).i32(lonTenMicroDeg)
.u16(semiMajorCm).u16(semiMinorCm).u16(orientationDeciDeg).u8(flags)
.i32(altitudeCm ?: 0).u16(speedCms ?: 0).u16(headingDeciDeg ?: 0)
.toByteArray()
}
}
/**
* BTP_DATA_REQUEST body for a BTP-B single-hop broadcast, the only shape this app sends (CAM, VAM).
* [permissions] is the SSP the authorization ticket must carry for [itsAid].
*/
data class BtpDataRequest(
val destinationPort: Int,
val itsAid: Long,
val securityProfile: Int,
val permissions: ByteArray,
val flSdu: ByteArray,
/** Raw GN lifetime octet; 0xFF = station default. */
val maximumPacketLifetime: Int = 0xFF,
) {
fun encode(): ByteArray = LinkWriter()
.u8(1) // BTP-B
.u16(destinationPort)
.u16(0) // destination port info
.u8(1) // SHB
.u8(1) // communication profile ITS-G5
.u8(securityProfile)
.u8(0xFF) // traffic class: station default
.u8(maximumPacketLifetime)
.u8(0) // hop limit: station default
.u16(0).u16(0) // no repetition
.u32(itsAid)
.u8(permissions.size).bytes(permissions)
.u8(0) // no SN-ENCAP context
.u16(flSdu.size).bytes(flSdu)
.toByteArray()
override fun equals(other: Any?): Boolean = other is BtpDataRequest && encode().contentEquals(other.encode())
override fun hashCode(): Int = encode().contentHashCode()
}
/** One CREDENTIALS_PROVISION segment of a `VCR1` bundle. */
fun credentialsSegment(totalLength: Int, offset: Int, segment: ByteArray): ByteArray {
require(segment.size <= 255)
return LinkWriter().u16(totalLength).u16(offset).u8(segment.size).bytes(segment).toByteArray()
}
data class LinkResult(val code: Int, val detail: ByteArray) {
val accepted: Boolean get() = code == LinkResultCode.ACCEPTED
override fun toString(): String = "LinkResult(${LinkResultCode.name(code)}, ${detail.size} B detail)"
override fun equals(other: Any?): Boolean = other is LinkResult && code == other.code && detail.contentEquals(other.detail)
override fun hashCode(): Int = 31 * code + detail.contentHashCode()
companion object {
fun decode(body: ByteArray): LinkResult? {
if (body.size < 2) return null
val length = body[1].toInt() and 0xFF
if (body.size != 2 + length) return null
return LinkResult(body[0].toInt() and 0xFF, body.copyOfRange(2, body.size))
}
}
}
/** STATUS body (103 octets), sent by the micrOBU every second. Counters are since the last configure. */
data class StationStatus(
val uptimeMs: Long,
val configured: Boolean,
val identifier: ByteArray,
val tickets: Int,
val signedMessages: Long,
val refusedNoTicket: Long,
val refusedChangePending: Long,
val refusedPermission: Long,
val signFailed: Long,
val verified: Long,
val rejected: Long,
val requestsAccepted: Long,
val requestsRefused: Long,
val radioSubmitted: Long,
val radioFailed: Long,
val radioReceived: Long,
val radioDropped: Long,
val linkCrcErrors: Long,
val linkMalformed: Long,
val potiUpdates: Long,
val itsTimeMs: Long,
) {
/** Signing refusals of every kind: no usable ticket, a pending id change, or a missing permission. */
val signRefused: Long get() = refusedNoTicket + refusedChangePending + refusedPermission + signFailed
override fun equals(other: Any?): Boolean = other is StationStatus && toString() == other.toString() &&
identifier.contentEquals(other.identifier)
override fun hashCode(): Int = toString().hashCode()
companion object {
const val SIZE = 103
fun decode(body: ByteArray): StationStatus? {
if (body.size != SIZE) return null
fun u8(i: Int) = body[i].toInt() and 0xFF
fun u32(i: Int) = (0 until 4).fold(0L) { acc, k -> acc or ((body[i + k].toLong() and 0xFF) shl (8 * k)) }
fun u64(i: Int) = (0 until 8).fold(0L) { acc, k -> acc or ((body[i + k].toLong() and 0xFF) shl (8 * k)) }
// [0] uptime u32, [4] configured, [5] gn_address 8, [13] identifier 8, [21] change_pending,
// [22] tickets, [23] 18 x u32 counters, [95] its_time u64
val c = 23
return StationStatus(
uptimeMs = u32(0),
configured = u8(4) != 0,
identifier = body.copyOfRange(13, 21),
tickets = u8(22),
signedMessages = u32(c), refusedNoTicket = u32(c + 4), refusedChangePending = u32(c + 8),
refusedPermission = u32(c + 12), signFailed = u32(c + 16), verified = u32(c + 20),
rejected = u32(c + 24), requestsAccepted = u32(c + 28), requestsRefused = u32(c + 32),
// c + 36: indications (the stack's own verified deliveries; the app uses V2X_RX)
radioSubmitted = u32(c + 40), radioFailed = u32(c + 44), radioReceived = u32(c + 48),
radioDropped = u32(c + 52),
// c + 56: link_rx_frames
linkCrcErrors = u32(c + 60), linkMalformed = u32(c + 64), potiUpdates = u32(c + 68),
itsTimeMs = u64(95),
)
}
}
}
@@ -35,9 +35,6 @@ import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
/** Connection lifecycle for the ESP32-C5 USB-serial link. */
enum class UsbSerialState { DISCONNECTED, DEVICE_ATTACHED, PERMISSION_REQUESTED, CONNECTED, ERROR }
private const val ACTION_USB_PERMISSION = "com.hawhamburg.micr0bu.USB_SERIAL_PERMISSION" private const val ACTION_USB_PERMISSION = "com.hawhamburg.micr0bu.USB_SERIAL_PERMISSION"
private const val TAG = "UsbSerialTransport" private const val TAG = "UsbSerialTransport"
@@ -63,6 +60,11 @@ private const val TAG = "UsbSerialTransport"
* Baud rate is not applicable here — USB Serial/JTAG has no baud concept; `setParameters` below * Baud rate is not applicable here — USB Serial/JTAG has no baud concept; `setParameters` below
* is a no-op the library requires anyway for API-shape reasons but the value is otherwise unused. * is a no-op the library requires anyway for API-shape reasons but the value is otherwise unused.
* *
* Since 2026-09-23 the rest of the app does not use this class directly but [Esp32Link], which
* picks this or [BleLinkTransport] and speaks either the previous firmware's frames (0x01-0x05,
* [sendCamTx]) or the current firmware's station-link messages (frame type [SERIAL_FRAME_LINK],
* [sendFrame]) over it.
*
* ## Ownership * ## Ownership
* This is a `@Singleton` shared by the UI ([com.hawhamburg.micr0bu.viewmodel.MqttViewModel]), the * This is a `@Singleton` shared by the UI ([com.hawhamburg.micr0bu.viewmodel.MqttViewModel]), the
* foreground [com.hawhamburg.micr0bu.service.TripRecordingService]'s * foreground [com.hawhamburg.micr0bu.service.TripRecordingService]'s
@@ -111,8 +113,8 @@ class UsbSerialTransport @Inject constructor(
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _state = MutableStateFlow(UsbSerialState.DISCONNECTED) private val _state = MutableStateFlow(Esp32LinkState.DISCONNECTED)
val state: StateFlow<UsbSerialState> = _state.asStateFlow() val state: StateFlow<Esp32LinkState> = _state.asStateFlow()
private val _incomingFrames = MutableSharedFlow<DecodedFrame>(extraBufferCapacity = 256) private val _incomingFrames = MutableSharedFlow<DecodedFrame>(extraBufferCapacity = 256)
/** Every valid frame the ESP32 sends (CAM_RX and STATUS) — callers filter by [DecodedFrame.type]. */ /** Every valid frame the ESP32 sends (CAM_RX and STATUS) — callers filter by [DecodedFrame.type]. */
@@ -150,7 +152,7 @@ class UsbSerialTransport @Inject constructor(
} else { } else {
Log.w(TAG, "usbReceiver: permission denied or device null " + Log.w(TAG, "usbReceiver: permission denied or device null " +
"(granted=$granted, device=$device)") "(granted=$granted, device=$device)")
_state.value = UsbSerialState.ERROR _state.value = Esp32LinkState.ERROR
} }
} }
UsbManager.ACTION_USB_DEVICE_DETACHED -> { UsbManager.ACTION_USB_DEVICE_DETACHED -> {
@@ -174,7 +176,7 @@ class UsbSerialTransport @Inject constructor(
* repeatedly (e.g. from a "retry" UI action) — no-ops if already connected. * repeatedly (e.g. from a "retry" UI action) — no-ops if already connected.
*/ */
fun connect() { fun connect() {
if (_state.value == UsbSerialState.CONNECTED) { if (_state.value == Esp32LinkState.CONNECTED) {
Log.i(TAG, "connect(): already connected, no-op") Log.i(TAG, "connect(): already connected, no-op")
return return
} }
@@ -197,7 +199,7 @@ class UsbSerialTransport @Inject constructor(
"(see device list logged above) - either nothing is attached at the Android " + "(see device list logged above) - either nothing is attached at the Android " +
"USB level, or it's attached but its VID/PID doesn't match any entry in " + "USB level, or it's attached but its VID/PID doesn't match any entry in " +
"customProber's table") "customProber's table")
_state.value = UsbSerialState.DISCONNECTED _state.value = Esp32LinkState.DISCONNECTED
return return
} }
if (espDriver == null) { if (espDriver == null) {
@@ -211,14 +213,14 @@ class UsbSerialTransport @Inject constructor(
Log.i(TAG, "connect(): matched device vid=0x${device.vendorId.toString(16)} " + Log.i(TAG, "connect(): matched device vid=0x${device.vendorId.toString(16)} " +
"pid=0x${device.productId.toString(16)} name=${device.deviceName} " + "pid=0x${device.productId.toString(16)} name=${device.deviceName} " +
"ports=${driver.ports.size}") "ports=${driver.ports.size}")
_state.value = UsbSerialState.DEVICE_ATTACHED _state.value = Esp32LinkState.DEVICE_ATTACHED
if (usbManager.hasPermission(device)) { if (usbManager.hasPermission(device)) {
Log.i(TAG, "connect(): permission already granted, opening directly") Log.i(TAG, "connect(): permission already granted, opening directly")
openDevice(device) openDevice(device)
} else { } else {
Log.i(TAG, "connect(): requesting USB permission from user") Log.i(TAG, "connect(): requesting USB permission from user")
_state.value = UsbSerialState.PERMISSION_REQUESTED _state.value = Esp32LinkState.PERMISSION_REQUESTED
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0 val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0
val permissionIntent = PendingIntent.getBroadcast( val permissionIntent = PendingIntent.getBroadcast(
context, 0, Intent(ACTION_USB_PERMISSION).setPackage(context.packageName), flags, context, 0, Intent(ACTION_USB_PERMISSION).setPackage(context.packageName), flags,
@@ -269,14 +271,14 @@ class UsbSerialTransport @Inject constructor(
if (driver == null || driver.ports.isEmpty()) { if (driver == null || driver.ports.isEmpty()) {
Log.w(TAG, "openDevice(): probeDevice returned null or no ports for " + Log.w(TAG, "openDevice(): probeDevice returned null or no ports for " +
"vid=0x${device.vendorId.toString(16)} pid=0x${device.productId.toString(16)}") "vid=0x${device.vendorId.toString(16)} pid=0x${device.productId.toString(16)}")
_state.value = UsbSerialState.ERROR _state.value = Esp32LinkState.ERROR
return return
} }
val connection = usbManager.openDevice(device) val connection = usbManager.openDevice(device)
if (connection == null) { if (connection == null) {
Log.w(TAG, "openDevice(): usbManager.openDevice() returned null - permission not " + Log.w(TAG, "openDevice(): usbManager.openDevice() returned null - permission not " +
"actually granted, or Android couldn't claim the device") "actually granted, or Android couldn't claim the device")
_state.value = UsbSerialState.ERROR _state.value = Esp32LinkState.ERROR
return return
} }
@@ -287,7 +289,7 @@ class UsbSerialTransport @Inject constructor(
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "openDevice(): port.open()/setParameters() threw", e) Log.e(TAG, "openDevice(): port.open()/setParameters() threw", e)
runCatching { newPort.close() } runCatching { newPort.close() }
_state.value = UsbSerialState.ERROR _state.value = Esp32LinkState.ERROR
return return
} }
@@ -318,7 +320,30 @@ class UsbSerialTransport @Inject constructor(
val frames = decoder.onBytes(data) val frames = decoder.onBytes(data)
frames.forEach { frame -> frames.forEach { frame ->
if (frame.type == SerialFrameType.STATUS) { if (frame.type == SerialFrameType.STATUS) {
EspLinkStatus.parse(frame.payload)?.let { _linkStatus.value = it } EspLinkStatus.parse(frame.payload)?.let { status ->
// Logged only when a counter moves, not on every 1 Hz beat: the
// interesting event is a drop appearing, and a per-second line
// would bury it. Without this the firmware's own drop counters are
// visible only on the CAM Pinger card, so a bench run captured
// through logcat has no record of whether anything was dropped.
val prev = _linkStatus.value
if (prev == null ||
prev.oversizeDrops != status.oversizeDrops ||
prev.txFailures != status.txFailures ||
prev.rxCrcErrors != status.rxCrcErrors ||
prev.status != status.status ||
prev.capabilities != status.capabilities ||
prev.rxQueueDrops != status.rxQueueDrops
) {
Log.i(TAG, "ESP32 counters: status=${status.status} " +
"oversizeDrops=${status.oversizeDrops} " +
"txFailures=${status.txFailures} " +
"rxCrcErrors=${status.rxCrcErrors} " +
"capabilities=${status.capabilities} " +
"rxQueueDrops=${status.rxQueueDrops}")
}
_linkStatus.value = status
}
} }
_incomingFrames.tryEmit(frame) _incomingFrames.tryEmit(frame)
} }
@@ -326,7 +351,7 @@ class UsbSerialTransport @Inject constructor(
override fun onRunError(e: Exception) { override fun onRunError(e: Exception) {
Log.e(TAG, "SerialInputOutputManager.onRunError()", e) Log.e(TAG, "SerialInputOutputManager.onRunError()", e)
_state.value = UsbSerialState.ERROR _state.value = Esp32LinkState.ERROR
} }
}) })
ioManager = manager ioManager = manager
@@ -339,13 +364,13 @@ class UsbSerialTransport @Inject constructor(
_consecutiveWriteFailures.value = 0 _consecutiveWriteFailures.value = 0
_linkStatus.value = null _linkStatus.value = null
lastFrameAtMs = SystemClock.elapsedRealtime() lastFrameAtMs = SystemClock.elapsedRealtime()
_state.value = UsbSerialState.CONNECTED _state.value = Esp32LinkState.CONNECTED
startWatchdog() startWatchdog()
} }
} }
/** /**
* Flips the link to [UsbSerialState.ERROR] once the firmware's 1 Hz STATUS heartbeat has been * Flips the link to [Esp32LinkState.ERROR] once the firmware's 1 Hz STATUS heartbeat has been
* missing for [LINK_TIMEOUT_MS]. Without this, "connected" only ever means "the port opened * missing for [LINK_TIMEOUT_MS]. Without this, "connected" only ever means "the port opened
* at some point in the past" — which on a bench is exactly the wrong thing to believe. * at some point in the past" — which on a bench is exactly the wrong thing to believe.
*/ */
@@ -354,19 +379,38 @@ class UsbSerialTransport @Inject constructor(
watchdogJob = scope.launch { watchdogJob = scope.launch {
while (isActive) { while (isActive) {
delay(WATCHDOG_POLL_MS) delay(WATCHDOG_POLL_MS)
if (_state.value != UsbSerialState.CONNECTED) continue if (_state.value != Esp32LinkState.CONNECTED) continue
val silentFor = SystemClock.elapsedRealtime() - lastFrameAtMs val silentFor = SystemClock.elapsedRealtime() - lastFrameAtMs
if (silentFor > LINK_TIMEOUT_MS) { if (silentFor > LINK_TIMEOUT_MS) {
Log.w(TAG, "watchdog: no frame from ESP32 for ${silentFor}ms (heartbeat " + Log.w(TAG, "watchdog: no frame from ESP32 for ${silentFor}ms (heartbeat " +
"expected at 1 Hz) - marking link ERROR. Either the firmware is wedged/" + "expected at 1 Hz) - marking link ERROR. Either the firmware is wedged/" +
"not running, or the host->device direction opened but device->host " + "not running, or the host->device direction opened but device->host " +
"never did (see the DTR note in openDevice()).") "never did (see the DTR note in openDevice()).")
_state.value = UsbSerialState.ERROR _state.value = Esp32LinkState.ERROR
} }
} }
} }
} }
/** 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
@@ -375,22 +419,57 @@ 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
} catch (e: Exception) { } catch (e: Exception) {
val failures = _consecutiveWriteFailures.updateAndGet { it + 1 } val failures = _consecutiveWriteFailures.updateAndGet { it + 1 }
Log.w(TAG, "sendCamTx(): write failed (consecutive failures: $failures)", e) Log.w(TAG, "sendCamTx(): write failed (consecutive failures: $failures)", e)
_state.value = UsbSerialState.ERROR _state.value = Esp32LinkState.ERROR
false
}
}
/**
* Writes one frame of any [type] (the station-link messages go as [SERIAL_FRAME_LINK]). Same
* failure accounting as [sendCamTx]. Blocking, 200 ms timeout: call from a background dispatcher.
*/
fun sendFrame(type: Int, payload: ByteArray): Boolean {
val p = port
if (p == null) {
_consecutiveWriteFailures.update { it + 1 }
return false
}
return try {
p.write(SerialFrameEncoder.encode(type, payload), /* timeout ms */ 200)
_consecutiveWriteFailures.value = 0
true
} catch (e: Exception) {
val failures = _consecutiveWriteFailures.updateAndGet { it + 1 }
Log.w(TAG, "sendFrame(0x${type.toString(16)}): write failed (consecutive failures: $failures)", e)
_state.value = Esp32LinkState.ERROR
false false
} }
} }
@@ -406,7 +485,7 @@ class UsbSerialTransport @Inject constructor(
openDeviceName = null openDeviceName = null
_linkStatus.value = null _linkStatus.value = null
_consecutiveWriteFailures.value = 0 _consecutiveWriteFailures.value = 0
_state.value = UsbSerialState.DISCONNECTED _state.value = Esp32LinkState.DISCONNECTED
} }
} }
@@ -8,7 +8,7 @@ import javax.inject.Singleton
* UPER (Unaligned Packed Encoding Rules) codec for CAM, used on the ESP32-C5 hardware path * UPER (Unaligned Packed Encoding Rules) codec for CAM, used on the ESP32-C5 hardware path
* (Phase 03, Section 13): the phone builds outgoing CAM itself * (Phase 03, Section 13): the phone builds outgoing CAM itself
* ([com.hawhamburg.micr0bu.domain.cam.PhoneCamBuilder]) and UPER-encodes it before handing bytes * ([com.hawhamburg.micr0bu.domain.cam.PhoneCamBuilder]) and UPER-encodes it before handing bytes
* to [com.hawhamburg.micr0bu.data.transport.UsbSerialTransport], and UPER-decodes whatever the * to [com.hawhamburg.micr0bu.data.transport.Esp32Link], and UPER-decodes whatever the
* ESP32-C5 forwards back on receive (already stripped of 802.11/GeoNetworking/BTP framing by * ESP32-C5 forwards back on receive (already stripped of 802.11/GeoNetworking/BTP framing by
* the firmware's `gn_unwrap.c` — this only ever sees CAM UPER bytes, never raw radio frames). * the firmware's `gn_unwrap.c` — this only ever sees CAM UPER bytes, never raw radio frames).
* *
@@ -28,12 +28,12 @@ import kotlin.math.roundToLong
*/ */
object CamUperCodec { object CamUperCodec {
/** HighFrequencyContainer CHOICE index for rsuContainerHighFrequency. */
private const val HF_CONTAINER_RSU = 1
/** 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
@@ -44,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()
@@ -144,7 +148,17 @@ object CamUperCodec {
?.let { (it * 100.0).roundToInt().coerceIn(-32766, 32766) } ?.let { (it * 100.0).roundToInt().coerceIn(-32766, 32766) }
?: YAW_RATE_UNAVAILABLE ?: YAW_RATE_UNAVAILABLE
bw.putBits(yawRateCentiDegS - (-32766), 16) bw.putBits(yawRateCentiDegS - (-32766), 16)
bw.putBits(7, 3) // yawRateConfidence: unavailable // YawRateConfidence has NINE enumerands (degSec-000-01(0) .. unavailable(8), see
// cdd_1_3_1_1.asn), so UPER needs 4 bits and "unavailable" is 8. This wrote 3 bits with
// value 7 - which is both one bit short and the wrong symbol (7 is outOfRange), shifting
// every field after yawRate for any standards-compliant receiver.
//
// Exactly the same failure mode as the CurvatureCalculationMode note above, and caught
// the same way it should have been the first time: asn1tools, decoding this project's own
// transmitted CAM with the real ETSI modules, rejected it with
// "yawRateConfidence: Expected enumeration index ...". The decoder below read 3 bits too,
// so phone <-> ESP32 agreed with each other and with nothing else.
bw.putBits(8, 4) // yawRateConfidence: unavailable(8)
// ---- LowFrequencyContainer CHOICE ---- extension(0) -> basicVehicleContainerLowFrequency // ---- LowFrequencyContainer CHOICE ---- extension(0) -> basicVehicleContainerLowFrequency
bw.putBits(0, 1) bw.putBits(0, 1)
@@ -217,14 +231,39 @@ object CamUperCodec {
br.getBits(20) // altitudeValue br.getBits(20) // altitudeValue
br.getBits(4) // altitudeConfidence br.getBits(4) // altitudeConfidence
// HighFrequencyContainer is an extensible CHOICE: extension bit, then a 1-bit index
// selecting basicVehicleContainerHighFrequency(0) or rsuContainerHighFrequency(1).
val highFreqExt = br.getBitsInt(1) val highFreqExt = br.getBitsInt(1)
val highFreqIndex = br.getBitsInt(1) val highFreqIndex = br.getBitsInt(1)
if (highFreqExt != 0 || highFreqIndex != 0) return null // extension, or rsuContainerHighFrequency if (highFreqExt != 0) return null // an alternative added in a later revision
if (highFreqIndex == HF_CONTAINER_RSU) {
// An RSU's CAM carries no kinematics at all - RSUContainerHighFrequency holds only an
// optional protected-zone list. Everything meaningful (position, stationType) has
// already been read from the basicContainer above, so return that rather than
// dropping the message: an RSU is exactly the station a rider wants to see, and the
// roadside unit at this bench sends CAM and SPATEM from the same station id.
//
// speed/heading are reported as zero because the model has no "unknown" for them.
// That is safe only because RSU CAMs are kept out of UseCaseDetectionEngine - a
// permanently stationary "vehicle" would otherwise trip the stopped-vehicle use case
// forever. See CamUseCaseRepository.handleCamFromSerial.
return Cam(
stationId = stationId,
stationType = stationType,
latitude = latitude,
longitude = longitude,
speedMps = 0.0,
headingDeg = 0.0,
timestamp = receivedAtEpochMs,
isOwn = false,
)
}
// Optional-presence bitmap for BasicVehicleContainerHighFrequency's 7 trailing OPTIONAL // Optional-presence bitmap for BasicVehicleContainerHighFrequency's 7 trailing OPTIONAL
// fields: accelerationControl, lanePosition, steeringWheelAngle, lateralAcceleration, // fields: accelerationControl, lanePosition, steeringWheelAngle, lateralAcceleration,
// verticalAcceleration, performanceClass, cenDsrcTollingZone (see // verticalAcceleration, performanceClass, cenDsrcTollingZone (see
// C-ITS-Parser/autogen/asn.1/cam_1_4_1.asn). // asn1/cam_1_4_1.asn).
// //
// Consumed but not acted on, and that is correct: UPER writes a SEQUENCE's presence // Consumed but not acted on, and that is correct: UPER writes a SEQUENCE's presence
// bitmap up front but each field's VALUE in declaration order, and all seven of these are // bitmap up front but each field's VALUE in declaration order, and all seven of these are
@@ -266,7 +305,7 @@ object CamUperCodec {
br.getBits(2) // curvatureCalculationMode root index br.getBits(2) // curvatureCalculationMode root index
val yawRateRaw = br.getBitsInt(16) + (-32766) val yawRateRaw = br.getBitsInt(16) + (-32766)
br.getBits(3) // yawRateConfidence br.getBits(4) // yawRateConfidence: 9 enumerands -> 4 bits, see the note in encode()
val yawRateDps = if (yawRateRaw == YAW_RATE_UNAVAILABLE) null else yawRateRaw / 100.0 val yawRateDps = if (yawRateRaw == YAW_RATE_UNAVAILABLE) null else yawRateRaw / 100.0
// Everything after yawRate is deliberately left unread: the 7 optional high-frequency // Everything after yawRate is deliberately left unread: the 7 optional high-frequency
@@ -277,7 +316,7 @@ object CamUperCodec {
// IMPORTANT: if a future change needs any of those - path history is the likely one - the // IMPORTANT: if a future change needs any of those - path history is the likely one - the
// 7 optionals must be parsed and consumed first, in declaration order, or every read after // 7 optionals must be parsed and consumed first, in declaration order, or every read after
// them lands at the wrong bit offset. At that point this hand-written decoder stops being // them lands at the wrong bit offset. At that point this hand-written decoder stops being
// the right tool; use the generated codec (see C-ITS-Parser) instead. // the right tool; use a generated codec (see asn1/README.md) instead.
return Cam( return Cam(
stationId = stationId, stationId = stationId,
stationType = stationType, stationType = stationType,
@@ -0,0 +1,169 @@
package com.hawhamburg.micr0bu.domain.asn1
import com.hawhamburg.micr0bu.domain.denm.DenmEvent
/**
* ASN.1 UPER **decoder** for DENM (ETSI EN 302 637-3 v1.3.1 DENM-PDU-Descriptions +
* TS 102 894-2 v1.3.1 ITS-Container), for messages received over the air on the ESP32-C5 path.
*
* Decode-only by design: this project transmits CAM, not DENM, so there is no encode direction to
* keep symmetric. (`obu-cam-transmistter/main/denm.c` does encode DENM, but that's a separate
* firmware with its own purpose.)
*
* ## Scope
* Decodes the `ManagementContainer` and the `SituationContainer`'s `eventType` — that is, *what*
* the hazard is, *where* it is, and *when* it was detected, which is everything the map and list
* need. It deliberately stops after `causeCode`/`subCauseCode` and does not parse `linkedCause`,
* `eventHistory`, the `LocationContainer` (traces, road type) or the `AlacarteContainer`. Those are
* large, deeply nested, and nothing consumes them yet.
*
* ## Field widths
* Every width below is taken from the ETSI ASN.1 modules in `asn1/` (`denm_1_3_1.asn`, `cdd_1_3_1_1.asn`), and every extension marker was
* cross-checked against how `rasn` renders the same type (`#[non_exhaustive]` marks an extensible
* SEQUENCE). That cross-check matters: hand-derived widths are exactly how this project shipped a
* one-bit `CurvatureCalculationMode` bug in CAM that was invisible until measured against real
* traffic. Two traps worth naming here:
*
* - **`ValidityDuration` is 17 bits**, not 16. It's `INTEGER (0..86400)`, and 86401 values need
* 17 bits. A hand-decode of a real frame landed on `causeCode` 47 instead of 94 purely from
* getting this one wrong — a single bit doubles or halves everything after it.
* - **`ManagementContainer`, `SituationContainer` and `CauseCode` are all extensible**, so each
* needs its own leading extension bit before its optional-presence bitmap. The DENM body
* SEQUENCE is *not* extensible and has no extension bit — only the three optional bits.
*
* Verified end-to-end against a live capture (2026-08-17): a CiT One HLN-SV trigger decodes as
* `causeCode` 94 (stationaryVehicle), `subCauseCode` 0.
*
* Returns null rather than guessing whenever an extension bit is set or a field is out of range —
* a dropped hazard is recoverable (they repeat at 1 Hz), a misplaced one is not.
*/
object DenmUperCodec {
private const val MESSAGE_ID_DENM = 1
private const val PROTOCOL_VERSION = 2
/** TimestampIts epoch: 2004-01-01T00:00:00Z in Unix epoch milliseconds. */
private const val TS_ITS_EPOCH_MS = 1_072_915_200_000L
/**
* Decodes a UPER DENM into a [DenmEvent].
*
* @param receivedAtEpochMs wall-clock receipt time, used only as a fallback if the message's
* own detectionTime is unusable.
* @param rssiDbm signal strength from the serial frame, carried through for range analysis.
* @param relevanceRadiusM the GeoBroadcast destination-area radius from the GeoNetworking
* header, if the frame carried one. Not part of the DENM payload itself.
*/
fun decode(
bytes: ByteArray,
receivedAtEpochMs: Long,
rssiDbm: Int? = null,
relevanceRadiusM: Int? = null,
): DenmEvent? = try {
decodeOrThrow(bytes, receivedAtEpochMs, rssiDbm, relevanceRadiusM)
} catch (e: IndexOutOfBoundsException) {
null // truncated frame
}
private fun decodeOrThrow(
bytes: ByteArray,
receivedAtEpochMs: Long,
rssiDbm: Int?,
relevanceRadiusM: Int?,
): DenmEvent? {
val br = BitReader(bytes)
// ---- ItsPduHeader ---- no extension marker, no optionals, so no preamble.
if (br.getBitsInt(8) != PROTOCOL_VERSION) return null
if (br.getBitsInt(8) != MESSAGE_ID_DENM) return null
br.getBits(32) // header stationID - actionID.originatingStationID below is the identity
// ---- DecentralizedEnvironmentalNotificationMessage ----
// NOT extensible (rasn renders it without #[non_exhaustive]), so three optional bits only
// and no leading extension bit.
val situationPresent = br.getBitsInt(1) == 1
br.getBits(1) // location container present - not parsed
br.getBits(1) // alacarte container present - not parsed
// ---- ManagementContainer ---- extensible: 1 extension bit + 5 optional/DEFAULT bits.
if (br.getBitsInt(1) != 0) return null // extension in use - can't trust later offsets
val terminationPresent = br.getBitsInt(1) == 1
val relevanceDistancePresent = br.getBitsInt(1) == 1
val relevanceTrafficDirectionPresent = br.getBitsInt(1) == 1
val validityDurationPresent = br.getBitsInt(1) == 1
val transmissionIntervalPresent = br.getBitsInt(1) == 1
// actionID: the real ETSI identity of an event. Successive repetitions of the same hazard
// reuse it, and GeoBroadcast means several stations may relay the same DENM - so this, not
// the radio source, is what dedup must key on.
val originatingStationId = br.getBits(32)
val sequenceNumber = br.getBitsInt(16)
val detectionTimeIts = br.getBits(42) // TimestampIts (0..4398046511103) -> 42 bits
br.getBits(42) // referenceTime - not used
val isTermination = if (terminationPresent) {
// Termination ::= ENUMERATED {isCancellation(0), isNegation(1)} - 2 values, not
// extensible, so a single bit. Either value means "this event is over".
br.getBits(1); true
} else false
// ---- eventPosition: ReferencePosition ---- same layout as CAM's, see CamUperCodec.
val latitude = (br.getBits(31) + (-900000000L)) / 1e7
val longitude = (br.getBits(32) + (-1800000000L)) / 1e7
br.getBits(12) // semiMajorConfidence
br.getBits(12) // semiMinorConfidence
br.getBits(12) // semiMajorOrientation
br.getBits(20) // altitudeValue
br.getBits(4) // altitudeConfidence
if (relevanceDistancePresent) br.getBits(3) // ENUMERATED, 8 values
if (relevanceTrafficDirectionPresent) br.getBits(2) // ENUMERATED, 4 values
if (validityDurationPresent) br.getBits(17) // INTEGER (0..86400) -> 17 bits
if (transmissionIntervalPresent) br.getBits(14) // INTEGER (1..10000) -> 14 bits
val stationType = br.getBitsInt(8)
// ---- SituationContainer ---- carries what the hazard actually is. Optional in the
// grammar; without it there is no causeCode and the event is not worth showing.
var causeCode: Int? = null
var subCauseCode: Int? = null
if (situationPresent) {
if (br.getBitsInt(1) != 0) return null // extensible: extension in use
br.getBits(1) // linkedCause present - not parsed
br.getBits(1) // eventHistory present - not parsed
br.getBits(3) // informationQuality (0..7)
// CauseCode is itself an extensible SEQUENCE, so it has its own extension bit before
// its two 8-bit fields. Omitting this bit is what made a real frame read 47 instead
// of 94.
if (br.getBitsInt(1) != 0) return null
causeCode = br.getBitsInt(8)
subCauseCode = br.getBitsInt(8)
}
// Everything after this point - the rest of the SituationContainer, the LocationContainer
// and the AlacarteContainer - is deliberately unread. Safe because nothing above depends
// on it; if any of it is ever needed, the unparsed optionals must be consumed in order
// first or every later read lands at the wrong bit offset.
val detectionTimeMs = detectionTimeIts + TS_ITS_EPOCH_MS
return DenmEvent(
stationId = originatingStationId,
sequenceNumber = sequenceNumber,
latitude = latitude,
longitude = longitude,
causeCode = causeCode,
subCauseCode = subCauseCode,
stationType = stationType,
isTermination = isTermination,
detectionTimeMs = detectionTimeMs.takeIf { it in 0..(receivedAtEpochMs + DAY_MS) },
relevanceRadiusM = relevanceRadiusM,
rssiDbm = rssiDbm,
timestamp = receivedAtEpochMs,
)
}
private const val DAY_MS = 86_400_000L
}
@@ -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)
}
@@ -0,0 +1,198 @@
package com.hawhamburg.micr0bu.domain.asn1
import com.hawhamburg.micr0bu.domain.spat.IntersectionSignalState
import com.hawhamburg.micr0bu.domain.spat.SignalMovement
import com.hawhamburg.micr0bu.domain.spat.SignalPhase
import com.hawhamburg.micr0bu.domain.spat.SignalPhaseEvent
import com.hawhamburg.micr0bu.domain.spat.SpatEvent
/**
* ASN.1 UPER **decoder** for SPATEM (ETSI TS 103 301 / SAE J2735 DSRC), for messages received over
* the air on the ESP32-C5 path.
*
* Decode-only: this project never transmits SPATEM, that is an RSU's job.
*
* ## Field widths
* Every width is taken from the ETSI ASN.1 modules in `asn1/` (`dsrc_2_2_1.asn`, `cdd_2_2_1.asn`):
*
* - `MinuteOfTheYear` (0..527040) = 20 bits
* - `DSecond` (0..65535) = 16 bits
* - `MsgCount` (0..127) = 7 bits
* - `SignalGroupID` / `LaneID` / `LaneConnectionID` (0..255) = 8 bits
* - `IntersectionID` / `RoadRegulatorID` (0..65535) = 16 bits
* - `TimeMark` (0..36001) = 16 bits
* - `TimeIntervalConfidence` (0..15) = 4 bits
* - `ZoneLength` (0..10000) = 14 bits
* - `IntersectionStatusObject` BIT STRING SIZE(16) = 16 bits
*
* A `SEQUENCE (SIZE(lo..hi)) OF` writes its count in `ceil(log2(hi-lo+1))` bits holding `n-lo`:
* intersections 1..32 gives 5 bits, movements 1..255 gives 8, events 1..16 gives 4.
*
* Two traps worth naming, both of which silently shift every later field:
* - `TimeChangeDetails` is **not** extensible, so it has 5 optional bits and no extension bit,
* unlike almost every other SEQUENCE here, which all carry one.
* - `maneuverAssistList` cannot be skipped when present. It is variable-length, so the only way
* to reach the next movement is to walk it, even though nothing here consumes it.
*
* ## Verification
* The layout was validated by replaying **79,042 real SPATEMs**, the entire 2026-03-18 drive
* (7+ RSUs) plus the live bench trigger, through a port of this decoder and comparing every field
* against `asn1tools` decoding the same bytes with the real ETSI modules. All 79,042 matched
* exactly, with no message hitting an unsupported branch.
*
* Returns null rather than guessing whenever an extension bit is set or an unsupported optional
* appears: a dropped SPATEM is recoverable (they repeat at ~2 Hz), a misread one shows a driver
* the wrong light.
*/
object SpatemUperCodec {
private const val MESSAGE_ID_SPATEM = 4
private const val PROTOCOL_VERSION = 2
/** Guards against a malformed length field turning into a long decode loop. */
private const val MAX_INTERSECTIONS = 32
private const val MAX_MOVEMENTS = 255
fun decode(bytes: ByteArray, receivedAtEpochMs: Long, rssiDbm: Int? = null): SpatEvent? = try {
decodeOrNull(bytes, receivedAtEpochMs, rssiDbm)
} catch (e: IndexOutOfBoundsException) {
null // truncated frame
}
private fun decodeOrNull(bytes: ByteArray, receivedAtEpochMs: Long, rssiDbm: Int?): SpatEvent? {
val br = BitReader(bytes)
// ---- ItsPduHeader ---- not extensible, no optionals, so no preamble.
if (br.getBitsInt(8) != PROTOCOL_VERSION) return null
if (br.getBitsInt(8) != MESSAGE_ID_SPATEM) return null
val stationId = br.getBits(32)
// ---- SPAT ---- extensible: extension bit, then timeStamp/name/regional optional bits.
if (br.getBitsInt(1) != 0) return null
val hasTimeStamp = br.getBitsInt(1) == 1
val hasName = br.getBitsInt(1) == 1
val hasRegional = br.getBitsInt(1) == 1
val minuteOfYear = if (hasTimeStamp) br.getBitsInt(20) else null
// DescriptiveName is a variable-length IA5String. Nothing in 79k real messages uses it,
// and guessing its length would desynchronise everything after it.
if (hasName) return null
val intersectionCount = br.getBitsInt(5) + 1
if (intersectionCount > MAX_INTERSECTIONS) return null
val intersections = ArrayList<IntersectionSignalState>(intersectionCount)
repeat(intersectionCount) {
intersections.add(readIntersection(br) ?: return null)
}
if (hasRegional) return null
return SpatEvent(
stationId = stationId,
minuteOfYear = minuteOfYear,
intersections = intersections,
rssiDbm = rssiDbm,
timestamp = receivedAtEpochMs,
)
}
private fun readIntersection(br: BitReader): IntersectionSignalState? {
if (br.getBitsInt(1) != 0) return null // IntersectionState extension
val hasName = br.getBitsInt(1) == 1
val hasMoy = br.getBitsInt(1) == 1
val hasTimeStamp = br.getBitsInt(1) == 1
val hasEnabledLanes = br.getBitsInt(1) == 1
val hasManeuvers = br.getBitsInt(1) == 1
val hasRegional = br.getBitsInt(1) == 1
if (hasName) return null
// IntersectionReferenceID - not extensible, one optional bit for region.
val region = if (br.getBitsInt(1) == 1) br.getBitsInt(16) else null
val id = br.getBitsInt(16)
val revision = br.getBitsInt(7)
br.getBits(16) // IntersectionStatusObject - not surfaced yet
val moy = if (hasMoy) br.getBitsInt(20) else null
val timeStampMs = if (hasTimeStamp) br.getBitsInt(16) else null
if (hasEnabledLanes) {
repeat(br.getBitsInt(4) + 1) { br.getBits(8) } // EnabledLaneList SIZE(1..16) OF LaneID
}
val movementCount = br.getBitsInt(8) + 1
if (movementCount > MAX_MOVEMENTS) return null
val movements = ArrayList<SignalMovement>(movementCount)
repeat(movementCount) {
movements.add(readMovement(br) ?: return null)
}
if (hasManeuvers && !skipManeuverAssistList(br)) return null
if (hasRegional) return null
return IntersectionSignalState(region, id, revision, moy, timeStampMs, movements)
}
private fun readMovement(br: BitReader): SignalMovement? {
if (br.getBitsInt(1) != 0) return null // MovementState extension
val hasName = br.getBitsInt(1) == 1
val hasManeuvers = br.getBitsInt(1) == 1
val hasRegional = br.getBitsInt(1) == 1
if (hasName) return null
val signalGroup = br.getBitsInt(8)
val eventCount = br.getBitsInt(4) + 1
val events = ArrayList<SignalPhaseEvent>(eventCount)
repeat(eventCount) {
if (br.getBitsInt(1) != 0) return null // MovementEvent extension
val hasTiming = br.getBitsInt(1) == 1
val hasSpeeds = br.getBitsInt(1) == 1
val hasRegionalEvent = br.getBitsInt(1) == 1
val phase = SignalPhase.fromWire(br.getBitsInt(4)) ?: return null
var minEnd: Int? = null
var maxEnd: Int? = null
var likely: Int? = null
if (hasTiming) {
// TimeChangeDetails: NOT extensible - 5 optional bits, no extension bit.
val hasStart = br.getBitsInt(1) == 1
val hasMaxEnd = br.getBitsInt(1) == 1
val hasLikely = br.getBitsInt(1) == 1
val hasConfidence = br.getBitsInt(1) == 1
val hasNext = br.getBitsInt(1) == 1
if (hasStart) br.getBits(16)
minEnd = br.getBitsInt(16)
if (hasMaxEnd) maxEnd = br.getBitsInt(16)
if (hasLikely) likely = br.getBitsInt(16)
if (hasConfidence) br.getBits(4)
if (hasNext) br.getBits(16)
}
if (hasSpeeds || hasRegionalEvent) return null
events.add(SignalPhaseEvent(phase, minEnd, maxEnd, likely))
}
if (hasManeuvers && !skipManeuverAssistList(br)) return null
if (hasRegional) return null
return SignalMovement(signalGroup, events)
}
/**
* Walks a `ManeuverAssistList` without keeping it. Nothing consumes queue lengths yet, but the
* list is variable-length, so it has to be parsed to find where the next field starts.
*
* Returns false if it contains something this decoder cannot size, in which case the whole
* message must be abandoned - the bit position is no longer trustworthy.
*/
private fun skipManeuverAssistList(br: BitReader): Boolean {
repeat(br.getBitsInt(4) + 1) { // SIZE(1..16)
if (br.getBitsInt(1) != 0) return false // ConnectionManeuverAssist extension
val hasQueue = br.getBitsInt(1) == 1
val hasStorage = br.getBitsInt(1) == 1
val hasWaitOnStop = br.getBitsInt(1) == 1
val hasPedBicycle = br.getBitsInt(1) == 1
val hasRegional = br.getBitsInt(1) == 1
br.getBits(8) // connectionID
if (hasQueue) br.getBits(14) // ZoneLength (0..10000)
if (hasStorage) br.getBits(14)
if (hasWaitOnStop) br.getBits(1) // BOOLEAN
if (hasPedBicycle) br.getBits(1) // BOOLEAN
if (hasRegional) return false
}
return true
}
}
@@ -0,0 +1,145 @@
package com.hawhamburg.micr0bu.domain.asn1
import com.hawhamburg.micr0bu.domain.cam.StationType
import kotlin.math.roundToInt
import kotlin.math.roundToLong
/**
* One VAM's content, in the units the phone has. Everything optional here is encoded as the ASN.1
* "unavailable" value when null, never as a made-up number.
*/
data class VamContent(
val stationId: Long,
/** Wall-clock epoch ms of the fix, GNSS-corrected; the generationDeltaTime source. */
val timestamp: Long,
val latitude: Double,
val longitude: Double,
/** Android horizontal accuracy, metres (68 %). Null or 0: unknown. */
val accuracyM: Float?,
val speedMps: Double,
val headingDeg: Double,
/** Along-track acceleration, m/s². */
val accelerationMps2: Double? = null,
/** Include the low-frequency container (profile and size class). */
val includeLowFrequency: Boolean,
)
/**
* UPER encoder for the VAM of ETSI TS 103 300-3 V2.3.1 (VAM-PDU-Descriptions major version 3,
* over the CDD of TS 102 894-2 V2.4.1), for a bicyclist in VRU profile 2.
*
* Covers the same field set as the colleague's reference VBS (microbu-esp32c5,
* station-link/python/microbu_link/vbs.py, `VbsLite.assemble`), which encodes with asn1tools from
* the ETSI modules: header, basic container, the three mandatory fields of the high-frequency
* container, and optionally the low-frequency container with profile and size class. No cluster
* or motion-prediction containers. The unit test cross-checks the bytes against asn1tools.
*
* Transmit only for now: the app neither decodes received VAMs nor shows them.
*/
object VamUperCodec {
const val PROTOCOL_VERSION = 3
const val MESSAGE_ID_VAM = 16
/** VruSubProfileBicyclist.bicyclist */
private const val SUBPROFILE_BICYCLIST = 1
/** VruSizeClass.low */
private const val SIZE_CLASS_LOW = 1
/** VruProfileAndSubprofile CHOICE index of bicyclistAndLightVruVehicle (root: 4 alternatives). */
private const val PROFILE_BICYCLIST_INDEX = 1
private const val SEMI_AXIS_OUT_OF_RANGE = 4094
private const val SEMI_AXIS_UNAVAILABLE = 4095
private const val WGS84_ANGLE_UNAVAILABLE = 3601
private const val ANGLE_CONFIDENCE_UNAVAILABLE = 127
private const val SPEED_OUT_OF_RANGE = 16382
private const val SPEED_CONFIDENCE_UNAVAILABLE = 127
private const val ACCEL_UNAVAILABLE = 161
private const val ACCEL_CONFIDENCE_UNAVAILABLE = 102
private const val ALTITUDE_UNAVAILABLE = 800001
private const val ALTITUDE_CONFIDENCE_UNAVAILABLE = 15
/**
* Android's accuracy is a 68 % radius; the confidence ellipse is 95 %. For a circular 2-D error
* the ratio is about 1.62, the same factor [com.hawhamburg.micr0bu.data.transport.GnPositionVector]
* uses for its PAI bound.
*/
private const val ACCURACY_68_TO_95 = 1.62
private const val ENCODE_BUFFER_BYTES = 64
fun encode(vam: VamContent): ByteArray {
val w = BitWriter(ENCODE_BUFFER_BYTES)
// VAM ::= SEQUENCE { header, vam } -- not extensible
// ItsPduHeader
w.putBits(PROTOCOL_VERSION, 8)
w.putBits(MESSAGE_ID_VAM, 8)
w.putBits(vam.stationId and 0xFFFFFFFFL, 32)
// VruAwareness ::= SEQUENCE { generationDeltaTime, vamParameters }
w.putBits(CamUperCodec.generationDeltaTime(vam.timestamp), 16)
// VamParameters ::= SEQUENCE { basic, hf, lf OPT, clusterInfo OPT, clusterOp OPT, motion OPT, ... }
w.putBits(0, 1) // extension bit
w.putBits(if (vam.includeLowFrequency) 0b1000 else 0b0000, 4)
// BasicContainer ::= SEQUENCE { stationType, referencePosition, ... }
w.putBits(0, 1)
w.putBits(StationType.CYCLIST, 8)
// ReferencePositionWithConfidence ::= SEQUENCE { latitude, longitude, ellipse, altitude }
w.putBits(latitude(vam.latitude) + 900_000_000L, 31)
w.putBits(longitude(vam.longitude) + 1_800_000_000L, 32)
val semiAxis = semiAxisCm(vam.accuracyM)
w.putBits(semiAxis, 12) // semiMajorAxisLength
w.putBits(semiAxis, 12) // semiMinorAxisLength
// Circular error: the orientation of the major axis says nothing, so it is unavailable.
w.putBits(WGS84_ANGLE_UNAVAILABLE, 12)
// Altitude: GnssReading carries no "has altitude" flag, so an honest unavailable.
w.putBits(ALTITUDE_UNAVAILABLE + 100_000, 20)
w.putBits(ALTITUDE_CONFIDENCE_UNAVAILABLE, 4)
// VruHighFrequencyContainer ::= SEQUENCE { heading, speed, longitudinalAcceleration, 11 OPTIONAL, ... }
w.putBits(0, 1)
w.putBits(0, 11)
w.putBits(headingDeciDeg(vam.headingDeg), 12)
w.putBits(ANGLE_CONFIDENCE_UNAVAILABLE - 1, 7) // Wgs84AngleConfidence (1..127)
w.putBits(speedCms(vam.speedMps), 14)
w.putBits(SPEED_CONFIDENCE_UNAVAILABLE - 1, 7) // SpeedConfidence (1..127)
w.putBits(accelDeciMps2(vam.accelerationMps2) + 160, 9)
w.putBits(ACCEL_CONFIDENCE_UNAVAILABLE, 7)
if (vam.includeLowFrequency) {
// VruLowFrequencyContainer ::= SEQUENCE { profileAndSubprofile, sizeClass OPT, exteriorLights OPT, ... }
w.putBits(0, 1)
w.putBits(0b10, 2)
// VruProfileAndSubprofile ::= CHOICE { pedestrian, bicyclistAndLightVruVehicle, motorcyclist, animal, ... }
w.putBits(0, 1)
w.putBits(PROFILE_BICYCLIST_INDEX, 2)
w.putBits(SUBPROFILE_BICYCLIST, 4)
w.putBits(SIZE_CLASS_LOW, 4)
}
return w.toByteArray()
}
private fun latitude(deg: Double): Long =
if (deg.isFinite()) (deg * 1e7).roundToLong().coerceIn(-900_000_000L, 900_000_000L) else 900_000_001L
private fun longitude(deg: Double): Long =
if (deg.isFinite()) (deg * 1e7).roundToLong().coerceIn(-1_799_999_999L, 1_800_000_000L) else 1_800_000_001L
private fun semiAxisCm(accuracyM: Float?): Int {
if (accuracyM == null || !accuracyM.isFinite() || accuracyM <= 0f) return SEMI_AXIS_UNAVAILABLE
val cm = (accuracyM * ACCURACY_68_TO_95 * 100).roundToInt()
return if (cm >= SEMI_AXIS_OUT_OF_RANGE) SEMI_AXIS_OUT_OF_RANGE else cm.coerceAtLeast(1)
}
private fun headingDeciDeg(deg: Double): Int =
if (deg.isFinite()) Math.floorMod((deg * 10).roundToInt(), 3600) else WGS84_ANGLE_UNAVAILABLE
private fun speedCms(mps: Double): Int =
if (mps.isFinite()) (mps * 100).roundToInt().coerceIn(0, SPEED_OUT_OF_RANGE) else 16383
private fun accelDeciMps2(mps2: Double?): Int =
if (mps2 == null || !mps2.isFinite()) ACCEL_UNAVAILABLE else (mps2 * 10).roundToInt().coerceIn(-160, 160)
}
@@ -8,6 +8,12 @@ package com.hawhamburg.micr0bu.domain.cam
object StationType { object StationType {
const val CYCLIST = 2 const val CYCLIST = 2
const val PASSENGER_CAR = 5 const val PASSENGER_CAR = 5
/**
* Roadside infrastructure. Note the gap: the enumeration runs 0..11 then jumps to 15, so this
* is 15 and not 12 - mapping by list position mislabels every RSU.
*/
const val ROAD_SIDE_UNIT = 15
} }
/** /**
@@ -47,8 +47,12 @@ internal object JsonFieldReader {
if (lat != null && lon != null) return lat to lon if (lat != null && lon != null) return lat to lon
} }
val geoJson = obj.optJSONObject("position") // A GeoJSON Point may be nested under "position", or `obj` may BE the Point itself - the
val coords = geoJson?.optJSONArray("coordinates") // Use Case API's DENM sends `"eventPosition": {"type":"Point","coordinates":[...]}`, so the
// caller passes eventPosition in directly. Missing this second case meant every DENM was
// rejected for having no position.
val coords = obj.optJSONObject("position")?.optJSONArray("coordinates")
?: obj.optJSONArray("coordinates")
if (coords != null && coords.length() >= 2) { if (coords != null && coords.length() >= 2) {
// GeoJSON coordinate order is [longitude, latitude, altitude?] // GeoJSON coordinate order is [longitude, latitude, altitude?]
val lon = coords.optDouble(0, Double.NaN) val lon = coords.optDouble(0, Double.NaN)
@@ -0,0 +1,81 @@
package com.hawhamburg.micr0bu.domain.cam
/**
* Which station IDs belong to this phone, and therefore must never be treated as another road
* user when a frame comes back off the air.
*
* ## Why this exists
* A receiver that fails to recognise its own transmissions tracks itself: a station sitting exactly
* on top of the ego position, moving at the ego's own speed and heading, handed to
* [com.hawhamburg.micr0bu.domain.usecase.UseCaseDetectionEngine] as a collision partner for itself.
* The phone's own frames can come back to it off the air, for example relayed by the CiT One's
* radio when a phone is connected to both OBUs at once.
*
* ## Which IDs count
* - 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].
*
* ## Why the bench ID is conditional
* It used to count as ours unconditionally, on every phone, and that hid other phones' pings. On
* the 2026-09-10 bench one phone pinged through an ESP32 while a second phone watched through the
* CiT One, and the watcher silently discarded every ping as its own frame heard back, although it
* had sent none. A fixed ID shared by every MicrOBU is only ours on the phone actually using it.
* The one case this cannot resolve is two phones pinging at the same time: each hides the other.
*/
object OwnStationIds {
/**
* The bench pinger's station ID. Fixed rather than derived so a ping is recognisable at a
* glance in a capture or a log line.
*/
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.
*
* [ownIds] is every non-bench ID currently counted as ours: the current and recently retired
* transmit pseudonyms, plus the CiT One's own ID on that path. [benchPingIsOurs] says whether
* [BENCH_PING] is ours right now; see the function of the same name.
*
* Station ID 0 is never ours: it is the "not known yet" placeholder used while the ego
* identity is still being resolved, and matching on it would swallow real traffic.
*/
fun isOwn(stationId: Long, ownIds: Set<Long>, benchPingIsOurs: Boolean): Boolean {
if (stationId == 0L) return false
if (stationId == BENCH_PING) return benchPingIsOurs
return stationId in ownIds
}
}
@@ -0,0 +1,30 @@
package com.hawhamburg.micr0bu.domain.cam
/**
* A tally of this phone's own transmissions heard back off the air.
*
* On the ESP32-C5 path the radio receives promiscuously, so a frame the phone sent out over the
* serial link comes back through the receive path a moment later. Those frames are deliberately
* kept out of the detection engine, since the phone is not a road user to itself, but they are
* worth counting: a frame completing that round trip is direct evidence that the serial link, the
* ESP32's transmit path and its receive path all work. That is exactly what
* [com.hawhamburg.micr0bu.service.CamPinger] exists to demonstrate.
*
* Compare [frames] against the pinger's own sent count to see the loop rate. Equal numbers mean
* every ping made it out and back; a shortfall means frames are being lost on air or dropped in
* the receive chain, which is a different fault from "nothing is being sent at all".
*/
data class OwnTxLoopback(
/** How many own frames have been heard back since the tally was last reset. */
val frames: Int,
/**
* Signal strength of the most recent one, dBm, or null if no transport reported it. Retained
* across frames that carry no reading rather than being cleared, so the figure does not blink
* in and out on screen.
*/
val lastRssiDbm: Int?,
/** Wall-clock ms the most recent own frame was heard back. */
val lastHeardMs: Long,
)
@@ -10,7 +10,7 @@ import kotlin.math.abs
* This class only does the sensor-fusion-into-CAM-fields part, independent of the wire protocol * This class only does the sensor-fusion-into-CAM-fields part, independent of the wire protocol
* to the ESP32-C5. Live flow, driven by [com.hawhamburg.micr0bu.service.CamTransmitLoop]: * to the ESP32-C5. Live flow, driven by [com.hawhamburg.micr0bu.service.CamTransmitLoop]:
* *
* `PhoneCamBuilder.build(...)` → `RealAsn1UperCodec.encodeCam(...)` → `UsbSerialTransport` (write). * `PhoneCamBuilder.build(...)` → `RealAsn1UperCodec.encodeCam(...)` → `Esp32Link` (write).
* *
* Position/speed/heading come straight from GNSS. Yaw rate is derived from the gyroscope's * Position/speed/heading come straight from GNSS. Yaw rate is derived from the gyroscope's
* z-axis reading (rotation about the vertical axis while the phone is roughly flat/mounted * z-axis reading (rotation about the vertical axis while the phone is roughly flat/mounted
@@ -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)
}
}
}
@@ -11,15 +11,25 @@ import org.json.JSONObject
* more (validity duration, relevance area, traffic direction, trace paths); none of it is used * more (validity duration, relevance area, traffic direction, trace paths); none of it is used
* yet, and inventing a fuller model before there's a consumer for it would just be guesswork. * yet, and inventing a fuller model before there's a consumer for it would just be guesswork.
* *
* **Availability:** DENM reaches the app only on the CiT One path, via the Use Case API's * **Availability:** both hardware paths. On the CiT One path DENM arrives as processed JSON on
* `v2x-uca/output/json/denm` topic. The ESP32-C5 path receives none — the firmware's * the Use Case API's `v2x-uca/output/json/denm` topic ([DenmParser]); on the ESP32-C5 path it is
* `gn_unwrap.c` accepts BTP-B destination port 2001 (CAM) only and drops port 2002 (DENM) before * decoded from over-the-air GeoBroadcast traffic on BTP-B port 2002
* anything is forwarded over the serial link. See that file's header comment. * ([com.hawhamburg.micr0bu.domain.asn1.DenmUperCodec]). Fields sourced from the GeoNetworking
* header - [relevanceRadiusM], [rssiDbm] - exist only on the ESP32-C5 path, since the Use Case
* API never exposes the GN layer.
*/ */
data class DenmEvent( data class DenmEvent(
/** Originating station ID. */ /** Originating station ID — `actionID.originatingStationID`, not the radio source. */
val stationId: Long, val stationId: Long,
/**
* `actionID.sequenceNumber`. Together with [stationId] this is ETSI's real event identity:
* repetitions of one hazard reuse it, and under GeoBroadcast several stations may relay the
* same DENM, so this pair is what dedup must key on. Both transports supply it - the Use Case
* API as a `sequenceNumber` key - so the fallback below is for malformed payloads only.
*/
val sequenceNumber: Int? = null,
/** Event position (WGS84 degrees) — where the hazard is, not where the sender is. */ /** Event position (WGS84 degrees) — where the hazard is, not where the sender is. */
val latitude: Double, val latitude: Double,
val longitude: Double, val longitude: Double,
@@ -30,30 +40,112 @@ data class DenmEvent(
/** SubCauseCode qualifying [causeCode], or null. */ /** SubCauseCode qualifying [causeCode], or null. */
val subCauseCode: Int?, val subCauseCode: Int?,
/** Originating station's ETSI stationType, where known. */
val stationType: Int? = null,
/**
* True when this DENM cancels or negates the event (`termination` present). A terminated event
* should be removed from the map rather than drawn — the hazard is over.
*/
val isTermination: Boolean = false,
/** Event detection time in epoch ms, where the message carried a usable one. */
val detectionTimeMs: Long? = null,
/**
* Radius of the GeoBroadcast destination area in metres, i.e. how far the warning is meant to
* apply. Comes from the GeoNetworking header rather than the DENM payload, so it's only
* available on the ESP32-C5 path (the MQTT path never exposes the GN layer).
*/
val relevanceRadiusM: Int? = null,
/** Received signal strength, dBm — ESP32-C5 path only. */
val rssiDbm: Int? = null,
/** Wall-clock ms this DENM was received. */ /** Wall-clock ms this DENM was received. */
val timestamp: Long, val timestamp: Long,
) { ) {
/** /**
* Stable identity for map/list dedup: successive DENMs about the same hazard from the same * Stable identity for map/list dedup. Prefers ETSI's actionID (`stationId` + `sequenceNumber`),
* station should replace each other rather than pile up as separate pins. ETSI's real identity * which both transports carry; falls back to station + cause only when a payload omits the
* is actionID (stationID + sequenceNumber); this approximates it with the cause, since the * sequence number. The fallback is weaker than it looks: a terminating DENM carries no
* Use Case API's JSON doesn't reliably expose a sequence number. * SituationContainer, so its cause is null and it would NOT collide with the event it ends.
*/ */
val dedupKey: String get() = "$stationId/${causeCode ?: -1}/${subCauseCode ?: -1}" val dedupKey: String
get() = if (sequenceNumber != null) "$stationId/$sequenceNumber"
else "$stationId/${causeCode ?: -1}/${subCauseCode ?: -1}"
} }
/** /**
* Parses the processed DENM JSON published by the consider it Use Case API on * Parses the processed DENM JSON published by the consider it Use Case API on
* `v2x-uca/output/json/denm`. * `v2x-uca/output/json/denm`.
* *
* Same field-name tolerance approach as [com.hawhamburg.micr0bu.domain.cam.CamParser] — confirmed * Field names follow `CI-CiT-MQTT_API_Documentation-v6-20250221.pdf` section 2.2.4 / listing 2.6,
* spellings first, plausible alternatives as fallbacks via [JsonFieldReader] — because the exact * which is the contract for this topic; `DenmParserMqttTest` pins this parser to that worked
* schema hasn't been pinned against real OBU payloads yet. Returns null rather than a * example. Alternative spellings are still accepted via [JsonFieldReader] as fallbacks.
* half-populated event when position is missing: a DENM with no position is useless to a map and *
* worse than absent on a hazard display. * Returns null rather than a half-populated event when position is missing: a DENM with no
* position is useless to a map and worse than absent on a hazard display.
*/ */
object DenmParser { object DenmParser {
/**
* The Use Case API's `causeCode` string enum mapped back to its ITS-G5 integer, so a DENM from
* the MQTT path and one decoded off the air are directly comparable. Values are from
* CauseCodeType in the ETSI CDD; the names are the API's spelling.
*/
private val CAUSE_CODE_BY_NAME = mapOf(
"trafficCondition" to 1, "accident" to 2, "roadworks" to 3, "impassability" to 5,
"adverseWeatherCondition_Adhesion" to 6,
// Three n's: that is how ETSI's CauseCodeType spells it, and the API follows.
// The correctly-spelled variant is accepted too, in case that is ever fixed.
"aquaplannning" to 7, "aquaplanning" to 7,
"hazardousLocation_SurfaceCondition" to 9, "hazardousLocation_ObstacleOnTheRoad" to 10,
"hazardousLocation_AnimalOnTheRoad" to 11, "humanPresenceOnTheRoad" to 12,
"wrongWayDriving" to 14, "rescueAndRecoveryWorkInProgress" to 15,
"adverseWeatherCondition_ExtremeWeatherCondition" to 17,
"adverseWeatherCondition_Visibility" to 18,
"adverseWeatherCondition_Precipitation" to 19, "slowVehicle" to 26,
"dangerousEndOfQueue" to 27, "vehicleBreakdown" to 91, "postCrash" to 92,
"humanProblem" to 93, "stationaryVehicle" to 94, "emergencyVehicleApproaching" to 95,
"hazardousLocation_DangerousCurve" to 96, "collisionRisk" to 97,
"signalViolation" to 98, "dangerousSituation" to 99,
)
private val NAME_BY_CAUSE_CODE = CAUSE_CODE_BY_NAME.entries.associate { (n, c) -> c to n }
/**
* The ETSI CauseCode name for [causeCode], or null for a code this table doesn't cover.
*
* Deliberately the API's own camelCase spelling ("stationaryVehicle") rather than prose: it's
* the vocabulary the MQTT payloads, the V2X2MAP dashboard and the sniffer all use, so a bench
* operator can compare what the app says against what those show without translating.
*/
fun causeCodeName(causeCode: Int?): String? = causeCode?.let { NAME_BY_CAUSE_CODE[it] }
/**
* The Use Case API's `stationType` string enum mapped to its ITS-G5 integer, per StationType in
* the ETSI CDD. Note roadSideUnit is **15**, not 12 - the enumeration has a gap after tram(11),
* so mapping by list position would silently mislabel every RSU.
*/
private val STATION_TYPE_BY_NAME = mapOf(
"unknown" to 0, "pedestrian" to 1, "cyclist" to 2, "moped" to 3, "motorcycle" to 4,
"passengerCar" to 5, "bus" to 6, "lightTruck" to 7, "heavyTruck" to 8, "trailer" to 9,
"specialVehicles" to 10, "tram" to 11, "roadSideUnit" to 15,
)
/**
* Parses the API's RFC3339 timestamps ("2021-05-11T12:01:02+00:00") to epoch millis. The air
* path carries a binary TimestampIts instead, so the two transports arrive here in completely
* different formats and both end up as epoch ms on [DenmEvent].
*/
private fun parseRfc3339(value: String?): Long? {
if (value.isNullOrBlank()) return null
return runCatching { java.time.OffsetDateTime.parse(value).toInstant().toEpochMilli() }
.recoverCatching { java.time.Instant.parse(value).toEpochMilli() }
.getOrNull()
}
fun parse(json: String, timestamp: Long = System.currentTimeMillis()): DenmEvent? { fun parse(json: String, timestamp: Long = System.currentTimeMillis()): DenmEvent? {
val obj = runCatching { JSONObject(json) }.getOrNull() ?: return null val obj = runCatching { JSONObject(json) }.getOrNull() ?: return null
@@ -65,24 +157,49 @@ object DenmParser {
?.let { JsonFieldReader.firstLatLon(it) } ?.let { JsonFieldReader.firstLatLon(it) }
?: return null ?: return null
val stationId = JsonFieldReader.firstLong(obj, "stationId", "stationID", "station_id") // "originatingStationId" is what the Use Case API actually sends (API doc listing 2.6);
// without it every DENM from the CiT One path was dropped here, before anything else in
// this function ran. The other spellings are kept as fallbacks.
val stationId = JsonFieldReader.firstLong(
obj, "originatingStationId", "originatingStationID", "stationId", "stationID", "station_id",
)
?: obj.optJSONObject("management")?.let { ?: obj.optJSONObject("management")?.let {
JsonFieldReader.firstLong(it, "stationId", "stationID", "station_id") JsonFieldReader.firstLong(
it, "originatingStationId", "originatingStationID", "stationId", "stationID", "station_id",
)
} }
?: return null ?: return null
val situation = obj.optJSONObject("situation") val situation = obj.optJSONObject("situation")
// The Use Case API sends causeCode as a STRING enum ("stationaryVehicle", "roadworks", ...),
// per CI-CiT-MQTT_API_Documentation-v6 section 2.2.4 - not the ITS-G5 integer. An earlier
// version of this parser read it as an Int and therefore always got null. Both forms are
// accepted: the air path (DenmUperCodec) produces the integer.
val causeCode = JsonFieldReader.firstInt(obj, "causeCode", "cause_code", "cause") val causeCode = JsonFieldReader.firstInt(obj, "causeCode", "cause_code", "cause")
?: situation?.let { JsonFieldReader.firstInt(it, "causeCode", "cause_code", "cause") } ?: situation?.let { JsonFieldReader.firstInt(it, "causeCode", "cause_code", "cause") }
?: CAUSE_CODE_BY_NAME[obj.optString("causeCode").takeIf { it.isNotBlank() }]
val subCauseCode = JsonFieldReader.firstInt(obj, "subCauseCode", "sub_cause_code", "subCause") val subCauseCode = JsonFieldReader.firstInt(obj, "subCauseCode", "sub_cause_code", "subCause")
?: situation?.let { JsonFieldReader.firstInt(it, "subCauseCode", "sub_cause_code", "subCause") } ?: situation?.let { JsonFieldReader.firstInt(it, "subCauseCode", "sub_cause_code", "subCause") }
// "Key is present, if the DENM is cancelled or negated" (API doc 2.2.4) - so presence is
// the signal, not the value. An explicit `false` is still honoured in case the API ever
// starts always emitting the key.
val isTermination = obj.has("termination") && obj.optBoolean("termination", true)
val stationType = JsonFieldReader.firstInt(obj, "stationType", "station_type")
?: STATION_TYPE_BY_NAME[obj.optString("stationType").takeIf { it.isNotBlank() }]
return DenmEvent( return DenmEvent(
stationId = stationId, stationId = stationId,
sequenceNumber = JsonFieldReader.firstInt(obj, "sequenceNumber", "sequence_number"),
latitude = lat, latitude = lat,
longitude = lon, longitude = lon,
causeCode = causeCode, causeCode = causeCode,
subCauseCode = subCauseCode, subCauseCode = subCauseCode,
stationType = stationType,
isTermination = isTermination,
detectionTimeMs = parseRfc3339(obj.optString("detectionTime").takeIf { it.isNotBlank() })
?: JsonFieldReader.firstLong(obj, "detectionTime", "detection_time"),
timestamp = timestamp, timestamp = timestamp,
) )
} }
@@ -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(
@@ -0,0 +1,141 @@
package com.hawhamburg.micr0bu.domain.spat
/**
* Signal phase and timing for one or more intersections, decoded from a SPATEM heard over the air.
*
* A SPATEM is the live counterpart to MAPEM's static geometry: MAPEM says where the lanes are,
* SPATEM says what the lights are doing right now. The two join on [IntersectionSignalState.key].
* Only SPATEM is decoded today - without MAPEM there is no lane geometry, so a signal group is
* shown as a bare number rather than "the left-turn lane you are in".
*
* Repetition is ~2 Hz per intersection, so consumers should key on [IntersectionSignalState.key]
* and keep the latest rather than accumulating a log.
*/
data class SpatEvent(
/** Originating RSU's station ID, from the ItsPduHeader. */
val stationId: Long,
/** `SPAT.timeStamp`, minute of the year, when present. */
val minuteOfYear: Int?,
val intersections: List<IntersectionSignalState>,
/** Received signal strength, dBm - ESP32-C5 path only. */
val rssiDbm: Int? = null,
/** Wall-clock ms this SPATEM was received. */
val timestamp: Long,
)
/** One intersection's current signal state. */
data class IntersectionSignalState(
/** `RoadRegulatorID`, when the sender qualifies its intersection id with one. */
val region: Int?,
/** `IntersectionID` - only unique *within* [region]. */
val id: Int,
/** `MsgCount`, bumped when the intersection's MAP geometry changes. */
val revision: Int,
/** Minute of the year this state refers to, when present. */
val moy: Int?,
/** `DSecond` - milliseconds within [moy]'s minute, when present. */
val timeStampMs: Int?,
val movements: List<SignalMovement>,
) {
/**
* Identity for dedup and for joining against MAPEM. `IntersectionID` alone is NOT unique -
* it is only unique within a `RoadRegulatorID`, and the recorded drive contains the same id
* under different regions - so the region must be part of the key.
*/
val key: String get() = "${region ?: -1}/$id"
}
/** The signal state of one signal group (one movement through the intersection). */
data class SignalMovement(
/** `SignalGroupID` - the number MAPEM's lane connections refer to. */
val signalGroup: Int,
/**
* Predicted phases, in order. The first entry is the state now; later entries are the
* upcoming sequence, which is what makes a countdown possible.
*/
val events: List<SignalPhaseEvent>,
) {
val current: SignalPhaseEvent? get() = events.firstOrNull()
}
data class SignalPhaseEvent(
val phase: SignalPhase,
/**
* `TimeMark`: tenths of a second within the current or next UTC hour, so it wraps hourly.
* 36001 means "unknown". Use [secondsUntil] rather than comparing these directly.
*/
val minEndTimeDs: Int?,
val maxEndTimeDs: Int?,
val likelyTimeDs: Int?,
) {
/**
* Seconds from [nowEpochMs] until [minEndTimeDs], or null if unknown.
*
* TimeMark counts tenths of a second from the top of the hour and wraps, so a mark that looks
* like it is in the past is really in the next hour - hence the wrap correction. Without it, a
* countdown reads as a large negative number for the seconds either side of the hour.
*/
fun secondsUntil(nowEpochMs: Long): Double? {
val mark = minEndTimeDs ?: return null
if (mark >= UNKNOWN_TIME_MARK) return null
val msIntoHour = nowEpochMs % 3_600_000L
var deltaMs = mark * 100L - msIntoHour
if (deltaMs < -HALF_HOUR_MS) deltaMs += 3_600_000L // mark is in the next hour
return deltaMs / 1000.0
}
private companion object {
const val UNKNOWN_TIME_MARK = 36001
const val HALF_HOUR_MS = 1_800_000L
}
}
/** `MovementPhaseState` (ETSI/SAE J2735), in enumeration order - the ordinal IS the wire value. */
enum class SignalPhase {
UNAVAILABLE,
DARK,
STOP_THEN_PROCEED,
STOP_AND_REMAIN,
PRE_MOVEMENT,
PERMISSIVE_MOVEMENT_ALLOWED,
PROTECTED_MOVEMENT_ALLOWED,
PERMISSIVE_CLEARANCE,
PROTECTED_CLEARANCE,
CAUTION_CONFLICTING_TRAFFIC;
/** True for the two "you may go" states. */
val isGo: Boolean
get() = this == PERMISSIVE_MOVEMENT_ALLOWED || this == PROTECTED_MOVEMENT_ALLOWED
/** True for the two "you must stop" states. */
val isStop: Boolean
get() = this == STOP_AND_REMAIN || this == STOP_THEN_PROCEED
/** True while the light is changing - amber, or red-amber before green. */
val isTransition: Boolean
get() = this == PRE_MOVEMENT || this == PERMISSIVE_CLEARANCE || this == PROTECTED_CLEARANCE
companion object {
fun fromWire(value: Int): SignalPhase? = entries.getOrNull(value)
}
}
/**
* One intersection's signal state as the UI consumes it: the decoded state plus who sent it and
* when, flattened out of the [SpatEvent] that carried it.
*
* A single SPATEM may describe several intersections, and the same intersection may be heard from
* more than one RSU, so the UI keys on the intersection rather than on the message.
*/
data class SpatIntersection(
val state: IntersectionSignalState,
val stationId: Long,
val rssiDbm: Int?,
val timestamp: Long,
) {
val key: String get() = state.key
}
@@ -0,0 +1,60 @@
package com.hawhamburg.micr0bu.domain.vam
import com.hawhamburg.micr0bu.domain.usecase.GeoMath
import kotlin.math.abs
/**
* When to send an individual VAM: ETSI TS 103 300-3 V2.3.1 clause 6.4, items 1 to 4, with the
* recommended values of Tables 16 and 17 — the same rules the colleague's reference VBS applies
* (microbu-esp32c5/station-link/python/microbu_link/vbs.py, `VbsLite.due`). No clustering, no
* redundancy mitigation, T_GenVam fixed at its minimum (no DCC input).
*
* Pure and clock-free (callers pass times in ms), so it can be tested without Android.
*/
class VamGenerationRules {
data class Kinematics(val latitude: Double, val longitude: Double, val speedMps: Double, val headingDeg: Double)
private var last: Kinematics? = null
private var lastAtMs = 0L
private var lastLowFrequencyAtMs: Long? = null
/** True if a VAM is due at [nowMs] for the VRU now at [now]. */
fun due(nowMs: Long, now: Kinematics): Boolean {
val previous = last ?: return true
val elapsed = nowMs - lastAtMs
if (elapsed < T_GEN_VAM_MIN_MS) return false
if (elapsed > T_GEN_VAM_MAX_MS) return true // item 1
if (GeoMath.haversineMeters(now.latitude, now.longitude, previous.latitude, previous.longitude) >
MIN_POSITION_CHANGE_M) return true // item 2
if (abs(now.speedMps - previous.speedMps) > MIN_SPEED_CHANGE_MPS) return true // item 3
val headingDelta = abs(((now.headingDeg - previous.headingDeg + 180.0) % 360.0 + 360.0) % 360.0 - 180.0)
return headingDelta > MIN_ORIENTATION_CHANGE_DEG // item 4
}
/** True if the VAM sent at [nowMs] carries the low-frequency container (first VAM, then every T_GenVamLFMin). */
fun includeLowFrequency(nowMs: Long): Boolean =
lastLowFrequencyAtMs.let { it == null || nowMs - it >= T_GEN_VAM_LF_MIN_MS }
/** Records that a VAM went out at [nowMs] for [sent], with or without the low-frequency container. */
fun onSent(nowMs: Long, sent: Kinematics, withLowFrequency: Boolean) {
last = sent
lastAtMs = nowMs
if (withLowFrequency) lastLowFrequencyAtMs = nowMs
}
fun reset() {
last = null
lastAtMs = 0L
lastLowFrequencyAtMs = null
}
companion object {
const val T_GEN_VAM_MIN_MS = 100L
const val T_GEN_VAM_MAX_MS = 5_000L
const val T_GEN_VAM_LF_MIN_MS = 2_000L
const val MIN_POSITION_CHANGE_M = 4.0
const val MIN_SPEED_CHANGE_MPS = 0.5
const val MIN_ORIENTATION_CHANGE_DEG = 4.0
}
}
@@ -1,10 +1,17 @@
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.UsbSerialTransport import com.hawhamburg.micr0bu.data.transport.GnPositionVector
import com.hawhamburg.micr0bu.data.mqtt.ObuHardwarePreferences
import com.hawhamburg.micr0bu.data.transport.Esp32Link
import com.hawhamburg.micr0bu.data.transport.OutgoingIts
import com.hawhamburg.micr0bu.data.transport.OutgoingMessage
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.PhoneCamBuilder import com.hawhamburg.micr0bu.domain.cam.PhoneCamBuilder
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -17,6 +24,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -45,7 +53,8 @@ import javax.inject.Singleton
@Singleton @Singleton
class CamPinger @Inject constructor( class CamPinger @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val usbSerialTransport: UsbSerialTransport, private val esp32Link: Esp32Link,
private val prefs: ObuHardwarePreferences,
private val codec: RealAsn1UperCodec, private val codec: RealAsn1UperCodec,
) { ) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@@ -67,6 +76,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
@@ -86,13 +109,18 @@ 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 = PING_STATION_ID, 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)
val signed = prefs.signOutgoingFlow.first()
if (esp32Link.send(OutgoingIts(OutgoingMessage.CAM, bytes, pv, gnss.accuracyM, signed))) {
_sentCount.update { it + 1 } _sentCount.update { it + 1 }
} }
} }
@@ -119,6 +147,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
@@ -131,11 +162,10 @@ class CamPinger @Inject constructor(
private const val MIN_ACCEL_DT_SEC = 0.2 private const val MIN_ACCEL_DT_SEC = 0.2
private const val MAX_ACCEL_DT_SEC = 3.0 private const val MAX_ACCEL_DT_SEC = 3.0
/** // The station id these pings go out under lives in
* Recognizable station id, deliberately distinct from the persisted real one // [com.hawhamburg.micr0bu.domain.cam.OwnStationIds.BENCH_PING], not here. It is not a
* [CamTransmitLoop] uses, so manual bench pings stay identifiable in captures and can't be // private detail of this class: the ESP32 hears these frames back off the air, so the
* confused with the recording-driven stream if both happen to run at once. // receive path has to recognise the same value, and a second copy of it is exactly how
*/ // the two sides would drift apart.
private const val PING_STATION_ID = 999_999L
} }
} }
@@ -2,14 +2,22 @@ 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.Esp32Link
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.OutgoingIts
import com.hawhamburg.micr0bu.data.transport.OutgoingMessage
import com.hawhamburg.micr0bu.domain.asn1.RealAsn1UperCodec import com.hawhamburg.micr0bu.domain.asn1.RealAsn1UperCodec
import com.hawhamburg.micr0bu.domain.asn1.VamContent
import com.hawhamburg.micr0bu.domain.asn1.VamUperCodec
import com.hawhamburg.micr0bu.domain.cam.CamTransmitConfig import com.hawhamburg.micr0bu.domain.cam.CamTransmitConfig
import com.hawhamburg.micr0bu.domain.cam.PhoneCamBuilder import com.hawhamburg.micr0bu.domain.cam.PhoneCamBuilder
import com.hawhamburg.micr0bu.domain.usecase.GeoMath import com.hawhamburg.micr0bu.domain.usecase.GeoMath
import com.hawhamburg.micr0bu.domain.vam.VamGenerationRules
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
@@ -42,13 +50,21 @@ import javax.inject.Singleton
* [com.hawhamburg.micr0bu.domain.detection.EventDetector] stream that already drives trip event * [com.hawhamburg.micr0bu.domain.detection.EventDetector] stream that already drives trip event
* logging). Both rate figures are placeholders pending real-world tuning, per * logging). Both rate figures are placeholders pending real-world tuning, per
* [CamTransmitConfig]'s own disclaimer. * [CamTransmitConfig]'s own disclaimer.
*
* ## CAM or VAM
* Settings chooses what goes out ([OutgoingMessage]). CAM follows the rate policy above. VAM is
* checked every [VAM_TICK_MS] against the generation rules of TS 103 300-3 clause 6.4
* ([VamGenerationRules]) and sent when one fires, from the same GNSS fix, pseudonym and position
* vector a CAM would use. Whether either is signed is the "Sign outgoing messages" setting; the
* micrOBU does the signing ([Esp32Link]).
*/ */
@Singleton @Singleton
class CamTransmitLoop @Inject constructor( class CamTransmitLoop @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val obuHardwarePrefs: ObuHardwarePreferences, private val obuHardwarePrefs: ObuHardwarePreferences,
private val usbSerialTransport: UsbSerialTransport, private val esp32Link: Esp32Link,
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,13 +79,9 @@ 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
/** @Volatile private var outgoing: OutgoingMessage = OutgoingMessage.CAM
* Own station id for the ESP32-C5 path, loaded once per [start] from @Volatile private var signOutgoing: Boolean = true
* [ObuHardwarePreferences.getOrCreateOwnStationId]. 0 means "not loaded yet" — the loop waits private val vamRules = VamGenerationRules()
* 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
@@ -89,8 +101,8 @@ class CamTransmitLoop @Inject constructor(
if (job?.isActive == true) return if (job?.isActive == true) return
elevatedUntilMs = 0L elevatedUntilMs = 0L
previousGnss = null previousGnss = null
vamRules.reset()
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()
@@ -107,16 +119,64 @@ class CamTransmitLoop @Inject constructor(
private suspend fun runTransmitLoop() = coroutineScope { private suspend fun runTransmitLoop() = coroutineScope {
launch { sensorRepository.gnssFlow().collect { latestGnss = it } } launch { sensorRepository.gnssFlow().collect { latestGnss = it } }
launch { sensorRepository.gyroscopeFlow().collect { latestGyroZ = it.z } } launch { sensorRepository.gyroscopeFlow().collect { latestGyroZ = it.z } }
launch { obuHardwarePrefs.signOutgoingFlow.collect { signOutgoing = it } }
launch {
obuHardwarePrefs.outgoingMessageFlow.collect {
if (it != outgoing) vamRules.reset()
outgoing = it
}
}
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)) when (outgoing) {
OutgoingMessage.CAM -> sendCam(gnss)
OutgoingMessage.VAM -> sendVamIfDue(gnss)
}
}
delay(if (outgoing == OutgoingMessage.VAM) VAM_TICK_MS else (1000.0 / currentRateHz(gnss)).toLong())
}
}
private suspend fun sendCam(gnss: GnssReading) {
// 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) esp32Link.send(OutgoingIts(OutgoingMessage.CAM, bytes,
} GnPositionVector.fromCam(cam, gnss.accuracyM, pseudonym.mac), gnss.accuracyM, signOutgoing))
delay((1000.0 / currentRateHz(gnss)).toLong())
} }
private suspend fun sendVamIfDue(gnss: GnssReading) {
val now = System.currentTimeMillis()
val kinematics = VamGenerationRules.Kinematics(gnss.latitude, gnss.longitude,
gnss.speedMs.toDouble(), gnss.bearingDeg.toDouble())
if (!vamRules.due(now, kinematics)) return
val pseudonym = pseudonymManager.current()
val fix = gnss.copy(timestamp = GnssTimeSource.correct(gnss.timestamp))
// The CAM view of this fix is built only for its position vector, so the GN header of a VAM
// follows exactly the rules a CAM's does. It is not transmitted.
val cam = PhoneCamBuilder.build(fix, latestGyroZ, pseudonym.stationId, longitudinalAccel(gnss))
val withLowFrequency = vamRules.includeLowFrequency(now)
val bytes = VamUperCodec.encode(VamContent(
stationId = pseudonym.stationId,
timestamp = cam.timestamp,
latitude = cam.latitude,
longitude = cam.longitude,
accuracyM = gnss.accuracyM,
speedMps = cam.speedMps,
headingDeg = cam.headingDeg,
accelerationMps2 = cam.accelerationMps2,
includeLowFrequency = withLowFrequency,
))
val handedOver = esp32Link.send(OutgoingIts(OutgoingMessage.VAM, bytes,
GnPositionVector.fromCam(cam, gnss.accuracyM, pseudonym.mac), gnss.accuracyM, signOutgoing))
if (handedOver) vamRules.onSent(now, kinematics, withLowFrequency)
} }
/** /**
@@ -157,6 +217,9 @@ class CamTransmitLoop @Inject constructor(
companion object { companion object {
private const val ELEVATED_HOLD_MS = 5_000L private const val ELEVATED_HOLD_MS = 5_000L
/** How often VAM generation rules are checked: T_GenVamMin, TS 103 300-3 Table 16. */
private const val VAM_TICK_MS = VamGenerationRules.T_GEN_VAM_MIN_MS
/** Below this gap, GNSS speed noise divided by a tiny dt produces absurd accelerations. */ /** Below this gap, GNSS speed noise divided by a tiny dt produces absurd accelerations. */
private const val MIN_ACCEL_DT_SEC = 0.2 private const val MIN_ACCEL_DT_SEC = 0.2
@@ -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,
) )
@@ -20,6 +20,8 @@ import androidx.compose.material.icons.filled.GpsOff
import androidx.compose.material.icons.filled.Sensors import androidx.compose.material.icons.filled.Sensors
import androidx.compose.material.icons.filled.SensorsOff import androidx.compose.material.icons.filled.SensorsOff
import androidx.compose.material.icons.filled.Usb import androidx.compose.material.icons.filled.Usb
import androidx.compose.material.icons.filled.BluetoothDisabled
import androidx.compose.material.icons.filled.Bluetooth
import androidx.compose.material.icons.filled.UsbOff import androidx.compose.material.icons.filled.UsbOff
import androidx.compose.material.icons.filled.Wifi import androidx.compose.material.icons.filled.Wifi
import androidx.compose.material.icons.filled.WifiOff import androidx.compose.material.icons.filled.WifiOff
@@ -39,7 +41,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
import com.hawhamburg.micr0bu.data.transport.UsbSerialState import com.hawhamburg.micr0bu.data.transport.Esp32LinkState
import com.hawhamburg.micr0bu.viewmodel.SensorUiState import com.hawhamburg.micr0bu.viewmodel.SensorUiState
private val GreenActive = Color(0xFF4CAF50) private val GreenActive = Color(0xFF4CAF50)
@@ -53,7 +55,9 @@ fun StatusTopBar(
state: SensorUiState, state: SensorUiState,
mqttConnectionState: MqttConnectionState, mqttConnectionState: MqttConnectionState,
isEsp32: Boolean = false, isEsp32: Boolean = false,
usbSerialState: UsbSerialState = UsbSerialState.DISCONNECTED, esp32LinkState: Esp32LinkState = Esp32LinkState.DISCONNECTED,
/** The ESP32-C5 is reached over BLE rather than its USB port (Settings). */
esp32Bluetooth: Boolean = false,
) { ) {
TopAppBar( TopAppBar(
title = { title = {
@@ -93,7 +97,7 @@ fun StatusTopBar(
) )
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
ObuStatusIcon(mqttConnectionState, isEsp32, usbSerialState) ObuStatusIcon(mqttConnectionState, isEsp32, esp32LinkState, esp32Bluetooth = esp32Bluetooth)
} }
}, },
colors = TopAppBarDefaults.topAppBarColors( colors = TopAppBarDefaults.topAppBarColors(
@@ -106,20 +110,21 @@ fun StatusTopBar(
* OBU link indicator. Which transport it reflects depends on the selected hardware: the CiT One * OBU link indicator. Which transport it reflects depends on the selected hardware: the CiT One
* reaches the phone over MQTT (Wi-Fi / USB-C tethering), the ESP32-C5 over a USB-serial link with * reaches the phone over MQTT (Wi-Fi / USB-C tethering), the ESP32-C5 over a USB-serial link with
* no broker at all - so on that path [mqttConnectionState] is permanently DISCONNECTED and would * no broker at all - so on that path [mqttConnectionState] is permanently DISCONNECTED and would
* report the OBU as offline while CAMs were streaming in. Uses a USB glyph there rather than the * report the OBU as offline while CAMs were streaming in. Uses a USB or Bluetooth glyph there
* Wi-Fi one, since that is literally what the connection is. * rather than the Wi-Fi one, since that is literally what the connection is.
*/ */
@Composable @Composable
private fun ObuStatusIcon( private fun ObuStatusIcon(
mqttState: MqttConnectionState, mqttState: MqttConnectionState,
isEsp32: Boolean, isEsp32: Boolean,
usbSerialState: UsbSerialState, esp32LinkState: Esp32LinkState,
esp32Bluetooth: Boolean,
) { ) {
val state = if (isEsp32) usbSerialState.asConnectionState() else mqttState val state = if (isEsp32) esp32LinkState.asConnectionState() else mqttState
val linkUp = state == MqttConnectionState.CONNECTED || state == MqttConnectionState.CONNECTING
val icon = when { val icon = when {
isEsp32 && state == MqttConnectionState.CONNECTED -> Icons.Default.Usb isEsp32 && esp32Bluetooth -> if (linkUp) Icons.Default.Bluetooth else Icons.Default.BluetoothDisabled
isEsp32 && state == MqttConnectionState.CONNECTING -> Icons.Default.Usb isEsp32 -> if (linkUp) Icons.Default.Usb else Icons.Default.UsbOff
isEsp32 -> Icons.Default.UsbOff
state == MqttConnectionState.CONNECTED || state == MqttConnectionState.CONNECTED ||
state == MqttConnectionState.CONNECTING -> Icons.Default.Wifi state == MqttConnectionState.CONNECTING -> Icons.Default.Wifi
else -> Icons.Default.WifiOff else -> Icons.Default.WifiOff
@@ -194,10 +199,10 @@ private fun RecordingPulse() {
* Maps the ESP32-C5 serial link's lifecycle onto the MQTT connection vocabulary this bar's colour * Maps the ESP32-C5 serial link's lifecycle onto the MQTT connection vocabulary this bar's colour
* and pulse logic already speaks, so one indicator serves both transports. * and pulse logic already speaks, so one indicator serves both transports.
*/ */
private fun UsbSerialState.asConnectionState(): MqttConnectionState = when (this) { private fun Esp32LinkState.asConnectionState(): MqttConnectionState = when (this) {
UsbSerialState.CONNECTED -> MqttConnectionState.CONNECTED Esp32LinkState.CONNECTED -> MqttConnectionState.CONNECTED
UsbSerialState.DEVICE_ATTACHED, Esp32LinkState.DEVICE_ATTACHED,
UsbSerialState.PERMISSION_REQUESTED -> MqttConnectionState.CONNECTING Esp32LinkState.PERMISSION_REQUESTED -> MqttConnectionState.CONNECTING
UsbSerialState.ERROR -> MqttConnectionState.ERROR Esp32LinkState.ERROR -> MqttConnectionState.ERROR
UsbSerialState.DISCONNECTED -> MqttConnectionState.DISCONNECTED Esp32LinkState.DISCONNECTED -> MqttConnectionState.DISCONNECTED
} }
@@ -34,6 +34,8 @@ sealed class Screen(val route: String, val labelRes: Int) {
data object Connection : Screen("connection", R.string.nav_connection) data object Connection : Screen("connection", R.string.nav_connection)
data object Map : Screen("map", R.string.map_title) data object Map : Screen("map", R.string.map_title)
data object MqttViewer : Screen("mqtt_viewer", R.string.nav_v2x) data object MqttViewer : Screen("mqtt_viewer", R.string.nav_v2x)
/** Full-screen V2X live map, opened from the V2X Monitor's map button. */
data object V2xMap : Screen("v2x_map", R.string.v2x_map_title)
// Phase A — Trip Recording // Phase A — Trip Recording
data object TripHistory : Screen("trip_history", R.string.nav_trips) data object TripHistory : Screen("trip_history", R.string.nav_trips)
@@ -63,6 +65,31 @@ private val bottomNavItems = listOf(
Screen.Settings, Screen.Settings,
) )
/**
* True if [route] is this tab's own screen or one of its sub-screens.
*
* The graph is flat, so ownership is derived from the route naming convention: every Settings
* sub-screen is "settings/...", and a trip review is "trip_review/{tripId}" belonging to Trips.
* Without this, a tab stops looking selected the moment you open anything inside it.
*/
private fun Screen.ownsRoute(route: String?): Boolean {
if (route == null) return false
if (route == this.route) return true
return when (this) {
Screen.Settings -> route.startsWith("settings/")
Screen.TripHistory -> route.startsWith("trip_review")
// Connection, Map and Sensors are only reachable from the Dashboard's own cards, and
// the session log only from Record, so those tabs stay lit while the rider is inside
// one of them. Without this the bar goes blank on screens that clearly belong to a tab.
Screen.Dashboard -> route == Screen.Connection.route ||
route == Screen.Map.route ||
route == Screen.Sensors.route
Screen.Record -> route == Screen.Log.route
Screen.MqttViewer -> route == Screen.V2xMap.route
else -> false
}
}
@Composable @Composable
fun BottomNavBar(navController: NavController) { fun BottomNavBar(navController: NavController) {
val backStackEntry by navController.currentBackStackEntryAsState() val backStackEntry by navController.currentBackStackEntryAsState()
@@ -71,12 +98,26 @@ fun BottomNavBar(navController: NavController) {
NavigationBar { NavigationBar {
bottomNavItems.forEach { screen -> bottomNavItems.forEach { screen ->
NavigationBarItem( NavigationBarItem(
selected = currentRoute == screen.route, selected = screen.ownsRoute(currentRoute),
onClick = { onClick = {
// One rule for every tab, including the one already selected: a tap lands on
// that tab's own screen. Nothing happens only when we are already on it.
if (currentRoute != screen.route) {
// Prefer a pop when this tab's screen is still on the back stack. That is
// exactly what Back or a back swipe would do, so tapping Settings from
// Settings > Connection, or Dashboard from the Map, behaves identically
// whichever way the rider asks for it. popBackStack reports false when the
// screen is not on the stack, which is the case for a genuine tab switch.
if (!navController.popBackStack(screen.route, inclusive = false)) {
// No saveState/restoreState here. The graph is flat, so a restored
// back stack brings back the sub-screen the rider was on rather than
// the tab's own screen, which is the opposite of what the tap asked
// for. Tab state that matters lives in the view models anyway.
navController.navigate(screen.route) { navController.navigate(screen.route) {
popUpTo(Screen.Dashboard.route) { saveState = true } popUpTo(Screen.Dashboard.route)
launchSingleTop = true launchSingleTop = true
restoreState = true }
}
} }
}, },
icon = { icon = {
@@ -44,7 +44,9 @@ import com.hawhamburg.micr0bu.R
import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
import com.hawhamburg.micr0bu.data.transport.ObuHardware import com.hawhamburg.micr0bu.data.transport.ObuHardware
import com.hawhamburg.micr0bu.data.transport.TransportType import com.hawhamburg.micr0bu.data.transport.TransportType
import com.hawhamburg.micr0bu.data.transport.UsbSerialState import com.hawhamburg.micr0bu.data.transport.Esp32LinkState
import com.hawhamburg.micr0bu.data.transport.Esp32Transport
import androidx.compose.material.icons.filled.Bluetooth
import com.hawhamburg.micr0bu.viewmodel.MqttViewModel import com.hawhamburg.micr0bu.viewmodel.MqttViewModel
private val UsbGreen = Color(0xFF4CAF50) private val UsbGreen = Color(0xFF4CAF50)
@@ -65,7 +67,11 @@ fun ConnectionSetupScreen(
val activeTransport by viewModel.activeTransport.collectAsState() val activeTransport by viewModel.activeTransport.collectAsState()
val mqttPrefs by viewModel.mqttPrefs.collectAsState() val mqttPrefs by viewModel.mqttPrefs.collectAsState()
val obuHardware by viewModel.obuHardware.collectAsState() val obuHardware by viewModel.obuHardware.collectAsState()
val usbSerialState by viewModel.usbSerialState.collectAsState() val esp32LinkState by viewModel.esp32LinkState.collectAsState()
val esp32Transport by viewModel.esp32Transport.collectAsState()
val esp32Detail by viewModel.esp32Detail.collectAsState()
val stationStatus by viewModel.stationStatus.collectAsState()
val signOutgoing by viewModel.signOutgoing.collectAsState()
val isConnected = connectionState == MqttConnectionState.CONNECTED val isConnected = connectionState == MqttConnectionState.CONNECTED
val isConnecting = connectionState == MqttConnectionState.CONNECTING val isConnecting = connectionState == MqttConnectionState.CONNECTING
@@ -229,22 +235,22 @@ fun ConnectionSetupScreen(
} }
} else { } else {
// ── ESP32-C5 real connection card (Phase 03) ──────────────────────── // ── ESP32-C5 real connection card (Phase 03) ────────────────────────
// Backed by UsbSerialTransport (native USB Serial/JTAG CDC-ACM link) - see that // Backed by Esp32Link: USB (UsbSerialTransport, native USB Serial/JTAG CDC-ACM - see
// class's KDoc for the VID/PID (0x303A/0x1001) and native-vs-UART-bridge port note. // its KDoc for the VID/PID and native-vs-UART-bridge port note) or BLE (BleLinkTransport).
val isEspConnected = usbSerialState == UsbSerialState.CONNECTED val isEspConnected = esp32LinkState == Esp32LinkState.CONNECTED
val isEspBusy = usbSerialState == UsbSerialState.DEVICE_ATTACHED || val isEspBusy = esp32LinkState == Esp32LinkState.DEVICE_ATTACHED ||
usbSerialState == UsbSerialState.PERMISSION_REQUESTED esp32LinkState == Esp32LinkState.PERMISSION_REQUESTED
val espContainerColor = when { val espContainerColor = when {
isEspConnected -> UsbGreenBg isEspConnected -> UsbGreenBg
isEspBusy -> UsbAmberBg isEspBusy -> UsbAmberBg
else -> UsbGrayBg else -> UsbGrayBg
} }
val (espColor, espStateLabel) = when (usbSerialState) { val (espColor, espStateLabel) = when (esp32LinkState) {
UsbSerialState.CONNECTED -> UsbGreen to stringResource(R.string.conn_esp32_state_connected) Esp32LinkState.CONNECTED -> UsbGreen to stringResource(R.string.conn_esp32_state_connected)
UsbSerialState.DEVICE_ATTACHED -> UsbAmber to stringResource(R.string.conn_esp32_state_device_attached) Esp32LinkState.DEVICE_ATTACHED -> UsbAmber to stringResource(R.string.conn_esp32_state_device_attached)
UsbSerialState.PERMISSION_REQUESTED -> UsbAmber to stringResource(R.string.conn_esp32_state_permission_requested) Esp32LinkState.PERMISSION_REQUESTED -> UsbAmber to stringResource(R.string.conn_esp32_state_permission_requested)
UsbSerialState.ERROR -> Color(0xFFFF5252) to stringResource(R.string.conn_esp32_state_error) Esp32LinkState.ERROR -> Color(0xFFFF5252) to stringResource(R.string.conn_esp32_state_error)
UsbSerialState.DISCONNECTED -> UsbGray to stringResource(R.string.conn_esp32_state_disconnected) Esp32LinkState.DISCONNECTED -> UsbGray to stringResource(R.string.conn_esp32_state_disconnected)
} }
Card( Card(
@@ -257,7 +263,7 @@ fun ConnectionSetupScreen(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
Icon( Icon(
Icons.Default.Usb, if (esp32Transport == Esp32Transport.BLE) Icons.Default.Bluetooth else Icons.Default.Usb,
contentDescription = null, contentDescription = null,
tint = espColor, tint = espColor,
modifier = Modifier.size(20.dp), modifier = Modifier.size(20.dp),
@@ -285,11 +291,36 @@ fun ConnectionSetupScreen(
Text(espStateLabel, style = MaterialTheme.typography.bodySmall, color = espColor) Text(espStateLabel, style = MaterialTheme.typography.bodySmall, color = espColor)
} }
Text(
stringResource(
if (esp32Transport == Esp32Transport.BLE) R.string.conn_esp32_via_ble else R.string.conn_esp32_via_usb
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// Pairing passkey, provisioning progress, or why the micrOBU refused something.
esp32Detail?.let {
Text(it, style = MaterialTheme.typography.bodySmall, color = UsbAmber)
}
if (isEspConnected) {
stationStatus?.let { s ->
Text(
stringResource(
R.string.conn_esp32_signing,
if (signOutgoing) stringResource(R.string.conn_esp32_signing_on) else stringResource(R.string.conn_esp32_signing_off),
s.tickets, s.signedMessages, s.signRefused, s.radioSubmitted,
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
if (isEspConnected) { if (isEspConnected || isEspBusy) {
OutlinedButton( OutlinedButton(
onClick = { viewModel.disconnectUsbSerial() }, onClick = { viewModel.disconnectEsp32() },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.outlinedButtonColors( colors = ButtonDefaults.outlinedButtonColors(
contentColor = Color(0xFFFF5252), contentColor = Color(0xFFFF5252),
@@ -297,13 +328,13 @@ fun ConnectionSetupScreen(
) { ) {
Icon(Icons.Default.LinkOff, null, modifier = Modifier.size(16.dp)) Icon(Icons.Default.LinkOff, null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(6.dp)) Spacer(Modifier.width(6.dp))
Text(stringResource(R.string.conn_disconnect)) // While connecting (BLE retries until it succeeds) this cancels the attempt.
Text(stringResource(if (isEspConnected) R.string.conn_disconnect else R.string.conn_esp32_cancel))
} }
} else { } else {
Button( Button(
onClick = { viewModel.connectUsbSerial() }, onClick = { viewModel.connectEsp32() },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
enabled = !isEspBusy,
) { ) {
Icon(Icons.Default.Link, null, modifier = Modifier.size(16.dp)) Icon(Icons.Default.Link, null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(6.dp)) Spacer(Modifier.width(6.dp))
@@ -1,9 +1,12 @@
package com.hawhamburg.micr0bu.ui.screens package com.hawhamburg.micr0bu.ui.screens
import android.content.Intent import android.content.Intent
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -28,6 +31,7 @@ import androidx.compose.material.icons.filled.GpsOff
import androidx.compose.material.icons.filled.Map import androidx.compose.material.icons.filled.Map
import androidx.compose.material.icons.filled.Sensors import androidx.compose.material.icons.filled.Sensors
import androidx.compose.material.icons.filled.SensorsOff import androidx.compose.material.icons.filled.SensorsOff
import androidx.compose.material.icons.filled.Traffic
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -37,14 +41,18 @@ import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.net.toUri import androidx.core.net.toUri
@@ -52,8 +60,15 @@ import com.hawhamburg.micr0bu.R
import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
import com.hawhamburg.micr0bu.data.transport.ObuHardware import com.hawhamburg.micr0bu.data.transport.ObuHardware
import com.hawhamburg.micr0bu.data.transport.TransportType import com.hawhamburg.micr0bu.data.transport.TransportType
import com.hawhamburg.micr0bu.data.transport.UsbSerialState import com.hawhamburg.micr0bu.data.transport.Esp32LinkState
import com.hawhamburg.micr0bu.domain.cam.Cam
import com.hawhamburg.micr0bu.domain.denm.DenmEvent
import com.hawhamburg.micr0bu.domain.denm.DenmParser
import com.hawhamburg.micr0bu.domain.spat.SignalPhase
import com.hawhamburg.micr0bu.domain.spat.SpatIntersection
import com.hawhamburg.micr0bu.domain.usecase.GeoMath
import com.hawhamburg.micr0bu.viewmodel.SensorUiState import com.hawhamburg.micr0bu.viewmodel.SensorUiState
import kotlinx.coroutines.delay
import kotlin.math.sqrt import kotlin.math.sqrt
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -63,14 +78,20 @@ fun DashboardScreen(
mqttConnectionState: MqttConnectionState, mqttConnectionState: MqttConnectionState,
activeTransport: TransportType = TransportType.USB_C, activeTransport: TransportType = TransportType.USB_C,
obuHardware: ObuHardware = ObuHardware.CIT_ONE, obuHardware: ObuHardware = ObuHardware.CIT_ONE,
usbSerialState: UsbSerialState = UsbSerialState.DISCONNECTED, esp32LinkState: Esp32LinkState = Esp32LinkState.DISCONNECTED,
/** The ESP32-C5 is reached over BLE rather than its USB port (Settings). */
esp32Bluetooth: Boolean = false,
usbCableConnected: Boolean = false, usbCableConnected: Boolean = false,
obuStationTypeWarning: Boolean = false, obuStationTypeWarning: Boolean = false,
obuStationType: Int? = null, obuStationType: Int? = null,
hazards: List<DenmEvent> = emptyList(),
signals: List<SpatIntersection> = emptyList(),
ownPosition: Cam? = null,
onNavigateToConnection: () -> Unit, onNavigateToConnection: () -> Unit,
onNavigateToSensors: () -> Unit, onNavigateToSensors: () -> Unit,
onNavigateToMap: () -> Unit, onNavigateToMap: () -> Unit,
onNavigateToRecord: () -> Unit = {}, onNavigateToRecord: () -> Unit = {},
onNavigateToV2x: () -> Unit = {},
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current val context = LocalContext.current
@@ -262,14 +283,14 @@ fun DashboardScreen(
// even once the serial link is actually up. // even once the serial link is actually up.
val isEsp32 = obuHardware == ObuHardware.ESP32_C5 val isEsp32 = obuHardware == ObuHardware.ESP32_C5
val mqttConnected = mqttConnectionState == MqttConnectionState.CONNECTED val mqttConnected = mqttConnectionState == MqttConnectionState.CONNECTED
val obuConnected = if (isEsp32) usbSerialState == UsbSerialState.CONNECTED else mqttConnected val obuConnected = if (isEsp32) esp32LinkState == Esp32LinkState.CONNECTED else mqttConnected
val transportIcon = when (activeTransport) { val transportIcon = if (isEsp32 && esp32Bluetooth) Icons.Default.Bluetooth else when (activeTransport) {
TransportType.USB_C -> Icons.Default.Usb TransportType.USB_C -> Icons.Default.Usb
TransportType.USB_SERIAL -> Icons.Default.Usb TransportType.USB_SERIAL -> Icons.Default.Usb
TransportType.WIFI -> Icons.Default.Wifi TransportType.WIFI -> Icons.Default.Wifi
TransportType.BLUETOOTH -> Icons.Default.Bluetooth TransportType.BLUETOOTH -> Icons.Default.Bluetooth
} }
val transportInactiveIcon = when (activeTransport) { val transportInactiveIcon = if (isEsp32 && esp32Bluetooth) Icons.Default.BluetoothDisabled else when (activeTransport) {
TransportType.USB_C -> Icons.Default.Usb TransportType.USB_C -> Icons.Default.Usb
TransportType.USB_SERIAL -> Icons.Default.Usb TransportType.USB_SERIAL -> Icons.Default.Usb
TransportType.WIFI -> Icons.Default.WifiOff TransportType.WIFI -> Icons.Default.WifiOff
@@ -305,12 +326,12 @@ fun DashboardScreen(
) )
Text( Text(
text = if (isEsp32) { text = if (isEsp32) {
when (usbSerialState) { when (esp32LinkState) {
UsbSerialState.CONNECTED -> stringResource(R.string.conn_esp32_state_connected) Esp32LinkState.CONNECTED -> stringResource(R.string.conn_esp32_state_connected)
UsbSerialState.DEVICE_ATTACHED -> stringResource(R.string.conn_esp32_state_device_attached) Esp32LinkState.DEVICE_ATTACHED -> stringResource(R.string.conn_esp32_state_device_attached)
UsbSerialState.PERMISSION_REQUESTED -> stringResource(R.string.conn_esp32_state_permission_requested) Esp32LinkState.PERMISSION_REQUESTED -> stringResource(R.string.conn_esp32_state_permission_requested)
UsbSerialState.ERROR -> stringResource(R.string.conn_esp32_state_error) Esp32LinkState.ERROR -> stringResource(R.string.conn_esp32_state_error)
UsbSerialState.DISCONNECTED -> stringResource(R.string.dash_tap_to_connect) Esp32LinkState.DISCONNECTED -> stringResource(R.string.dash_tap_to_connect)
} }
} else when (mqttConnectionState) { } else when (mqttConnectionState) {
MqttConnectionState.CONNECTED -> stringResource(R.string.dash_mqtt_connected) MqttConnectionState.CONNECTED -> stringResource(R.string.dash_mqtt_connected)
@@ -343,20 +364,67 @@ fun DashboardScreen(
TransportChip( TransportChip(
label = stringResource(R.string.dash_transport_usb_serial), label = stringResource(R.string.dash_transport_usb_serial),
icon = Icons.Default.Usb, icon = Icons.Default.Usb,
active = activeTransport == TransportType.USB_SERIAL, active = !esp32Bluetooth,
hasCable = usbCableConnected, hasCable = usbCableConnected,
) )
} }
TransportChip( TransportChip(
label = stringResource(R.string.dash_transport_bt), label = stringResource(R.string.dash_transport_bt),
icon = Icons.Default.Bluetooth, icon = Icons.Default.Bluetooth,
active = activeTransport == TransportType.BLUETOOTH, active = isEsp32 && esp32Bluetooth,
dimmed = true, // Phase 03 — production BT transport still under discussion // Only the ESP32-C5 has a BLE link; the CiT One has none.
dimmed = !isEsp32,
) )
} }
} }
} }
// Live V2X, below the status cards: the hazard that matters most and the signalised
// intersection about to change. Both are summaries of what the V2X screen shows in full,
// so tapping either opens that screen rather than repeating its detail here. One of each
// is shown deliberately: a dashboard read from a bike mount has room for the single most
// relevant thing, not for a list.
val ownLatLon = ownPosition?.let { it.latitude to it.longitude }
?: state.gnss?.let { it.latitude to it.longitude }
val rankedHazards = remember(hazards, ownLatLon) {
hazards
.map { denm ->
val distance = ownLatLon?.let { (lat, lon) ->
GeoMath.haversineMeters(lat, lon, denm.latitude, denm.longitude)
}
denm to distance
}
// Closest first. A hazard whose distance cannot be worked out, because there is
// no fix yet, sorts last rather than being dropped: it is still a real hazard,
// we just cannot say how far away it is.
.sortedBy { (_, d) -> d ?: Double.MAX_VALUE }
}
rankedHazards.firstOrNull()?.let { (denm, distance) ->
HazardCard(
hazard = denm,
distanceMeters = distance,
additionalCount = rankedHazards.size - 1,
onClick = onNavigateToV2x,
)
}
// Signals cannot be ranked by distance: SPATEM carries no position at all. The geometry
// that would place an intersection lives in MAPEM, which nothing on the air is currently
// sending. So the one shown is the one changing soonest, which is in any case the one a
// rider approaching a junction needs to see.
val nextSignal = remember(signals) {
val now = System.currentTimeMillis()
signals.minByOrNull { it.secondsToNextChange(now) ?: Double.MAX_VALUE }
}
nextSignal?.let { signal ->
SignalCard(
signal = signal,
additionalCount = signals.size - 1,
onClick = onNavigateToV2x,
)
}
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
if (state.pressureHpa != null) if (state.pressureHpa != null)
@@ -485,3 +553,208 @@ private fun QuickStatRow(label: String, value: String) {
Text(value, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium) Text(value, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
} }
} }
// Hazard red and the three signal states. Kept local to this file for the same reason the V2X
// screen keeps its own: these are traffic-light and warning semantics, not theme roles, and
// tying them to the colour scheme would let a theme change turn a red light amber.
private val HazardRed = Color(0xFFE53935)
private val HazardRedBg = Color(0xFF3A0A0A)
private val SignalGreen = Color(0xFF4CAF50)
private val SignalAmber = Color(0xFFFFC107)
private val SignalGray = Color(0xFF8B949E)
/** How many signal groups fit on the dashboard before the rest are summarised as a count. */
private const val DASH_MAX_SIGNAL_GROUPS = 6
/**
* Seconds until the first of this intersection's signal groups changes, or null when no group
* supplies a usable countdown. Marks already in the past are excluded: a change that has already
* happened says nothing about what the light will do next.
*/
private fun SpatIntersection.secondsToNextChange(nowMs: Long): Double? =
state.movements
.mapNotNull { it.current?.secondsUntil(nowMs) }
.filter { it >= 0.0 }
.minOrNull()
/**
* The nearest received hazard, as a glanceable summary.
*
* Deliberately says less than the V2X screen's row: what it is, how far away, and whether there
* are others behind it. Anything more detailed belongs on the screen this card opens.
*/
@Composable
private fun HazardCard(
hazard: DenmEvent,
distanceMeters: Double?,
additionalCount: Int,
onClick: () -> Unit,
) {
val title = DenmParser.causeCodeName(hazard.causeCode)
?: hazard.causeCode?.let {
stringResource(R.string.v2x_denm_rx_cause_code, it, hazard.subCauseCode ?: 0)
}
?: stringResource(R.string.v2x_map_denm_plain, hazard.stationId)
val detail = listOfNotNull(
distanceMeters?.let { stringResource(R.string.v2x_cam_rx_distance, it) }
?: stringResource(R.string.v2x_cam_rx_distance_unknown),
stringResource(R.string.dash_hazard_station, hazard.stationId),
if (additionalCount > 0) stringResource(R.string.dash_more_count, additionalCount) else null,
).joinToString(" · ")
androidx.compose.material3.Card(
modifier = Modifier.fillMaxWidth().clickable { onClick() },
colors = androidx.compose.material3.CardDefaults.cardColors(containerColor = HazardRedBg),
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(Icons.Default.Warning, null, tint = HazardRed, modifier = Modifier.size(28.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
stringResource(R.string.dash_hazard_warning),
style = MaterialTheme.typography.labelLarge,
color = HazardRed,
fontWeight = FontWeight.SemiBold,
)
Text(
title,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(Modifier.height(2.dp))
Text(
detail,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
/**
* The signalised intersection changing soonest: its leading phase with a countdown, then every
* signal group as a coloured chip.
*
* Signal groups are bare numbers because that is all the app knows. Calling one "your lane" needs
* MAPEM geometry, and a friendlier label would claim knowledge that is not there.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun SignalCard(
signal: SpatIntersection,
additionalCount: Int,
onClick: () -> Unit,
) {
// The countdown has to advance on its own clock. SPATEM repeats at about 2 Hz, so
// recomposition would roughly keep pace while the RSU is transmitting, but the moment it
// stops, a frozen "3 s" would go on claiming the light is about to change.
val nowMs = remember { mutableLongStateOf(System.currentTimeMillis()) }
LaunchedEffect(Unit) {
while (true) {
nowMs.longValue = System.currentTimeMillis()
delay(500L)
}
}
val now = nowMs.longValue
val leading = signal.state.movements.minByOrNull { movement ->
movement.current?.secondsUntil(now)?.takeIf { it >= 0.0 } ?: Double.MAX_VALUE
}
val phase = leading?.current?.phase
val tint = phaseTint(phase)
val countdown = leading?.current?.secondsUntil(now)?.takeIf { it in 0.0..99.0 }
val hiddenGroups = signal.state.movements.size - DASH_MAX_SIGNAL_GROUPS
val footer = listOfNotNull(
if (hiddenGroups > 0) stringResource(R.string.dash_more_count, hiddenGroups) else null,
if (additionalCount > 0) stringResource(R.string.dash_signal_more, additionalCount) else null,
).joinToString(" · ")
androidx.compose.material3.Card(
modifier = Modifier.fillMaxWidth().clickable { onClick() },
colors = androidx.compose.material3.CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(Icons.Default.Traffic, null, tint = tint, modifier = Modifier.size(28.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
stringResource(R.string.dash_signal_title, signal.state.key),
style = MaterialTheme.typography.labelLarge,
color = tint,
fontWeight = FontWeight.SemiBold,
)
Text(
text = countdown
?.let { stringResource(R.string.dash_signal_countdown, phaseLabel(phase), it) }
?: phaseLabel(phase),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(Modifier.height(6.dp))
// Wraps rather than scrolls: a horizontal scroller inside a scrolling dashboard
// is awkward to drive one-handed, and the chip row is short by construction.
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
signal.state.movements.take(DASH_MAX_SIGNAL_GROUPS).forEach { movement ->
val groupTint = phaseTint(movement.current?.phase)
val groupCountdown =
movement.current?.secondsUntil(now)?.takeIf { it in 0.0..99.0 }
Text(
text = stringResource(R.string.v2x_spat_group, movement.signalGroup) +
(groupCountdown?.let { " " + stringResource(R.string.v2x_spat_countdown, it) } ?: ""),
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = groupTint,
modifier = Modifier
.padding(vertical = 2.dp)
.clip(androidx.compose.foundation.shape.RoundedCornerShape(4.dp))
.background(groupTint.copy(alpha = 0.15f))
.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
}
if (footer.isNotEmpty()) {
Spacer(Modifier.height(4.dp))
Text(
footer,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
/** Traffic-light colour for a phase: go is green, stop is red, anything in between is amber. */
@Composable
private fun phaseTint(phase: SignalPhase?): Color = when {
phase == null -> MaterialTheme.colorScheme.onSurfaceVariant
phase.isGo -> SignalGreen
phase.isStop -> HazardRed
phase.isTransition -> SignalAmber
else -> SignalGray
}
@Composable
private fun phaseLabel(phase: SignalPhase?): String = when {
phase == null -> stringResource(R.string.dash_signal_phase_unknown)
phase.isGo -> stringResource(R.string.dash_signal_phase_go)
phase.isStop -> stringResource(R.string.dash_signal_phase_stop)
phase.isTransition -> stringResource(R.string.dash_signal_phase_changing)
phase == SignalPhase.DARK -> stringResource(R.string.dash_signal_phase_dark)
else -> stringResource(R.string.dash_signal_phase_unknown)
}
@@ -8,6 +8,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -28,8 +29,10 @@ import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Circle import androidx.compose.material.icons.filled.Circle
import androidx.compose.material.icons.filled.Link import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.LinkOff import androidx.compose.material.icons.filled.LinkOff
import androidx.compose.material.icons.filled.Map
import androidx.compose.material.icons.filled.NotificationsActive import androidx.compose.material.icons.filled.NotificationsActive
import androidx.compose.material.icons.filled.VerticalAlignBottom import androidx.compose.material.icons.filled.VerticalAlignBottom
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Badge import androidx.compose.material3.Badge
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
@@ -67,8 +70,10 @@ import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
import com.hawhamburg.micr0bu.data.mqtt.MqttMessage import com.hawhamburg.micr0bu.data.mqtt.MqttMessage
import com.hawhamburg.micr0bu.data.transport.EspLinkStatus import com.hawhamburg.micr0bu.data.transport.EspLinkStatus
import com.hawhamburg.micr0bu.data.transport.ObuHardware import com.hawhamburg.micr0bu.data.transport.ObuHardware
import com.hawhamburg.micr0bu.data.transport.UsbSerialState import com.hawhamburg.micr0bu.data.transport.Esp32LinkState
import com.hawhamburg.micr0bu.domain.cam.CamParser import com.hawhamburg.micr0bu.domain.cam.CamParser
import com.hawhamburg.micr0bu.domain.cam.StationType
import com.hawhamburg.micr0bu.domain.denm.DenmParser
import com.hawhamburg.micr0bu.domain.denm.DenmUseCase import com.hawhamburg.micr0bu.domain.denm.DenmUseCase
import com.hawhamburg.micr0bu.domain.usecase.AlertLevel import com.hawhamburg.micr0bu.domain.usecase.AlertLevel
import com.hawhamburg.micr0bu.domain.usecase.GeoMath import com.hawhamburg.micr0bu.domain.usecase.GeoMath
@@ -82,8 +87,16 @@ import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
/** List (raw topics) vs Map (V2X live map, Section 13) toggle for [TopicListPane]. */ /**
private enum class TopicViewMode { LIST, MAP } * View toggle for [TopicListPane]: decoded CAM/DENM traffic (LIST) or the raw MQTT topic list
* (TOPICS, CiT One only - there is no broker on the ESP32-C5 path).
*
* The live map used to be a third mode here. It is now its own full-screen destination
* ([V2xMapScreen]), reached from the map button in this screen's header: sharing the screen with
* the alert panel and the TX cards left the map about a third of a phone display tall, which is
* not enough to see where anything is relative to anything else.
*/
private enum class TopicViewMode { LIST, TOPICS }
private val timeFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.US) private val timeFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.US)
@@ -108,6 +121,7 @@ private val WarningRedBg = Color(0xFF3A0A0A)
@Composable @Composable
fun MqttTopicViewerScreen( fun MqttTopicViewerScreen(
viewModel: MqttViewModel = hiltViewModel(), viewModel: MqttViewModel = hiltViewModel(),
onOpenMap: () -> Unit = {},
) { ) {
val connectionState by viewModel.connectionState.collectAsState() val connectionState by viewModel.connectionState.collectAsState()
val topicMessages by viewModel.topicMessages.collectAsState() val topicMessages by viewModel.topicMessages.collectAsState()
@@ -120,14 +134,17 @@ fun MqttTopicViewerScreen(
val ownStationId by viewModel.ownStationId.collectAsState() val ownStationId by viewModel.ownStationId.collectAsState()
val obuHardware by viewModel.obuHardware.collectAsState() val obuHardware by viewModel.obuHardware.collectAsState()
val ownCamPosition by viewModel.ownCamPosition.collectAsState() val ownCamPosition by viewModel.ownCamPosition.collectAsState()
val remoteCamPositions by viewModel.remoteCamPositions.collectAsState() // Engine road users PLUS roadside units - the engine deliberately does not track RSUs.
val usbSerialState by viewModel.usbSerialState.collectAsState() val remoteCamPositions by viewModel.stationsInRange.collectAsState()
val esp32LinkState by viewModel.esp32LinkState.collectAsState()
val camPingerActive by viewModel.camPingerActive.collectAsState() val camPingerActive by viewModel.camPingerActive.collectAsState()
val camPingerSentCount by viewModel.camPingerSentCount.collectAsState() val camPingerSentCount by viewModel.camPingerSentCount.collectAsState()
val camPingerHasFix by viewModel.camPingerHasFix.collectAsState() val camPingerHasFix by viewModel.camPingerHasFix.collectAsState()
val ownTxLoopback by viewModel.ownTxLoopback.collectAsState()
val camSendFailures by viewModel.camSendFailures.collectAsState() val camSendFailures by viewModel.camSendFailures.collectAsState()
val espLinkStatus by viewModel.espLinkStatus.collectAsState() val espLinkStatus by viewModel.espLinkStatus.collectAsState()
val denmEvents by viewModel.denmEvents.collectAsState() val denmEvents by viewModel.denmEvents.collectAsState()
val spatIntersections by viewModel.spatIntersections.collectAsState()
// Sort: sys/ topics first (heartbeat/health), then alphabetical // Sort: sys/ topics first (heartbeat/health), then alphabetical
val sortedTopics = topicMessages.keys.sortedWith( val sortedTopics = topicMessages.keys.sortedWith(
@@ -144,7 +161,7 @@ fun MqttTopicViewerScreen(
// DISCONNECTED and using it here made the screen report "offline" while CAMs streamed in over // DISCONNECTED and using it here made the screen report "offline" while CAMs streamed in over
// serial. Everything on this screen that means "is the OBU link up?" follows the serial link // serial. Everything on this screen that means "is the OBU link up?" follows the serial link
// instead when that hardware is selected. // instead when that hardware is selected.
val effectiveState = if (isEsp32) usbSerialState.asConnectionState() else connectionState val effectiveState = if (isEsp32) esp32LinkState.asConnectionState() else connectionState
val isConnected = effectiveState == MqttConnectionState.CONNECTED val isConnected = effectiveState == MqttConnectionState.CONNECTED
val isConnecting = effectiveState == MqttConnectionState.CONNECTING val isConnecting = effectiveState == MqttConnectionState.CONNECTING
@@ -182,14 +199,25 @@ fun MqttTopicViewerScreen(
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
} }
// Full-screen live map. In the header rather than in the view-mode row below, so it
// is reachable from the message detail pane too and does not move around as the
// available view modes change with the selected hardware.
IconButton(onClick = onOpenMap) {
Icon(
Icons.Default.Map,
contentDescription = stringResource(R.string.v2x_map_title),
tint = MaterialTheme.colorScheme.primary,
)
}
ConnectionChip(effectiveState) ConnectionChip(effectiveState)
Spacer(Modifier.width(2.dp)) Spacer(Modifier.width(2.dp))
IconButton( IconButton(
onClick = { onClick = {
// Route to whichever transport this hardware actually uses. // Route to whichever transport this hardware actually uses.
if (isEsp32) { if (isEsp32) {
if (isConnected || isConnecting) viewModel.disconnectUsbSerial() if (isConnected || isConnecting) viewModel.disconnectEsp32()
else viewModel.connectUsbSerial() else viewModel.connectEsp32()
} else { } else {
if (isConnected || isConnecting) viewModel.disconnect() else viewModel.connect() if (isConnected || isConnecting) viewModel.disconnect() else viewModel.connect()
} }
@@ -215,7 +243,7 @@ fun MqttTopicViewerScreen(
TopicListPane( TopicListPane(
topics = sortedTopics, topics = sortedTopics,
topicMessages = topicMessages, topicMessages = topicMessages,
connectionState = connectionState, connectionState = effectiveState,
denmActive = denmActive, denmActive = denmActive,
lastDenmPayload = lastDenmPayload, lastDenmPayload = lastDenmPayload,
activeDenmUseCase = activeDenmUseCase, activeDenmUseCase = activeDenmUseCase,
@@ -224,10 +252,12 @@ fun MqttTopicViewerScreen(
showCamPinger = isEsp32, showCamPinger = isEsp32,
isEsp32 = isEsp32, isEsp32 = isEsp32,
denmEvents = denmEvents, denmEvents = denmEvents,
usbSerialState = usbSerialState, spatIntersections = spatIntersections,
esp32LinkState = esp32LinkState,
camPingerActive = camPingerActive, camPingerActive = camPingerActive,
camPingerSentCount = camPingerSentCount, camPingerSentCount = camPingerSentCount,
camPingerHasFix = camPingerHasFix, camPingerHasFix = camPingerHasFix,
ownTxLoopback = ownTxLoopback,
camSendFailures = camSendFailures, camSendFailures = camSendFailures,
espLinkStatus = espLinkStatus, espLinkStatus = espLinkStatus,
ownCamPosition = ownCamPosition, ownCamPosition = ownCamPosition,
@@ -263,10 +293,12 @@ private fun TopicListPane(
showCamPinger: Boolean = false, showCamPinger: Boolean = false,
isEsp32: Boolean = false, isEsp32: Boolean = false,
denmEvents: List<com.hawhamburg.micr0bu.domain.denm.DenmEvent> = emptyList(), denmEvents: List<com.hawhamburg.micr0bu.domain.denm.DenmEvent> = emptyList(),
usbSerialState: UsbSerialState = UsbSerialState.DISCONNECTED, spatIntersections: List<com.hawhamburg.micr0bu.domain.spat.SpatIntersection> = emptyList(),
esp32LinkState: Esp32LinkState = Esp32LinkState.DISCONNECTED,
camPingerActive: Boolean = false, camPingerActive: Boolean = false,
camPingerSentCount: Int = 0, camPingerSentCount: Int = 0,
camPingerHasFix: Boolean = false, camPingerHasFix: Boolean = false,
ownTxLoopback: com.hawhamburg.micr0bu.domain.cam.OwnTxLoopback? = null,
camSendFailures: Int = 0, camSendFailures: Int = 0,
espLinkStatus: EspLinkStatus? = null, espLinkStatus: EspLinkStatus? = null,
ownCamPosition: com.hawhamburg.micr0bu.domain.cam.Cam? = null, ownCamPosition: com.hawhamburg.micr0bu.domain.cam.Cam? = null,
@@ -306,10 +338,11 @@ private fun TopicListPane(
// ── CAM Pinger card — ESP32-C5-only manual bench test, mirrors the DENM card above ── // ── CAM Pinger card — ESP32-C5-only manual bench test, mirrors the DENM card above ──
if (showCamPinger) { if (showCamPinger) {
CamPingerCard( CamPingerCard(
usbConnected = usbSerialState == UsbSerialState.CONNECTED, usbConnected = esp32LinkState == Esp32LinkState.CONNECTED,
pingerActive = camPingerActive, pingerActive = camPingerActive,
sentCount = camPingerSentCount, sentCount = camPingerSentCount,
hasFix = camPingerHasFix, hasFix = camPingerHasFix,
loopback = ownTxLoopback,
sendFailures = camSendFailures, sendFailures = camSendFailures,
linkStatus = espLinkStatus, linkStatus = espLinkStatus,
onStart = onStartCamPinger, onStart = onStartCamPinger,
@@ -319,45 +352,44 @@ private fun TopicListPane(
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f)) HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f))
} }
// ── List / Map toggle — the raw topic list stays available either way (Section 13 // ── List / Topics toggle ──────────────────────────────────────────────
// asks for the map "in addition to", not instead of, the topic list). ────────────── // Decoded traffic is the default on BOTH hardware paths: what a tester wants to see is
// the road users and hazards, not the transport that carried them. The raw MQTT topic
// list stays one tap away on the CiT One path (Section 13 asks for the map "in addition
// to", not instead of, the topic list).
//
// The whole row is hidden on the ESP32-C5 path: there is no broker there, `topics` is
// permanently empty, and a toggle offering a single choice is just noise.
if (!isEsp32) {
Row( Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp), modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
OutlinedButton( ViewModeButton(
onClick = { viewMode = TopicViewMode.LIST }, label = stringResource(R.string.mqtt_view_list),
colors = ButtonDefaults.outlinedButtonColors( selected = viewMode == TopicViewMode.LIST,
containerColor = if (viewMode == TopicViewMode.LIST) MaterialTheme.colorScheme.primaryContainer else Color.Transparent, ) { viewMode = TopicViewMode.LIST }
contentColor = if (viewMode == TopicViewMode.LIST) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
), ViewModeButton(
) { Text(stringResource(R.string.mqtt_view_list)) } label = stringResource(R.string.mqtt_view_topics),
OutlinedButton( selected = viewMode == TopicViewMode.TOPICS,
onClick = { viewMode = TopicViewMode.MAP }, ) { viewMode = TopicViewMode.TOPICS }
colors = ButtonDefaults.outlinedButtonColors( }
containerColor = if (viewMode == TopicViewMode.MAP) MaterialTheme.colorScheme.primaryContainer else Color.Transparent,
contentColor = if (viewMode == TopicViewMode.MAP) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
),
) { Text(stringResource(R.string.mqtt_view_map)) }
} }
// ── Topic rows / received CAMs / live map ───────────────────────────── // ── Decoded traffic / raw topics ──────────────────────────────────────
if (viewMode == TopicViewMode.MAP) { // TOPICS can still be the saved selection from a CiT One session after switching hardware
V2xLiveMapView( // to the ESP32-C5, where that button no longer exists - fall back to the decoded list
own = ownCamPosition, // rather than stranding the user on a pane they can't navigate away from.
remotes = remoteCamPositions, val shownMode = if (viewMode == TopicViewMode.TOPICS && isEsp32) TopicViewMode.LIST else viewMode
alerts = useCaseAlerts,
denms = denmEvents, if (shownMode == TopicViewMode.LIST) {
modifier = Modifier.fillMaxSize(),
)
} else if (isEsp32) {
// The MQTT topic list is meaningless on this path - there is no broker, so `topics`
// is permanently empty and the list would read as "nothing is happening" even while
// CAMs stream in over the serial link. Show the decoded traffic instead.
ReceivedCamPane( ReceivedCamPane(
own = ownCamPosition, own = ownCamPosition,
remotes = remoteCamPositions, remotes = remoteCamPositions,
alerts = useCaseAlerts, alerts = useCaseAlerts,
denms = denmEvents,
spats = spatIntersections,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
} else if (topics.isEmpty()) { } else if (topics.isEmpty()) {
@@ -407,15 +439,21 @@ private fun TopicListPane(
* *
* Sorted nearest-first: on a bike, the closest station is the one that matters. Rows are tinted * Sorted nearest-first: on a bike, the closest station is the one that matters. Rows are tinted
* by that station's most severe active alert, matching [UseCaseAlertPanel] and the map markers. * by that station's most severe active alert, matching [UseCaseAlertPanel] and the map markers.
*
* Hazards ([denms]) are listed above the stations rather than mixed in: a DENM is a warning about
* a place, a CAM is a report about a moving road user, and a hazard outranks a neighbour even when
* the neighbour is closer. Both sections live in one [LazyColumn] so the pane scrolls as a whole.
*/ */
@Composable @Composable
private fun ReceivedCamPane( private fun ReceivedCamPane(
own: com.hawhamburg.micr0bu.domain.cam.Cam?, own: com.hawhamburg.micr0bu.domain.cam.Cam?,
remotes: Map<Long, com.hawhamburg.micr0bu.domain.cam.Cam>, remotes: Map<Long, com.hawhamburg.micr0bu.domain.cam.Cam>,
alerts: List<UseCaseAlert>, alerts: List<UseCaseAlert>,
denms: List<com.hawhamburg.micr0bu.domain.denm.DenmEvent>,
spats: List<com.hawhamburg.micr0bu.domain.spat.SpatIntersection>,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
if (remotes.isEmpty()) { if (remotes.isEmpty() && denms.isEmpty() && spats.isEmpty()) {
Box(modifier = modifier, contentAlignment = Alignment.Center) { Box(modifier = modifier, contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text( Text(
@@ -452,21 +490,195 @@ private fun ReceivedCamPane(
.sortedBy { (_, d) -> d ?: Double.MAX_VALUE } .sortedBy { (_, d) -> d ?: Double.MAX_VALUE }
} }
Column(modifier = modifier) { // Same treatment as the CAM rows: distance resolved once here so sort order and the displayed
// value can't disagree. A DENM's position is the hazard's, not the sender's.
val hazards = remember(denms, own) {
denms
.map { denm ->
val distance = own?.let {
GeoMath.haversineMeters(it.latitude, it.longitude, denm.latitude, denm.longitude)
}
denm to distance
}
.sortedBy { (_, d) -> d ?: Double.MAX_VALUE }
}
LazyColumn(modifier = modifier) {
if (hazards.isNotEmpty()) {
item {
PaneSectionHeader(stringResource(R.string.v2x_denm_rx_count, hazards.size))
}
// Keys can't collide with the CAM rows below - dedupKey is a String, stationId a Long.
items(hazards, key = { (denm, _) -> denm.dedupKey }) { (denm, distance) ->
ReceivedDenmRow(denm, distance)
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f))
}
}
if (spats.isNotEmpty()) {
item { PaneSectionHeader(stringResource(R.string.v2x_spat_rx_count, spats.size)) }
items(spats, key = { "spat/" + it.key }) { spat ->
ReceivedSpatRow(spat)
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f))
}
}
item {
PaneSectionHeader(
if (rows.isEmpty()) stringResource(R.string.v2x_cam_rx_none_stations)
else stringResource(R.string.v2x_cam_rx_count, rows.size)
)
}
items(rows, key = { (cam, _) -> cam.stationId }) { (cam, distance) ->
ReceivedCamRow(cam, distance, alertByStation[cam.stationId])
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f))
}
}
}
@Composable
private fun ViewModeButton(label: String, selected: Boolean, onClick: () -> Unit) {
OutlinedButton(
onClick = onClick,
colors = ButtonDefaults.outlinedButtonColors(
containerColor = if (selected) MaterialTheme.colorScheme.primaryContainer else Color.Transparent,
contentColor = if (selected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
),
) { Text(label) }
}
/**
* One signalised intersection: every signal group's current phase, with a countdown where the RSU
* supplies one.
*
* Signal groups are shown as bare numbers because that is genuinely all we know - mapping a group
* to "your lane" needs MAPEM geometry, which nothing on the air is currently sending. Inventing a
* friendlier label would imply knowledge the app does not have.
*/
@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class)
@Composable
private fun ReceivedSpatRow(spat: com.hawhamburg.micr0bu.domain.spat.SpatIntersection) {
val now = System.currentTimeMillis()
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 10.dp),
) {
Text( Text(
text = stringResource(R.string.v2x_cam_rx_count, rows.size), text = stringResource(R.string.v2x_spat_rx_title, spat.state.key, spat.stationId),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.height(4.dp))
// Wraps rather than scrolls: a busy intersection has 20+ groups and a horizontal
// scroller inside a vertical list is awkward to drive one-handed on a bike mount.
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
spat.state.movements.forEach { movement ->
val phase = movement.current?.phase
val tint = when {
phase == null -> MaterialTheme.colorScheme.onSurfaceVariant
phase.isGo -> ConnectedGreen
phase.isStop -> WarningRed
phase.isTransition -> AwarenessAmber
else -> DisconnectedGray
}
val countdown = movement.current?.secondsUntil(now)?.takeIf { it in 0.0..99.0 }
Text(
text = stringResource(R.string.v2x_spat_group, movement.signalGroup) +
(countdown?.let { " " + stringResource(R.string.v2x_spat_countdown, it) } ?: ""),
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = tint,
modifier = Modifier
.padding(vertical = 2.dp)
.clip(RoundedCornerShape(4.dp))
.background(tint.copy(alpha = 0.15f))
.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
}
spat.rssiDbm?.let {
Spacer(Modifier.height(3.dp))
Text(
text = stringResource(R.string.v2x_cam_rx_rssi, it),
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun PaneSectionHeader(text: String) {
Column {
Text(
text = text,
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f)) HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f))
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(rows, key = { (cam, _) -> cam.stationId }) { (cam, distance) ->
ReceivedCamRow(cam, distance, alertByStation[cam.stationId])
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f))
} }
} }
/**
* One hazard row: what it is, where it is, and how well it was heard.
*
* [DenmEvent.relevanceRadiusM] and [DenmEvent.rssiDbm] come from the GeoNetworking header and the
* serial prefix rather than the DENM payload, so they're only populated on the ESP32-C5 path and
* are omitted rather than shown as zeroes when absent.
*/
@Composable
private fun ReceivedDenmRow(
denm: com.hawhamburg.micr0bu.domain.denm.DenmEvent,
distanceMeters: Double?,
) {
val title = DenmParser.causeCodeName(denm.causeCode)
?: denm.causeCode?.let {
stringResource(R.string.v2x_denm_rx_cause_code, it, denm.subCauseCode ?: 0)
}
?: stringResource(R.string.v2x_map_denm_plain, denm.stationId)
val detail = listOfNotNull(
distanceMeters?.let { stringResource(R.string.v2x_cam_rx_distance, it) }
?: stringResource(R.string.v2x_cam_rx_distance_unknown),
denm.relevanceRadiusM?.let { stringResource(R.string.v2x_denm_rx_radius, it) },
denm.rssiDbm?.let { stringResource(R.string.v2x_cam_rx_rssi, it) },
).joinToString(" · ")
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Default.Warning,
contentDescription = null,
tint = DenmRed,
modifier = Modifier.size(14.dp),
)
Spacer(Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.v2x_denm_rx_hazard, title, denm.stationId),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
color = DenmRed,
)
Spacer(Modifier.height(2.dp))
Text(
text = detail,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace,
)
}
} }
} }
@@ -510,7 +722,12 @@ private fun ReceivedCamRow(
) )
Spacer(Modifier.height(2.dp)) Spacer(Modifier.height(2.dp))
Text( Text(
text = stringResource( // An RSU's CAM carries no kinematics at all (rsuContainerHighFrequency), so the
// zeroes in the model are placeholders, not measurements. Printing "0.0 km/h -
// heading 0" would assert a stationary vehicle pointing due north.
text = if (cam.stationType == StationType.ROAD_SIDE_UNIT) {
stringResource(R.string.v2x_cam_rx_no_kinematics)
} else stringResource(
R.string.v2x_cam_rx_kinematics, R.string.v2x_cam_rx_kinematics,
cam.speedMps * 3.6, cam.speedMps * 3.6,
cam.headingDeg, cam.headingDeg,
@@ -901,6 +1118,7 @@ private fun CamPingerCard(
pingerActive: Boolean, pingerActive: Boolean,
sentCount: Int, sentCount: Int,
hasFix: Boolean, hasFix: Boolean,
loopback: com.hawhamburg.micr0bu.domain.cam.OwnTxLoopback?,
sendFailures: Int, sendFailures: Int,
linkStatus: EspLinkStatus?, linkStatus: EspLinkStatus?,
onStart: () -> Unit, onStart: () -> Unit,
@@ -969,8 +1187,24 @@ private fun CamPingerCard(
// ── Link diagnostics ────────────────────────────────────────────── // ── Link diagnostics ──────────────────────────────────────────────
// "Sent: 240" is meaningless on its own if all 240 writes failed, or if the ESP32 // "Sent: 240" is meaningless on its own if all 240 writes failed, or if the ESP32
// accepted them and the radio rejected every one. These two lines are the difference // accepted them and the radio rejected every one. These lines are the difference
// between a bench session that tells you something and one that doesn't. // between a bench session that tells you something and one that doesn't.
// The round trip closing: sent over serial, transmitted, and heard again by the same
// radio. Compared against Sent above, a shortfall separates "nothing is going out"
// from "it goes out but is not coming back".
loopback?.takeIf { it.frames > 0 }?.let { lb ->
Spacer(Modifier.height(6.dp))
Text(
text = lb.lastRssiDbm?.let {
stringResource(R.string.mqtt_cam_pinger_loopback, lb.frames, it)
} ?: stringResource(R.string.mqtt_cam_pinger_loopback_no_rssi, lb.frames),
style = MaterialTheme.typography.labelSmall,
color = ConnectedGreen,
fontFamily = FontFamily.Monospace,
)
}
if (sendFailures > 0) { if (sendFailures > 0) {
Spacer(Modifier.height(6.dp)) Spacer(Modifier.height(6.dp))
Text( Text(
@@ -985,11 +1219,12 @@ private fun CamPingerCard(
Text( Text(
stringResource( stringResource(
R.string.mqtt_cam_pinger_fw_counters, R.string.mqtt_cam_pinger_fw_counters,
s.txFailures, s.oversizeDrops, s.rxCrcErrors, s.txFailures, s.oversizeDrops, s.rxCrcErrors, s.rxQueueDrops,
), ),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = if (s.txFailures > 0 || s.oversizeDrops > 0 || s.rxCrcErrors > 0) color = if (s.txFailures > 0 || s.oversizeDrops > 0 || s.rxCrcErrors > 0 ||
ErrorRed else MaterialTheme.colorScheme.onSurfaceVariant, s.rxQueueDrops > 0
) ErrorRed else MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
) )
} }
@@ -1238,10 +1473,10 @@ private fun prettyPrintJson(raw: String): String {
* connection UI on this screen already speaks, so one indicator can serve both transports rather * connection UI on this screen already speaks, so one indicator can serve both transports rather
* than duplicating the chip and its colours per hardware type. * than duplicating the chip and its colours per hardware type.
*/ */
private fun UsbSerialState.asConnectionState(): MqttConnectionState = when (this) { private fun Esp32LinkState.asConnectionState(): MqttConnectionState = when (this) {
UsbSerialState.CONNECTED -> MqttConnectionState.CONNECTED Esp32LinkState.CONNECTED -> MqttConnectionState.CONNECTED
UsbSerialState.DEVICE_ATTACHED, Esp32LinkState.DEVICE_ATTACHED,
UsbSerialState.PERMISSION_REQUESTED -> MqttConnectionState.CONNECTING Esp32LinkState.PERMISSION_REQUESTED -> MqttConnectionState.CONNECTING
UsbSerialState.ERROR -> MqttConnectionState.ERROR Esp32LinkState.ERROR -> MqttConnectionState.ERROR
UsbSerialState.DISCONNECTED -> MqttConnectionState.DISCONNECTED Esp32LinkState.DISCONNECTED -> MqttConnectionState.DISCONNECTED
} }
@@ -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(
@@ -47,7 +47,9 @@ import androidx.compose.ui.unit.dp
import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat
import com.hawhamburg.micr0bu.R import com.hawhamburg.micr0bu.R
import com.hawhamburg.micr0bu.data.mqtt.MqttPrefs import com.hawhamburg.micr0bu.data.mqtt.MqttPrefs
import com.hawhamburg.micr0bu.data.transport.Esp32Transport
import com.hawhamburg.micr0bu.data.transport.ObuHardware import com.hawhamburg.micr0bu.data.transport.ObuHardware
import com.hawhamburg.micr0bu.data.transport.OutgoingMessage
import com.hawhamburg.micr0bu.domain.usecase.UseCaseDetectionConfig import com.hawhamburg.micr0bu.domain.usecase.UseCaseDetectionConfig
import com.hawhamburg.micr0bu.domain.usecase.UseCaseType import com.hawhamburg.micr0bu.domain.usecase.UseCaseType
import com.hawhamburg.micr0bu.viewmodel.SensorUiState import com.hawhamburg.micr0bu.viewmodel.SensorUiState
@@ -165,13 +167,19 @@ fun ConnectionSettingsScreen(
onMqttPrefsChange: (MqttPrefs) -> Unit, onMqttPrefsChange: (MqttPrefs) -> Unit,
obuHardware: ObuHardware = ObuHardware.CIT_ONE, obuHardware: ObuHardware = ObuHardware.CIT_ONE,
onObuHardwareChange: (ObuHardware) -> Unit = {}, onObuHardwareChange: (ObuHardware) -> Unit = {},
esp32Transport: Esp32Transport = Esp32Transport.USB,
onEsp32TransportChange: (Esp32Transport) -> Unit = {},
outgoingMessage: OutgoingMessage = OutgoingMessage.CAM,
onOutgoingMessageChange: (OutgoingMessage) -> Unit = {},
signOutgoing: Boolean = true,
onSignOutgoingChange: (Boolean) -> Unit = {},
onBack: () -> Unit, onBack: () -> Unit,
) { ) {
SubScreen(stringResource(R.string.settings_connection), onBack) { SubScreen(stringResource(R.string.settings_connection), onBack) {
SectionCard { SectionCard {
// OBU Hardware selector — CiT One / ESP32-C5 (Phase 03, Section 13). Everything // OBU Hardware selector — CiT One / ESP32-C5 (Phase 03, Section 13). The transport
// below (transport, USB-C options) only really applies to CiT One; ESP32-C5 uses // cards below apply to the CiT One; the ESP32-C5 has its own card (USB-C or BLE,
// USB Serial exclusively and has no transport choice to make here. // CAM or VAM, signing).
Text( Text(
stringResource(R.string.settings_obu_hardware), stringResource(R.string.settings_obu_hardware),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
@@ -212,6 +220,48 @@ fun ConnectionSettingsScreen(
} }
} }
if (obuHardware == ObuHardware.ESP32_C5) {
SectionCard {
TwoWayChoice(
label = stringResource(R.string.settings_esp32_link),
first = stringResource(R.string.settings_transport_usbc),
second = stringResource(R.string.settings_esp32_link_ble),
firstSelected = esp32Transport == Esp32Transport.USB,
onFirst = { onEsp32TransportChange(Esp32Transport.USB) },
onSecond = { onEsp32TransportChange(Esp32Transport.BLE) },
)
if (esp32Transport == Esp32Transport.BLE) {
Text(
stringResource(R.string.settings_esp32_link_ble_note),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 8.dp),
)
}
Divider()
TwoWayChoice(
label = stringResource(R.string.settings_esp32_message),
first = stringResource(R.string.settings_esp32_message_cam),
second = stringResource(R.string.settings_esp32_message_vam),
firstSelected = outgoingMessage == OutgoingMessage.CAM,
onFirst = { onOutgoingMessageChange(OutgoingMessage.CAM) },
onSecond = { onOutgoingMessageChange(OutgoingMessage.VAM) },
)
Divider()
SettingToggleRow(
label = stringResource(R.string.settings_esp32_sign),
checked = signOutgoing,
onCheckedChange = onSignOutgoingChange,
)
Text(
stringResource(R.string.settings_esp32_sign_note),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 8.dp),
)
}
}
if (obuHardware == ObuHardware.CIT_ONE) { if (obuHardware == ObuHardware.CIT_ONE) {
SectionCard { SectionCard {
// Active transport selector // Active transport selector
@@ -611,6 +661,40 @@ private fun LanguageSection() {
} }
} }
/** A labelled pair of outlined buttons, the selected one filled — the style of the OBU hardware picker. */
@Composable
private fun TwoWayChoice(
label: String,
first: String,
second: String,
firstSelected: Boolean,
onFirst: () -> Unit,
onSecond: () -> Unit,
) {
Text(
label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp),
)
Spacer(Modifier.height(6.dp))
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
for ((text, selected, onClick) in listOf(Triple(first, firstSelected, onFirst), Triple(second, !firstSelected, onSecond))) {
OutlinedButton(
onClick = onClick,
modifier = Modifier.weight(1f),
colors = ButtonDefaults.outlinedButtonColors(
containerColor = if (selected) MaterialTheme.colorScheme.primaryContainer else Color.Transparent,
contentColor = if (selected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
),
) { Text(text, fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal) }
}
}
}
@Composable @Composable
private fun RowDivider() = HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.4f)) private fun RowDivider() = HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.4f))
@@ -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 ─────────────────────────────────────────────────────────
@@ -1,12 +1,13 @@
package com.hawhamburg.micr0bu.ui.screens package com.hawhamburg.micr0bu.ui.screens
import android.content.Context import android.content.Context
import android.graphics.drawable.Drawable
import android.view.MotionEvent
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.GpsOff import androidx.compose.material.icons.filled.GpsOff
@@ -30,25 +31,37 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
import com.hawhamburg.micr0bu.R import com.hawhamburg.micr0bu.R
import com.hawhamburg.micr0bu.domain.cam.Cam import com.hawhamburg.micr0bu.domain.cam.Cam
import com.hawhamburg.micr0bu.domain.denm.DenmEvent import com.hawhamburg.micr0bu.domain.denm.DenmEvent
import com.hawhamburg.micr0bu.domain.spat.SpatIntersection
import com.hawhamburg.micr0bu.domain.usecase.AlertLevel import com.hawhamburg.micr0bu.domain.usecase.AlertLevel
import com.hawhamburg.micr0bu.domain.usecase.UseCaseAlert import com.hawhamburg.micr0bu.domain.usecase.UseCaseAlert
import org.osmdroid.config.Configuration import org.osmdroid.config.Configuration
import org.osmdroid.tileprovider.tilesource.TileSourceFactory import org.osmdroid.tileprovider.tilesource.TileSourceFactory
import org.osmdroid.util.GeoPoint import org.osmdroid.util.GeoPoint
import org.osmdroid.views.CustomZoomButtonsController
import org.osmdroid.views.MapView import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.Marker import org.osmdroid.views.overlay.Marker
/** /**
* V2X Monitor live map view (Phase 03, Section 13) — plots the ego bike's own position plus * V2X Monitor live map (Phase 03, Section 13) — the map body behind [V2xMapScreen], plotting the
* every currently-tracked remote road user's last-known CAM position, in addition to (not * ego bike's own position, every currently-tracked remote road user's last-known CAM position,
* replacing) the raw topic list already on this screen. Reuses the same osmdroid pattern as * every live hazard (DENM) and every signalised intersection heard over SPATEM.
* [MapScreen]; unlike that screen, this one has no phone-GNSS-only fallback because [own] here
* always reflects whichever ego source [com.hawhamburg.micr0bu.data.cam.CamUseCaseRepository]
* currently trusts (obu_gnss / phone GNSS / CAM-topic-own — see that class's KDoc).
* *
* Remote markers are colored by that station's most severe active alert level, if any, so a * Marker vocabulary, one shape per message type so the map reads without a legend:
* glance at the map shows not just "who's nearby" but "who's a warning right now" — the same * - CAM — teardrop pin, tinted by that station's most severe active alert level
* severity coloring already used by [UseCaseAlertPanel]. * - DENM — hazard warning triangle
* - SPATEM — traffic light, with the lamp for the intersection's leading phase lit
*
* Unlike [MapScreen] this has no phone-GNSS-only fallback: [own] always reflects whichever ego
* source [com.hawhamburg.micr0bu.data.cam.CamUseCaseRepository] currently trusts (obu_gnss /
* phone GNSS / CAM-topic-own — see that class's KDoc).
*
* **Markers are reused across updates, not rebuilt.** CAMs arrive at up to 10 Hz per station, and
* every arrival recomposes this view; the previous version cleared the overlay list and rebuilt
* every Marker — decoding and mutating a fresh Drawable per marker per update — which is what
* made panning stutter under live traffic. Drawables are now loaded once per level/phase and
* shared (osmdroid sets the icon's bounds on each draw, so sharing one instance across markers is
* safe), and Marker objects are cached by key. The overlay list is still reordered each update,
* which costs nothing: it moves existing references, it does not allocate.
*/ */
@Composable @Composable
fun V2xLiveMapView( fun V2xLiveMapView(
@@ -56,6 +69,9 @@ fun V2xLiveMapView(
remotes: Map<Long, Cam>, remotes: Map<Long, Cam>,
alerts: List<UseCaseAlert>, alerts: List<UseCaseAlert>,
denms: List<DenmEvent> = emptyList(), denms: List<DenmEvent> = emptyList(),
spats: List<SpatIntersection> = emptyList(),
followOwn: Boolean = true,
onUserPanned: () -> Unit = {},
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current val context = LocalContext.current
@@ -71,8 +87,17 @@ fun V2xLiveMapView(
.mapValues { (_, a) -> a.maxByOrNull { it.alertLevel.ordinal }?.alertLevel } .mapValues { (_, a) -> a.maxByOrNull { it.alertLevel.ordinal }?.alertLevel }
} }
// Loaded once and shared by every marker that needs them. mutate() on the remote pin is still
// essential: without it all four tinted copies would share one ConstantState and the last
// tint applied would recolour every pin on the map.
val icons = remember(context) { MapIcons(context) }
val markers = remember { mutableMapOf<String, Marker>() }
val mapViewRef = remember { mutableStateOf<MapView?>(null) } val mapViewRef = remember { mutableStateOf<MapView?>(null) }
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
// Tracks whether the last update already recentred for this follow session, so re-enabling
// follow animates once instead of fighting the rider's own panning on every frame.
val wasFollowing = remember { mutableStateOf(false) }
DisposableEffect(lifecycleOwner) { DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event -> val observer = LifecycleEventObserver { _, event ->
@@ -85,46 +110,62 @@ fun V2xLiveMapView(
lifecycleOwner.lifecycle.addObserver(observer) lifecycleOwner.lifecycle.addObserver(observer)
onDispose { onDispose {
lifecycleOwner.lifecycle.removeObserver(observer) lifecycleOwner.lifecycle.removeObserver(observer)
mapViewRef.value?.onDetach() // onDetach() deliberately NOT called here - see AndroidView's onRelease below.
} }
} }
Column(modifier = modifier.fillMaxSize()) {
Text(
text = stringResource(R.string.v2x_map_remote_count, remotes.size),
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(4.dp))
AndroidView( AndroidView(
factory = { ctx -> factory = { ctx ->
initOsmForV2xMap(ctx) initOsmForV2xMap(ctx)
MapView(ctx).apply { MapView(ctx).apply {
setTileSource(TileSourceFactory.MAPNIK) setTileSource(TileSourceFactory.MAPNIK)
setMultiTouchControls(true) setMultiTouchControls(true)
// Raster tiles are authored for ~160 dpi; without this they are upscaled by the
// display density and labels come out soft on a modern phone.
isTilesScaledToDpi = true
// The floating +/- buttons sit exactly where the rider's thumb lands and
// duplicate pinch-zoom. Pinch and double-tap still work.
zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER)
setMinZoomLevel(4.0)
setMaxZoomLevel(20.0)
controller.setZoom(17.0) controller.setZoom(17.0)
controller.setCenter(ownGeoPoint) controller.setCenter(ownGeoPoint)
// Any touch means the rider is driving the map; follow-own hands over to them
// until they ask for it back. false: the MapView's own gesture handling still
// runs, this only observes.
setOnTouchListener { _, event ->
if (event.actionMasked == MotionEvent.ACTION_DOWN) onUserPanned()
false
}
mapViewRef.value = this mapViewRef.value = this
} }
}, },
update = { mv -> update = { mv ->
mv.overlays.clear() val now = System.currentTimeMillis()
// Own position: a centred "you are here" dot, not a pin. Own position is a fact // Intersections we can actually place: SPATEM carries signal state but no geometry
// about the viewer rather than one of the tracked objects, and when both used // (that is MAPEM's job), so the only position available is the sending RSU's own CAM.
// osmdroid's identical default pin the two were indistinguishable at a glance. val locatedSpats = spats.mapNotNull { spat ->
mv.overlays.add( remotes[spat.stationId]?.let { rsu -> spat to rsu }
Marker(mv).apply { }
// An RSU drawn as a traffic light must not also be drawn as a CAM pin underneath it:
// two markers on one point, the lower one unreachable.
val spatStationIds = locatedSpats.map { (spat, _) -> spat.stationId }.toSet()
val live = mutableSetOf<String>()
// Own position: a centred "you are here" dot, not a pin. Own position is a fact about
// the viewer rather than one of the tracked objects, and when both used osmdroid's
// identical default pin the two were indistinguishable at a glance.
markers.marker(mv, KEY_OWN, live).apply {
position = ownGeoPoint position = ownGeoPoint
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER) setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
icon = ContextCompat.getDrawable(context, R.drawable.ic_map_own) icon = icons.own
title = context.getString(R.string.v2x_map_own_label) title = context.getString(R.string.v2x_map_own_label)
} }
)
remotes.forEach { (stationId, cam) -> remotes.forEach { (stationId, cam) ->
if (stationId in spatStationIds) return@forEach
val level = alertByStation[stationId] val level = alertByStation[stationId]
val label = when (level) { val label = when (level) {
AlertLevel.WARNING -> context.getString(R.string.v2x_map_remote_warning, stationId) AlertLevel.WARNING -> context.getString(R.string.v2x_map_remote_warning, stationId)
@@ -132,32 +173,23 @@ fun V2xLiveMapView(
AlertLevel.INFO -> context.getString(R.string.v2x_map_remote_info, stationId) AlertLevel.INFO -> context.getString(R.string.v2x_map_remote_info, stationId)
null -> context.getString(R.string.v2x_map_remote_plain, stationId) null -> context.getString(R.string.v2x_map_remote_plain, stationId)
} }
// Teardrop pin anchored at its tip, tinted by severity. Now that these are // Teardrop pin anchored at its tip, tinted by severity, so severity no longer
// custom drawables, per-instance tinting is possible - severity no longer // depends on tapping the marker to read its label.
// depends on tapping the marker to read its label. mutate() is essential: markers.marker(mv, "$KEY_CAM$stationId", live).apply {
// without it every marker shares one ConstantState and the last tint applied
// would recolour all of them.
val pin = ContextCompat.getDrawable(context, R.drawable.ic_map_remote_station)
?.mutate()
?.apply { setTint(level.toMarkerColor()) }
mv.overlays.add(
Marker(mv).apply {
position = GeoPoint(cam.latitude, cam.longitude) position = GeoPoint(cam.latitude, cam.longitude)
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
icon = pin icon = icons.remotePin(level)
title = label title = label
} }
)
} }
// DENM hazard pins, added last so they draw on top of vehicle markers - a hazard // Hazards and signals are added after the vehicle pins, so they draw on top: a hazard
// hidden behind a CAM pin defeats the point of showing it. // hidden behind a CAM pin defeats the point of showing it.
denms.forEach { denm -> denms.forEach { denm ->
mv.overlays.add( markers.marker(mv, "$KEY_DENM${denm.dedupKey}", live).apply {
Marker(mv).apply {
position = GeoPoint(denm.latitude, denm.longitude) position = GeoPoint(denm.latitude, denm.longitude)
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
icon = ContextCompat.getDrawable(context, R.drawable.ic_denm_warning) icon = icons.denm
title = denm.causeCode?.let { title = denm.causeCode?.let {
context.getString( context.getString(
R.string.v2x_map_denm_labeled, R.string.v2x_map_denm_labeled,
@@ -167,14 +199,151 @@ fun V2xLiveMapView(
) )
} ?: context.getString(R.string.v2x_map_denm_plain, denm.stationId) } ?: context.getString(R.string.v2x_map_denm_plain, denm.stationId)
} }
}
locatedSpats.forEach { (spat, rsu) ->
val phase = spat.leadingPhase(now)
markers.marker(mv, "$KEY_SPAT${spat.key}", live).apply {
position = GeoPoint(rsu.latitude, rsu.longitude)
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
icon = icons.signal(phase)
title = context.getString(
R.string.v2x_spat_rx_title, spat.state.key, spat.stationId,
)
snippet = spat.state.movements.joinToString(" · ") { movement ->
val seconds = movement.current?.secondsUntil(now)?.takeIf { it in 0.0..99.0 }
context.getString(R.string.v2x_spat_group, movement.signalGroup) +
(seconds?.let { " " + context.getString(R.string.v2x_spat_countdown, it) } ?: "")
}
}
}
// Drop markers for stations, hazards and intersections that have expired, then rebuild
// the overlay list in draw order from the cached Markers. Reordering moves references;
// nothing here allocates a Marker or decodes a Drawable.
markers.keys.retainAll { key ->
(key in live).also { kept -> if (!kept) markers[key]?.closeInfoWindow() }
}
mv.overlays.clear()
markers.entries
.sortedBy { (key, _) -> key.drawOrder() }
.forEach { (_, marker) -> mv.overlays.add(marker) }
// setCenter, not animateTo: an animation restarted on every CAM never finishes, which
// is exactly the judder this used to show under live traffic. The one animated move is
// the rider re-enabling follow, where the travel is worth seeing.
if (followOwn) {
if (wasFollowing.value) mv.controller.setCenter(ownGeoPoint)
else mv.controller.animateTo(ownGeoPoint)
}
wasFollowing.value = followOwn
mv.invalidate()
},
// osmdroid's onDetach() permanently tears the MapView down: afterwards its
// MapViewRepository holds a null MapView, so constructing a Marker against it throws
// NullPointerException from deep inside InfoWindow's constructor.
//
// This used to run in the DisposableEffect's onDispose, which is NOT safe: that effect
// is keyed on the lifecycle owner and disposes independently of this AndroidView, so
// the update block above could still run against an already-detached MapView and
// rebuild its markers. It crashed the app on 2026-08-17 once DENMs started arriving,
// because every incoming message recomposes this view and there are far more updates
// to land in that window than there used to be.
//
// onRelease is the callback that actually means "this View is gone": Compose
// guarantees no further update after it.
onRelease = {
markers.clear()
it.onDetach()
},
modifier = modifier.fillMaxSize(),
) )
} }
mv.controller.animateTo(ownGeoPoint) // ── Marker cache ──────────────────────────────────────────────────────────────
mv.invalidate()
}, private const val KEY_OWN = "own"
modifier = Modifier.fillMaxSize(), private const val KEY_CAM = "cam:"
) private const val KEY_DENM = "denm:"
private const val KEY_SPAT = "spat:"
/** Draw order: own dot at the bottom, then vehicles, with hazards and signals on top. */
private fun String.drawOrder(): Int = when {
this == KEY_OWN -> 0
startsWith(KEY_CAM) -> 1
startsWith(KEY_DENM) -> 2
else -> 3
}
/**
* The cached [Marker] for [key], created against [mv] on first use, recording the key in [live]
* so the caller can drop whatever it did not ask for this update.
*/
private fun MutableMap<String, Marker>.marker(
mv: MapView,
key: String,
live: MutableSet<String>,
): Marker {
live += key
return getOrPut(key) { Marker(mv) }
}
/**
* Marker artwork, loaded once per composition rather than per update.
*
* The remote pin is drawn white and tinted per severity here; [mutate] is what keeps the four
* tinted copies independent, since without it they would share one ConstantState and the last
* tint applied would recolour all of them.
*/
private class MapIcons(context: Context) {
val own: Drawable? = ContextCompat.getDrawable(context, R.drawable.ic_map_own)
val denm: Drawable? = ContextCompat.getDrawable(context, R.drawable.ic_denm_warning)
private val pins: Map<AlertLevel?, Drawable?> =
(listOf(null) + AlertLevel.entries).associateWith { level ->
ContextCompat.getDrawable(context, R.drawable.ic_map_remote_station)
?.mutate()
?.apply { setTint(level.toMarkerColor()) }
}
private val signals: Map<SignalLamp, Drawable?> = SignalLamp.entries.associateWith { lamp ->
ContextCompat.getDrawable(context, lamp.drawableRes)
}
fun remotePin(level: AlertLevel?): Drawable? = pins[level]
fun signal(lamp: SignalLamp): Drawable? = signals[lamp]
}
// ── Signal phase → lamp ───────────────────────────────────────────────────────
/** Which lamp of the traffic-light marker is lit. */
private enum class SignalLamp(val drawableRes: Int) {
RED(R.drawable.ic_map_spat_red),
AMBER(R.drawable.ic_map_spat_amber),
GREEN(R.drawable.ic_map_spat_green),
DARK(R.drawable.ic_map_spat_dark),
}
/**
* The lamp to light for this intersection.
*
* Without MAPEM there is no lane geometry, so there is no way to know which of an intersection's
* signal groups applies to the rider's own approach. This follows the rule the Dashboard's
* SignalCard already uses — the group changing soonest speaks for the intersection — so the same
* intersection reads the same way in both places rather than inventing a second convention.
*/
private fun SpatIntersection.leadingPhase(nowMs: Long): SignalLamp {
val leading = state.movements.minByOrNull { movement ->
movement.current?.secondsUntil(nowMs)?.takeIf { it >= 0.0 } ?: Double.MAX_VALUE
}
val phase = leading?.current?.phase
return when {
phase == null -> SignalLamp.DARK
phase.isGo -> SignalLamp.GREEN
phase.isStop -> SignalLamp.RED
phase.isTransition -> SignalLamp.AMBER
else -> SignalLamp.DARK // UNAVAILABLE / DARK / caution
} }
} }
@@ -214,5 +383,10 @@ private fun initOsmForV2xMap(context: Context) {
Configuration.getInstance().apply { Configuration.getInstance().apply {
load(context, context.getSharedPreferences("osmdroid", Context.MODE_PRIVATE)) load(context, context.getSharedPreferences("osmdroid", Context.MODE_PRIVATE))
userAgentValue = context.packageName userAgentValue = context.packageName
// Panning off the edge of the cache is what makes a raster map feel slow: the default
// 600 MB cap is plenty, but the default 2 download threads are not when a pan exposes a
// screenful of new tiles at once.
tileDownloadThreads = 6.toShort()
tileFileSystemThreads = 6.toShort()
} }
} }
@@ -0,0 +1,203 @@
package com.hawhamburg.micr0bu.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.MyLocation
import androidx.compose.material.icons.filled.Place
import androidx.compose.material.icons.filled.Traffic
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.hawhamburg.micr0bu.R
import com.hawhamburg.micr0bu.viewmodel.MqttViewModel
/**
* Full-screen V2X live map — the map and nothing else, reached from the map button on the V2X
* Monitor screen.
*
* Identical on both hardware paths. Everything drawn here comes from
* [com.hawhamburg.micr0bu.data.cam.CamUseCaseRepository], which already merges the CiT One's MQTT
* feed and the ESP32-C5's serial feed into one set of flows, so this screen never has to know
* which OBU is connected.
*
* The chrome is deliberately minimal and floats over the map rather than boxing it in: a back
* button, a live count per message type, and a recentre button. The counts double as the map's
* legend — each one carries the same icon family as the marker it counts.
*/
@Composable
fun V2xMapScreen(
viewModel: MqttViewModel,
onBack: () -> Unit,
) {
val ownCamPosition by viewModel.ownCamPosition.collectAsState()
// Road users from the detection engine PLUS roadside units, which it deliberately does not
// track - an RSU is what carries the traffic lights below.
val stations by viewModel.stationsInRange.collectAsState()
val alerts by viewModel.useCaseAlerts.collectAsState()
val denms by viewModel.denmEvents.collectAsState()
val spats by viewModel.spatIntersections.collectAsState()
// Follow is on until the rider touches the map, and comes back when they ask for it. Without
// the hand-over, every incoming CAM would drag the viewport back to the ego position and the
// map could not be panned at all while traffic is flowing.
var followOwn by remember { mutableStateOf(true) }
// SPATEM carries no geometry of its own, so an intersection can only be placed if its RSU has
// also been heard over CAM. Saying so is better than silently dropping it: "the map shows two
// of the three lights I can see in the list" is otherwise an unexplained discrepancy.
val unlocatedSpats = spats.count { it.stationId !in stations.keys }
Box(modifier = Modifier.fillMaxSize()) {
V2xLiveMapView(
own = ownCamPosition,
remotes = stations,
alerts = alerts,
denms = denms,
spats = spats,
followOwn = followOwn,
onUserPanned = { followOwn = false },
modifier = Modifier.fillMaxSize(),
)
// ── Floating header: back + live counts, which double as the legend ──
Row(
modifier = Modifier
.align(Alignment.TopStart)
.fillMaxWidth()
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
MapChrome {
IconButton(onClick = onBack, modifier = Modifier.size(36.dp)) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.v2x_map_back),
)
}
}
Spacer(Modifier.width(8.dp))
MapChrome {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
MapCount(
icon = Icons.Default.Place,
tint = CamPinBlue,
count = stations.size,
label = stringResource(R.string.v2x_map_legend_cam),
)
MapCount(
icon = Icons.Default.Warning,
tint = HazardAmber,
count = denms.size,
label = stringResource(R.string.v2x_map_legend_denm),
)
MapCount(
icon = Icons.Default.Traffic,
tint = SignalGreenDot,
count = spats.size,
label = stringResource(R.string.v2x_map_legend_spat),
)
}
}
}
if (unlocatedSpats > 0) {
MapChrome(
modifier = Modifier
.align(Alignment.BottomStart)
.padding(12.dp),
) {
Text(
text = stringResource(R.string.v2x_map_spat_unlocated, unlocatedSpats),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
)
}
}
// Recentre: lit while following, so the button also reports which mode the map is in.
FloatingActionButton(
onClick = { followOwn = true },
containerColor = if (followOwn) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(16.dp),
) {
Icon(
Icons.Default.MyLocation,
contentDescription = stringResource(R.string.v2x_map_follow),
tint = if (followOwn) MaterialTheme.colorScheme.onPrimary
else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
/** A translucent pill for anything floating over the map, so chrome stays readable over tiles. */
@Composable
private fun MapChrome(
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
Surface(
shape = RoundedCornerShape(18.dp),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.88f),
tonalElevation = 3.dp,
shadowElevation = 2.dp,
modifier = modifier,
) { content() }
}
@Composable
private fun MapCount(icon: ImageVector, tint: Color, count: Int, label: String) {
Row(
horizontalArrangement = Arrangement.spacedBy(3.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(icon, contentDescription = label, tint = tint, modifier = Modifier.size(16.dp))
Text(
text = count.toString(),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
// Legend tints, matching the marker artwork rather than the theme: these name the drawables on
// the map, so they must not shift with light/dark mode the way theme colours do.
private val CamPinBlue = Color(0xFF78909C)
private val HazardAmber = Color(0xFFFFC107)
private val SignalGreenDot = Color(0xFF4CAF50)
@@ -13,16 +13,25 @@ import com.hawhamburg.micr0bu.data.transport.EspLinkStatus
import com.hawhamburg.micr0bu.data.transport.ObuHardware import com.hawhamburg.micr0bu.data.transport.ObuHardware
import com.hawhamburg.micr0bu.data.transport.TransportType import com.hawhamburg.micr0bu.data.transport.TransportType
import com.hawhamburg.micr0bu.data.transport.UsbNetworkDetector import com.hawhamburg.micr0bu.data.transport.UsbNetworkDetector
import com.hawhamburg.micr0bu.data.transport.UsbSerialState import com.hawhamburg.micr0bu.data.transport.Esp32LinkState
import com.hawhamburg.micr0bu.data.transport.UsbSerialTransport import com.hawhamburg.micr0bu.data.transport.Esp32Link
import com.hawhamburg.micr0bu.data.transport.Esp32Transport
import com.hawhamburg.micr0bu.data.transport.OutgoingMessage
import com.hawhamburg.micr0bu.data.transport.StationStatus
import com.hawhamburg.micr0bu.domain.denm.DenmEvent import com.hawhamburg.micr0bu.domain.denm.DenmEvent
import com.hawhamburg.micr0bu.domain.denm.DenmParser import com.hawhamburg.micr0bu.domain.denm.DenmParser
import com.hawhamburg.micr0bu.domain.spat.SpatIntersection
import com.hawhamburg.micr0bu.domain.denm.DenmUseCase import com.hawhamburg.micr0bu.domain.denm.DenmUseCase
import com.hawhamburg.micr0bu.domain.usecase.UseCaseAlert import com.hawhamburg.micr0bu.domain.usecase.UseCaseAlert
import com.hawhamburg.micr0bu.domain.usecase.UseCaseType import com.hawhamburg.micr0bu.domain.usecase.UseCaseType
import com.hawhamburg.micr0bu.service.CamPinger import com.hawhamburg.micr0bu.service.CamPinger
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.runningFold
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -39,7 +48,7 @@ class MqttViewModel @Inject constructor(
private val usbDetector: UsbNetworkDetector, private val usbDetector: UsbNetworkDetector,
private val camUseCaseRepository: CamUseCaseRepository, private val camUseCaseRepository: CamUseCaseRepository,
private val obuHardwarePrefs: ObuHardwarePreferences, private val obuHardwarePrefs: ObuHardwarePreferences,
private val usbSerialTransport: UsbSerialTransport, private val esp32Link: Esp32Link,
private val camPinger: CamPinger, private val camPinger: CamPinger,
) : ViewModel() { ) : ViewModel() {
@@ -74,14 +83,42 @@ class MqttViewModel @Inject constructor(
/** Auto-detected OBU gateway IP on the USB interface. */ /** Auto-detected OBU gateway IP on the USB interface. */
val detectedObuIp: StateFlow<String?> = usbDetector.detectedGatewayIp val detectedObuIp: StateFlow<String?> = usbDetector.detectedGatewayIp
/** ESP32-C5 USB-serial link state (Phase 03) — see [UsbSerialTransport]. */ /** ESP32-C5 link state, over USB or BLE per [esp32Transport] — see [Esp32Link]. */
val usbSerialState: StateFlow<UsbSerialState> = usbSerialTransport.state val esp32LinkState: StateFlow<Esp32LinkState> = esp32Link.state
/** Latest firmware heartbeat + drop counters, null until the first STATUS frame arrives. */ /** Latest firmware heartbeat + drop counters, null until the first STATUS frame arrives. */
val espLinkStatus: StateFlow<EspLinkStatus?> = usbSerialTransport.linkStatus val espLinkStatus: StateFlow<EspLinkStatus?> = esp32Link.linkStatus
/** Non-zero means CAMs are being built and dropped — see [UsbSerialTransport.sendCamTx]. */ /** Non-zero means CAMs are being built and dropped — see [Esp32Link.send]. */
val camSendFailures: StateFlow<Int> = usbSerialTransport.consecutiveWriteFailures val camSendFailures: StateFlow<Int> = esp32Link.consecutiveWriteFailures
/** Signing and radio counters of the current obu-firmware; null with the previous firmware. */
val stationStatus: StateFlow<StationStatus?> = esp32Link.stationStatus
/** One line about the link session (pairing passkey, provisioning, refusals); null when quiet. */
val esp32Detail: StateFlow<String?> = esp32Link.detail
// ── ESP32-C5 settings ─────────────────────────────────────────────────────
val esp32Transport: StateFlow<Esp32Transport> = esp32Link.transport
fun setEsp32Transport(transport: Esp32Transport) {
viewModelScope.launch { obuHardwarePrefs.setEsp32Transport(transport) }
}
val outgoingMessage: StateFlow<OutgoingMessage> = obuHardwarePrefs.outgoingMessageFlow
.stateIn(viewModelScope, SharingStarted.Eagerly, OutgoingMessage.CAM)
fun setOutgoingMessage(message: OutgoingMessage) {
viewModelScope.launch { obuHardwarePrefs.setOutgoingMessage(message) }
}
val signOutgoing: StateFlow<Boolean> = obuHardwarePrefs.signOutgoingFlow
.stateIn(viewModelScope, SharingStarted.Eagerly, true)
fun setSignOutgoing(sign: Boolean) {
viewModelScope.launch { obuHardwarePrefs.setSignOutgoing(sign) }
}
// ── ESP32-C5 CAM pinger (manual bench test, Phase 03) ───────────────────── // ── ESP32-C5 CAM pinger (manual bench test, Phase 03) ─────────────────────
// The ESP32-C5-path equivalent of the CiT One's manual DENM trigger below — a fixed- // The ESP32-C5-path equivalent of the CiT One's manual DENM trigger below — a fixed-
@@ -95,7 +132,24 @@ class MqttViewModel @Inject constructor(
/** False while the pinger runs without a GNSS fix — it has no position to build a CAM from. */ /** False while the pinger runs without a GNSS fix — it has no position to build a CAM from. */
val camPingerHasFix: StateFlow<Boolean> = camPinger.hasFix val camPingerHasFix: StateFlow<Boolean> = camPinger.hasFix
fun startCamPinger() = camPinger.start() /**
* Own transmissions heard back off the air, null until one is.
*
* This is the pinger's actual proof of life. [camPingerSentCount] only says frames were
* handed to the ESP32; this says they went out and came back, which is the round trip the
* bench test is there to demonstrate. See
* [com.hawhamburg.micr0bu.domain.cam.OwnTxLoopback].
*/
val ownTxLoopback: StateFlow<com.hawhamburg.micr0bu.domain.cam.OwnTxLoopback?> =
camUseCaseRepository.ownTxLoopback
fun startCamPinger() {
// Reset first, so the tally counts this run rather than accumulating across runs and
// making the comparison against sent count meaningless.
camUseCaseRepository.resetOwnTxLoopback()
camPinger.start()
}
fun stopCamPinger() = camPinger.stop() fun stopCamPinger() = camPinger.stop()
// ── Prefs ───────────────────────────────────────────────────────────────── // ── Prefs ─────────────────────────────────────────────────────────────────
@@ -121,13 +175,27 @@ class MqttViewModel @Inject constructor(
val obuStationType: StateFlow<Int?> = _obuStationType.asStateFlow() val obuStationType: StateFlow<Int?> = _obuStationType.asStateFlow()
/** /**
* True when the OBU has reported a stationType other than 2 (cyclist). * True when the CiT One has reported a stationType other than 2 (cyclist).
* Triggers a persistent warning banner — an incorrect stationType means this OBU will * Triggers a persistent warning banner — an incorrect stationType means this OBU will
* not be detected as a VRU at equipped intersections. * not be detected as a VRU at equipped intersections.
*
* Suppressed in ESP32-C5 mode. The value behind it comes from the CiT One's
* `v2x/rx/obu_gnss` topic, which the ESP32-C5 does not publish, so a warning raised before a
* mode switch would otherwise stay on screen reporting on an OBU that is no longer in use.
* There is nothing for it to warn about on that path either: the phone builds its own CAM
* ([com.hawhamburg.micr0bu.domain.cam.PhoneCamBuilder]), which sets stationType to cyclist
* locally rather than reading it back from an OBU.
*
* The underlying [obuStationType] is deliberately not cleared on the switch. It remains the
* last thing that OBU actually said, and obu_gnss refreshes it at ~4 Hz on returning to the
* CiT One path, so the warning re-evaluates against fresh data within a fraction of a second.
*/ */
val obuStationTypeWarning: StateFlow<Boolean> = _obuStationType val obuStationTypeWarning: StateFlow<Boolean> = combine(
.map { it != null && it != 2 } _obuStationType,
.stateIn(viewModelScope, SharingStarted.Eagerly, false) repo.obuHardware,
) { stationType, hardware ->
hardware == ObuHardware.CIT_ONE && stationType != null && stationType != 2
}.stateIn(viewModelScope, SharingStarted.Eagerly, false)
// ── DENM reception (live map hazard pins) ───────────────────────────────── // ── DENM reception (live map hazard pins) ─────────────────────────────────
@@ -135,26 +203,105 @@ class MqttViewModel @Inject constructor(
* Hazards received from other stations, newest first, deduped by [DenmEvent.dedupKey] so a * Hazards received from other stations, newest first, deduped by [DenmEvent.dedupKey] so a
* repeating DENM about the same hazard stays one pin instead of stacking up. * repeating DENM about the same hazard stays one pin instead of stacking up.
* *
* Derived from the raw `v2x-uca/output/json/denm` messages the repository already buffers, * Two sources, merged: the CiT One Use Case app's `v2x-uca/output/json/denm` MQTT topic
* rather than a second subscription — the repository caps each topic's history, so this is * (parsed by [DenmParser]), and UPER decoded by
* bounded by construction. * [com.hawhamburg.micr0bu.domain.asn1.DenmUperCodec] from whichever raw path is live, the
* ESP32-C5 serial link or the CiT One's `v2x/rx/denm` protobuf topic.
* *
* Always empty on the ESP32-C5 path: that firmware forwards BTP-B port 2001 (CAM) only and * Where both describe the same hazard, the decoded one wins. Both key on ETSI's actionID, so
* drops DENM before it reaches the phone. See [DenmEvent]'s KDoc. * the `associateBy` below collapses them to one entry, and the decoded list is concatenated
* second so it is the one that survives. That is the intended preference: the Use Case app
* rate-limits and drops messages, and reduces what it does publish to the fields it cared
* about, so it can only ever be a lossier account of the same event.
*
* Events carrying `termination` are filtered out rather than shown — the hazard is over.
*/ */
val denmEvents: StateFlow<List<DenmEvent>> = repo.topicMessages val denmEvents: StateFlow<List<DenmEvent>> = combine(
.map { byTopic -> repo.topicMessages.map { byTopic ->
(byTopic[DENM_RX_TOPIC] ?: emptyList()) (byTopic[DENM_RX_TOPIC] ?: emptyList())
.mapNotNull { DenmParser.parse(it.payload, it.timestamp) } .mapNotNull { DenmParser.parse(it.payload, it.timestamp) }
.associateBy { it.dedupKey } // last write wins = most recent per hazard },
// Air DENMs accumulate here rather than being a snapshot: the serial path delivers one
// event at a time, so runningFold keeps the set of hazards heard so far.
camUseCaseRepository.decodedDenm
.runningFold(emptyMap<String, DenmEvent>()) { acc, denm -> acc + (denm.dedupKey to denm) }
.map { it.values.toList() },
// Expiry has to be driven by a clock, not by arrivals. Both upstream flows only re-emit
// when a DENM arrives, so a sender that simply stops transmitting - drives away, loses
// power, leaves range - would otherwise leave its hazard on the map forever: there is no
// further emission to recompute the list. This tick is what makes a hazard fade.
tickerFlow(DENM_EXPIRY_TICK_MS),
) { fromUseCaseApp, fromDecoder, _ ->
val now = System.currentTimeMillis()
(fromUseCaseApp + fromDecoder)
.filterNot { it.isTermination } // the hazard is over - stop drawing it
.associateBy { it.dedupKey } // last write wins, so the decoded one is kept
.values .values
// Not heard from in DENM_TTL_MS: treat as gone. DENMs repeat at roughly 1 Hz, so a
// full minute of silence is ~60 missed repetitions - well past "we briefly lost one".
.filter { now - it.timestamp <= DENM_TTL_MS }
.sortedByDescending { it.timestamp } .sortedByDescending { it.timestamp }
}.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
/**
* Live signal state per intersection, newest first, keyed by [IntersectionSignalState.key].
*
* Both hardware paths: SPATEM arrives over the air on BTP port 2004 via the ESP32-C5 serial
* link, or on the CiT One's `v2x/rx/spatem` protobuf topic. The CiT One's processed
* `v2x-uca/output/json/spat` topic is not used, since the raw topic carries every repetition.
*
* One entry per intersection, not per message: SPATEM repeats at ~2 Hz per RSU, so a log would
* grow without telling anyone anything. Entries expire like DENMs do - an intersection left
* behind stops transmitting, and the same clock-driven argument applies.
*/
val spatIntersections: StateFlow<List<SpatIntersection>> = combine(
camUseCaseRepository.decodedSpat
.runningFold(emptyMap<String, SpatIntersection>()) { acc, spat ->
acc + spat.intersections.associate { i ->
i.key to SpatIntersection(i, spat.stationId, spat.rssiDbm, spat.timestamp)
}
},
tickerFlow(SPAT_EXPIRY_TICK_MS),
) { byKey, _ ->
val now = System.currentTimeMillis()
byKey.values
.filter { now - it.timestamp <= SPAT_TTL_MS }
.sortedByDescending { it.timestamp }
}.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
/** Emits immediately, then every [periodMs], purely to re-trigger a time-dependent combine. */
private fun tickerFlow(periodMs: Long): Flow<Long> = flow {
while (true) {
emit(System.currentTimeMillis())
delay(periodMs)
}
} }
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
private companion object { private companion object {
/** Use Case API topic carrying received DENMs (CiT One path only). */ /** Use Case API topic carrying received DENMs (CiT One path only). */
const val DENM_RX_TOPIC = "v2x-uca/output/json/denm" const val DENM_RX_TOPIC = "v2x-uca/output/json/denm"
/**
* How long a hazard stays listed after its last repetition. A DENM has no "still here"
* guarantee beyond the sender repeating it, and its own validityDuration is not decoded
* yet, so silence is the only expiry signal available.
*/
const val DENM_TTL_MS = 60_000L
/** How often the list is re-evaluated for expiry. Sets the worst-case lateness of a fade. */
const val DENM_EXPIRY_TICK_MS = 5_000L
/**
* SPATEM repeats at ~2 Hz, so 15 s of silence is ~30 missed repetitions: the RSU is out of
* range. Much shorter than the DENM window because a stale traffic light is more
* misleading than a stale hazard - a light that stopped updating is not "still green".
*/
const val SPAT_TTL_MS = 15_000L
const val SPAT_EXPIRY_TICK_MS = 2_000L
/** RSU CAMs arrive at ~2 Hz, same as any other station, so the same window applies. */
const val RSU_TTL_MS = 15_000L
const val RSU_EXPIRY_TICK_MS = 2_000L
} }
// ── DENM transmission ───────────────────────────────────────────────────── // ── DENM transmission ─────────────────────────────────────────────────────
@@ -187,6 +334,24 @@ class MqttViewModel @Inject constructor(
/** Latest known CAM per tracked remote road user, for the live map view (Section 13). */ /** Latest known CAM per tracked remote road user, for the live map view (Section 13). */
val remoteCamPositions: StateFlow<Map<Long, com.hawhamburg.micr0bu.domain.cam.Cam>> = camUseCaseRepository.remotePositions val remoteCamPositions: StateFlow<Map<Long, com.hawhamburg.micr0bu.domain.cam.Cam>> = camUseCaseRepository.remotePositions
/**
* Every station to draw: road users from the detection engine, plus roadside units, which are
* tracked outside it (see [com.hawhamburg.micr0bu.data.cam.CamUseCaseRepository.rsuStations]).
*
* The engine prunes its own stale entries; nothing prunes the RSU map, so the staleness window
* is applied here. As with hazards and signals, expiry has to be clock-driven - an RSU that
* goes out of range simply stops transmitting, and no further emission would arrive to
* recompute the list.
*/
val stationsInRange: StateFlow<Map<Long, com.hawhamburg.micr0bu.domain.cam.Cam>> = combine(
camUseCaseRepository.remotePositions,
camUseCaseRepository.rsuStations,
tickerFlow(RSU_EXPIRY_TICK_MS),
) { roadUsers, rsus, _ ->
val now = System.currentTimeMillis()
roadUsers + rsus.filterValues { now - it.timestamp <= RSU_TTL_MS }
}.stateIn(viewModelScope, SharingStarted.Eagerly, emptyMap())
/** True if [stationId] is the ego OBU's own — used for OWN/REMOTE badges in the raw message list. */ /** True if [stationId] is the ego OBU's own — used for OWN/REMOTE badges in the raw message list. */
fun isOwnStationId(stationId: Long): Boolean = camUseCaseRepository.isOwnStationId(stationId) fun isOwnStationId(stationId: Long): Boolean = camUseCaseRepository.isOwnStationId(stationId)
@@ -218,10 +383,10 @@ class MqttViewModel @Inject constructor(
fun connect() = repo.connect() fun connect() = repo.connect()
fun disconnect() = repo.disconnect() fun disconnect() = repo.disconnect()
/** Connect/disconnect the ESP32-C5 USB-serial link — separate from [connect]/[disconnect], /** Connect/disconnect the ESP32-C5 link (USB or BLE per [esp32Transport]) — separate from
* which drive the CiT One's MQTT-over-USB-C/Wi-Fi path. See [ConnectionSetupScreen]. */ * [connect]/[disconnect], which drive the CiT One's MQTT-over-USB-C/Wi-Fi path. */
fun connectUsbSerial() = usbSerialTransport.connect() fun connectEsp32() = esp32Link.connect()
fun disconnectUsbSerial() = usbSerialTransport.disconnect() fun disconnectEsp32() = esp32Link.disconnect()
fun selectTopic(topic: String?) { _selectedTopic.value = topic } fun selectTopic(topic: String?) { _selectedTopic.value = topic }
fun setAutoScroll(enabled: Boolean) { _autoScroll.value = enabled } fun setAutoScroll(enabled: Boolean) { _autoScroll.value = enabled }
@@ -255,7 +420,7 @@ class MqttViewModel @Inject constructor(
super.onCleared() super.onCleared()
repo.disconnect() repo.disconnect()
camPinger.stop() camPinger.stop()
// Deliberately NOT usbSerialTransport.disconnect(): the transport is an app-scoped // Deliberately NOT esp32Link.disconnect(): the link is an app-scoped
// @Singleton also held by the foreground TripRecordingService (via CamTransmitLoop). // @Singleton also held by the foreground TripRecordingService (via CamTransmitLoop).
// Closing it here would tear the port down when the Activity goes away — e.g. swiping // Closing it here would tear the port down when the Activity goes away — e.g. swiping
// the app from Recents mid-recording — leaving the still-running service beaconing into // the app from Recents mid-recording — leaving the still-running service beaconing into
@@ -194,7 +194,7 @@ class SensorViewModel(application: Application) : AndroidViewModel(application)
csvWriter = BufferedWriter(FileWriter(File(sessionsDir, "$recordingSessionId.csv"))) csvWriter = BufferedWriter(FileWriter(File(sessionsDir, "$recordingSessionId.csv")))
csvWriter?.apply { csvWriter?.apply {
appendLine("# MicrOBU Session Export") appendLine("# MicrOBU Session Export")
appendLine("# Generated by MicrOBU v0.2.0 — HAW Hamburg / Project MicrOBU") appendLine("# Generated by MicrOBU v0.2.0 - HAW Hamburg / Project MicrOBU")
appendLine("# Session ID,$recordingSessionId") appendLine("# Session ID,$recordingSessionId")
appendLine("# Start,${isoFmt.format(Date(startTime))}") appendLine("# Start,${isoFmt.format(Date(startTime))}")
appendLine() appendLine()
@@ -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),
) )
} }
@@ -0,0 +1,38 @@
<!--
Live-map marker for a signalised intersection heard over SPATEM: a traffic light housing with
the lamp for the intersection's leading phase lit and the other two dimmed.
One drawable per lit lamp rather than one drawable tinted at runtime: setTint recolours every
path in a vector, so a single shared asset could not keep the unlit lamps dark while colouring
the lit one - the whole light would turn one flat colour and stop reading as a traffic light.
Anchored at the bottom in V2xLiveMapView, so the housing sits above the intersection rather
than covering it.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="30dp"
android:height="30dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- White outline first, so the marker stays legible over dark map features. -->
<path
android:fillColor="#FFFFFFFF"
android:pathData="M4.4,0.8H19.6V23.2H4.4z" />
<!-- Housing. -->
<path
android:fillColor="#FF263238"
android:pathData="M5.8,2.0H18.2V22.0H5.8z" />
<!-- Lamps, top to bottom: red, amber, green. -->
<path
android:fillColor="#FF5A2220"
android:pathData="M12,6.6m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
<path
android:fillColor="#FFFFC107"
android:pathData="M12,12.0m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
<path
android:fillColor="#FF1E4D2B"
android:pathData="M12,17.4m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
</vector>
@@ -0,0 +1,38 @@
<!--
Live-map marker for a signalised intersection heard over SPATEM: a traffic light housing with
the lamp for the intersection's leading phase lit and the other two dimmed.
One drawable per lit lamp rather than one drawable tinted at runtime: setTint recolours every
path in a vector, so a single shared asset could not keep the unlit lamps dark while colouring
the lit one - the whole light would turn one flat colour and stop reading as a traffic light.
Anchored at the bottom in V2xLiveMapView, so the housing sits above the intersection rather
than covering it.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="30dp"
android:height="30dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- White outline first, so the marker stays legible over dark map features. -->
<path
android:fillColor="#FFFFFFFF"
android:pathData="M4.4,0.8H19.6V23.2H4.4z" />
<!-- Housing. -->
<path
android:fillColor="#FF263238"
android:pathData="M5.8,2.0H18.2V22.0H5.8z" />
<!-- Lamps, top to bottom: red, amber, green. -->
<path
android:fillColor="#FF5A2220"
android:pathData="M12,6.6m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
<path
android:fillColor="#FF4A2E1C"
android:pathData="M12,12.0m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
<path
android:fillColor="#FF1E4D2B"
android:pathData="M12,17.4m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
</vector>
@@ -0,0 +1,38 @@
<!--
Live-map marker for a signalised intersection heard over SPATEM: a traffic light housing with
the lamp for the intersection's leading phase lit and the other two dimmed.
One drawable per lit lamp rather than one drawable tinted at runtime: setTint recolours every
path in a vector, so a single shared asset could not keep the unlit lamps dark while colouring
the lit one - the whole light would turn one flat colour and stop reading as a traffic light.
Anchored at the bottom in V2xLiveMapView, so the housing sits above the intersection rather
than covering it.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="30dp"
android:height="30dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- White outline first, so the marker stays legible over dark map features. -->
<path
android:fillColor="#FFFFFFFF"
android:pathData="M4.4,0.8H19.6V23.2H4.4z" />
<!-- Housing. -->
<path
android:fillColor="#FF263238"
android:pathData="M5.8,2.0H18.2V22.0H5.8z" />
<!-- Lamps, top to bottom: red, amber, green. -->
<path
android:fillColor="#FF5A2220"
android:pathData="M12,6.6m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
<path
android:fillColor="#FF4A2E1C"
android:pathData="M12,12.0m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
<path
android:fillColor="#FF4CAF50"
android:pathData="M12,17.4m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
</vector>
@@ -0,0 +1,38 @@
<!--
Live-map marker for a signalised intersection heard over SPATEM: a traffic light housing with
the lamp for the intersection's leading phase lit and the other two dimmed.
One drawable per lit lamp rather than one drawable tinted at runtime: setTint recolours every
path in a vector, so a single shared asset could not keep the unlit lamps dark while colouring
the lit one - the whole light would turn one flat colour and stop reading as a traffic light.
Anchored at the bottom in V2xLiveMapView, so the housing sits above the intersection rather
than covering it.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="30dp"
android:height="30dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- White outline first, so the marker stays legible over dark map features. -->
<path
android:fillColor="#FFFFFFFF"
android:pathData="M4.4,0.8H19.6V23.2H4.4z" />
<!-- Housing. -->
<path
android:fillColor="#FF263238"
android:pathData="M5.8,2.0H18.2V22.0H5.8z" />
<!-- Lamps, top to bottom: red, amber, green. -->
<path
android:fillColor="#FFFF5252"
android:pathData="M12,6.6m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
<path
android:fillColor="#FF4A2E1C"
android:pathData="M12,12.0m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
<path
android:fillColor="#FF1E4D2B"
android:pathData="M12,17.4m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
</vector>
+74 -35
View File
@@ -1,11 +1,11 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<!-- Language names — intentionally NOT translated so they always read in their own language --> <!-- Language names - intentionally NOT translated so they always read in their own language -->
<string name="lang_english">English</string> <string name="lang_english">English</string>
<string name="lang_german">Deutsch</string> <string name="lang_german">Deutsch</string>
<!-- App --> <!-- App -->
<string name="app_subtitle">V2X Begleiter — Phase 02</string> <string name="app_subtitle">V2X Begleiter - Phase 02</string>
<!-- Navigation --> <!-- Navigation -->
<string name="nav_dashboard">Dashboard</string> <string name="nav_dashboard">Dashboard</string>
@@ -26,11 +26,24 @@
<string name="dash_connected">Verbunden</string> <string name="dash_connected">Verbunden</string>
<string name="dash_mqtt_connected">MQTT verbunden</string> <string name="dash_mqtt_connected">MQTT verbunden</string>
<string name="dash_mqtt_connecting">Verbindung zum Broker…</string> <string name="dash_mqtt_connecting">Verbindung zum Broker…</string>
<string name="dash_mqtt_error">Broker nicht erreichbar — V2X-Einstellungen prüfen</string> <string name="dash_mqtt_error">Broker nicht erreichbar - V2X-Einstellungen prüfen</string>
<string name="dash_recording">Aufnahme</string> <string name="dash_recording">Aufnahme</string>
<string name="dash_samples">Messwerte</string> <string name="dash_samples">Messwerte</string>
<string name="dash_start_driving_session">Fahrsitzung starten</string> <string name="dash_start_driving_session">Fahrsitzung starten</string>
<string name="dash_initialising">Wird initialisiert…</string> <string name="dash_initialising">Wird initialisiert…</string>
<!-- Dashboard live V2X panel: nearest hazard and nearest signalised intersection -->
<string name="dash_hazard_warning">Gefahrenwarnung</string>
<string name="dash_hazard_station">Station %1$d</string>
<string name="dash_more_count">+%1$d weitere</string>
<string name="dash_signal_title">Ampel %1$s</string>
<string name="dash_signal_more">+%1$d weitere Kreuzung(en)</string>
<string name="dash_signal_phase_go">Grün</string>
<string name="dash_signal_phase_stop">Rot</string>
<string name="dash_signal_phase_changing">Wechselt</string>
<string name="dash_signal_phase_dark">Dunkel</string>
<string name="dash_signal_phase_unknown">Unbekannt</string>
<string name="dash_signal_countdown">%1$s · %2$.0f s</string>
<string name="stat_pressure">Luftdruck</string> <string name="stat_pressure">Luftdruck</string>
<string name="stat_altitude">Höhe</string> <string name="stat_altitude">Höhe</string>
<string name="stat_heading">Richtung</string> <string name="stat_heading">Richtung</string>
@@ -63,12 +76,12 @@
<string name="conn_bluetooth_phase3">Bluetooth</string> <string name="conn_bluetooth_phase3">Bluetooth</string>
<string name="conn_bluetooth_phase3_desc">Bluetooth-Verbindung ist für Phase 03 geplant und noch nicht implementiert.</string> <string name="conn_bluetooth_phase3_desc">Bluetooth-Verbindung ist für Phase 03 geplant und noch nicht implementiert.</string>
<string name="conn_esp32_title">ESP32-C5 (USB Seriell)</string> <string name="conn_esp32_title">ESP32-C5 (USB Seriell)</string>
<string name="conn_esp32_phase3_desc">Die USB-Seriell-Verbindung zum ESP32-C5 ist für Phase 03 vorgesehen, aber noch nicht funktionsfähig — dafür muss zuerst das ESP32-Firmware-Protokoll nach Kotlin übersetzt werden.</string> <string name="conn_esp32_phase3_desc">Die USB-Seriell-Verbindung zum ESP32-C5 ist für Phase 03 vorgesehen, aber noch nicht funktionsfähig - dafür muss zuerst das ESP32-Firmware-Protokoll nach Kotlin übersetzt werden.</string>
<string name="conn_esp32_state_disconnected">Nicht verbunden — ESP32-C5 an den nativen USB-C-Port anschließen und Verbinden antippen.</string> <string name="conn_esp32_state_disconnected">Nicht verbunden - ESP32-C5 an den nativen USB-C-Port anschließen und Verbinden antippen.</string>
<string name="conn_esp32_state_device_attached">Gerät erkannt, wird geöffnet…</string> <string name="conn_esp32_state_device_attached">Gerät erkannt, wird geöffnet…</string>
<string name="conn_esp32_state_permission_requested">Warte auf USB-Berechtigung…</string> <string name="conn_esp32_state_permission_requested">Warte auf USB-Berechtigung…</string>
<string name="conn_esp32_state_connected">Verbunden</string> <string name="conn_esp32_state_connected">Verbunden</string>
<string name="conn_esp32_state_error">Verbindungsfehler — Kabel und nativen USB-C-Port prüfen und erneut versuchen.</string> <string name="conn_esp32_state_error">Verbindungsfehler - Kabel und nativen USB-C-Port prüfen und erneut versuchen.</string>
<string name="conn_esp32_connect">Mit ESP32-C5 verbinden</string> <string name="conn_esp32_connect">Mit ESP32-C5 verbinden</string>
<string name="conn_scan">Geräte suchen</string> <string name="conn_scan">Geräte suchen</string>
<string name="conn_scanning">Suche läuft…</string> <string name="conn_scanning">Suche läuft…</string>
@@ -126,15 +139,21 @@
<string name="gnss_open_maps_sub">Öffnet Ihre bevorzugte Karten-App</string> <string name="gnss_open_maps_sub">Öffnet Ihre bevorzugte Karten-App</string>
<string name="gnss_view_inapp">In App anzeigen</string> <string name="gnss_view_inapp">In App anzeigen</string>
<string name="gnss_view_inapp_sub">Aktuellen Standort auf einer In-App-Karte anzeigen</string> <string name="gnss_view_inapp_sub">Aktuellen Standort auf einer In-App-Karte anzeigen</string>
<string name="gnss_no_fix">Noch kein GPS-Signal — gehen Sie ins Freie</string> <string name="gnss_no_fix">Noch kein GPS-Signal - gehen Sie ins Freie</string>
<string name="map_title">Standortkarte</string> <string name="map_title">Standortkarte</string>
<string name="map_location_label">Aktueller Standort</string> <string name="map_location_label">Aktueller Standort</string>
<string name="v2x_map_remote_count">%1$d erfasste externe Verkehrsteilnehmer</string>
<string name="v2x_map_own_label">Eigen (Ego)</string> <string name="v2x_map_own_label">Eigen (Ego)</string>
<string name="v2x_map_remote_plain">Extern #%1$d</string> <string name="v2x_map_remote_plain">Extern #%1$d</string>
<string name="v2x_map_remote_info">Extern #%1$d · Info</string> <string name="v2x_map_remote_info">Extern #%1$d · Info</string>
<string name="v2x_map_remote_awareness">Extern #%1$d · Aufmerksamkeit</string> <string name="v2x_map_remote_awareness">Extern #%1$d · Aufmerksamkeit</string>
<string name="v2x_map_remote_warning">Extern #%1$d · Warnung</string> <string name="v2x_map_remote_warning">Extern #%1$d · Warnung</string>
<string name="v2x_map_title">V2X-Live-Karte</string>
<string name="v2x_map_back">Zurück</string>
<string name="v2x_map_follow">Auf eigene Position zentrieren</string>
<string name="v2x_map_legend_cam">Verkehrsteilnehmer (CAM)</string>
<string name="v2x_map_legend_denm">Gefahren (DENM)</string>
<string name="v2x_map_legend_spat">Signale (SPATEM)</string>
<string name="v2x_map_spat_unlocated">%1$d Signal(e) nicht dargestellt - Senderposition unbekannt</string>
<!-- Settings --> <!-- Settings -->
<string name="settings_title">Einstellungen</string> <string name="settings_title">Einstellungen</string>
@@ -160,17 +179,17 @@
<string name="settings_developer">Entwickler</string> <string name="settings_developer">Entwickler</string>
<string name="settings_dev_mode">Entwicklermodus</string> <string name="settings_dev_mode">Entwicklermodus</string>
<string name="settings_wifi">WLAN-OBU-Verbindung</string> <string name="settings_wifi">WLAN-OBU-Verbindung</string>
<string name="settings_wifi_val">Nur Entwicklermodus — noch nicht implementiert</string> <string name="settings_wifi_val">Nur Entwicklermodus - noch nicht implementiert</string>
<string name="settings_about">Über</string> <string name="settings_about">Über</string>
<string name="settings_app_version">App-Version</string> <string name="settings_app_version">App-Version</string>
<string name="settings_app_version_val">0.5.0 (Phase 03 — ESP32-C5-Seriellverbindung + CAM vom Smartphone)</string> <string name="settings_app_version_val">0.5.0 (Phase 03 - ESP32-C5-Seriellverbindung + CAM vom Smartphone)</string>
<string name="settings_connection">Verbindung</string> <string name="settings_connection">Verbindung</string>
<string name="settings_usb_auto_detect">OBU per USB-C automatisch erkennen</string> <string name="settings_usb_auto_detect">OBU per USB-C automatisch erkennen</string>
<string name="settings_usb_manual_ip">OBU-IP (manuell)</string> <string name="settings_usb_manual_ip">OBU-IP (manuell)</string>
<string name="settings_obu_hardware">OBU-Hardware</string> <string name="settings_obu_hardware">OBU-Hardware</string>
<string name="settings_obu_hardware_cit_one">CiT One</string> <string name="settings_obu_hardware_cit_one">CiT One</string>
<string name="settings_obu_hardware_esp32">ESP32-C5</string> <string name="settings_obu_hardware_esp32">ESP32-C5</string>
<string name="settings_obu_hardware_esp32_note">Der ESP32-C5 arbeitet als „dummer" Transceiver: CAM wird auf dem Smartphone erstellt und kodiert, über USB-Seriell an den ESP32 gesendet und über ITS-G5 gesendet. Auf diesem Pfad gibt es keinen MQTT-Broker und keine DENM-Use-Case-Engine — siehe den CAM-Pinger im V2X-Monitor für ein manuelles Testwerkzeug.</string> <string name="settings_obu_hardware_esp32_note">Das Smartphone erstellt CAM oder VAM und übergibt sie per USB-C oder Bluetooth an den ESP32-C5; der ESP32 ergänzt GeoNetworking, signiert (optional) und sendet über ITS-G5. Auf diesem Pfad gibt es keinen MQTT-Broker und keine DENM-Use-Case-Engine - siehe den CAM-Pinger im V2X-Monitor für ein manuelles Testwerkzeug.</string>
<string name="settings_usb_transport">Aktiver Transport</string> <string name="settings_usb_transport">Aktiver Transport</string>
<string name="settings_transport_usbc">USB-C</string> <string name="settings_transport_usbc">USB-C</string>
<string name="settings_transport_wifi">WLAN</string> <string name="settings_transport_wifi">WLAN</string>
@@ -183,36 +202,38 @@
<string name="mqtt_auto_scroll">Automatisch scrollen</string> <string name="mqtt_auto_scroll">Automatisch scrollen</string>
<string name="mqtt_no_topics">Noch keine Nachrichten</string> <string name="mqtt_no_topics">Noch keine Nachrichten</string>
<string name="mqtt_view_list">Liste</string> <string name="mqtt_view_list">Liste</string>
<string name="mqtt_view_map">Karte</string> <string name="mqtt_view_topics">Topics</string>
<string name="mqtt_no_topics_hint">Mit der OBU verbinden und auf V2X-Verkehr warten</string> <string name="mqtt_no_topics_hint">Mit der OBU verbinden und auf V2X-Verkehr warten</string>
<string name="mqtt_no_messages">Noch keine Nachrichten zu diesem Thema</string> <string name="mqtt_no_messages">Noch keine Nachrichten zu diesem Thema</string>
<!-- DENM TX — nur manueller Antennen-/RSU-Reichweitentest, nicht Use-Case-gesteuert --> <!-- DENM TX - nur manueller Antennen-/RSU-Reichweitentest, nicht Use-Case-gesteuert -->
<string name="mqtt_last_tx">Zuletzt gesendete Nutzlast:</string> <string name="mqtt_last_tx">Zuletzt gesendete Nutzlast:</string>
<string name="mqtt_denm_tx_title">DENM-Übertragung (manueller Test)</string> <string name="mqtt_denm_tx_title">DENM-Übertragung (manueller Test)</string>
<string name="mqtt_send_denm">Test-DENM senden</string> <string name="mqtt_send_denm">Test-DENM senden</string>
<string name="mqtt_stop_denm">DENM stoppen</string> <string name="mqtt_stop_denm">DENM stoppen</string>
<string name="mqtt_denm_use_case_desc">Liegengebliebenes Fahrzeug (causeCode 94)</string> <string name="mqtt_denm_use_case_desc">Liegengebliebenes Fahrzeug (causeCode 94)</string>
<string name="mqtt_denm_not_connected">Mit OBU verbinden, um DENM-Auslösung zu aktivieren</string> <string name="mqtt_denm_not_connected">Mit OBU verbinden, um DENM-Auslösung zu aktivieren</string>
<string name="mqtt_denm_active">DENM aktiv — OBU sendet über ITS-G5</string> <string name="mqtt_denm_active">DENM aktiv - OBU sendet über ITS-G5</string>
<string name="mqtt_denm_hint">Manueller Antennen-/RSU-Reichweitentest — sendet uca-denmctrl (retained) an v2x-uca/input/denmtrg. Wird nicht durch erkannte Ereignisse oder Use-Case-Alarme ausgelöst.</string> <string name="mqtt_denm_hint">Manueller Antennen-/RSU-Reichweitentest - sendet uca-denmctrl (retained) an v2x-uca/input/denmtrg. Wird nicht durch erkannte Ereignisse oder Use-Case-Alarme ausgelöst.</string>
<string name="mqtt_denm_show">Letztes TX anzeigen</string> <string name="mqtt_denm_show">Letztes TX anzeigen</string>
<string name="mqtt_denm_hide">Ausblenden</string> <string name="mqtt_denm_hide">Ausblenden</string>
<!-- CAM-Pinger — nur ESP32-C5, manueller Bank-Test, Gegenstück zur DENM-TX-Karte oben --> <!-- CAM-Pinger - nur ESP32-C5, manueller Bank-Test, Gegenstück zur DENM-TX-Karte oben -->
<string name="mqtt_cam_pinger_title">CAM-Pinger (manueller Test)</string> <string name="mqtt_cam_pinger_title">CAM-Pinger (manueller Test)</string>
<string name="mqtt_cam_pinger_desc">1-Hz-CAM-Ping aus Live-GNSS- und IMU-Daten — prüft die serielle Verbindung und den ESP32-Funkpfad ohne Fahrtaufzeichnung.</string> <string name="mqtt_cam_pinger_desc">1-Hz-CAM-Ping aus Live-GNSS- und IMU-Daten - prüft die serielle Verbindung und den ESP32-Funkpfad ohne Fahrtaufzeichnung.</string>
<string name="mqtt_cam_pinger_no_fix">Warte auf GNSS-Fix — noch nichts gesendet</string> <string name="mqtt_cam_pinger_no_fix">Warte auf GNSS-Fix - noch nichts gesendet</string>
<!-- Empfangene CAMs (ESP32-C5-Pfad) --> <!-- Empfangene CAMs (ESP32-C5-Pfad) -->
<string name="v2x_cam_rx_count">%1$d Station(en) in Reichweite — jeweils neueste CAM</string> <string name="v2x_cam_rx_count">%1$d Station(en) in Reichweite - jeweils neueste CAM</string>
<string name="v2x_cam_rx_none">Keine CAMs empfangen</string> <string name="v2x_cam_rx_none">Keine CAMs empfangen</string>
<string name="v2x_cam_rx_none_hint">Dekodierte CAMs benachbarter Stationen erscheinen hier, sobald sie über die serielle Verbindung eintreffen.</string> <string name="v2x_cam_rx_none_hint">Dekodierte CAMs benachbarter Stationen erscheinen hier, sobald sie eintreffen.</string>
<string name="v2x_cam_rx_station">Station %1$d · %2$s</string> <string name="v2x_cam_rx_station">Station %1$d · %2$s</string>
<string name="v2x_cam_rx_kinematics">%1$.1f km/h · Kurs %2$.0f°</string> <string name="v2x_cam_rx_kinematics">%1$.1f km/h · Kurs %2$.0f°</string>
<string name="v2x_cam_rx_distance">%1$.0f m</string> <string name="v2x_cam_rx_distance">%1$.0f m</string>
<string name="v2x_cam_rx_distance_unknown">— m</string> <string name="v2x_cam_rx_distance_unknown">- m</string>
<string name="v2x_cam_rx_rssi">%1$d dBm</string> <string name="v2x_cam_rx_rssi">%1$d dBm</string>
<string name="v2x_cam_rx_none_stations">Keine CAMs empfangen</string>
<string name="v2x_cam_rx_no_kinematics">Straßenseiteneinheit - keine Kinematik</string>
<!-- DENM-Kartenmarker --> <!-- DENM-Kartenmarker -->
<string name="v2x_map_denm_labeled">Gefahr: Ursache %1$d/%2$d (Station %3$d)</string> <string name="v2x_map_denm_labeled">Gefahr: Ursache %1$d/%2$d (Station %3$d)</string>
@@ -227,10 +248,12 @@
<string name="station_type_rsu">Straßenseiteneinheit</string> <string name="station_type_rsu">Straßenseiteneinheit</string>
<string name="station_type_other">Typ %1$d</string> <string name="station_type_other">Typ %1$d</string>
<string name="mqtt_cam_pinger_not_connected">ESP32-C5 verbinden, um den CAM-Pinger zu aktivieren</string> <string name="mqtt_cam_pinger_not_connected">ESP32-C5 verbinden, um den CAM-Pinger zu aktivieren</string>
<string name="mqtt_cam_pinger_active">Sendet — 1 CAM/s über die serielle Verbindung</string> <string name="mqtt_cam_pinger_active">Sendet - 1 CAM/s über die serielle Verbindung</string>
<string name="mqtt_cam_pinger_sent_count">Gesendet: %1$d</string> <string name="mqtt_cam_pinger_sent_count">Gesendet: %1$d</string>
<string name="mqtt_cam_pinger_send_failures">Schreibfehler: %1$d in Folge — CAMs erreichen den ESP32 nicht</string> <string name="mqtt_cam_pinger_send_failures">Schreibfehler: %1$d in Folge - CAMs erreichen den ESP32 nicht</string>
<string name="mqtt_cam_pinger_fw_counters">ESP32: TX-Fehler %1$d · zu groß %2$d · CRC-Fehler %3$d</string> <string name="mqtt_cam_pinger_fw_counters">ESP32: TX-Fehler %1$d · zu groß %2$d · CRC-Fehler %3$d</string>
<string name="mqtt_cam_pinger_loopback">Eigene Sendung empfangen: %1$d Frames · %2$d dBm</string>
<string name="mqtt_cam_pinger_loopback_no_rssi">Eigene Sendung empfangen: %1$d Frames</string>
<string name="mqtt_start_pinger">Pinger starten</string> <string name="mqtt_start_pinger">Pinger starten</string>
<string name="mqtt_stop_pinger">Pinger stoppen</string> <string name="mqtt_stop_pinger">Pinger stoppen</string>
@@ -241,15 +264,15 @@
<string name="mqtt_usecase_detail">Station %1$d · %2$.0f m · Annäherung %3$.1f m/s · TTC %4$.1f s · %5$s</string> <string name="mqtt_usecase_detail">Station %1$d · %2$.0f m · Annäherung %3$.1f m/s · TTC %4$.1f s · %5$s</string>
<!-- Verständliche Use-Case-Beschreibungen --> <!-- Verständliche Use-Case-Beschreibungen -->
<string name="usecase_narrative_ima_b">Kreuzendes Fahrzeug — %1$.0f s bis Konflikt</string> <string name="usecase_narrative_ima_b">Kreuzendes Fahrzeug - %1$.0f s bis Konflikt</string>
<string name="usecase_narrative_ima_s">Stehendes Fahrzeug könnte losfahren — %1$.0f s</string> <string name="usecase_narrative_ima_s">Stehendes Fahrzeug könnte losfahren - %1$.0f s</string>
<string name="usecase_narrative_rtw_b">Auto biegt rechts auf dich zu — %1$.0f s</string> <string name="usecase_narrative_rtw_b">Auto biegt rechts auf dich zu - %1$.0f s</string>
<string name="usecase_narrative_ltw_b">Auto biegt links auf dich zu — %1$.0f s</string> <string name="usecase_narrative_ltw_b">Auto biegt links auf dich zu - %1$.0f s</string>
<string name="usecase_narrative_smva_bcw_b">Schnell nahendes Fahrzeug — %1$.0f s</string> <string name="usecase_narrative_smva_bcw_b">Schnell nahendes Fahrzeug - %1$.0f s</string>
<!-- Einstellungen > Use Case Alerts --> <!-- Einstellungen > Use Case Alerts -->
<string name="settings_usecase_alerts">Use Case Alerts</string> <string name="settings_usecase_alerts">Use Case Alerts</string>
<string name="settings_usecase_alerts_desc">Ein-/Ausschalten pro Use Case für das CAM-basierte Use-Case-Alarm-Panel im V2X-Monitor. Alle Use Cases sind CAM-only — keiner löst ein DENM aus.</string> <string name="settings_usecase_alerts_desc">Ein-/Ausschalten pro Use Case für das CAM-basierte Use-Case-Alarm-Panel im V2X-Monitor. Alle Use Cases sind CAM-only - keiner löst ein DENM aus.</string>
<string name="settings_usecase_alert_levels_title">Alarmstufen-Schwellenwerte (nur lesend)</string> <string name="settings_usecase_alert_levels_title">Alarmstufen-Schwellenwerte (nur lesend)</string>
<string name="settings_usecase_level_warning">Warning</string> <string name="settings_usecase_level_warning">Warning</string>
<string name="settings_usecase_level_awareness">Awareness</string> <string name="settings_usecase_level_awareness">Awareness</string>
@@ -263,21 +286,17 @@
<string name="dash_transport_bt">BT</string> <string name="dash_transport_bt">BT</string>
<!-- OBU stationType warning --> <!-- OBU stationType warning -->
<string name="dash_station_type_warning">⚠ OBU-stationType ≠ 2 (Radfahrer) — VRU-Erkennung an ausgerüsteten Kreuzungen ggf. beeinträchtigt</string> <string name="dash_station_type_warning">⚠ OBU-stationType ≠ 2 (Radfahrer) - VRU-Erkennung an ausgerüsteten Kreuzungen ggf. beeinträchtigt</string>
<string name="settings_platform">Plattform</string> <string name="settings_platform">Plattform</string>
<string name="settings_platform_val">Android / Kotlin / Jetpack Compose</string> <string name="settings_platform_val">Android / Kotlin / Jetpack Compose</string>
<string name="settings_project">Projekt</string> <string name="settings_project">Projekt</string>
<string name="settings_project_val">MicrOBU — HAW Hamburg &amp; consider it GmbH</string> <string name="settings_project_val">MicrOBU - HAW Hamburg &amp; consider it GmbH</string>
<!-- Phase A: Trips (bottom nav) --> <!-- Phase A: Trips (bottom nav) -->
<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>
@@ -293,4 +312,24 @@
<!-- Phase A: Trip Review screen --> <!-- Phase A: Trip Review screen -->
<string name="trip_review_title">Fahrtanalyse</string> <string name="trip_review_title">Fahrtanalyse</string>
<!-- Empfangene SPATEM-Liste (ESP32-C5) -->
<string name="v2x_spat_rx_count">%1$d signalisierte Kreuzung(en) - Live-SPAT</string>
<string name="v2x_spat_rx_title">Kreuzung %1$s · Station %2$d</string>
<string name="v2x_spat_group">SG%1$d</string>
<string name="v2x_spat_countdown">%1$.0f s</string>
<string name="settings_esp32_link">ESP32-C5-Verbindung</string>
<string name="settings_esp32_link_ble">Bluetooth</string>
<string name="settings_esp32_link_ble_note">Beim ersten Verbinden wird das Koppeln mit micrOBU-XXXX angefragt: Passkey 123456 eingeben. Die Platine wirbt nur, solange ihr USB-C-Port nicht benutzt wird. BLE teilt sich das Funk-Frontend mit ITS-G5; der Einfluss auf den 5,9-GHz-Empfang ist noch nicht gemessen.</string>
<string name="settings_esp32_message">Senden während der Aufzeichnung</string>
<string name="settings_esp32_message_cam">CAM</string>
<string name="settings_esp32_message_vam">VAM</string>
<string name="settings_esp32_sign">Ausgehende Nachrichten signieren</string>
<string name="settings_esp32_sign_note">Signiert mit einer Demo-PKI, nicht der EU-Vertrauensliste: Empfänger, die dagegen prüfen, verwerfen diese Nachrichten. Aus sendet sie wie bisher unsigniert.</string>
<string name="conn_esp32_via_usb">über USB-C (nativer Port)</string>
<string name="conn_esp32_via_ble">über Bluetooth (Passkey 123456 beim ersten Koppeln)</string>
<string name="conn_esp32_cancel">Abbrechen</string>
<string name="conn_esp32_signing">Signieren %1$s · Tickets %2$d · signiert %3$d · abgelehnt %4$d · gesendet %5$d</string>
<string name="conn_esp32_signing_on">an</string>
<string name="conn_esp32_signing_off">aus</string>
</resources> </resources>
+81 -36
View File
@@ -2,7 +2,7 @@
<resources> <resources>
<!-- App --> <!-- App -->
<string name="app_name">MicrOBU</string> <string name="app_name">MicrOBU</string>
<string name="app_subtitle">V2X Companion — Phase 02</string> <string name="app_subtitle">V2X Companion - Phase 02</string>
<!-- Language names (always shown in their own language) --> <!-- Language names (always shown in their own language) -->
<string name="lang_english">English</string> <string name="lang_english">English</string>
@@ -27,11 +27,24 @@
<string name="dash_connected">Connected</string> <string name="dash_connected">Connected</string>
<string name="dash_mqtt_connected">MQTT connected</string> <string name="dash_mqtt_connected">MQTT connected</string>
<string name="dash_mqtt_connecting">Connecting to broker…</string> <string name="dash_mqtt_connecting">Connecting to broker…</string>
<string name="dash_mqtt_error">Broker unreachable — check V2X settings</string> <string name="dash_mqtt_error">Broker unreachable - check V2X settings</string>
<string name="dash_recording">Recording</string> <string name="dash_recording">Recording</string>
<string name="dash_samples">samples</string> <string name="dash_samples">samples</string>
<string name="dash_start_driving_session">Start Driving Session</string> <string name="dash_start_driving_session">Start Driving Session</string>
<string name="dash_initialising">Initialising…</string> <string name="dash_initialising">Initialising…</string>
<!-- Dashboard live V2X panel: nearest hazard and nearest signalised intersection -->
<string name="dash_hazard_warning">Hazard warning</string>
<string name="dash_hazard_station">station %1$d</string>
<string name="dash_more_count">+%1$d more</string>
<string name="dash_signal_title">Traffic light %1$s</string>
<string name="dash_signal_more">+%1$d more intersection(s)</string>
<string name="dash_signal_phase_go">Green</string>
<string name="dash_signal_phase_stop">Red</string>
<string name="dash_signal_phase_changing">Changing</string>
<string name="dash_signal_phase_dark">Dark</string>
<string name="dash_signal_phase_unknown">Unknown</string>
<string name="dash_signal_countdown">%1$s · %2$.0f s</string>
<string name="stat_pressure">Pressure</string> <string name="stat_pressure">Pressure</string>
<string name="stat_altitude">Altitude</string> <string name="stat_altitude">Altitude</string>
<string name="stat_heading">Heading</string> <string name="stat_heading">Heading</string>
@@ -64,12 +77,12 @@
<string name="conn_bluetooth_phase3">Bluetooth</string> <string name="conn_bluetooth_phase3">Bluetooth</string>
<string name="conn_bluetooth_phase3_desc">Bluetooth connection is planned for Phase 03 and is not yet implemented.</string> <string name="conn_bluetooth_phase3_desc">Bluetooth connection is planned for Phase 03 and is not yet implemented.</string>
<string name="conn_esp32_title">ESP32-C5 (USB Serial)</string> <string name="conn_esp32_title">ESP32-C5 (USB Serial)</string>
<string name="conn_esp32_phase3_desc">USB-serial connection to the ESP32-C5 is scaffolded for Phase 03 but not yet functional — it needs the ESP32 firmware protocol translated to Kotlin first.</string> <string name="conn_esp32_phase3_desc">USB-serial connection to the ESP32-C5 is scaffolded for Phase 03 but not yet functional - it needs the ESP32 firmware protocol translated to Kotlin first.</string>
<string name="conn_esp32_state_disconnected">Not connected — plug the ESP32-C5 into the native USB-C port and tap Connect.</string> <string name="conn_esp32_state_disconnected">Not connected - plug the ESP32-C5 into the native USB-C port and tap Connect.</string>
<string name="conn_esp32_state_device_attached">Device detected, opening…</string> <string name="conn_esp32_state_device_attached">Device detected, opening…</string>
<string name="conn_esp32_state_permission_requested">Waiting for USB permission…</string> <string name="conn_esp32_state_permission_requested">Waiting for USB permission…</string>
<string name="conn_esp32_state_connected">Connected</string> <string name="conn_esp32_state_connected">Connected</string>
<string name="conn_esp32_state_error">Connection error — check the cable and native USB-C port, then try again.</string> <string name="conn_esp32_state_error">Connection error - check the cable and native USB-C port, then try again.</string>
<string name="conn_esp32_connect">Connect to ESP32-C5</string> <string name="conn_esp32_connect">Connect to ESP32-C5</string>
<string name="conn_scan">Scan for Devices</string> <string name="conn_scan">Scan for Devices</string>
<string name="conn_scanning">Scanning…</string> <string name="conn_scanning">Scanning…</string>
@@ -127,15 +140,21 @@
<string name="gnss_open_maps_sub">Opens in your preferred maps application</string> <string name="gnss_open_maps_sub">Opens in your preferred maps application</string>
<string name="gnss_view_inapp">View in App</string> <string name="gnss_view_inapp">View in App</string>
<string name="gnss_view_inapp_sub">Show current location on an in-app map</string> <string name="gnss_view_inapp_sub">Show current location on an in-app map</string>
<string name="gnss_no_fix">No GPS fix yet — move to an open area</string> <string name="gnss_no_fix">No GPS fix yet - move to an open area</string>
<string name="map_title">Location Map</string> <string name="map_title">Location Map</string>
<string name="map_location_label">Current Location</string> <string name="map_location_label">Current Location</string>
<string name="v2x_map_remote_count">%1$d tracked remote road user(s)</string>
<string name="v2x_map_own_label">Own (ego)</string> <string name="v2x_map_own_label">Own (ego)</string>
<string name="v2x_map_remote_plain">Remote #%1$d</string> <string name="v2x_map_remote_plain">Remote #%1$d</string>
<string name="v2x_map_remote_info">Remote #%1$d · Info</string> <string name="v2x_map_remote_info">Remote #%1$d · Info</string>
<string name="v2x_map_remote_awareness">Remote #%1$d · Awareness</string> <string name="v2x_map_remote_awareness">Remote #%1$d · Awareness</string>
<string name="v2x_map_remote_warning">Remote #%1$d · Warning</string> <string name="v2x_map_remote_warning">Remote #%1$d · Warning</string>
<string name="v2x_map_title">V2X Live Map</string>
<string name="v2x_map_back">Back</string>
<string name="v2x_map_follow">Centre on own position</string>
<string name="v2x_map_legend_cam">Road users (CAM)</string>
<string name="v2x_map_legend_denm">Hazards (DENM)</string>
<string name="v2x_map_legend_spat">Signals (SPATEM)</string>
<string name="v2x_map_spat_unlocated">%1$d signal(s) not shown - sender position unknown</string>
<!-- Settings --> <!-- Settings -->
<string name="settings_title">Settings</string> <string name="settings_title">Settings</string>
@@ -161,17 +180,17 @@
<string name="settings_developer">Developer</string> <string name="settings_developer">Developer</string>
<string name="settings_dev_mode">Developer mode</string> <string name="settings_dev_mode">Developer mode</string>
<string name="settings_wifi">Wi-Fi OBU connection</string> <string name="settings_wifi">Wi-Fi OBU connection</string>
<string name="settings_wifi_val">Dev mode only — not implemented</string> <string name="settings_wifi_val">Dev mode only - not implemented</string>
<string name="settings_about">About</string> <string name="settings_about">About</string>
<string name="settings_app_version">App version</string> <string name="settings_app_version">App version</string>
<string name="settings_app_version_val">0.5.0 (Phase 03 — ESP32-C5 serial link + phone-built CAM)</string> <string name="settings_app_version_val">0.5.0 (Phase 03 - ESP32-C5 serial link + phone-built CAM)</string>
<string name="settings_connection">Connection</string> <string name="settings_connection">Connection</string>
<string name="settings_usb_auto_detect">Auto-detect OBU via USB-C</string> <string name="settings_usb_auto_detect">Auto-detect OBU via USB-C</string>
<string name="settings_usb_manual_ip">Manual OBU IP</string> <string name="settings_usb_manual_ip">Manual OBU IP</string>
<string name="settings_obu_hardware">OBU Hardware</string> <string name="settings_obu_hardware">OBU Hardware</string>
<string name="settings_obu_hardware_cit_one">CiT One</string> <string name="settings_obu_hardware_cit_one">CiT One</string>
<string name="settings_obu_hardware_esp32">ESP32-C5</string> <string name="settings_obu_hardware_esp32">ESP32-C5</string>
<string name="settings_obu_hardware_esp32_note">ESP32-C5 acts as a "dumb" transceiver: CAM is built and encoded on the phone, sent to the ESP32 over USB serial, and broadcast over ITS-G5. No MQTT broker or DENM use-case engine on this path — see the V2X Monitor screen\'s CAM Pinger for a manual test tool.</string> <string name="settings_obu_hardware_esp32_note">The phone builds CAM or VAM and hands it to the ESP32-C5 over USB-C or Bluetooth; the ESP32 adds GeoNetworking, signs it (optional) and broadcasts it over ITS-G5. No MQTT broker or DENM use-case engine on this path - see the V2X Monitor screen\'s CAM Pinger for a manual test tool.</string>
<string name="settings_usb_transport">Active transport</string> <string name="settings_usb_transport">Active transport</string>
<string name="settings_transport_usbc">USB-C</string> <string name="settings_transport_usbc">USB-C</string>
<string name="settings_transport_wifi">Wi-Fi</string> <string name="settings_transport_wifi">Wi-Fi</string>
@@ -184,36 +203,50 @@
<string name="mqtt_auto_scroll">Auto-scroll to latest</string> <string name="mqtt_auto_scroll">Auto-scroll to latest</string>
<string name="mqtt_no_topics">No messages yet</string> <string name="mqtt_no_topics">No messages yet</string>
<string name="mqtt_view_list">List</string> <string name="mqtt_view_list">List</string>
<string name="mqtt_view_map">Map</string> <string name="mqtt_view_topics">Topics</string>
<string name="mqtt_no_topics_hint">Connect to the OBU and wait for V2X traffic</string> <string name="mqtt_no_topics_hint">Connect to the OBU and wait for V2X traffic</string>
<string name="mqtt_no_messages">No messages on this topic yet</string> <string name="mqtt_no_messages">No messages on this topic yet</string>
<!-- DENM TX — manual antenna/RSU-range test tool only, not use-case-driven --> <!-- DENM TX - manual antenna/RSU-range test tool only, not use-case-driven -->
<string name="mqtt_last_tx">Last transmitted payload:</string> <string name="mqtt_last_tx">Last transmitted payload:</string>
<string name="mqtt_denm_tx_title">DENM Transmission (Manual Test)</string> <string name="mqtt_denm_tx_title">DENM Transmission (Manual Test)</string>
<string name="mqtt_send_denm">Send Test DENM</string> <string name="mqtt_send_denm">Send Test DENM</string>
<string name="mqtt_stop_denm">Stop DENM</string> <string name="mqtt_stop_denm">Stop DENM</string>
<string name="mqtt_denm_use_case_desc">Aftermarket Stationary Vehicle (causeCode 94)</string> <string name="mqtt_denm_use_case_desc">Aftermarket Stationary Vehicle (causeCode 94)</string>
<string name="mqtt_denm_not_connected">Connect to the OBU to enable DENM triggering</string> <string name="mqtt_denm_not_connected">Connect to the OBU to enable DENM triggering</string>
<string name="mqtt_denm_active">DENM active — OBU broadcasting via ITS-G5</string> <string name="mqtt_denm_active">DENM active - OBU broadcasting via ITS-G5</string>
<string name="mqtt_denm_hint">Manual antenna/RSU range test — publishes uca-denmctrl (retained) to v2x-uca/input/denmtrg. Not triggered by detected events or use case alerts.</string> <string name="mqtt_denm_hint">Manual antenna/RSU range test - publishes uca-denmctrl (retained) to v2x-uca/input/denmtrg. Not triggered by detected events or use case alerts.</string>
<string name="mqtt_denm_show">Show last TX</string> <string name="mqtt_denm_show">Show last TX</string>
<string name="mqtt_denm_hide">Hide</string> <string name="mqtt_denm_hide">Hide</string>
<!-- CAM Pinger — ESP32-C5-only manual bench test, equivalent to the DENM TX card above --> <!-- CAM Pinger - ESP32-C5-only manual bench test, equivalent to the DENM TX card above -->
<string name="mqtt_cam_pinger_title">CAM Pinger (Manual Test)</string> <string name="mqtt_cam_pinger_title">CAM Pinger (Manual Test)</string>
<string name="mqtt_cam_pinger_desc">1 Hz CAM ping built from live GNSS and IMU data — verifies the serial link and ESP32 radio path without needing a trip recording.</string> <string name="mqtt_cam_pinger_desc">1 Hz CAM ping built from live GNSS and IMU data - verifies the serial link and ESP32 radio path without needing a trip recording.</string>
<string name="mqtt_cam_pinger_no_fix">Waiting for GNSS fix — nothing transmitted yet</string> <string name="mqtt_cam_pinger_no_fix">Waiting for GNSS fix - nothing transmitted yet</string>
<!-- Received-CAM list (ESP32-C5 path) --> <!-- Received-CAM list (ESP32-C5 path) -->
<string name="v2x_cam_rx_count">%1$d station(s) in range — latest CAM per station</string> <string name="v2x_cam_rx_count">%1$d station(s) in range - latest CAM per station</string>
<string name="v2x_cam_rx_none">No CAMs received</string> <string name="v2x_cam_rx_none">No CAMs received</string>
<string name="v2x_cam_rx_none_hint">Decoded CAMs from nearby stations appear here as they arrive over the serial link.</string> <string name="v2x_cam_rx_none_hint">Decoded CAMs from nearby stations appear here as they arrive.</string>
<string name="v2x_cam_rx_station">Station %1$d · %2$s</string> <string name="v2x_cam_rx_station">Station %1$d · %2$s</string>
<string name="v2x_cam_rx_kinematics">%1$.1f km/h · heading %2$.0f°</string> <string name="v2x_cam_rx_kinematics">%1$.1f km/h · heading %2$.0f°</string>
<string name="v2x_cam_rx_distance">%1$.0f m</string> <string name="v2x_cam_rx_distance">%1$.0f m</string>
<string name="v2x_cam_rx_distance_unknown">— m</string> <string name="v2x_cam_rx_distance_unknown">- m</string>
<string name="v2x_cam_rx_rssi">%1$d dBm</string> <string name="v2x_cam_rx_rssi">%1$d dBm</string>
<string name="v2x_cam_rx_none_stations">No CAMs received</string>
<string name="v2x_cam_rx_no_kinematics">roadside unit - no kinematics reported</string>
<!-- Received-DENM list (ESP32-C5 path) -->
<string name="v2x_denm_rx_count">%1$d active hazard(s) - latest DENM per event</string>
<string name="v2x_denm_rx_hazard">%1$s · station %2$d</string>
<string name="v2x_denm_rx_cause_code">cause %1$d/%2$d</string>
<string name="v2x_denm_rx_radius">%1$d m radius</string>
<!-- Received-SPATEM list (ESP32-C5 path) -->
<string name="v2x_spat_rx_count">%1$d signalised intersection(s) - live SPAT</string>
<string name="v2x_spat_rx_title">Intersection %1$s · station %2$d</string>
<string name="v2x_spat_group">SG%1$d</string>
<string name="v2x_spat_countdown">%1$.0f s</string>
<!-- DENM map pins --> <!-- DENM map pins -->
<string name="v2x_map_denm_labeled">Hazard: cause %1$d/%2$d (station %3$d)</string> <string name="v2x_map_denm_labeled">Hazard: cause %1$d/%2$d (station %3$d)</string>
@@ -228,10 +261,12 @@
<string name="station_type_rsu">Roadside unit</string> <string name="station_type_rsu">Roadside unit</string>
<string name="station_type_other">Type %1$d</string> <string name="station_type_other">Type %1$d</string>
<string name="mqtt_cam_pinger_not_connected">Connect the ESP32-C5 to enable the CAM pinger</string> <string name="mqtt_cam_pinger_not_connected">Connect the ESP32-C5 to enable the CAM pinger</string>
<string name="mqtt_cam_pinger_active">Pinging — 1 CAM/s over the serial link</string> <string name="mqtt_cam_pinger_active">Pinging - 1 CAM/s over the serial link</string>
<string name="mqtt_cam_pinger_sent_count">Sent: %1$d</string> <string name="mqtt_cam_pinger_sent_count">Sent: %1$d</string>
<string name="mqtt_cam_pinger_send_failures">Write failures: %1$d consecutive — CAMs are not reaching the ESP32</string> <string name="mqtt_cam_pinger_send_failures">Write failures: %1$d consecutive - CAMs are not reaching the ESP32</string>
<string name="mqtt_cam_pinger_fw_counters">ESP32: tx fail %1$d · oversize %2$d · crc err %3$d</string> <string name="mqtt_cam_pinger_fw_counters">ESP32: tx fail %1$d · oversize %2$d · crc err %3$d · rx queue drop %4$d</string>
<string name="mqtt_cam_pinger_loopback">Own TX heard back: %1$d frames · %2$d dBm</string>
<string name="mqtt_cam_pinger_loopback_no_rssi">Own TX heard back: %1$d frames</string>
<string name="mqtt_start_pinger">Start Pinger</string> <string name="mqtt_start_pinger">Start Pinger</string>
<string name="mqtt_stop_pinger">Stop Pinger</string> <string name="mqtt_stop_pinger">Stop Pinger</string>
@@ -241,16 +276,16 @@
<string name="mqtt_usecase_none_active">No active use case alerts</string> <string name="mqtt_usecase_none_active">No active use case alerts</string>
<string name="mqtt_usecase_detail">Station %1$d · %2$.0f m · closing %3$.1f m/s · TTC %4$.1f s · %5$s</string> <string name="mqtt_usecase_detail">Station %1$d · %2$.0f m · closing %3$.1f m/s · TTC %4$.1f s · %5$s</string>
<!-- Human-readable use-case narratives (Section 10.4 — not just raw JSON) --> <!-- Human-readable use-case narratives (Section 10.4 - not just raw JSON) -->
<string name="usecase_narrative_ima_b">Crossing vehicle ahead — %1$.0f s to conflict</string> <string name="usecase_narrative_ima_b">Crossing vehicle ahead - %1$.0f s to conflict</string>
<string name="usecase_narrative_ima_s">Stopped vehicle may pull out — %1$.0f s</string> <string name="usecase_narrative_ima_s">Stopped vehicle may pull out - %1$.0f s</string>
<string name="usecase_narrative_rtw_b">Car turning right toward you — %1$.0f s</string> <string name="usecase_narrative_rtw_b">Car turning right toward you - %1$.0f s</string>
<string name="usecase_narrative_ltw_b">Car turning left toward you — %1$.0f s</string> <string name="usecase_narrative_ltw_b">Car turning left toward you - %1$.0f s</string>
<string name="usecase_narrative_smva_bcw_b">Fast-closing vehicle nearby — %1$.0f s</string> <string name="usecase_narrative_smva_bcw_b">Fast-closing vehicle nearby - %1$.0f s</string>
<!-- Settings > Use Case Alerts --> <!-- Settings > Use Case Alerts -->
<string name="settings_usecase_alerts">Use Case Alerts</string> <string name="settings_usecase_alerts">Use Case Alerts</string>
<string name="settings_usecase_alerts_desc">Per-use-case enable/disable for the CAM-based Use Case Alert panel on the V2X Monitor screen. All use cases are CAM-only — none of them trigger a DENM.</string> <string name="settings_usecase_alerts_desc">Per-use-case enable/disable for the CAM-based Use Case Alert panel on the V2X Monitor screen. All use cases are CAM-only - none of them trigger a DENM.</string>
<string name="settings_usecase_alert_levels_title">Alert level thresholds (read-only)</string> <string name="settings_usecase_alert_levels_title">Alert level thresholds (read-only)</string>
<string name="settings_usecase_level_warning">Warning</string> <string name="settings_usecase_level_warning">Warning</string>
<string name="settings_usecase_level_awareness">Awareness</string> <string name="settings_usecase_level_awareness">Awareness</string>
@@ -264,21 +299,17 @@
<string name="dash_transport_bt">BT</string> <string name="dash_transport_bt">BT</string>
<!-- OBU stationType warning --> <!-- OBU stationType warning -->
<string name="dash_station_type_warning">⚠ OBU stationType ≠ 2 (cyclist) — VRU detection may be impaired at equipped intersections</string> <string name="dash_station_type_warning">⚠ OBU stationType ≠ 2 (cyclist) - VRU detection may be impaired at equipped intersections</string>
<string name="settings_platform">Platform</string> <string name="settings_platform">Platform</string>
<string name="settings_platform_val">Android / Kotlin / Jetpack Compose</string> <string name="settings_platform_val">Android / Kotlin / Jetpack Compose</string>
<string name="settings_project">Project</string> <string name="settings_project">Project</string>
<string name="settings_project_val">MicrOBU — HAW Hamburg &amp; consider it GmbH</string> <string name="settings_project_val">MicrOBU - HAW Hamburg &amp; consider it GmbH</string>
<!-- Phase A: Trip Recording (bottom nav) --> <!-- Phase A: Trip Recording (bottom nav) -->
<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>
@@ -294,4 +325,18 @@
<!-- Phase A: Trip Review screen --> <!-- Phase A: Trip Review screen -->
<string name="trip_review_title">Trip Review</string> <string name="trip_review_title">Trip Review</string>
<string name="settings_esp32_link">ESP32-C5 link</string>
<string name="settings_esp32_link_ble">Bluetooth</string>
<string name="settings_esp32_link_ble_note">The first connection asks to pair with micrOBU-XXXX: enter passkey 123456. The board only advertises while nothing uses its USB-C port. BLE shares the radio front end with ITS-G5; its effect on 5.9 GHz reception has not been measured yet.</string>
<string name="settings_esp32_message">Transmit while recording</string>
<string name="settings_esp32_message_cam">CAM</string>
<string name="settings_esp32_message_vam">VAM</string>
<string name="settings_esp32_sign">Sign outgoing messages</string>
<string name="settings_esp32_sign_note">Signed with a demo PKI, not the EU trust list: receivers that verify against it will drop these messages. Off sends them unsigned, as before.</string>
<string name="conn_esp32_via_usb">via USB-C (native port)</string>
<string name="conn_esp32_via_ble">via Bluetooth (passkey 123456 on first pairing)</string>
<string name="conn_esp32_cancel">Cancel</string>
<string name="conn_esp32_signing">Signing %1$s · tickets %2$d · signed %3$d · refused %4$d · on air %5$d</string>
<string name="conn_esp32_signing_on">on</string>
<string name="conn_esp32_signing_off">off</string>
</resources> </resources>
@@ -0,0 +1,78 @@
package com.hawhamburg.micr0bu
import com.hawhamburg.micr0bu.domain.asn1.CamUperCodec
import com.hawhamburg.micr0bu.domain.cam.Cam
import com.hawhamburg.micr0bu.domain.cam.StationType
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Golden-byte test for the CAM this app transmits.
*
* ## Why a byte-for-byte fixture
* This project has now shipped the same class of bug three times: a field encoded with the wrong
* number of bits, which both ends of this codebase then read back with the *same* wrong number.
* Phone and ESP32 agree perfectly with each other and with nothing else, so every internal test
* passes while the frames on air are malformed. It cost a hardware session each time
* (`CurvatureCalculationMode`, the GeoNetworking reserved bytes, and `yawRateConfidence`).
*
* A round-trip test through this codebase's own decoder cannot catch that - it shares the
* mistake. Only an independent implementation can. So the expected bytes below were produced by
* `asn1tools` compiled from the real ETSI modules in `asn1/`: it decoded this
* encoder's output and re-encoded it, and the result was byte-identical to what is asserted here.
* That is stronger than "it parses" - it means this encoder emits exactly what the reference
* encoder emits.
*
* If a field width is ever "tidied up", this test fails. Do not regenerate the expected value from
* this encoder's own output - regenerate it through asn1tools, or the test is worthless.
*/
class CamEncodeGoldenTest {
/**
* asn1tools-verified encoding of [referenceCam]. The trap this pins down: `YawRateConfidence`
* has nine enumerands (0..8), so it needs 4 bits and `unavailable` is 8 - not 3 bits and 7.
*/
private val expectedHex =
"0202000f423f3700402ab215af6e286477dffffffc23b7743e0027ffc0d0fe0118329337feebfff6000000"
private val referenceCam = Cam(
stationId = 999_999L,
stationType = StationType.CYCLIST,
latitude = 53.5544955,
longitude = 10.0225470,
speedMps = 4.17,
headingDeg = 63.9,
yawRateDps = null,
driveDirection = 0,
vehicleLengthM = 1.8,
vehicleWidthM = 0.7,
accelerationMps2 = 0.4,
timestamp = 1_787_100_000_000L,
isOwn = true,
)
@Test
fun `encodes a CAM exactly as the ETSI reference encoder does`() {
val encoded = CamUperCodec.encode(referenceCam)
.joinToString("") { "%02x".format(it) }
assertEquals(expectedHex, encoded)
}
@Test
fun `own decoder agrees with the encoder on every field it reads`() {
// Self-consistency is necessary but NOT sufficient - see the class KDoc. This guards the
// decoder against drifting away from the encoder, while the golden bytes above are what
// guards both of them against drifting away from the standard.
val round = CamUperCodec.decode(CamUperCodec.encode(referenceCam), referenceCam.timestamp)
requireNotNull(round)
assertEquals(referenceCam.stationId, round.stationId)
assertEquals(referenceCam.stationType, round.stationType)
assertEquals(referenceCam.latitude, round.latitude, 1e-7)
assertEquals(referenceCam.longitude, round.longitude, 1e-7)
assertEquals(referenceCam.speedMps, round.speedMps, 1e-9)
assertEquals(referenceCam.headingDeg, round.headingDeg, 1e-9)
assertEquals(referenceCam.vehicleLengthM!!, round.vehicleLengthM!!, 1e-9)
assertEquals(referenceCam.vehicleWidthM!!, round.vehicleWidthM!!, 1e-9)
assertEquals(referenceCam.accelerationMps2!!, round.accelerationMps2!!, 1e-9)
}
}
@@ -0,0 +1,192 @@
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)
}
@Test
fun `firmware that predates the rx queue drop counter reports zero`() {
// 8-byte heartbeat (status + counters + capabilities, no rx queue drops tail).
val status = EspLinkStatus.parse("0000000000000001".hexToBytes())!!
assertEquals(0, status.rxQueueDrops)
}
@Test
fun `rx queue drops are read little-endian from the 10-byte payload`() {
// status=0, oversize=0, txFail=0, rxCrc=0, capabilities=0x01, rxQueueDrops=0x0102 (LE: 02 01)
val status = EspLinkStatus.parse("00000000000000010201".hexToBytes())!!
assertEquals(0x0102, status.rxQueueDrops)
assertTrue(status.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) }
}
@@ -0,0 +1,199 @@
package com.hawhamburg.micr0bu
import com.hawhamburg.micr0bu.data.transport.BtpPort
import com.hawhamburg.micr0bu.data.transport.V2xRxFrame
import com.hawhamburg.micr0bu.domain.asn1.DenmUperCodec
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Regression tests for the ESP32-C5 over-the-air receive path: the `V2X_RX` serial payload layout
* and [DenmUperCodec].
*
* ## Where the fixtures come from
* These are **real frames**, not hand-built ones. They were taken from
* `its-g5-receiver-firmware/recordings/capture_20260817_171055.pcap` — a live capture of a CiT One
* OBU running the HLN-SV use case — by replaying the capture through a port of the firmware's
* `gn_unwrap_its()` and `serial_link_send_v2x_rx()`, so each fixture is byte-for-byte what the
* ESP32-C5 hands the phone over USB. The capture's 4-byte 802.11 FCS is trimmed, because the WiFi
* driver strips it before the promiscuous callback ever sees the frame.
*
* ## Why the expected values can be trusted
* Every asserted field was cross-checked against `asn1tools` decoding the same bytes with the real
* ETSI modules in `asn1/` (`denm_1_3_1.asn` + `cdd_1_3_1_1.asn`) — an
* independent implementation, not this codebase's own arithmetic. Across the full capture set that
* cross-check agreed on all 1885 decodable DENMs, on every field below including `detectionTime`.
*
* That matters because this project has twice shipped a UPER bug that was invisible until measured
* against real traffic (a one-bit `CurvatureCalculationMode` in CAM, and a 16-vs-17-bit
* `ValidityDuration` here that made a real frame read causeCode 47 instead of 94). Hand-built
* fixtures would have happily reproduced both. If a field width is ever "cleaned up", these tests
* are what should fail.
*/
class DenmAirReceiveTest {
// ---- real captured V2X_RX serial payloads ----------------------------------------------
/**
* An active DENM: GeoBroadcast, BTP port 2002, stationaryVehicle (94/0), 1000 m relevance
* radius. 406-byte UPER message — most of it the AlacarteContainer this decoder deliberately
* stops before reading.
*/
private val ACTIVE_V2X_RX =
"d207c10102bfeb1f7c53f905e8030201fa012bd0e77d0095e8000314c8317dba65320c5f6ff5590a8027143257c1dd1d" +
"d0001970898000781432f0030008b9f1be8a2fe943f9e6d390895181e3603696f542543bf04d0052201c02d9df83d7f6" +
"e159a88c4f016c402b2d548063f814fa02d66d24044bc0f2d018fb8cb0275e07d480681e550595eff16bfc5cc4e0040f" +
"80989ffe970e40d0bbffceffdcb1e20045dffe5802e184200bdefd313f9f4b3b008977e571fb0c58c00d1fc0ed301a7b" +
"5840121e04a380d518ce008beffa8c0044c2dc018f7f80a00775bf401dfbf39500fda4ea038de000d800a18970036f02" +
"7fc01fee0b006b781ee6007a7e2c026bbfc0eff7e2f6c012bdf86b7f4953a1018af014b4004cd890031f8088200fb67c" +
"0018fc00e4ffe4328100efe024e7ff41afe0090f00bc3fff8d1880207802e1ffcd666a00c7c082aff763c1e017bdffe1" +
"7fee59230167efd073fb70abe005ef7f31dffaf5a3c05b3bff6effecb16a0063e0036804dd8380077efe833ff62b7c00" +
"4d77ee4200a249c010dfc0f84fecfc3f808d3dfbb47fa795fe007cc0e1178000f9010000"
/**
* The termination of that same event, sent once when the hazard ended. Note it carries **no
* SituationContainer** — a terminating DENM says "event over", not what the event was — so
* `causeCode` is legitimately null here and code must not treat that as a decode failure.
*/
private val TERMINATION_V2X_RX =
"d207c101b6c6eb1f2158f905e8030201fa012bd00f7d0095e8000314c8329f58e5320ca7d792ac857db38a1951098498" +
"48000ccd7d40003c0a00000019"
/** A real CAM from the same capture, for checking the port routing rejects non-DENM cleanly. */
private val CAM_V2X_RX =
"d107c100000000000000000000000202fa012bd0f8e8605ab214f9ae2864b2415015000032c950487c1fa0010ebfe9ea" +
"7b33ff01fffa0028331400fbfab8fe6eb5a222ebe078d80da5bd50950efc134014880980b677e0f5fdb856542313c05b" +
"100acb552018fe053f80b59b490112f03cb4063ee33009d781f5201a079541657bfc5aff1731380103e02627ffa5c390" +
"342efff3bff72c7b001177ff9600b8610802f7bf4c4fe7d2ce20225df95c7ec316300347f03b4c069ed6100486900000" +
"0801"
/** Later than every fixture's detectionTime, so the decoder's sanity window accepts them. */
private val receivedAt = 1_787_100_000_000L
private fun String.hexToBytes(): ByteArray =
chunked(2).map { it.toInt(16).toByte() }.toByteArray()
// ---- the V2X_RX prefix contract --------------------------------------------------------
@Test
fun `active DENM frame parses its metadata prefix`() {
val frame = V2xRxFrame.parse(ACTIVE_V2X_RX.hexToBytes())
assertNotNull(frame)
frame!!
assertEquals(BtpPort.DENM, frame.btpPort)
assertEquals(-63, frame.rssiDbm) // int8: must survive as negative, not 193
// GeoBroadcast destination area, converted from GeoNetworking's big-endian 1/10 microdegree
// to the little-endian prefix and back out again.
val area = frame.geoArea
assertNotNull(area)
assertEquals(53.5543554, area!!.latitude, 1e-7)
assertEquals(10.0225916, area.longitude, 1e-7)
assertEquals(1000, area.radiusMeters)
assertEquals(406, frame.uper.size)
}
@Test
fun `frame no larger than the firmware's serial payload cap`() {
// SERIAL_LINK_MAX_PAYLOAD is 512 on both sides; the firmware counts an oversize drop rather
// than truncating. A real GeoBroadcast DENM is the largest thing this path carries today.
assertTrue(
"real DENM V2X_RX payload must fit SERIAL_LINK_MAX_PAYLOAD",
ACTIVE_V2X_RX.hexToBytes().size <= 512,
)
}
@Test
fun `parse rejects a payload with no room for a message`() {
assertNull(V2xRxFrame.parse(ByteArray(V2xRxFrame.PREFIX_SIZE)))
assertNull(V2xRxFrame.parse(ByteArray(3)))
}
// ---- DENM decode ----------------------------------------------------------------------
@Test
fun `decodes a real stationaryVehicle DENM`() {
val frame = V2xRxFrame.parse(ACTIVE_V2X_RX.hexToBytes())!!
val denm = DenmUperCodec.decode(
bytes = frame.uper,
receivedAtEpochMs = receivedAt,
rssiDbm = frame.rssiDbm,
relevanceRadiusM = frame.geoArea?.radiusMeters,
)
assertNotNull("real captured DENM must decode", denm)
denm!!
// actionID - the ETSI event identity, cross-checked against asn1tools.
assertEquals(4_194_380_752L, denm.stationId)
assertEquals(6, denm.sequenceNumber)
assertEquals(53.5543554, denm.latitude, 1e-7)
assertEquals(10.0225916, denm.longitude, 1e-7)
assertEquals(94, denm.causeCode) // stationaryVehicle
assertEquals(0, denm.subCauseCode)
assertEquals(5, denm.stationType) // passengerCar
assertFalse(denm.isTermination)
// detectionTime is a 42-bit TimestampIts counted from the 2004 ITS epoch. Getting either
// the width or the epoch wrong lands the hazard decades away, so the absolute value is
// asserted rather than a range.
assertEquals(1_786_979_460_563L, denm.detectionTimeMs)
// Carried through from the GeoNetworking header and the serial prefix, not the payload.
assertEquals(1000, denm.relevanceRadiusM)
assertEquals(-63, denm.rssiDbm)
}
@Test
fun `decodes a termination DENM and keeps the same event identity`() {
val active = V2xRxFrame.parse(ACTIVE_V2X_RX.hexToBytes())!!
val term = V2xRxFrame.parse(TERMINATION_V2X_RX.hexToBytes())!!
val activeDenm = DenmUperCodec.decode(active.uper, receivedAt)!!
val termDenm = DenmUperCodec.decode(term.uper, receivedAt)!!
assertTrue(termDenm.isTermination)
assertNull("a terminating DENM carries no SituationContainer", termDenm.causeCode)
assertEquals(4_194_380_752L, termDenm.stationId)
assertEquals(6, termDenm.sequenceNumber)
assertEquals(1_786_980_053_703L, termDenm.detectionTimeMs)
// The whole point of keying dedup on actionID: the termination must land on the same key as
// the event it ends, so filtering terminations actually removes that hazard from the map
// instead of leaving the active pin behind next to a hidden one.
assertEquals(activeDenm.dedupKey, termDenm.dedupKey)
}
@Test
fun `does not decode a CAM as a DENM`() {
val cam = V2xRxFrame.parse(CAM_V2X_RX.hexToBytes())!!
assertEquals(BtpPort.CAM, cam.btpPort)
assertNull("CAM must not decode as DENM - messageID guards this", DenmUperCodec.decode(cam.uper, receivedAt))
}
@Test
fun `returns null for a truncated DENM rather than a misplaced hazard`() {
val frame = V2xRxFrame.parse(ACTIVE_V2X_RX.hexToBytes())!!
// Cut inside the ManagementContainer: the BitReader runs out mid-field.
assertNull(DenmUperCodec.decode(frame.uper.copyOfRange(0, 12), receivedAt))
}
@Test
fun `future detection time beyond the sanity window is dropped, not surfaced`() {
val frame = V2xRxFrame.parse(ACTIVE_V2X_RX.hexToBytes())!!
// A phone whose clock is more than a day behind the sender: the event still decodes, but
// the implausible timestamp is reported as unknown instead of being shown.
val denm = DenmUperCodec.decode(frame.uper, receivedAtEpochMs = 1_700_000_000_000L)
assertNotNull(denm)
assertEquals(94, denm!!.causeCode)
assertNull(denm.detectionTimeMs)
}
}
@@ -0,0 +1,119 @@
package com.hawhamburg.micr0bu
import com.hawhamburg.micr0bu.domain.denm.DenmParser
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Tests [DenmParser] against the CiT One Use Case API's documented DENM schema.
*
* The payload below is the worked example from `CI-CiT-MQTT_API_Documentation-v6-20250221.pdf`,
* listing 2.6 (section 2.2.4, "Processed DENM"), reproduced field-for-field. That document is the
* contract for this topic, so it is the right thing to pin against - the previous parser was
* written before the schema was checked and silently dropped every real DENM for two independent
* reasons: the station id is `originatingStationId` (not `stationId`), and `eventPosition` is
* itself a GeoJSON Point rather than an object containing one.
*/
class DenmParserMqttTest {
/** Listing 2.6 from the API documentation, with the doc's inline comments removed. */
private val documentedDenm = """
{
"type": "v2x-denm",
"originatingStationId": 1345267,
"sequenceNumber": 1,
"detectionTime": "2021-05-11T12:01:02+00:00",
"referenceTime": "2021-05-11T12:01:02+00:00",
"eventPosition": { "type": "Point", "coordinates": [9.9800230, 53.5560783, 15] },
"relevanceTrafficDirection": "upstreamTraffic",
"stationType": "roadSideUnit",
"causeCode": "trafficCondition",
"subCauseCode": 0
}
""".trimIndent()
@Test
fun `parses the documented DENM payload`() {
val denm = DenmParser.parse(documentedDenm, timestamp = 1_787_000_000_000L)
assertNotNull("the API's own documented payload must parse", denm)
denm!!
assertEquals(1_345_267L, denm.stationId)
assertEquals(1, denm.sequenceNumber)
// GeoJSON is [longitude, latitude, altitude] - getting this order wrong puts a Hamburg
// hazard in Somalia, and both values are plausible-looking numbers either way.
assertEquals(53.5560783, denm.latitude, 1e-7)
assertEquals(9.9800230, denm.longitude, 1e-7)
assertEquals(1, denm.causeCode) // trafficCondition
assertEquals(0, denm.subCauseCode)
assertEquals(15, denm.stationType) // roadSideUnit is 15, not 12 - the enum has a gap
assertFalse(denm.isTermination)
// 2021-05-11T12:01:02Z
assertEquals(1_620_734_462_000L, denm.detectionTimeMs)
}
@Test
fun `termination is signalled by the key being present`() {
val terminated = documentedDenm.replace(
"\"sequenceNumber\": 1,",
"\"sequenceNumber\": 1,\n \"termination\": true,",
)
val denm = DenmParser.parse(terminated)
assertNotNull(denm)
assertTrue(denm!!.isTermination)
}
@Test
fun `a termination shares the dedup key of the event it ends`() {
val active = DenmParser.parse(documentedDenm)!!
val terminated = DenmParser.parse(
documentedDenm.replace(
"\"sequenceNumber\": 1,",
"\"sequenceNumber\": 1,\n \"termination\": true,",
)
)!!
// Without this, a cancelled hazard would be filtered out while the active pin it was
// meant to cancel stayed on the map forever.
assertEquals(active.dedupKey, terminated.dedupKey)
}
@Test
fun `cause code names follow the ETSI spelling the API uses`() {
// ETSI's CauseCodeType really does spell it with three n's, and the API follows.
val aqua = documentedDenm.replace("\"trafficCondition\"", "\"aquaplannning\"")
assertEquals(7, DenmParser.parse(aqua)!!.causeCode)
val stationary = documentedDenm.replace("\"trafficCondition\"", "\"stationaryVehicle\"")
assertEquals(94, DenmParser.parse(stationary)!!.causeCode)
}
@Test
fun `a payload with no usable position is rejected rather than placed at null island`() {
val noPosition = documentedDenm.replace(
"\"eventPosition\": { \"type\": \"Point\", \"coordinates\": [9.9800230, 53.5560783, 15] },",
"",
)
assertNull(DenmParser.parse(noPosition))
}
@Test
fun `integer causeCode and stationId spellings still parse`() {
// The air path and any future firmware-side JSON produce integers; those must keep working.
val numeric = """
{"stationId": 42, "causeCode": 94, "subCauseCode": 1,
"eventPosition": {"type": "Point", "coordinates": [10.0, 53.5]}}
""".trimIndent()
val denm = DenmParser.parse(numeric)
assertNotNull(denm)
assertEquals(42L, denm!!.stationId)
assertEquals(94, denm.causeCode)
assertEquals(1, denm.subCauseCode)
}
}
@@ -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,
@@ -116,12 +116,17 @@ class EventDetectorTest {
// ─── Hard brake ─────────────────────────────────────────────────────────── // ─── Hard brake ───────────────────────────────────────────────────────────
@Test fun `hard brake triggers BRAKING event`() = runCollecting { events -> @Test fun `hard brake triggers BRAKING event`() = runCollecting { events ->
// Phase 1: fill window at 10 m/s with constant accel (no std dev → no braking) // Phase 1: cruising at 10 m/s with the accelerometer variability a moving bike actually
repeat(config.windowSize) { i -> // has. This matters: [EventDetector] requires the speed drop and the accel std dev to be
detector.processSample(1.0, 0.05, 10.0, 0.0, 53.5, 10.0, i * 20L) // true on the SAME frame, and the std dev is a rolling window. Filling phase 1 with a
} // constant accel drives that window to zero, so on the one frame where the speed drop
// Phase 2: GPS drops to 4 m/s (drop = 6 m/s > 0.5 threshold). // exists the std dev is still ~0.75 and braking can never start - by the time the window
// Alternate hi/lo accel to exceed the std-dev threshold. // has recovered, prevSpeedMps has caught up and the drop is gone.
//
// Constant accel right up to the instant of a brake is also not physical. The IMU is
// sampled continuously while GPS speed lags, so the shaking precedes the reported drop.
alternatingAccelFrames(n = config.windowSize, speedMps = 10.0, timeOffset = 0)
// Phase 2: GPS reports 4 m/s (drop = 6 m/s > 0.5 threshold).
alternatingAccelFrames( alternatingAccelFrames(
n = config.brakingSustainedFrames + 5, n = config.brakingSustainedFrames + 5,
speedMps = 4.0, speedMps = 4.0,
@@ -132,15 +137,14 @@ 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 ->
repeat(config.windowSize) { i -> // Variability established before the drop - see the note in the test above.
detector.processSample(1.0, 0.05, 10.0, 0.0, 53.5, 10.0, i * 20L) alternatingAccelFrames(n = config.windowSize, speedMps = 10.0, timeOffset = 0)
} // Drop of 8 m/s > brakingHighConfidencePeakDrop (1.5)
// Drop of 8 m/s > brakingHighConfidenceRate (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 }
@@ -153,15 +157,18 @@ class EventDetectorTest {
} }
@Test fun `moderate speed drop has MEDIUM confidence`() = runCollecting { events -> @Test fun `moderate speed drop has MEDIUM confidence`() = runCollecting { events ->
repeat(config.windowSize) { i -> // Variability established before the drop - see `hard brake triggers BRAKING event`.
detector.processSample(1.0, 0.05, 3.0, 0.0, 53.5, 10.0, i * 20L) alternatingAccelFrames(n = config.windowSize, speedMps = 3.0, timeOffset = 0)
} // Drop of 1.2 m/s — above the speed-drop threshold (1.0) but below the
// Drop of 0.8 m/s — above speed-drop threshold (0.5) but below high-conf rate (1.5) // 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 }
@@ -176,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,
@@ -190,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())
@@ -202,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,
@@ -250,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,
@@ -262,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())
} }
@@ -294,8 +301,14 @@ class EventDetectorTest {
repeat(5) { repeat(5) {
detector.processSample(0.5, 0.1, 5.0, 2.0, 53.5, 10.0, t++ * 20L) detector.processSample(0.5, 0.1, 5.0, 2.0, 53.5, 10.0, t++ * 20L)
} }
// Second stop episode // Second stop episode. Deliberately longer than the first: stopping also requires the
repeat(stopFrames) { // accel std dev to be BELOW a threshold, and the rolling window still holds the five
// moving samples above. At the shipping threshold of 0.10 m/s² even a single 0.5 sample
// left in a 10-sample window gives a std dev of ~0.14, so ALL five have to be evicted
// 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
// episode needs no such allowance because the window begins empty.
repeat(config.stoppingFrames + 20) {
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))
}
}
@@ -0,0 +1,95 @@
package com.hawhamburg.micr0bu
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.assertTrue
import org.junit.Test
/**
* Pins the rule that decides whether a received CAM is one this phone sent.
*
* ## The bugs this exists to prevent
* Getting it wrong fails in two opposite directions, and each has happened:
*
* - **Too narrow.** An own frame that is not recognised comes back as a remote road user sitting
* exactly on the ego position, and is fed to the detection engine as a collision partner for
* itself. That happened with the bench pinger's separate ID, and pseudonym rotation creates the
* 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
* through the CiT One silently discarded another phone's pings as its own, although it had sent
* none. Nothing appeared on its V2X screen while the broker was full of them.
*/
class OwnStationIdsTest {
private val current = 1_691_338_363L
private val retired = 2_222_222_222L
private val ours = setOf(current, retired)
@Test
fun `recognises the current transmit id`() {
assertTrue(OwnStationIds.isOwn(current, ours, benchPingIsOurs = false))
}
@Test
fun `recognises a recently retired id, so a frame sent just before a rotation is still ours`() {
assertTrue(OwnStationIds.isOwn(retired, ours, benchPingIsOurs = false))
}
@Test
fun `another phone's bench ping is shown, not swallowed as our own`() {
// The 2026-09-10 regression: this phone is not pinging, so 999999 is someone else.
assertFalse(OwnStationIds.isOwn(BENCH_PING, ours, benchPingIsOurs = false))
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
fun `treats a genuine remote station as remote`() {
assertFalse(OwnStationIds.isOwn(2_741_041_966L, ours, benchPingIsOurs = true))
assertFalse(OwnStationIds.isOwn(2_741_041_966L, emptySet(), benchPingIsOurs = false))
}
@Test
fun `station id zero is never ours`() {
// 0 is the "not resolved yet" placeholder for the ego identity. Matching on it would
// swallow real traffic from any station that reported 0.
assertFalse(OwnStationIds.isOwn(0L, setOf(0L), benchPingIsOurs = true))
}
// ---- 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()
}
}
@@ -0,0 +1,151 @@
package com.hawhamburg.micr0bu
import com.hawhamburg.micr0bu.data.mqtt.RecvV2xMessage
import com.hawhamburg.micr0bu.domain.asn1.CamUperCodec
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Pins [RecvV2xMessage] to the protobuf wire format of consider it's `RecvV2XMessage`
* (`v2x_interface.proto`, V2X RX protocol v2.4.2), the envelope the CiT One publishes on its raw
* `v2x/rx` topics.
*
* ## Where the fixtures come from
* The envelope bytes are written out here by hand from the protobuf encoding rules and the field
* numbers in that `.proto`, with the derivation in the comments, so a reviewer can check them
* without running anything. They are deliberately **not** produced by an encoder in this
* repository: a fixture generated by our own code would agree with our own reader no matter how
* wrong both were, which is exactly the failure mode the ASN.1 work in this project ran into
* three times.
*
* The CAM payload inside is the golden UPER frame from [CamEncodeGoldenTest], itself verified
* against `asn1tools` and the real ETSI modules in `asn1/`.
*
* ## Why this matters
* Field numbers are wire-format constants with no self-describing names on the wire. Reading
* field 2 where the schema says field 3 does not fail loudly, it silently yields a plausible
* looking byte string that decodes to nothing. These tests are what should fail if the constants
* in [RecvV2xMessage] are ever "tidied".
*/
class RecvV2xMessageTest {
/**
* The golden CAM UPER, 43 bytes, from [CamEncodeGoldenTest]. Its ItsPduHeader reads
* protocolVersion 2, messageID 2 (CAM), stationID 0x000f423f = 999999.
*/
private val goldenCam =
"0202000f423f3700402ab215af6e286477dffffffc23b7743e0027ffc0d0fe0118329337feebfff6000000"
/**
* A complete `RecvV2XMessage` carrying [goldenCam], byte by byte:
*
* ```
* 0a 05 field 1 (btpHeader), length-delimited, 5 bytes
* 08 02 field 1 (type) varint = 2, CAM
* 10 d1 0f field 2 (destinationPort) varint = 2001
* 12 07 field 2 (gnHeader), length-delimited, 7 bytes
* 42 05 field 8 (dest), length-delimited, 5 bytes
* 0a 03 field 1 (area), length-delimited, 3 bytes
* 18 f4 03 field 3 (distA) varint = 500 metres
* 1a 2b field 3 (payload), length-delimited, 0x2b = 43 bytes
* ```
*/
private val camEnvelope = "0a05080210d10f120742050a0318f4031a2b" + goldenCam
private fun String.hexToBytes(): ByteArray =
chunked(2).map { it.toInt(16).toByte() }.toByteArray()
// ---- the happy path --------------------------------------------------------------------
@Test
fun `parses btp header, geo radius and payload from a full envelope`() {
val msg = RecvV2xMessage.parse(camEnvelope.hexToBytes())
assertNotNull("envelope should parse", msg)
msg!!
assertEquals("btpHeader.type: CAM", 2, msg.pduType)
assertEquals("btpHeader.destinationPort", 2001, msg.destinationPort)
assertEquals("gnHeader.dest.area.distA, metres", 500, msg.destAreaRadiusM)
assertTrue(
"payload must be the CAM UPER byte for byte",
msg.payload.contentEquals(goldenCam.hexToBytes()),
)
}
@Test
fun `extracted payload is decodable UPER, not a mangled copy`() {
val msg = RecvV2xMessage.parse(camEnvelope.hexToBytes())!!
// The whole point of carrying bytes rather than a String through the MQTT layer: a UTF-8
// round trip would replace most of these bytes and this decode would fail.
val cam = CamUperCodec.decode(msg.payload, receivedAtEpochMs = 1_787_100_000_000L)
assertNotNull("payload should decode as a CAM", cam)
assertEquals("stationID from the ItsPduHeader", 999_999L, cam!!.stationId)
}
@Test
fun `reads a DENM envelope's relevance radius`() {
// Same shape, DENM values: type 1, port 2002, distA 1000 m, a 2-byte stand-in payload.
// 0a 05 08 01 10 d2 0f | 12 07 42 05 0a 03 18 e8 07 | 1a 02 02 01
val msg = RecvV2xMessage.parse("0a05080110d20f120742050a0318e8071a020201".hexToBytes())
assertNotNull(msg)
assertEquals(1, msg!!.pduType)
assertEquals(2002, msg.destinationPort)
assertEquals(1000, msg.destAreaRadiusM)
}
// ---- forward compatibility -------------------------------------------------------------
@Test
fun `skips unknown fields and does not depend on field order`() {
// payload first, then an unknown varint (field 7) and an unknown fixed32 (field 6) that
// this schema revision does not define, then the btpHeader. Protobuf permits all three,
// and a reader that assumed order or choked on unknowns would break the first time
// consider it added a field.
val bytes = ("1a2b" + goldenCam + "38b96035deadbeef0a05080210d10f").hexToBytes()
val msg = RecvV2xMessage.parse(bytes)
assertNotNull(msg)
assertEquals(2, msg!!.pduType)
assertEquals(2001, msg.destinationPort)
assertTrue(msg.payload.contentEquals(goldenCam.hexToBytes()))
}
@Test
fun `accepts an envelope carrying nothing but a payload`() {
val msg = RecvV2xMessage.parse(("1a2b" + goldenCam).hexToBytes())
assertNotNull(msg)
assertNull("no btpHeader was sent", msg!!.pduType)
assertNull("no gnHeader was sent", msg.destAreaRadiusM)
assertTrue(msg.payload.contentEquals(goldenCam.hexToBytes()))
}
// ---- malformed input -------------------------------------------------------------------
// These arrive off a network topic. A reader that throws takes the MQTT callback thread with
// it, so every one of these must return null instead.
@Test
fun `returns null for a truncated envelope`() {
val full = camEnvelope.hexToBytes()
assertNull(RecvV2xMessage.parse(full.copyOfRange(0, full.size / 2)))
}
@Test
fun `returns null when the payload field is present but empty`() {
assertNull(RecvV2xMessage.parse("1a00".hexToBytes()))
}
@Test
fun `returns null when there is no payload field at all`() {
assertNull(RecvV2xMessage.parse("0a05080210d10f".hexToBytes()))
}
@Test
fun `returns null for empty input and for bytes that are not protobuf`() {
assertNull(RecvV2xMessage.parse(ByteArray(0)))
// A run of continuation bytes: a varint that never terminates, which is what would walk
// an unguarded reader off the end of the buffer.
assertNull(RecvV2xMessage.parse(ByteArray(24) { 0xFF.toByte() }))
}
}
@@ -0,0 +1,48 @@
package com.hawhamburg.micr0bu
import com.hawhamburg.micr0bu.domain.asn1.CamUperCodec
import com.hawhamburg.micr0bu.domain.cam.StationType
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Test
/**
* A roadside unit's CAM must decode, not be dropped.
*
* ETSI's `HighFrequencyContainer` is a CHOICE, and an RSU picks `rsuContainerHighFrequency`
* instead of `basicVehicleContainerHighFrequency`. That container holds no kinematics at all -
* only an optional protected-zone list - so an earlier version of the decoder bailed on it and
* every RSU CAM was silently discarded. The bench RSU sends CAM and SPATEM from the same station
* id, so dropping its CAM meant the one station a rider most wants to see never appeared.
*
* The fixture is a real 26-byte RSU CAM taken live from the OBU's `v2x/rx/cam` topic; the
* expected values are asn1tools' decoding of those same bytes using the ETSI modules in the
* `asn1/`.
*/
class RsuCamDecodeTest {
private val rsuCam = "020239b9de898b8b00fab215af6e286477c0c20c200033fa4e80"
private fun String.hexToBytes(): ByteArray =
chunked(2).map { it.toInt(16).toByte() }.toByteArray()
@Test
fun `decodes a roadside unit CAM for position and station type`() {
val cam = CamUperCodec.decode(rsuCam.hexToBytes(), receivedAtEpochMs = 1_787_100_000_000L)
assertNotNull("an RSU CAM must not be dropped", cam)
cam!!
assertEquals(968_482_441L, cam.stationId)
assertEquals(StationType.ROAD_SIDE_UNIT, cam.stationType)
assertEquals(15, cam.stationType) // the enumeration jumps 11 -> 15; 12 would be wrong
assertEquals(53.5544955, cam.latitude, 1e-7)
assertEquals(10.0225470, cam.longitude, 1e-7)
// An RSU has no kinematics to report. Zero is a placeholder, which is only safe because
// CamUseCaseRepository keeps RSU CAMs out of UseCaseDetectionEngine - otherwise this
// would read as a permanently stopped vehicle and raise a standing false alert.
assertEquals(0.0, cam.speedMps, 0.0)
assertEquals(0.0, cam.headingDeg, 0.0)
}
}
@@ -0,0 +1,168 @@
package com.hawhamburg.micr0bu
import com.hawhamburg.micr0bu.domain.asn1.SpatemUperCodec
import com.hawhamburg.micr0bu.domain.spat.SignalPhase
import com.hawhamburg.micr0bu.domain.spat.SignalPhaseEvent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Regression tests for [SpatemUperCodec], using real SPATEMs as fixtures.
*
* ## Where the fixtures come from
* Both are genuine over-the-air messages, taken from the protobuf-wrapped UPER the OBU publishes
* (field 3 of the blob) - one from the live bench RSU trigger, one from the 2026-03-18 drive
* recorded in `its-g5-receiver-firmware/recordings/its_messages_*.db`.
*
* ## Why the expected values can be trusted
* The bit layout was validated by replaying **79,042 real SPATEMs** - the whole drive across 7+
* RSUs plus the live trigger - through a port of this decoder and comparing every field against
* `asn1tools` decoding the same bytes with the ETSI modules in `asn1/`.
* All 79,042 matched exactly, and none hit an unsupported branch. The values asserted below are
* that independent decoder's output, not this codebase's own arithmetic.
*
* The two fixtures are deliberately different shapes: the live one is minimal (no region, no
* maneuverAssistList), the recorded one exercises `region`, a 7-entry event list, and the
* variable-length `maneuverAssistList` that has to be walked to find the next field.
*/
class SpatemUperCodecTest {
/**
* Live bench RSU, 58 bytes: one intersection (id 23, no region), two signal groups.
* This is the smallest shape seen in practice.
*/
private val liveSpatem =
"020439b9de89451672018000b81040051672adb401001143707ff07ff07ff7a23840484048404bc00851dc1fd41fd41f" +
"d5e86e112a112a112af0"
/**
* Real RSU from the drive, 251 bytes: region 3 / intersection 121, four signal groups, up to
* seven predicted phases each, and a maneuverAssistList.
*/
private val recordedSpatem =
"0204001233b441ae520188001803c8402001ae527d6b032016467032f0424039d2a43819f021981d6150dc0ed812ac10" +
"91088e077609600852846703f705dc04fb3a43820302f582851d0dc120c19a0161d020008004404c8ae065e0848073a5" +
"477033e043303ac2a1b81db025582122111c0eec12c010a508ae07ee0bb809f67477040605eb050a3a1b8241833402c3" +
"a04003001480d919c0cbc10900e74a90e067c08660758543703b604ab0424422381dd82580214a119c0fdc177013ece9" +
"0e080c0bd60a1474370483066805874080080031021a1b81a6821201d89919c0dfc11580f8ce86e08200b0409f694670" +
"44205b9052d48801800680"
private val receivedAt = 1_787_100_000_000L
private fun String.hexToBytes(): ByteArray =
chunked(2).map { it.toInt(16).toByte() }.toByteArray()
@Test
fun `decodes the live bench SPATEM`() {
val spat = SpatemUperCodec.decode(liveSpatem.hexToBytes(), receivedAt, rssiDbm = -55)
assertNotNull("real captured SPATEM must decode", spat)
spat!!
assertEquals(968_482_441L, spat.stationId)
assertEquals(333_426, spat.minuteOfYear)
assertEquals(-55, spat.rssiDbm)
assertEquals(1, spat.intersections.size)
val i = spat.intersections[0]
assertNull("this RSU sends no RoadRegulatorID", i.region)
assertEquals(23, i.id)
assertEquals(1, i.revision)
assertEquals(333_426, i.moy)
assertEquals(44_468, i.timeStampMs)
assertEquals("-1/23", i.key)
assertEquals(2, i.movements.size)
val sg1 = i.movements[0]
assertEquals(1, sg1.signalGroup)
assertEquals(2, sg1.events.size)
assertEquals(SignalPhase.STOP_AND_REMAIN, sg1.current!!.phase)
assertEquals(4094, sg1.current!!.minEndTimeDs)
val sg2 = i.movements[1]
assertEquals(2, sg2.signalGroup)
assertEquals(SignalPhase.PERMISSIVE_CLEARANCE, sg2.current!!.phase)
assertEquals(4074, sg2.current!!.minEndTimeDs)
}
@Test
fun `decodes a real RSU SPATEM with region and maneuverAssistList`() {
val spat = SpatemUperCodec.decode(recordedSpatem.hexToBytes(), receivedAt)
assertNotNull(spat)
spat!!
assertEquals(1_192_884L, spat.stationId)
assertEquals(1, spat.intersections.size)
val i = spat.intersections[0]
// IntersectionID is only unique within a RoadRegulatorID, so the region must survive
// decoding - the drive contains the same intersection id under different regions.
assertEquals(3, i.region)
assertEquals(121, i.id)
assertEquals(4, i.revision)
assertEquals(110_162, i.moy)
assertEquals(32_107, i.timeStampMs)
assertEquals("3/121", i.key)
assertEquals(4, i.movements.size)
assertEquals(listOf(1, 2, 3, 4), i.movements.map { it.signalGroup })
// A 7-entry prediction list: the current phase plus the upcoming sequence.
val sg1 = i.movements[0]
assertEquals(7, sg1.events.size)
assertEquals(SignalPhase.PROTECTED_MOVEMENT_ALLOWED, sg1.current!!.phase)
assertEquals(1630, sg1.current!!.minEndTimeDs)
assertEquals(2120, sg1.current!!.maxEndTimeDs)
assertEquals(1850, sg1.current!!.likelyTimeDs)
assertEquals(SignalPhase.PERMISSIVE_MOVEMENT_ALLOWED, i.movements[1].current!!.phase)
assertEquals(SignalPhase.STOP_AND_REMAIN, i.movements[3].current!!.phase)
assertEquals(4, i.movements[3].events.size)
}
@Test
fun `phase helpers classify the states a driver cares about`() {
assertTrue(SignalPhase.PROTECTED_MOVEMENT_ALLOWED.isGo)
assertTrue(SignalPhase.PERMISSIVE_MOVEMENT_ALLOWED.isGo)
assertTrue(SignalPhase.STOP_AND_REMAIN.isStop)
assertTrue(SignalPhase.PRE_MOVEMENT.isTransition)
assertTrue(SignalPhase.PROTECTED_CLEARANCE.isTransition)
// dark and unavailable are none of the three - they must not read as "go".
assertTrue(!SignalPhase.DARK.isGo && !SignalPhase.DARK.isStop)
}
@Test
fun `countdown handles TimeMark wrapping at the hour boundary`() {
val topOfHour = receivedAt - (receivedAt % 3_600_000L)
// 400.0 s into the hour, light changes at 409.4 s -> 9.4 s away.
val soon = SignalPhaseEvent(SignalPhase.STOP_AND_REMAIN, 4094, null, null)
assertEquals(9.4, soon.secondsUntil(topOfHour + 400_000L)!!, 1e-6)
// 3590 s into the hour, mark is 10.0 s - that is the NEXT hour, i.e. 20 s away, not
// 3580 s in the past. Without the wrap correction a countdown goes hugely negative once
// per hour, which is exactly when a driver is watching it.
val wrapped = SignalPhaseEvent(SignalPhase.STOP_AND_REMAIN, 100, null, null)
assertEquals(20.0, wrapped.secondsUntil(topOfHour + 3_590_000L)!!, 1e-6)
// 36001 is the spec's "unknown" marker and must not be shown as a real countdown.
val unknown = SignalPhaseEvent(SignalPhase.DARK, 36001, null, null)
assertNull(unknown.secondsUntil(topOfHour))
assertNull(SignalPhaseEvent(SignalPhase.DARK, null, null, null).secondsUntil(topOfHour))
}
@Test
fun `does not decode another message type as SPATEM`() {
// protocolVersion 2, messageId 2 (CAM) - the header guard must reject it outright.
val cam = byteArrayOf(2, 2, 0, 0, 0, 1, 0, 0, 0, 0)
assertNull(SpatemUperCodec.decode(cam, receivedAt))
}
@Test
fun `returns null for a truncated SPATEM rather than a wrong light`() {
val full = liveSpatem.hexToBytes()
assertNull(SpatemUperCodec.decode(full.copyOfRange(0, 12), receivedAt))
}
}
@@ -0,0 +1,119 @@
package com.hawhamburg.micr0bu
import com.hawhamburg.micr0bu.data.transport.BtpDataRequest
import com.hawhamburg.micr0bu.data.transport.LinkMessage
import com.hawhamburg.micr0bu.data.transport.LinkOpcode
import com.hawhamburg.micr0bu.data.transport.LinkResult
import com.hawhamburg.micr0bu.data.transport.LinkSecurityProfile
import com.hawhamburg.micr0bu.data.transport.PotiUpdate
import com.hawhamburg.micr0bu.data.transport.StationConfigure
import com.hawhamburg.micr0bu.data.transport.StationInfo
import com.hawhamburg.micr0bu.data.transport.StationStatus
import com.hawhamburg.micr0bu.data.transport.credentialsSegment
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Pins the phone side of the station-link message layer.
*
* ## Where the expected bytes come from
* The colleague's Python implementation of the same protocol, microbu-esp32c5
* station-link/python/microbu_link/messages.py (`encode_message` over each body's `encode`),
* run with the same field values. That module is what their phone emulator drives the firmware
* with, so an agreement here is agreement with a second, independent implementation.
*/
class StationLinkTest {
private fun String.hexToBytes(): ByteArray = chunked(2).map { it.toInt(16).toByte() }.toByteArray()
private fun ByteArray.hex(): String = joinToString("") { "%02x".format(it) }
@Test
fun `STATION_CONFIGURE matches the Python encoder`() {
val body = StationConfigure(stationType = 2, mid = "021122334455".hexToBytes()).encode()
assertEquals(
"0100070002010002112233445500b40014020205",
LinkMessage(LinkOpcode.STATION_CONFIGURE, 7, body).encode().hex(),
)
}
@Test
fun `POTI_UPDATE matches the Python encoder`() {
val body = PotiUpdate(
timestampMs = 717_254_800_123L, latTenMicroDeg = 535_546_667, lonTenMicroDeg = 100_223_889,
semiMajorCm = 486, semiMinorCm = 486, speedCms = 543, headingDeciDeg = 1234, pai = true,
).encode()
assertEquals(
"02000800fbb2b7ffa60000002bcbeb1f914bf905e601e60100000e000000001f02d204",
LinkMessage(LinkOpcode.POTI_UPDATE, 8, body).encode().hex(),
)
}
@Test
fun `BTP_DATA_REQUEST for a signed CAM matches the Python encoder`() {
val body = BtpDataRequest(
destinationPort = 2001, itsAid = 36, securityProfile = LinkSecurityProfile.SECURED,
permissions = "010000".hexToBytes(), flSdu = "0102030405".hexToBytes(),
).encode()
assertEquals(
"0300090001d1070000010102ffff000000000024000000030100000005000102030405",
LinkMessage(LinkOpcode.BTP_DATA_REQUEST, 9, body).encode().hex(),
)
}
@Test
fun `CREDENTIALS_PROVISION segment matches the Python encoder`() {
val body = credentialsSegment(totalLength = 695, offset = 240, segment = ByteArray(3) { 0xAB.toByte() })
assertEquals("04000a00b702f00003ababab", LinkMessage(LinkOpcode.CREDENTIALS_PROVISION, 10, body).encode().hex())
}
@Test
fun `STATUS from the Python encoder decodes field by field`() {
val message = LinkMessage.decode(
("8400341240e20100010800021122334455b80b49387a4c12eb00010b0000000c0000000d0000000e000000" +
"0f000000100000001100000012000000130000001400000015000000160000001700000018000000" +
"190000001a0000001b0000001c000000fbb2b7ffa6000000").hexToBytes(),
)
assertNotNull(message)
assertEquals(LinkOpcode.STATUS, message!!.opcode)
assertEquals(0x1234, message.sequence)
val status = StationStatus.decode(message.body)!!
assertEquals(123_456L, status.uptimeMs)
assertTrue(status.configured)
assertArrayEquals("b80b49387a4c12eb".hexToBytes(), status.identifier)
assertEquals(1, status.tickets)
assertEquals(11L, status.signedMessages)
assertEquals(12L, status.refusedNoTicket)
assertEquals(13L, status.refusedChangePending)
assertEquals(14L, status.refusedPermission)
assertEquals(15L, status.signFailed)
assertEquals(16L, status.verified)
assertEquals(17L, status.rejected)
assertEquals(18L, status.requestsAccepted)
assertEquals(19L, status.requestsRefused)
assertEquals(21L, status.radioSubmitted)
assertEquals(22L, status.radioFailed)
assertEquals(23L, status.radioReceived)
assertEquals(24L, status.radioDropped)
assertEquals(26L, status.linkCrcErrors)
assertEquals(27L, status.linkMalformed)
assertEquals(28L, status.potiUpdates)
assertEquals(717_254_800_123L, status.itsTimeMs)
}
@Test
fun `a STATUS of the wrong length is refused, as the Python decoder does`() {
assertNull(StationStatus.decode(ByteArray(StationStatus.SIZE + 1)))
}
@Test
fun `RESULT of STATION_CONFIGURE carries the credential state`() {
val message = LinkMessage.decode("8000070000120800021122334455b80b49387a4c12eb0101".hexToBytes())!!
val result = LinkResult.decode(message.body)!!
assertTrue(result.accepted)
assertEquals(StationInfo(credentialsLoaded = true, tickets = 1), StationInfo.decode(result.detail))
}
}
@@ -0,0 +1,60 @@
package com.hawhamburg.micr0bu
import com.hawhamburg.micr0bu.domain.vam.VamGenerationRules
import com.hawhamburg.micr0bu.domain.vam.VamGenerationRules.Kinematics
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/** TS 103 300-3 clause 6.4 items 1 to 4, with the Table 16/17 values. */
class VamGenerationRulesTest {
private val here = Kinematics(53.5546667, 10.0223889, speedMps = 3.0, headingDeg = 90.0)
private fun sentAt(ms: Long, k: Kinematics = here) = VamGenerationRules().apply { onSent(ms, k, withLowFrequency = true) }
@Test
fun `the first VAM is always due`() {
assertTrue(VamGenerationRules().due(0, here))
}
@Test
fun `nothing is due within T_GenVamMin even after a big jump`() {
assertFalse(sentAt(1_000).due(1_050, here.copy(latitude = here.latitude + 0.001)))
}
@Test
fun `a stationary VRU gets one VAM every T_GenVamMax`() {
val rules = sentAt(1_000)
assertFalse(rules.due(5_900, here))
assertTrue(rules.due(6_001, here))
}
@Test
fun `position, speed and heading changes trigger past their thresholds only`() {
val rules = sentAt(1_000)
// ~3.3 m north: under 4 m. ~5.6 m: over.
assertFalse(rules.due(2_000, here.copy(latitude = here.latitude + 0.00003)))
assertTrue(rules.due(2_000, here.copy(latitude = here.latitude + 0.00005)))
assertFalse(rules.due(2_000, here.copy(speedMps = 3.4)))
assertTrue(rules.due(2_000, here.copy(speedMps = 3.6)))
assertFalse(rules.due(2_000, here.copy(headingDeg = 93.0)))
assertTrue(rules.due(2_000, here.copy(headingDeg = 95.0)))
}
@Test
fun `heading change is measured the short way round north`() {
val rules = sentAt(1_000, here.copy(headingDeg = 358.0))
assertFalse(rules.due(2_000, here.copy(headingDeg = 1.0))) // 3 degrees across north
assertTrue(rules.due(2_000, here.copy(headingDeg = 3.0))) // 5 degrees
}
@Test
fun `the low-frequency container goes with the first VAM, then every T_GenVamLFMin`() {
val rules = VamGenerationRules()
assertTrue(rules.includeLowFrequency(0))
rules.onSent(0, here, withLowFrequency = true)
assertFalse(rules.includeLowFrequency(1_999))
assertTrue(rules.includeLowFrequency(2_000))
}
}
@@ -0,0 +1,59 @@
package com.hawhamburg.micr0bu
import com.hawhamburg.micr0bu.domain.asn1.VamContent
import com.hawhamburg.micr0bu.domain.asn1.VamUperCodec
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Pins the VAM encoder against bytes this code did not produce.
*
* ## Where the expected bytes come from
* asn1tools 0.167, compiling the ETSI modules vanetza-idf ships (asn1/release2:
* TS102894-2v241-CDD.asn, TS103300-3v231/VAM-PDU-Descriptions.asn and its motorcyclist container),
* encoding the same values as a Python dict: the same toolchain the colleague's reference VBS
* (microbu-esp32c5/station-link/python/microbu_link/vbs.py) builds its VAMs with. Station
* 0x12345678, fix at Unix ms 1790170000123 (generationDeltaTime 45819), 53.5546667 N
* 10.0223889 E, 5.43 m/s, heading 123.4 deg, -1.26 m/s^2; every confidence and the altitude
* unavailable, as the encoder sends them.
*/
class VamUperCodecTest {
private fun ByteArray.hex(): String = joinToString("") { "%02x".format(it) }
private fun content(includeLowFrequency: Boolean, accuracyM: Float? = 3.0f) = VamContent(
stationId = 0x12345678,
timestamp = 1_790_170_000_123L,
latitude = 53.5546667,
longitude = 10.0223889,
accuracyM = accuracyM,
speedMps = 5.43,
headingDeg = 123.4,
accelerationMps2 = -1.26,
includeLowFrequency = includeLowFrequency,
)
@Test
fun `VAM with the low-frequency container matches asn1tools`() {
assertEquals(
"031012345678b2fb400aac85a15b8a18ec88f30f3708eddd0f8002697e087ff24f322220",
VamUperCodec.encode(content(includeLowFrequency = true)).hex(),
)
}
@Test
fun `VAM without the low-frequency container matches asn1tools`() {
assertEquals(
"031012345678b2fb000aac85a15b8a18ec88f30f3708eddd0f8002697e087ff24f30",
VamUperCodec.encode(content(includeLowFrequency = false)).hex(),
)
}
@Test
fun `unknown accuracy encodes the confidence ellipse as unavailable`() {
assertEquals(
"031012345678b2fb000aac85a15b8a18ec8fffffff08eddd0f8002697e087ff24f30",
VamUperCodec.encode(content(includeLowFrequency = false, accuracyM = null)).hex(),
)
}
}
+21
View File
@@ -0,0 +1,21 @@
MIT License (MIT)
Copyright (c) 2026 consider it GmbH
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+70
View File
@@ -0,0 +1,70 @@
# ETSI ASN.1 modules
The ASN.1 schemas this project's UPER codecs are validated against. **Schemas only** — no parser
code. Nothing in the app or the firmware reads these files at build or run time; they exist so the
hand-written codecs in `app/src/main/java/.../domain/asn1/` can be checked against an independent
implementation.
## Why this is here rather than just documented
Three of this project's tests assert exact bytes:
- `CamEncodeGoldenTest` — the CAM this app transmits, byte for byte
- `DenmAirReceiveTest`, `SpatemUperCodecTest` — real captured frames with field values
Those expected values were produced by decoding with `asn1tools` compiled from these modules. Without
them the fixtures cannot be regenerated or re-verified, and a golden-byte test you cannot regenerate
is a test nobody can safely touch.
This matters because the project has shipped the same class of bug three times: a field encoded with
the wrong number of bits, which this codebase then read back with the *same* wrong number. Phone and
ESP32 agree perfectly with each other and with nothing else, so every internal round-trip test passes
while the frames on air are malformed (`CurvatureCalculationMode`, the GeoNetworking reserved bytes,
`yawRateConfidence`). Only a second, independent implementation catches that — which is what these
modules provide.
## Provenance
Taken from consider it GmbH's C-ITS-Parser:
- <https://github.com/consider-it/C-ITS-Parser>
- commit `f457426efc2486fac49a02fc9a1c8c7762d160e9`
- MIT licensed — see `LICENSE`, retained here as the licence requires
Only the seven `.asn` files below are copied, out of a 4.2 MB checkout. The upstream Rust parser is
not used by this project in any way. The schemas themselves are ETSI's standard definitions; the
upstream repo's contribution is assembling them into a compilable set.
| file | used for |
|---|---|
| `cam_1_4_1.asn` + `cdd_1_3_1_1.asn` | CAM encode/decode |
| `denm_1_3_1.asn` + `cdd_1_3_1_1.asn` | DENM decode |
| `spatem_2_2_1.asn`, `mapem_2_2_1.asn`, `dsrc_2_2_1.asn`, `cdd_2_2_1.asn` | SPATEM/MAPEM decode |
Note CAM/DENM use the release-1 common dictionary (`cdd_1_3_1_1`) while SPATEM/MAPEM use release 2
(`cdd_2_2_1`). Both are needed; they are not interchangeable.
These seven were verified sufficient on their own: copied into an empty directory, all three specs
compile and reproduce the committed golden bytes.
## Regenerating a fixture
Requires Python with `asn1tools` (verified with 0.167.0):
```python
import asn1tools
spec = asn1tools.compile_files(["asn1/cam_1_4_1.asn", "asn1/cdd_1_3_1_1.asn"], "uper")
spec.decode("CAM", raw_uper_bytes)
```
For SPATEM, compile `spatem_2_2_1.asn`, `mapem_2_2_1.asn`, `dsrc_2_2_1.asn`, `cdd_2_2_1.asn`
together and decode `"SPATEM"`.
**Never regenerate a golden fixture from this project's own encoder output** — that is precisely the
mistake these files exist to catch. Regenerate through `asn1tools`, or the test is worthless.
## Updating
Re-pin deliberately, not casually. If a newer ETSI release is adopted, copy the new modules, then
re-run the golden-byte tests and confirm any change in expected bytes is explained by the spec
change rather than by a decoder regression.
+132
View File
@@ -0,0 +1,132 @@
CAM-PDU-Descriptions {
itu-t (0) identified-organization (4) etsi (0) itsDomain (5) wg1 (1) en (302637) cam (2) version (2)
}
DEFINITIONS AUTOMATIC TAGS ::=
BEGIN
IMPORTS
ItsPduHeader, CauseCode, ReferencePosition, AccelerationControl, Curvature, CurvatureCalculationMode, Heading, LanePosition, EmergencyPriority, EmbarkationStatus, Speed, DriveDirection, LongitudinalAcceleration, LateralAcceleration, VerticalAcceleration, StationType, ExteriorLights, DangerousGoodsBasic, SpecialTransportType, LightBarSirenInUse, VehicleRole, VehicleLength, VehicleWidth, PathHistory, RoadworksSubCauseCode, ClosedLanes, TrafficRule, SpeedLimit, SteeringWheelAngle, PerformanceClass, YawRate, ProtectedCommunicationZone, PtActivation, Latitude, Longitude, ProtectedCommunicationZonesRSU, CenDsrcTollingZone FROM ITS-Container {
itu-t (0) identified-organization (4) etsi (0) itsDomain (5) wg1 (1) ts (102894) cdd (2) version (2)
};
-- The root data frame for cooperative awareness messages
CAM ::= SEQUENCE {
header ItsPduHeader,
cam CoopAwareness
}
CoopAwareness ::= SEQUENCE {
generationDeltaTime GenerationDeltaTime,
camParameters CamParameters
}
CamParameters ::= SEQUENCE {
basicContainer BasicContainer,
highFrequencyContainer HighFrequencyContainer,
lowFrequencyContainer LowFrequencyContainer OPTIONAL,
specialVehicleContainer SpecialVehicleContainer OPTIONAL,
...
}
HighFrequencyContainer ::= CHOICE {
basicVehicleContainerHighFrequency BasicVehicleContainerHighFrequency,
rsuContainerHighFrequency RSUContainerHighFrequency,
...
}
LowFrequencyContainer ::= CHOICE {
basicVehicleContainerLowFrequency BasicVehicleContainerLowFrequency,
...
}
SpecialVehicleContainer ::= CHOICE {
publicTransportContainer PublicTransportContainer,
specialTransportContainer SpecialTransportContainer,
dangerousGoodsContainer DangerousGoodsContainer,
roadWorksContainerBasic RoadWorksContainerBasic,
rescueContainer RescueContainer,
emergencyContainer EmergencyContainer,
safetyCarContainer SafetyCarContainer,
...
}
BasicContainer ::= SEQUENCE {
stationType StationType,
referencePosition ReferencePosition,
...
}
BasicVehicleContainerHighFrequency ::= SEQUENCE {
heading Heading,
speed Speed,
driveDirection DriveDirection,
vehicleLength VehicleLength,
vehicleWidth VehicleWidth,
longitudinalAcceleration LongitudinalAcceleration,
curvature Curvature,
curvatureCalculationMode CurvatureCalculationMode,
yawRate YawRate,
accelerationControl AccelerationControl OPTIONAL,
lanePosition LanePosition OPTIONAL,
steeringWheelAngle SteeringWheelAngle OPTIONAL,
lateralAcceleration LateralAcceleration OPTIONAL,
verticalAcceleration VerticalAcceleration OPTIONAL,
performanceClass PerformanceClass OPTIONAL,
cenDsrcTollingZone CenDsrcTollingZone OPTIONAL
}
BasicVehicleContainerLowFrequency ::= SEQUENCE {
vehicleRole VehicleRole,
exteriorLights ExteriorLights,
pathHistory PathHistory
}
PublicTransportContainer ::= SEQUENCE {
embarkationStatus EmbarkationStatus,
ptActivation PtActivation OPTIONAL
}
SpecialTransportContainer ::= SEQUENCE {
specialTransportType SpecialTransportType,
lightBarSirenInUse LightBarSirenInUse
}
DangerousGoodsContainer ::= SEQUENCE {
dangerousGoodsBasic DangerousGoodsBasic
}
RoadWorksContainerBasic ::= SEQUENCE {
roadworksSubCauseCode RoadworksSubCauseCode OPTIONAL,
lightBarSirenInUse LightBarSirenInUse,
closedLanes ClosedLanes OPTIONAL
}
RescueContainer ::= SEQUENCE {
lightBarSirenInUse LightBarSirenInUse
}
EmergencyContainer ::= SEQUENCE {
lightBarSirenInUse LightBarSirenInUse,
incidentIndication CauseCode OPTIONAL,
emergencyPriority EmergencyPriority OPTIONAL
}
SafetyCarContainer ::= SEQUENCE {
lightBarSirenInUse LightBarSirenInUse,
incidentIndication CauseCode OPTIONAL,
trafficRule TrafficRule OPTIONAL,
speedLimit SpeedLimit OPTIONAL
}
RSUContainerHighFrequency ::= SEQUENCE {
protectedCommunicationZonesRSU ProtectedCommunicationZonesRSU OPTIONAL,
...
}
GenerationDeltaTime ::= INTEGER { oneMilliSec(1) } (0..65535)
END
+511
View File
@@ -0,0 +1,511 @@
ITS-Container {
itu-t (0) identified-organization (4) etsi (0) itsDomain (5) wg1 (1) ts (102894) cdd (2) version (2)
}
DEFINITIONS AUTOMATIC TAGS ::=
BEGIN
ItsPduHeader ::= SEQUENCE {
protocolVersion INTEGER (0..255),
messageID INTEGER{ denm(1), cam(2), poi(3), spatem(4), mapem(5), ivim(6), ev-rsr(7), tistpgtransaction(8), srem(9), ssem(10), evcsn(11), saem(12), rtcmem(13) } (0..255), -- Mantis #7209, #7005
stationID StationID
}
StationID ::= INTEGER(0..4294967295)
ReferencePosition ::= SEQUENCE {
latitude Latitude,
longitude Longitude,
positionConfidenceEllipse PosConfidenceEllipse ,
altitude Altitude
}
DeltaReferencePosition ::= SEQUENCE {
deltaLatitude DeltaLatitude,
deltaLongitude DeltaLongitude,
deltaAltitude DeltaAltitude
}
Longitude ::= INTEGER {oneMicrodegreeEast (10), oneMicrodegreeWest (-10), unavailable(1800000001)} (-1800000000..1800000001)
Latitude ::= INTEGER {oneMicrodegreeNorth (10), oneMicrodegreeSouth (-10), unavailable(900000001)} (-900000000..900000001)
Altitude ::= SEQUENCE {
altitudeValue AltitudeValue,
altitudeConfidence AltitudeConfidence
}
AltitudeValue ::= INTEGER {referenceEllipsoidSurface(0), oneCentimeter(1), unavailable(800001)} (-100000..800001)
AltitudeConfidence ::= ENUMERATED {
alt-000-01 (0),
alt-000-02 (1),
alt-000-05 (2),
alt-000-10 (3),
alt-000-20 (4),
alt-000-50 (5),
alt-001-00 (6),
alt-002-00 (7),
alt-005-00 (8),
alt-010-00 (9),
alt-020-00 (10),
alt-050-00 (11),
alt-100-00 (12),
alt-200-00 (13),
outOfRange (14),
unavailable (15)
}
DeltaLongitude ::= INTEGER {oneMicrodegreeEast (10), oneMicrodegreeWest (-10), unavailable(131072)} (-131071..131072)
DeltaLatitude ::= INTEGER {oneMicrodegreeNorth (10), oneMicrodegreeSouth (-10) , unavailable(131072)} (-131071..131072)
DeltaAltitude ::= INTEGER {oneCentimeterUp (1), oneCentimeterDown (-1), unavailable(12800)} (-12700..12800)
PosConfidenceEllipse ::= SEQUENCE {
semiMajorConfidence SemiAxisLength,
semiMinorConfidence SemiAxisLength,
semiMajorOrientation HeadingValue
}
PathPoint ::= SEQUENCE {
pathPosition DeltaReferencePosition,
pathDeltaTime PathDeltaTime OPTIONAL
}
PathDeltaTime ::= INTEGER {tenMilliSecondsInPast(1)} (1..65535, ...)
PtActivation ::= SEQUENCE {
ptActivationType PtActivationType,
ptActivationData PtActivationData
}
PtActivationType ::= INTEGER {undefinedCodingType(0), r09-16CodingType(1), vdv-50149CodingType(2)} (0..255)
PtActivationData ::= OCTET STRING (SIZE(1..20))
AccelerationControl ::= BIT STRING {
brakePedalEngaged (0),
gasPedalEngaged (1),
emergencyBrakeEngaged (2),
collisionWarningEngaged (3),
accEngaged (4),
cruiseControlEngaged (5),
speedLimiterEngaged (6)
} (SIZE(7))
SemiAxisLength ::= INTEGER{oneCentimeter(1), outOfRange(4094), unavailable(4095)} (0..4095)
CauseCode ::= SEQUENCE {
causeCode CauseCodeType,
subCauseCode SubCauseCodeType,
...
}
CauseCodeType ::= INTEGER {
reserved (0),
trafficCondition (1),
accident (2),
roadworks (3),
impassability (5),
adverseWeatherCondition-Adhesion (6),
aquaplannning (7),
hazardousLocation-SurfaceCondition (9),
hazardousLocation-ObstacleOnTheRoad (10),
hazardousLocation-AnimalOnTheRoad (11),
humanPresenceOnTheRoad (12),
wrongWayDriving (14),
rescueAndRecoveryWorkInProgress (15),
adverseWeatherCondition-ExtremeWeatherCondition (17),
adverseWeatherCondition-Visibility (18),
adverseWeatherCondition-Precipitation (19),
slowVehicle (26),
dangerousEndOfQueue (27),
vehicleBreakdown (91),
postCrash (92),
humanProblem (93),
stationaryVehicle (94),
emergencyVehicleApproaching (95),
hazardousLocation-DangerousCurve (96),
collisionRisk (97),
signalViolation (98),
dangerousSituation (99)
} (0..255)
SubCauseCodeType ::= INTEGER (0..255)
TrafficConditionSubCauseCode ::= INTEGER {unavailable(0), increasedVolumeOfTraffic(1), trafficJamSlowlyIncreasing(2), trafficJamIncreasing(3), trafficJamStronglyIncreasing(4), trafficStationary(5), trafficJamSlightlyDecreasing(6), trafficJamDecreasing(7), trafficJamStronglyDecreasing(8)} (0..255)
AccidentSubCauseCode ::= INTEGER {unavailable(0), multiVehicleAccident(1), heavyAccident(2), accidentInvolvingLorry(3), accidentInvolvingBus(4), accidentInvolvingHazardousMaterials(5), accidentOnOppositeLane(6), unsecuredAccident(7), assistanceRequested(8)} (0..255)
RoadworksSubCauseCode ::= INTEGER {unavailable(0), majorRoadworks(1), roadMarkingWork(2), slowMovingRoadMaintenance(3), shortTermStationaryRoadworks(4), streetCleaning(5), winterService(6)} (0..255)
HumanPresenceOnTheRoadSubCauseCode ::= INTEGER {unavailable(0), childrenOnRoadway(1), cyclistOnRoadway(2), motorcyclistOnRoadway(3)} (0..255)
WrongWayDrivingSubCauseCode ::= INTEGER {unavailable(0), wrongLane(1), wrongDirection(2)} (0..255)
AdverseWeatherCondition-ExtremeWeatherConditionSubCauseCode ::= INTEGER {unavailable(0), strongWinds(1), damagingHail(2), hurricane(3), thunderstorm(4), tornado(5), blizzard(6)} (0..255)
AdverseWeatherCondition-AdhesionSubCauseCode ::= INTEGER {unavailable(0), heavyFrostOnRoad(1), fuelOnRoad(2), mudOnRoad(3), snowOnRoad(4), iceOnRoad(5), blackIceOnRoad(6), oilOnRoad(7), looseChippings(8), instantBlackIce(9), roadsSalted(10)} (0..255)
AdverseWeatherCondition-VisibilitySubCauseCode ::= INTEGER {unavailable(0), fog(1), smoke(2), heavySnowfall(3), heavyRain(4), heavyHail(5), lowSunGlare(6), sandstorms(7), swarmsOfInsects(8)} (0..255)
AdverseWeatherCondition-PrecipitationSubCauseCode ::= INTEGER {unavailable(0), heavyRain(1), heavySnowfall(2), softHail(3)} (0..255)
SlowVehicleSubCauseCode ::= INTEGER {unavailable(0), maintenanceVehicle(1), vehiclesSlowingToLookAtAccident(2), abnormalLoad(3), abnormalWideLoad(4), convoy(5), snowplough(6), deicing(7), saltingVehicles(8)} (0..255)
StationaryVehicleSubCauseCode ::= INTEGER {unavailable(0), humanProblem(1), vehicleBreakdown(2), postCrash(3), publicTransportStop(4), carryingDangerousGoods(5)} (0..255)
HumanProblemSubCauseCode ::= INTEGER {unavailable(0), glycemiaProblem(1), heartProblem(2)} (0..255)
EmergencyVehicleApproachingSubCauseCode ::= INTEGER {unavailable(0), emergencyVehicleApproaching(1), prioritizedVehicleApproaching(2)} (0..255)
HazardousLocation-DangerousCurveSubCauseCode ::= INTEGER {unavailable(0), dangerousLeftTurnCurve(1), dangerousRightTurnCurve(2), multipleCurvesStartingWithUnknownTurningDirection(3), multipleCurvesStartingWithLeftTurn(4), multipleCurvesStartingWithRightTurn(5)} (0..255)
HazardousLocation-SurfaceConditionSubCauseCode ::= INTEGER {unavailable(0), rockfalls(1), earthquakeDamage(2), sewerCollapse(3), subsidence(4), snowDrifts(5), stormDamage(6), burstPipe(7), volcanoEruption(8), fallingIce(9)} (0..255)
HazardousLocation-ObstacleOnTheRoadSubCauseCode ::= INTEGER {unavailable(0), shedLoad(1), partsOfVehicles(2), partsOfTyres(3), bigObjects(4), fallenTrees(5), hubCaps(6), waitingVehicles(7)} (0..255)
HazardousLocation-AnimalOnTheRoadSubCauseCode ::= INTEGER {unavailable(0), wildAnimals(1), herdOfAnimals(2), smallAnimals(3), largeAnimals(4)} (0..255)
CollisionRiskSubCauseCode ::= INTEGER {unavailable(0), longitudinalCollisionRisk(1), crossingCollisionRisk(2), lateralCollisionRisk(3), vulnerableRoadUser(4)} (0..255)
SignalViolationSubCauseCode ::= INTEGER {unavailable(0), stopSignViolation(1), trafficLightViolation(2), turningRegulationViolation(3)} (0..255)
RescueAndRecoveryWorkInProgressSubCauseCode ::= INTEGER {unavailable(0), emergencyVehicles(1), rescueHelicopterLanding(2), policeActivityOngoing(3), medicalEmergencyOngoing(4), childAbductionInProgress(5)} (0..255)
DangerousEndOfQueueSubCauseCode ::= INTEGER {unavailable(0), suddenEndOfQueue(1), queueOverHill(2), queueAroundBend(3), queueInTunnel(4)} (0..255)
DangerousSituationSubCauseCode ::= INTEGER {unavailable(0), emergencyElectronicBrakeEngaged(1), preCrashSystemEngaged(2), espEngaged(3), absEngaged(4), aebEngaged(5), brakeWarningEngaged(6), collisionRiskWarningEngaged(7)} (0..255)
VehicleBreakdownSubCauseCode ::= INTEGER {unavailable(0), lackOfFuel (1), lackOfBatteryPower (2), engineProblem(3), transmissionProblem(4), engineCoolingProblem(5), brakingSystemProblem(6), steeringProblem(7), tyrePuncture(8), tyrePressureProblem(9)} (0..255)
PostCrashSubCauseCode ::= INTEGER {unavailable(0), accidentWithoutECallTriggered (1), accidentWithECallManuallyTriggered (2), accidentWithECallAutomaticallyTriggered (3), accidentWithECallTriggeredWithoutAccessToCellularNetwork(4)} (0..255)
Curvature ::= SEQUENCE {
curvatureValue CurvatureValue,
curvatureConfidence CurvatureConfidence
}
CurvatureValue ::= INTEGER {straight(0), unavailable(1023)} (-1023..1023)
CurvatureConfidence ::= ENUMERATED {
onePerMeter-0-00002 (0),
onePerMeter-0-0001 (1),
onePerMeter-0-0005 (2),
onePerMeter-0-002 (3),
onePerMeter-0-01 (4),
onePerMeter-0-1 (5),
outOfRange (6),
unavailable (7)
}
CurvatureCalculationMode ::= ENUMERATED {yawRateUsed(0), yawRateNotUsed(1), unavailable(2), ...}
Heading ::= SEQUENCE {
headingValue HeadingValue,
headingConfidence HeadingConfidence
}
HeadingValue ::= INTEGER {wgs84North(0), wgs84East(900), wgs84South(1800), wgs84West(2700), unavailable(3601)} (0..3601)
HeadingConfidence ::= INTEGER {equalOrWithinZeroPointOneDegree (1), equalOrWithinOneDegree (10), outOfRange(126), unavailable(127)} (1..127)
LanePosition ::= INTEGER {offTheRoad(-1), innerHardShoulder(0),
innermostDrivingLane(1), secondLaneFromInside(2), outerHardShoulder(14) } (-1..14)
ClosedLanes ::= SEQUENCE {
innerhardShoulderStatus HardShoulderStatus OPTIONAL,
outerhardShoulderStatus HardShoulderStatus OPTIONAL,
drivingLaneStatus DrivingLaneStatus OPTIONAL,
...
}
HardShoulderStatus ::= ENUMERATED {availableForStopping(0), closed(1), availableForDriving(2)}
DrivingLaneStatus ::= BIT STRING (SIZE (1..13))
PerformanceClass ::= INTEGER {unavailable(0), performanceClassA(1), performanceClassB(2)} (0..7)
SpeedValue ::= INTEGER {standstill(0), oneCentimeterPerSec(1), unavailable(16383)} (0..16383)
SpeedConfidence ::= INTEGER {equalOrWithinOneCentimeterPerSec(1), equalOrWithinOneMeterPerSec(100), outOfRange(126), unavailable(127)} (1..127)
VehicleMass ::= INTEGER {hundredKg(1), unavailable(1024)} (1..1024)
Speed ::= SEQUENCE {
speedValue SpeedValue,
speedConfidence SpeedConfidence
}
DriveDirection ::= ENUMERATED {forward (0), backward (1), unavailable (2)}
EmbarkationStatus ::= BOOLEAN
LongitudinalAcceleration ::= SEQUENCE {
longitudinalAccelerationValue LongitudinalAccelerationValue,
longitudinalAccelerationConfidence AccelerationConfidence
}
LongitudinalAccelerationValue ::= INTEGER {pointOneMeterPerSecSquaredForward(1), pointOneMeterPerSecSquaredBackward(-1), unavailable(161)} (-160 .. 161)
AccelerationConfidence ::= INTEGER {pointOneMeterPerSecSquared(1), outOfRange(101), unavailable(102)} (0 .. 102)
LateralAcceleration ::= SEQUENCE {
lateralAccelerationValue LateralAccelerationValue,
lateralAccelerationConfidence AccelerationConfidence
}
LateralAccelerationValue ::= INTEGER {pointOneMeterPerSecSquaredToRight(-1), pointOneMeterPerSecSquaredToLeft(1), unavailable(161)} (-160 .. 161)
VerticalAcceleration ::= SEQUENCE {
verticalAccelerationValue VerticalAccelerationValue,
verticalAccelerationConfidence AccelerationConfidence
}
VerticalAccelerationValue ::= INTEGER {pointOneMeterPerSecSquaredUp(1), pointOneMeterPerSecSquaredDown(-1), unavailable(161)} (-160 .. 161)
StationType ::= INTEGER {unknown(0), pedestrian(1), cyclist(2), moped(3), motorcycle(4), passengerCar(5), bus(6),
lightTruck(7), heavyTruck(8), trailer(9), specialVehicles(10), tram(11), roadSideUnit(15)} (0..255)
ExteriorLights ::= BIT STRING {
lowBeamHeadlightsOn (0),
highBeamHeadlightsOn (1),
leftTurnSignalOn (2),
rightTurnSignalOn (3),
daytimeRunningLightsOn (4),
reverseLightOn (5),
fogLightOn (6),
parkingLightsOn (7)
} (SIZE(8))
DangerousGoodsBasic::= ENUMERATED {
explosives1(0),
explosives2(1),
explosives3(2),
explosives4(3),
explosives5(4),
explosives6(5),
flammableGases(6),
nonFlammableGases(7),
toxicGases(8),
flammableLiquids(9),
flammableSolids(10),
substancesLiableToSpontaneousCombustion(11),
substancesEmittingFlammableGasesUponContactWithWater(12),
oxidizingSubstances(13),
organicPeroxides(14),
toxicSubstances(15),
infectiousSubstances(16),
radioactiveMaterial(17),
corrosiveSubstances(18),
miscellaneousDangerousSubstances(19)
}
DangerousGoodsExtended ::= SEQUENCE {
dangerousGoodsType DangerousGoodsBasic,
unNumber INTEGER (0..9999),
elevatedTemperature BOOLEAN,
tunnelsRestricted BOOLEAN,
limitedQuantity BOOLEAN,
emergencyActionCode IA5String (SIZE (1..24)) OPTIONAL,
phoneNumber PhoneNumber OPTIONAL,
companyName UTF8String (SIZE (1..24)) OPTIONAL,
...
}
SpecialTransportType ::= BIT STRING {heavyLoad(0), excessWidth(1), excessLength(2), excessHeight(3)} (SIZE(4))
LightBarSirenInUse ::= BIT STRING {
lightBarActivated (0),
sirenActivated (1)
} (SIZE(2))
HeightLonCarr ::= INTEGER {oneCentimeter(1), unavailable(100)} (1..100)
PosLonCarr ::= INTEGER {oneCentimeter(1), unavailable(127)} (1..127)
PosPillar ::= INTEGER {tenCentimeters(1), unavailable(30)} (1..30)
PosCentMass ::= INTEGER {tenCentimeters(1), unavailable(63)} (1..63)
RequestResponseIndication ::= ENUMERATED {request(0), response(1)}
SpeedLimit ::= INTEGER {oneKmPerHour(1)} (1..255)
StationarySince ::= ENUMERATED {lessThan1Minute(0), lessThan2Minutes(1), lessThan15Minutes(2), equalOrGreater15Minutes(3)}
Temperature ::= INTEGER {equalOrSmallerThanMinus60Deg (-60), oneDegreeCelsius(1), equalOrGreaterThan67Deg(67)} (-60..67)
TrafficRule ::= ENUMERATED {noPassing(0), noPassingForTrucks(1), passToRight(2), passToLeft(3), ...
}
WheelBaseVehicle ::= INTEGER {tenCentimeters(1), unavailable(127)} (1..127)
TurningRadius ::= INTEGER {point4Meters(1), unavailable(255)} (1..255)
PosFrontAx ::= INTEGER {tenCentimeters(1), unavailable(20)} (1..20)
PositionOfOccupants ::= BIT STRING {
row1LeftOccupied (0),
row1RightOccupied (1),
row1MidOccupied (2),
row1NotDetectable (3),
row1NotPresent (4),
row2LeftOccupied (5),
row2RightOccupied (6),
row2MidOccupied (7),
row2NotDetectable (8),
row2NotPresent (9),
row3LeftOccupied (10),
row3RightOccupied (11),
row3MidOccupied (12),
row3NotDetectable (13),
row3NotPresent (14),
row4LeftOccupied (15),
row4RightOccupied (16),
row4MidOccupied (17),
row4NotDetectable (18),
row4NotPresent (19)} (SIZE(20))
PositioningSolutionType ::= ENUMERATED {noPositioningSolution(0), sGNSS(1), dGNSS(2), sGNSSplusDR(3), dGNSSplusDR(4), dR(5), ...}
VehicleIdentification ::= SEQUENCE {
wMInumber WMInumber OPTIONAL,
vDS VDS OPTIONAL,
...
}
WMInumber ::= IA5String (SIZE(1..3))
VDS ::= IA5String (SIZE(6))
EnergyStorageType ::= BIT STRING {hydrogenStorage(0), electricEnergyStorage(1), liquidPropaneGas(2), compressedNaturalGas(3), diesel(4), gasoline(5), ammonia(6)} (SIZE(7))
VehicleLength ::= SEQUENCE {
vehicleLengthValue VehicleLengthValue,
vehicleLengthConfidenceIndication VehicleLengthConfidenceIndication
}
VehicleLengthValue ::= INTEGER {tenCentimeters(1), outOfRange(1022), unavailable(1023)} (1..1023)
VehicleLengthConfidenceIndication ::= ENUMERATED {noTrailerPresent(0), trailerPresentWithKnownLength(1), trailerPresentWithUnknownLength(2), trailerPresenceIsUnknown(3), unavailable(4)}
VehicleWidth ::= INTEGER {tenCentimeters(1), outOfRange(61), unavailable(62)} (1..62)
PathHistory::= SEQUENCE (SIZE(0..40)) OF PathPoint
EmergencyPriority ::= BIT STRING {requestForRightOfWay(0), requestForFreeCrossingAtATrafficLight(1)} (SIZE(2))
InformationQuality ::= INTEGER {unavailable(0), lowest(1), highest(7)} (0..7)
RoadType ::= ENUMERATED {
urban-NoStructuralSeparationToOppositeLanes(0),
urban-WithStructuralSeparationToOppositeLanes(1),
nonUrban-NoStructuralSeparationToOppositeLanes(2),
nonUrban-WithStructuralSeparationToOppositeLanes(3)}
SteeringWheelAngle ::= SEQUENCE {
steeringWheelAngleValue SteeringWheelAngleValue,
steeringWheelAngleConfidence SteeringWheelAngleConfidence
}
SteeringWheelAngleValue ::= INTEGER {straight(0), onePointFiveDegreesToRight(-1), onePointFiveDegreesToLeft(1), unavailable(512)} (-511..512)
SteeringWheelAngleConfidence ::= INTEGER {equalOrWithinOnePointFiveDegree (1), outOfRange(126), unavailable(127)} (1..127)
TimestampIts ::= INTEGER {utcStartOf2004(0), oneMillisecAfterUTCStartOf2004(1)} (0..4398046511103)
VehicleRole ::= ENUMERATED {default(0), publicTransport(1), specialTransport(2), dangerousGoods(3), roadWork(4), rescue(5), emergency(6), safetyCar(7), agriculture(8), commercial(9), military(10), roadOperator(11), taxi(12), reserved1(13), reserved2(14), reserved3(15)}
YawRate::= SEQUENCE {
yawRateValue YawRateValue,
yawRateConfidence YawRateConfidence
}
YawRateValue ::= INTEGER {straight(0), degSec-000-01ToRight(-1), degSec-000-01ToLeft(1), unavailable(32767)} (-32766..32767)
YawRateConfidence ::= ENUMERATED {
degSec-000-01 (0),
degSec-000-05 (1),
degSec-000-10 (2),
degSec-001-00 (3),
degSec-005-00 (4),
degSec-010-00 (5),
degSec-100-00 (6),
outOfRange (7),
unavailable (8)
}
ProtectedZoneType::= ENUMERATED { permanentCenDsrcTolling (0), ..., temporaryCenDsrcTolling (1) }
RelevanceDistance ::= ENUMERATED {lessThan50m(0), lessThan100m(1), lessThan200m(2), lessThan500m(3), lessThan1000m(4), lessThan5km(5), lessThan10km(6), over10km(7)}
RelevanceTrafficDirection ::= ENUMERATED {allTrafficDirections(0), upstreamTraffic(1), downstreamTraffic(2), oppositeTraffic(3)}
TransmissionInterval ::= INTEGER {oneMilliSecond(1), tenSeconds(10000)} (1..10000)
ValidityDuration ::= INTEGER {timeOfDetection(0), oneSecondAfterDetection(1)} (0..86400)
ActionID ::= SEQUENCE {
originatingStationID StationID,
sequenceNumber SequenceNumber
}
ItineraryPath ::= SEQUENCE SIZE(1..40) OF ReferencePosition
ProtectedCommunicationZone ::= SEQUENCE {
protectedZoneType ProtectedZoneType,
expiryTime TimestampIts OPTIONAL,
protectedZoneLatitude Latitude,
protectedZoneLongitude Longitude,
protectedZoneRadius ProtectedZoneRadius OPTIONAL,
protectedZoneID ProtectedZoneID OPTIONAL,
...
}
Traces ::= SEQUENCE SIZE(1..7) OF PathHistory
NumberOfOccupants ::= INTEGER {oneOccupant (1), unavailable(127)} (0 .. 127)
SequenceNumber ::= INTEGER (0..65535)
PositionOfPillars ::= SEQUENCE (SIZE(1..3, ...)) OF PosPillar
RestrictedTypes ::= SEQUENCE (SIZE(1..3, ...)) OF StationType
EventHistory::= SEQUENCE (SIZE(1..23)) OF EventPoint
EventPoint ::= SEQUENCE {
eventPosition DeltaReferencePosition,
eventDeltaTime PathDeltaTime OPTIONAL,
informationQuality InformationQuality
}
ProtectedCommunicationZonesRSU ::= SEQUENCE (SIZE(1..16)) OF ProtectedCommunicationZone
CenDsrcTollingZone ::= SEQUENCE {
protectedZoneLatitude Latitude,
protectedZoneLongitude Longitude,
cenDsrcTollingZoneID CenDsrcTollingZoneID OPTIONAL,
...
}
ProtectedZoneRadius ::= INTEGER {oneMeter(1)} (1..255,...)
ProtectedZoneID ::= INTEGER (0.. 134217727)
CenDsrcTollingZoneID ::= ProtectedZoneID
DigitalMap ::= SEQUENCE (SIZE(1..256)) OF ReferencePosition
OpeningDaysHours ::= UTF8String
PhoneNumber ::= NumericString (SIZE(1..16))
END
+7260
View File
File diff suppressed because it is too large Load Diff
+108
View File
@@ -0,0 +1,108 @@
DENM-PDU-Descriptions {itu-t (0) identified-organization (4) etsi (0) itsDomain (5) wg1 (1) en (302637) denm (1) version (2)
}
DEFINITIONS AUTOMATIC TAGS ::=
BEGIN
IMPORTS
ItsPduHeader, CauseCode, Speed, InformationQuality, ReferencePosition, ClosedLanes, DangerousGoodsExtended, Heading, LanePosition, LightBarSirenInUse, RoadType, HeightLonCarr, PosLonCarr, PosCentMass, PositioningSolutionType, RequestResponseIndication, StationType, SpeedLimit, StationarySince, TimestampIts, WheelBaseVehicle, TurningRadius, PosFrontAx, PositionOfOccupants, Temperature, VehicleMass, VehicleIdentification, EnergyStorageType, ActionID, ItineraryPath, NumberOfOccupants, PositionOfPillars, RelevanceTrafficDirection, RestrictedTypes, Traces, TransmissionInterval, ValidityDuration, RelevanceDistance, EventHistory, TrafficRule, DeltaReferencePosition FROM ITS-Container {
itu-t (0) identified-organization (4) etsi (0) itsDomain (5) wg1 (1) ts (102894) cdd (2) version (2)
};
DENM ::= SEQUENCE {
header ItsPduHeader,
denm DecentralizedEnvironmentalNotificationMessage
}
DecentralizedEnvironmentalNotificationMessage ::= SEQUENCE {
management ManagementContainer,
situation SituationContainer OPTIONAL,
location LocationContainer OPTIONAL,
alacarte AlacarteContainer OPTIONAL
}
ManagementContainer ::= SEQUENCE {
actionID ActionID,
detectionTime TimestampIts,
referenceTime TimestampIts,
termination Termination OPTIONAL,
eventPosition ReferencePosition,
relevanceDistance RelevanceDistance OPTIONAL,
relevanceTrafficDirection RelevanceTrafficDirection OPTIONAL,
validityDuration ValidityDuration DEFAULT defaultValidity,
transmissionInterval TransmissionInterval OPTIONAL,
stationType StationType,
...
}
SituationContainer ::= SEQUENCE {
informationQuality InformationQuality,
eventType CauseCode,
linkedCause CauseCode OPTIONAL,
eventHistory EventHistory OPTIONAL,
...
}
LocationContainer ::= SEQUENCE {
eventSpeed Speed OPTIONAL,
eventPositionHeading Heading OPTIONAL,
traces Traces,
roadType RoadType OPTIONAL,
...
}
ImpactReductionContainer ::= SEQUENCE {
heightLonCarrLeft HeightLonCarr,
heightLonCarrRight HeightLonCarr,
posLonCarrLeft PosLonCarr,
posLonCarrRight PosLonCarr,
positionOfPillars PositionOfPillars,
posCentMass PosCentMass,
wheelBaseVehicle WheelBaseVehicle,
turningRadius TurningRadius,
posFrontAx PosFrontAx,
positionOfOccupants PositionOfOccupants,
vehicleMass VehicleMass,
requestResponseIndication RequestResponseIndication
}
RoadWorksContainerExtended ::= SEQUENCE {
lightBarSirenInUse LightBarSirenInUse OPTIONAL,
closedLanes ClosedLanes OPTIONAL,
restriction RestrictedTypes OPTIONAL,
speedLimit SpeedLimit OPTIONAL,
incidentIndication CauseCode OPTIONAL,
recommendedPath ItineraryPath OPTIONAL,
startingPointSpeedLimit DeltaReferencePosition OPTIONAL,
trafficFlowRule TrafficRule OPTIONAL,
referenceDenms ReferenceDenms OPTIONAL
}
StationaryVehicleContainer ::= SEQUENCE {
stationarySince StationarySince OPTIONAL,
stationaryCause CauseCode OPTIONAL,
carryingDangerousGoods DangerousGoodsExtended OPTIONAL,
numberOfOccupants NumberOfOccupants OPTIONAL,
vehicleIdentification VehicleIdentification OPTIONAL,
energyStorageType EnergyStorageType OPTIONAL
}
AlacarteContainer ::= SEQUENCE {
lanePosition LanePosition OPTIONAL,
impactReduction ImpactReductionContainer OPTIONAL,
externalTemperature Temperature OPTIONAL,
roadWorks RoadWorksContainerExtended OPTIONAL,
positioningSolution PositioningSolutionType OPTIONAL,
stationaryVehicle StationaryVehicleContainer OPTIONAL,
...
}
defaultValidity INTEGER ::= 600
Termination ::= ENUMERATED {isCancellation(0), isNegation (1)}
ReferenceDenms ::= SEQUENCE (SIZE(1..8, ...)) OF ActionID
END
+4468
View File
File diff suppressed because it is too large Load Diff
+54
View File
@@ -0,0 +1,54 @@
/** draft 001 of the MAPEM-PDU-Descriptions module for TS 103 831 V2.2.1 integrating:
* initial revision based on ASN.1 files of [ISO TS 19091] and [SAE J2735]
*/
-- Note: the above information will be deleted before publication
--! @options: no-fields-header
MAPEM-PDU-Descriptions {
itu-t (0) identified-organization (4) etsi (0) itsDomain (5) wg1 (1) ts103301 (103301) mapem (1) version2 (2)
}
DEFINITIONS AUTOMATIC TAGS ::=
BEGIN
IMPORTS
/**
* Includes from ETSI-ITS-DSRC
*/
MapData
FROM ETSI-ITS-DSRC {
itu-t (0) identified-organization (4) etsi (0) itsDomain (5) wg1 (1) ts103301 (103301) dsrc (6) major-version-2 (2) minor-version-1 (1)
}
WITH SUCCESSORS
/**
* Include ETSI TS 102 894-2 (ETSI-ITS-CDD)
*/
ItsPduHeader
FROM ETSI-ITS-CDD {
itu-t (0) identified-organization (4) etsi (0) itsDomain (5) wg1 (1) 102894 cdd (2) major-version-4 (4) minor-version-1 (1)
}
WITH SUCCESSORS;
/**
* Map (lane topology) extended Message
* This DF includes DEs for the MAPEM: protocolVersion, the MAPEM message type identifier `messageID`,
* the station identifier `stationID` of the originating ITS-S and the Map data from ETSI-ITS-DSRC.
*
* @field header: The DE `protocolVersion` is used to select the appropriate protocol decoder at the receiving ITS-S.
* It shall be set to 2.
* The DE `messageID` shall be mapem(5).
* @field map: contains the MAP data as defined in ETSI-ITS-DSRC.
*
* @category: Basic Information
* @revision: V1.3.1
*/
MAPEM ::= SEQUENCE {
header ItsPduHeader,
map MapData
}
END
+56
View File
@@ -0,0 +1,56 @@
/** draft 001 of the SPATEM-PDU-Descriptions module for TS 103 301 V2.2.1 integrating:
* initial revision based on ASN.1 files of [ISO TS 19091] and [SAE J2735]
*/
-- Note: the above information will be deleted before publication
--! @options: no-fields-header
SPATEM-PDU-Descriptions {
itu-t (0) identified-organization (4) etsi (0) itsDomain (5) wg1 (1) ts103301 (103301) spatem (0) major-version-2 (2) minor-version-1 (1)
}
DEFINITIONS AUTOMATIC TAGS ::=
BEGIN
IMPORTS
/**
* Includes from ETSI-ITS-DSRC
*/
SPAT
FROM ETSI-ITS-DSRC {
itu-t (0) identified-organization (4) etsi (0) itsDomain (5) wg1 (1) ts103301 (103301) dsrc (6) major-version-2 (2) minor-version-1 (1)
}
WITH SUCCESSORS
/**
* Include ETSI TS 102 894-2 (ETSI-ITS-CDD)
*/
ItsPduHeader
FROM ETSI-ITS-CDD {
itu-t (0) identified-organization (4) etsi (0) itsDomain (5) wg1 (1) 102894 cdd (2) major-version-4 (4) minor-version-1 (1)
}
WITH SUCCESSORS;
/**
* Signal phase and timing extended Message
*
* Signal phase and timing extended Message Root
* This DF includes DEs for the SPATEM: protocolVersion, the SPATEM message type identifier `messageID`,
* the station identifier `stationID` of the originating ITS-S and the SPaT data from ETSI-ITS-DSRC module.
*
* @field header: The DE `protocolVersion` used to select the appropriate protocol decoder at the receiving ITS-S.
* It shall be set to 2.
* The DE `messageID` shall be spatem(4).
* @field spat: contains the SPaT data as defined in ETSI-ITS-DSRC.
*
* @category: Basic Information
* @revision: V1.3.1
*/
SPATEM ::= SEQUENCE {
header ItsPduHeader,
spat SPAT
}
END
+249
View File
@@ -0,0 +1,249 @@
# Requirements traceability matrix
Maps every section of `V2X_MicrOBU_Android_App_Requirements_v15.pdf` (37 pages, chapters 0–13) to
its implementation and to the evidence that it works.
**Status as of 2026-08-25**, commit `cc35994`. Test counts refer to `app/src/test/` (41 tests,
0 failures); bench measurements refer to `05-obu-bench-test-2026-08-25.md`.
## How to read this
The project began against the **consider it CiT One OBU** and later added the **ESP32-C5** as a
second hardware path (requirements chapter 13). That transition is the single biggest source of
divergence in this table, and it is deliberate rather than drift: chapter 13 was written to describe
it. Where a requirement was authored assuming the CiT One, the ESP32-C5 path may satisfy it by a
different mechanism, satisfy it only partly, or make it inapplicable.
Status values:
| status | meaning |
|---|---|
| **Done** | implemented and exercised on both hardware paths |
| **Done (CiT One)** | implemented; applies only to the CiT One path by design |
| **Done (ESP32-C5)** | implemented; applies only to the ESP32-C5 path by design |
| **Partial** | implemented in part, with the remainder identified |
| **Backlog** | explicitly deferred in the requirements themselves |
| **Superseded** | the ESP32-C5 transition changed the answer; see the note |
| **Not started** | no implementation |
"Cited" means a source file names the section in a KDoc comment — 23 files do. Absence of a citation
is **not** absence of implementation; several well-covered areas were written before that convention
and are mapped here by inspection.
---
## 0. Project Context & Main Goal
| § | Title | Status | Implementation | Evidence |
|---|---|---|---|---|
| 0.1 | Technological Innovation Context | n/a | context, not a requirement | — |
| 0.2 | Bicycle Safety Use Case Foundation (C2C-CC) | **Done** | `DenmUseCase.kt` *(cited)*, `UseCaseType.kt` | use cases 1.1/1.2 below |
**Note.** 0.2 is the origin of the project's central architectural rule: the CAM-based use cases
never generate DENM. `DenmUseCase.kt` records this, and it survived the ESP32 transition unchanged.
## 1. Phase 01 — Data Collection
| § | Title | Status | Implementation | Evidence |
|---|---|---|---|---|
| 1.1 | Test Intersections & Use Cases | **Done** | `UseCaseDetectionEngine.kt`, `UseCaseType.kt` *(both cited)* | IMA-S observed firing on hardware, 2026-08-25 |
| 1.2 | C2C-CC Use Case Mapping & Roadmap | **Done** | `UseCaseType.kt`, `UseCaseDetectionConfig.kt` *(both cited)* | 5 use cases enumerated: IMA-B, IMA-S, RTW-B, LTW-B, SMVA/BCW-B |
## 2. Functional Requirements
| § | Title | Status | Implementation | Evidence |
|---|---|---|---|---|
| 2.1 | Screens & Features | **Done** | `ui/screens/` — Dashboard, Record, Trips, V2X, Settings | bench screenshots |
| 2.2 | User Interactions | **Done** | `UseCaseAlertPreferences.kt` *(cited)*, Settings screen | per-use-case toggles |
| 2.3 | Backend, Database & Login | **Partial** | `data/db/` (Room: sessions, trips, events, V2X messages) | no login/backend; local-only by design |
**Gap.** 2.3's backend and login have no implementation. Everything is on-device. Worth stating
explicitly in the thesis as a scope boundary rather than leaving it to be discovered.
## 3. Non-Functional Requirements
| § | Title | Status | Implementation | Evidence |
|---|---|---|---|---|
| 3.1 | Android Version & Target Devices | **Done** | `minSdk 29`, `targetSdk 36` | runs on Pixel 9 Pro |
| 3.2 | Performance & Offline | **Done** | offline by construction on the ESP32-C5 path | 210 MB PSS, 43 threads, no GC pressure attributable to the app |
**ESP32-C5 note.** 3.2's offline requirement is *more* satisfied after the transition: the ESP32-C5
path needs no MQTT broker and no network at all.
## 4. Technical Requirements
| § | Title | Status | Implementation | Evidence |
|---|---|---|---|---|
| 4.1 | Development Environment | **Done** | Gradle 8.10.2, AGP, Android Studio JBR | builds from CLI |
| 4.2 | Architecture | **Done** | `UseCaseDetectionEngine.kt` *(cited)*; MVVM + Hilt + repositories | pure-domain classes unit-tested without Android |
| 4.3 | Key Libraries | **Done** | Compose, Room, Hilt, osmdroid, usb-serial-for-android, Paho MQTT | — |
| 4.4 | Security Requirements (future) | **Backlog** | — | see note |
**Security note (4.4).** ETSI TS 103 097 message signing is not implemented and is out of scope. The
firmware rejects secured packets (GN `NextHeader=2`) rather than mis-parsing them; 75 such frames
were observed on the bench. The OBU under test runs `ItsGnSecurity = 0`, so this has not blocked
anything. **This is the most likely reviewer question and the answer should be pre-written.**
## 5. Design Requirements
| § | Title | Status | Implementation | Evidence |
|---|---|---|---|---|
| 5.1–5.4 | Visual language, system bars, UX, wireframes | **Done** | Compose Material 3, edge-to-edge | screenshots |
| 5.5 | Alert Level Model (C2C-CC three-tier) | **Done** | `AlertLevel.kt`, `UseCaseDetectionConfig.kt`, `UseCaseDetectionEngine.kt` *(all cited)* | Info / Awareness / Warning; observed WARNING on hardware |
## 6. Sensor Data Streams (Phase 01)
| § | Title | Status | Implementation | Evidence |
|---|---|---|---|---|
| 6 | Sensor Data Streams | **Done** | `SensorRepository.kt`, `CamUseCaseRepository.kt` *(cited)* | GNSS, accel, gyro, magnetometer, barometer |
## 7. Recording Session & Data Export
| § | Title | Status | Implementation | Evidence |
|---|---|---|---|---|
| 7.1 | Recording Mode | **Done** | `TripRecordingService.kt`, `RecordingScreen.kt` | — |
| 7.2 | CSV Export Format | **Done** | `CsvExporter.kt`, `TripExporter.kt` | export includes V2X messages and RSSI |
## 8. Connectivity
| § | Title | Status | Implementation | Evidence |
|---|---|---|---|---|
| 8.1 | Connection Phase Roadmap | **Superseded** | `ObuHardware.kt`, `TransportType.kt` | chapter 13 replaced the roadmap |
| 8.2 | Transport Methods Detail | **Partial** | `UsbSerialTransport.kt`, `UsbNetworkDetector.kt`, `MqttRepository.kt` | USB-C both paths; **Bluetooth not implemented** (13.9 keeps it open) |
## 9. Phase 01 Key Design Principles
| § | Title | Status | Implementation | Evidence |
|---|---|---|---|---|
| 9 | Key Design Principles | **Done** | pure-domain `domain/` packages, no Android imports | `EventDetectorTest`, `UseCaseDetectionEngine` unit-testable |
## 10. Phase 02 — OBU Communication (CAM-based use cases)
| § | Title | Status | Implementation | Evidence |
|---|---|---|---|---|
| 10.1 | USB-C Wired Transport | **Done (CiT One)** | `UsbNetworkDetector.kt` (IP over USB tethering) | superseded on the ESP32-C5 path by USB serial |
| 10.2 | CAM Reception & Use Case Detection | **Done** | `Cam.kt`, `CamUseCaseRepository.kt`, `MqttViewModel.kt`, `MqttTopicViewerScreen.kt` *(all cited)* | 1244 CAMs decoded in 305 s, 0 failures |
| 10.2.1 | Detection Algorithm Detail | **Done** | `UseCaseDetectionEngine.kt`, `UseCaseDetectionConfig.kt` | IMA-S fired with TTC 4.8 s on hardware |
| 10.3 | Test and Verification Procedure | **Done** | `CamParser.kt`, `UseCaseDetectionConfig.kt`, `UseCaseDetectionEngine.kt` *(all cited)* | extended well beyond the original procedure — see chapter 14 below |
| 10.4 | New/Updated UI Elements | **Done** | `MqttTopicViewerScreen.kt` *(cited)* | alert panel, list/topics/map |
| 10.5 | Phase 02 Key Technical Decisions | **Done** | `DenmUseCase.kt` | DENM decoupled from sensor triggers |
**Transition note (10.1).** Phase 02 assumed IP-over-USB tethering to the CiT One. The ESP32-C5 path
uses a custom framed serial protocol over USB CDC instead. Both are "USB-C wired transport", but they
share no code. The requirement is satisfied twice, by different means.
## 11. Phase A — Trip Recording & Cyclist Event Detection
| § | Title | Status | Implementation | Evidence |
|---|---|---|---|---|
| 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.3 | Trip Recording Architecture | **Done** | `TripRepository.kt`, `TripRecordingService.kt` *(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 | **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 |
**Correction note (11.2).** Four `EventDetectorTest` cases had been failing since the initial commit.
Investigation (2026-08-25) established the **detector was correct and the tests were wrong**: they
described stimuli the detector cannot physically see, because they ignored the settling time of the
rolling standard-deviation window. Tests corrected, assertions unchanged, detector untouched. This is
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
| § | Title | Status | Notes |
|---|---|---|---|
| 12.1 | Multi-Vehicle Handling & Notification Limits | **Partial** | engine tracks per-station history; no notification cap |
| 12.2 | Intersection Ambiguity: Traffic-Light State | **Partial — advanced** | **SPATEM now decoded** (`SpatemUperCodec.kt`), see below |
| 12.3 | Shared-Road / Bike Lane Awareness Dataset | **Not started** | would need MAPEM lane geometry |
| 12.4 | Geo-Server for Crowdsourced Trajectory Data | **Not started** | related to the 2.3 backend gap |
| 12.5 | Geofencing Around High-Risk Intersections | **Not started** | — |
| 12.6 | Sensor Fusion Roadmap (Kalman Filter) | **Not started** | — |
| 12.7 | EventDetector Evolution | **Not started** | — |
**12.2 is the notable movement.** The requirements listed traffic-light state as future/backlog. It
is now partly delivered: SPATEM is received over the air and decoded to per-signal-group phase and
timing, validated against 79,042 real messages. What remains is the *association* problem — knowing
which signal group applies to the rider's lane — which needs MAPEM geometry (12.3). Worth presenting
as a backlog item advanced ahead of schedule, with the remaining half named precisely.
## 13. Phase 03 — ESP32-C5 Dual-OBU & Phone-Generated CAM
The transition chapter. Cited by **16 source files**, more than any other.
| § | Title | Status | Implementation | Evidence |
|---|---|---|---|---|
| 13.1 | ESP32-C5 as a Second OBU Option | **Done** | `obu-firmware/`, `ObuHardware.kt` | bench campaign T1–T9 |
| 13.2 | Transport: USB-C to ESP32 | **Done** | `SerialFrame.kt`, `serial_link.c` | 2868 frames / 305 s, 0 errors |
| 13.3 | Settings: OBU Hardware Selection | **Done** | `ObuHardwarePreferences.kt`, Settings | both paths selectable |
| 13.4 | DENM Trigger Retained for CiT One Only | **Done (CiT One)** | `MqttTopicViewerScreen.kt` gating | trigger hidden on the ESP32-C5 path |
| 13.5 | Phone-Generated CAM | **Done (ESP32-C5)** | `PhoneCamBuilder.kt`, `CamUperCodec.kt`, `CamTransmitLoop.kt` | **golden-byte test**; 26 own CAMs verified off-air by an independent decoder |
| 13.6 | Adaptive CAM Transmission Rate | **Partial** | `CamTransmitConfig.kt` | fixed 1 Hz pinger; adaptive rate not implemented |
| 13.7 | UI Updates: Map View & Dashboard | **Done** | `V2xLiveMapView.kt`, `DashboardScreen.kt` | screenshots |
| 13.8 | Detection Engine: Unchanged for Reception | **Done** | `UseCaseDetectionEngine.kt` | engine is transport-agnostic; confirmed by inspection — zero SPATEM/MAPEM references |
| 13.9 | Bluetooth Transport (Open Discussion) | **Not started** | — | remains open, as the requirement says |
**13.8 is the load-bearing claim of the transition** and it holds: the same detection engine serves
both hardware paths, fed by `CamUseCaseRepository` from either MQTT or serial. That is what makes the
ESP32-C5 a drop-in second OBU rather than a fork of the application.
---
## Beyond the requirements
Work delivered that chapter 13 does not cover, because it postdates v15 of the document. For a
publication these are contributions rather than scope creep, and they need their own section.
| area | what | evidence |
|---|---|---|
| DENM over-the-air receive | GeoBroadcast unwrapping, `DenmUperCodec` | 1885 DENMs cross-checked, 0 mismatches |
| SPATEM receive | `SpatemUperCodec`, live signal phase UI | 79,042 messages cross-checked, 0 mismatches |
| RSU CAM support | `rsuContainerHighFrequency` decoding | 611 RSU CAMs decoded on the bench |
| Verification methodology | independent ETSI oracle, golden bytes, real captures | three encoding bugs found that self-consistent tests structurally cannot catch |
## Open items
Ranked by what a reviewer is most likely to probe.
1. **11.6 Phase A success criteria** — needs a real ride. The bench proves reception; it cannot
prove the use case behaves correctly with two genuinely moving stations. This is the main
remaining evidence gap for the central claim.
2. **4.4 Security** — no message signing. Pre-write the answer.
3. **2.3 Backend & login** — not implemented; state as a scope boundary.
4. **13.6 Adaptive CAM rate** — fixed 1 Hz.
5. **8.2 / 13.9 Bluetooth** — not implemented; the requirements leave it open.
6. **12.2 remainder** — signal-group-to-lane association needs MAPEM.
## Known limitations carried deliberately
Documented decisions, not oversights. Each has its reasoning recorded in the commit history and in
`05-obu-bench-test-2026-08-25.md`.
- **512-byte serial payload cap.** ~70% of *road* RSU SPATEMs would be dropped. Accepted: the
intersection use case is CAM-driven and needs none of it, and raising the cap would put a
measured, zero-failure chain at risk for an add-on. CAM 26–211 B and DENM 402 B both fit.
- **No MAPEM decoder.** Nothing on air transmits it; it exists only in recorded drive data.
- **Secured messages rejected**, not mis-parsed.
- **No auto-reconnect** after USB re-enumeration; requires a manual Connect.
- **Station IDs rotate** (observed twice within one session), so they cannot identify a physical
unit over time.
+141
View File
@@ -0,0 +1,141 @@
# 06 – Signed ITS messages, VAM and the BLE link (2026-09-23)
What changed when the ESP32-C5 OBU moved onto the colleague's vanetza-idf station, how the pieces
fit together, how it was verified, and what is still open. Hardware checks still to do are in
`TODO.md` ("Signed-TX firmware ...").
## Summary
- **Signing lives on the ESP32-C5.** The authorization ticket's private key is in the board's NVS;
vanetza-idf's security entity signs every secured message there (IEEE 1609.2 / ETSI TS 103 097,
ECDSA NIST P-256). The phone never holds a key and never signs.
- **The phone decides what to send and when.** It builds CAM or VAM (UPER) from its own GNSS/IMU,
hands each message to the board with the flag "signed" or "unsigned", and keeps the board's clock
and position current.
- **Two links, one protocol.** USB-C (native USB Serial/JTAG) or Bluetooth LE, chosen in Settings.
Both carry the colleague's station-link protocol v1 plus one MicrOBU extension for reception.
- **Reception is unchanged for the app.** Every ITS message heard on air reaches the phone, signed
or not, verifiable or not, exactly as with the previous firmware.
- **Demo PKI, not the EU trust list.** Signed messages carry a throwaway chain. Receivers that
verify against the EU trust list drop them; unsigned sending remains available.
## Who does what
| | Phone (app) | ESP32-C5 (obu-firmware) |
|---|---|---|
| CAM / VAM content and UPER encoding | yes | – |
| Send cadence (CAM 1 Hz baseline; VAM per TS 103 300-3 clause 6.4) | yes | – |
| Pseudonym (station ID + MAC, rotated together) | yes | uses the MAC it is configured with |
| Time and position (PoTi) | yes, per new GNSS fix | keeps an ITS clock from it |
| GeoNetworking + BTP headers | – | yes |
| Signing (TS 103 097), certificate handling | – | yes |
| Credentials | ships the demo bundle, provisions it once | stores it in NVS |
| 802.11p radio at 5 900 MHz | – | yes |
| Reception: unwrap GN/BTP, forward | decodes CAM / DENM / SPATEM | yes (all frames) |
## Firmware (obu-firmware)
obu-firmware is now a port of `microbu-esp32c5/firmware`, from the colleague's own repository
(not part of this one; nothing is pushed there). The C-ITS library it needs is copied into this
repository as `obu-firmware/external/vanetza-idf` (their commit cf4b99f, unchanged), so
obu-firmware builds from a plain clone. It builds
with **ESP-IDF 6.0.2 only**: the raw-TX path uses private Wi-Fi driver structures that vanetza-idf
pins to that version. The previous C firmware (IDF 6.1) is backed up as a full flash image in
`firmware-backups/` (gitignored, restore command in its README.txt); its sources stay on disk,
unbuilt. Setup and flashing: `obu-firmware/FLASHING.md`. Design notes and every deviation from the
colleague's code (`MicrOBU:` in the sources): `obu-firmware/NOTES.md`.
Main changes against the colleague's firmware:
- **Raw receive path kept.** vanetza-idf decapsulates strictly and would drop unsigned frames (the
bench car) and anything not signed under the demo root (every RSU). Every captured frame also
goes through the previous firmware's `gn_unwrap.c` and reaches the phone as link opcode
`V2X_RX` (0x85), whose body is the old `SERIAL_MSG_V2X_RX` payload.
- **Unsigned sending kept.** The colleague's station refuses unsecured requests; here they go out
with the previous firmware's `geonet.c` header.
- **Console on UART0** (CH343, COM3 on the bench); the native USB port carries only link frames.
- **BLE pauses advertising while USB is in use** (BLE and ITS-G5 share one RF front end).
- **NVS 80 KB instead of 24 KB**, app at 0x20000. At 24 KB the BLE bond could not be stored and the
phone had to pair on every connection.
- Fixes found on the bench: radio queue drained before the first PoTi (no RX, ~177 queue drops
before); station loop waited 0 ticks at 100 Hz and starved the idle task; 2.4 KB RX buffer moved
off the Wi-Fi task stack; no silent truncation of BLE notifications; ATT MTU 517; serial writes
skipped when no USB host is present.
## Link protocol
Station-link v1 (colleague's repository, `station-link/README.md`): `[opcode][flags][sequence LE][body]`,
little-endian, at most 512 octets. Over USB each message is one `0xAA55` frame of type `0x10`
(the old framing and CRC). Over BLE each message is one GATT value on service
`0000C175-BA5E-4C17-8000-00805F9B34FB` (the README describes a different, Nordic-UART layout; the
firmware is what counts).
| Direction | Message | Used for |
|---|---|---|
| phone → board | `STATION_CONFIGURE` | pseudonym MAC, station type, channel 180, 20 dBm; starts the radio |
| phone → board | `CREDENTIALS_PROVISION` | the demo bundle, once, when the board reports no ticket |
| phone → board | `POTI_UPDATE` | position and ITS time, once per new GNSS fix |
| phone → board | `BTP_DATA_REQUEST` | one CAM (port 2001, psid 36) or VAM (port 2018, psid 638), signed or not |
| board → phone | `RESULT` | answer to a request |
| board → phone | `STATUS` | every second: counters, tickets, signed/refused counts |
| board → phone | `V2X_RX` (0x85, MicrOBU) | every ITS message heard on air |
The app side is `Esp32Link.kt` (session), `StationLink.kt` (codec, pinned by unit tests to bytes
from the colleague's Python implementation), `UsbSerialTransport.kt` and `BleLinkTransport.kt`.
A board still on the previous firmware is recognised by its old heartbeat and keeps working for
CAM over USB.
## Redundancy and recovery
| Situation | What notices | What happens |
|---|---|---|
| USB link dead (board hung, cable) | app watchdog: no frame for 3.5 s (the board's `STATUS` comes every second) | link marked ERROR on the card |
| USB unplugged | Android detach broadcast | port closed; Connect again after re-plugging |
| BLE link lost | BLE supervision timeout (4 s) | app reconnects by itself: 1 s after a drop, then backing off to 30 s if attempts fail |
| Board reset / power cycle | first `STATUS` says "not configured" | app reconfigures (and re-provisions if needed) without user action |
| App closed and reopened | new session | app configures the board again; BLE reconnects with the stored bond, no passkey (confirmed) |
| Phone clock or GNSS time jumping | app tracks the board's clock | PoTi never moves it backwards (except a real correction of ≥ 60 s), so the board does not restart its stack |
| Board firmware wedged | ESP task watchdog (30 s, logs on COM3) | the phone sees it as a dead link (above) |
## App changes
- Settings > Connection > ESP32-C5: **link** USB-C / Bluetooth, **transmit** CAM / VAM, **sign
outgoing messages** (on by default). The connection card, top bar and dashboard show the link in
use, the pairing passkey when needed, and signing counters.
- VAM encoder (`VamUperCodec.kt`, TS 103 300-3 V2.3.1, checked against asn1tools) and the VAM
generation rules (`VamGenerationRules.kt`).
- `GnssTimeSource` keeps the last measured phone-clock error while GNSS time drops out indoors. The
bench phone's clock was 14 minutes fast; falling back to it made every transmitted timestamp
jump by 14 minutes.
- Bluetooth permissions (Android 12+) requested at start-up.
## Credentials (demo PKI)
`app/src/main/assets/demo-chain.vcr`, generated 2026-09-23 with the colleague's `vidf_issue`: root
`6E7D0374FB021901` → AA `B3312F29844299E0` → AT `B80B49387A4C12EB` (two years; psid 36 SSP `010000`,
psid 638 SSP `01`). It is throwaway and not EU-registered; its private key ships with the app on
purpose. The colleague's own demo chain only grants psid 638 and cannot sign CAMs.
## Verification
- **Unit tests** (103): VAM bytes against asn1tools, station-link messages against the colleague's
Python encoder, VAM generation rules.
- **Signatures on air**: `obu-firmware/test/verify_signed_pcap.py` checks a pcap with asn1tools
and OpenSSL, sharing no code with the firmware. Pinger capture of 2026-09-23: 12/12 signed CAMs,
psid 36, signer the demo AT, all signatures valid, chain valid.
- **Third-party stack**: the CiT One receives the signed CAMs (~1 Hz on `v2x/rx/cam`), so its
stack unwraps our envelope. Its MQTT interface exposes no security information, and it forwards
unsigned and unknown-root messages alike, so it cannot tell whether it verified the signature.
- **V2X2MAP (COM10)**: now the colleague's v2x2map 0.3.0 bridge from source with a new
`verify.py` and `--trust demo-chain.vcr`; it shows "signature verified", "SIGNATURE INVALID" or
"not verified" per packet. Signed CAMs and signed VAMs verified live; a one-bit change in a
signed CAM comes out invalid. Launcher: `micrOBU_workspace/v2x-obu-esp32c5/start-v2x2map-signed.bat`.
## Open
- BLE/ITS-G5 coexistence is not measured: does an active BLE connection cost 5.9 GHz reception?
- `time_regression` standing still indoors for several minutes, and board reset recovery over BLE,
after the last fixes.
- The signature's generationTime follows the app's UTC-based `ItsTime`; the colleague's VBS adds
the 5 leap seconds (TAI). Which is right is the open question in `ItsTime.kt`.
- Real EU PKI enrolment/authorisation (TS 102 941) instead of the demo chain.
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 660 KiB

+508
View File
@@ -0,0 +1,508 @@
<mxfile host="Electron" agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) draw.io/22.1.2 Chrome/114.0.5735.289 Electron/25.9.4 Safari/537.36" modified="2026-09-10T12:53:19.876Z" etag="qSD0sUq7Dcw_rxL1Y7Bl" version="22.1.2" type="device">
<diagram id="90a13364-a465-7bf4-72fc-28e22215d7a0" name="Seite-1">
<mxGraphModel dx="1678" dy="1125" grid="1" gridSize="10" guides="1" tooltips="1" connect="0" arrows="1" fold="1" page="1" pageScale="1.5" pageWidth="1169" pageHeight="826" background="none" math="0" shadow="0">
<root>
<mxCell id="0" style=";html=1;" />
<mxCell id="1" style=";html=1;" parent="0" />
<mxCell id="ynnYWYYo9pkcd0MtkCud-92" value="" style="rounded=0;whiteSpace=wrap;html=1;dashed=1;" parent="1" vertex="1">
<mxGeometry x="950" y="960" width="70" height="80" as="geometry" />
</mxCell>
<mxCell id="3a17f1ce550125da-2" value="Mobile Phone" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
<mxGeometry x="290" y="280" width="650" height="780" as="geometry" />
</mxCell>
<mxCell id="3a17f1ce550125da-10" value="ESP32-C5" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
<mxGeometry x="1040" y="540" width="580" height="520" as="geometry" />
</mxCell>
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-4" value="&lt;font style=&quot;&quot;&gt;ESPAR&lt;br&gt;Antenna&lt;/font&gt;" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;" parent="1" vertex="1">
<mxGeometry x="1150" y="320" width="110" height="90" as="geometry" />
</mxCell>
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-11" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.5;entryY=0;entryDx=0;entryDy=0;exitX=0.499;exitY=0.954;exitDx=0;exitDy=0;exitPerimeter=0;" parent="1" source="Iy5nDPde1Y9wQlhL-YeU-10" target="3a17f1ce550125da-2" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-30" value="static map data" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontColor=#B3B3B3;" parent="Iy5nDPde1Y9wQlhL-YeU-11" connectable="0" vertex="1">
<mxGeometry x="-0.2209" y="2" relative="1" as="geometry">
<mxPoint x="-8" y="3" as="offset" />
</mxGeometry>
</mxCell>
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-10" value="&lt;font style=&quot;font-size: 18px;&quot;&gt;GeoServer&lt;/font&gt;" style="ellipse;shape=cloud;whiteSpace=wrap;html=1;align=center;fontColor=#B3B3B3;" parent="1" vertex="1">
<mxGeometry x="540" y="130" width="150" height="100" as="geometry" />
</mxCell>
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-12" value="&lt;font style=&quot;&quot;&gt;PoTi&lt;/font&gt;" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
<mxGeometry x="780" y="670" width="150" height="110" as="geometry" />
</mxCell>
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-13" value="&lt;font style=&quot;&quot;&gt;GNSS&lt;/font&gt;" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
<mxGeometry x="793" y="700" width="60" height="30" as="geometry" />
</mxCell>
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-14" value="&lt;font style=&quot;&quot;&gt;IMU&lt;/font&gt;" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
<mxGeometry x="863" y="700" width="60" height="30" as="geometry" />
</mxCell>
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-15" value="&lt;font style=&quot;&quot;&gt;Time Source&lt;/font&gt;" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
<mxGeometry x="793" y="740" width="132" height="30" as="geometry" />
</mxCell>
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-16" value="&lt;font style=&quot;&quot;&gt;HMI Support&lt;/font&gt;" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
<mxGeometry x="440" y="670" width="171" height="110" as="geometry" />
</mxCell>
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-17" value="&lt;font style=&quot;&quot;&gt;Display&lt;/font&gt;" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
<mxGeometry x="450" y="700" width="70" height="30" as="geometry" />
</mxCell>
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-18" value="Haptic" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
<mxGeometry x="530" y="700" width="70" height="30" as="geometry" />
</mxCell>
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-19" value="&lt;font style=&quot;&quot;&gt;Speaker&lt;/font&gt;" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
<mxGeometry x="489.5" y="740" width="78" height="30" as="geometry" />
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-84" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="Iy5nDPde1Y9wQlhL-YeU-21" target="Iy5nDPde1Y9wQlhL-YeU-33" edge="1">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="700" y="770" />
<mxPoint x="650" y="770" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-85" value="encoded&lt;br&gt;VAM" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-84" vertex="1" connectable="0">
<mxGeometry x="0.01" y="1" relative="1" as="geometry">
<mxPoint y="-12" as="offset" />
</mxGeometry>
</mxCell>
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-21" value="&lt;font style=&quot;&quot;&gt;VBS&lt;/font&gt;" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
<mxGeometry x="440" y="500" width="410" height="160" as="geometry" />
</mxCell>
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-22" value="&lt;font style=&quot;&quot;&gt;Encode&lt;/font&gt;&lt;div&gt;&lt;font style=&quot;&quot;&gt;VAM&lt;/font&gt;&lt;/div&gt;" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
<mxGeometry x="651.75" y="600" width="68.5" height="52" as="geometry" />
</mxCell>
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-23" value="&lt;font style=&quot;&quot;&gt;Transmission&lt;/font&gt;&lt;div&gt;&lt;font style=&quot;&quot;&gt;Management&lt;/font&gt;&lt;/div&gt;" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
<mxGeometry x="730" y="600" width="111.5" height="52" as="geometry" />
</mxCell>
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-24" value="GeoServer to&lt;br&gt;area-risk mapping" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
<mxGeometry x="527" y="370" width="149" height="60" as="geometry" />
</mxCell>
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-29" value="&lt;font style=&quot;font-size: 20px;&quot;&gt;VRU-Tx ITS-S Architecture of micrOBU&lt;/font&gt;" style="text;html=1;whiteSpace=wrap;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;rounded=0;labelBorderColor=default;" parent="1" vertex="1">
<mxGeometry x="850" y="154" width="223" height="52" as="geometry" />
</mxCell>
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-31" value="VRU Basic Service Management" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
<mxGeometry x="567.5" y="530" width="203" height="52" as="geometry" />
</mxCell>
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-32" value="&lt;font style=&quot;&quot;&gt;GeoNetworking&lt;/font&gt;" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
<mxGeometry x="685" y="947" width="150" height="30" as="geometry" />
</mxCell>
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-33" value="&lt;font style=&quot;&quot;&gt;BTP-B&lt;/font&gt;" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
<mxGeometry x="606.5" y="807" width="77" height="30" as="geometry" />
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-53" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.75;entryY=1;entryDx=0;entryDy=0;endArrow=classic;endFill=1;startArrow=classic;startFill=1;" parent="1" source="Iy5nDPde1Y9wQlhL-YeU-34" target="Iy5nDPde1Y9wQlhL-YeU-4" edge="1">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="1270" y="480" />
<mxPoint x="1232" y="480" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-147" value="RF Signal" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-53" vertex="1" connectable="0">
<mxGeometry x="0.4593" y="1" relative="1" as="geometry">
<mxPoint x="9" y="27" as="offset" />
</mxGeometry>
</mxCell>
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-34" value="&lt;font style=&quot;&quot;&gt;ITS-G5 radio&lt;br&gt;&lt;/font&gt;" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
<mxGeometry x="1230" y="568" width="140" height="32" as="geometry" />
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-38" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.25;entryY=1;entryDx=0;entryDy=0;" parent="1" source="Iy5nDPde1Y9wQlhL-YeU-39" target="Iy5nDPde1Y9wQlhL-YeU-4" edge="1">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="1100" y="480" />
<mxPoint x="1178" y="480" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-41" value="Steering Signal" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-38" vertex="1" connectable="0">
<mxGeometry x="0.2737" y="-1" relative="1" as="geometry">
<mxPoint x="39" y="8" as="offset" />
</mxGeometry>
</mxCell>
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-39" value="&lt;font style=&quot;&quot;&gt;Antenna&lt;br&gt;Steering&lt;br&gt;&lt;/font&gt;" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
<mxGeometry x="1060" y="746" width="80" height="50" as="geometry" />
</mxCell>
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-40" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;innerLoopWaypoints=1;exitX=0.5;exitY=1;exitDx=0;exitDy=0;" parent="1" source="3a17f1ce550125da-10" target="3a17f1ce550125da-10" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-1" value="" style="endArrow=none;html=1;rounded=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0;" parent="1" edge="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="430" y="790" as="sourcePoint" />
<mxPoint x="940" y="790" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-3" value="&lt;font style=&quot;&quot;&gt;BLE&lt;/font&gt;" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
<mxGeometry x="520" y="900" width="330" height="150" as="geometry" />
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-7" value="&lt;font style=&quot;&quot;&gt;ATT/ GATT&lt;/font&gt;" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
<mxGeometry x="685" y="927" width="150" height="30" as="geometry" />
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-8" value="&lt;font style=&quot;&quot;&gt;L2CAP&lt;/font&gt;" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
<mxGeometry x="685" y="968" width="150" height="30" as="geometry" />
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-9" value="&lt;font style=&quot;&quot;&gt;BLE Link Layer&lt;/font&gt;" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
<mxGeometry x="685" y="1010" width="150" height="30" as="geometry" />
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-10" value="&lt;strong data-end=&quot;7298&quot; data-start=&quot;7253&quot;&gt;≤512 B GATT attribute/application message&lt;/strong&gt;&lt;br data-end=&quot;7301&quot; data-start=&quot;7298&quot;&gt;&lt;br/&gt;&lt;em data-end=&quot;7372&quot; data-start=&quot;7303&quot;&gt;L2CAP / Link Layer segmentation and reassembly handled by BLE stack&lt;/em&gt;" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" parent="1" vertex="1">
<mxGeometry x="530" y="930" width="140" height="110" as="geometry" />
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-34" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.5;entryY=1;entryDx=0;entryDy=0;" parent="1" source="ynnYWYYo9pkcd0MtkCud-15" target="ynnYWYYo9pkcd0MtkCud-32" edge="1">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="1215" y="890" />
<mxPoint x="1215" y="890" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-151" value="TX Message" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-34" vertex="1" connectable="0">
<mxGeometry x="-0.2929" y="-2" relative="1" as="geometry">
<mxPoint as="offset" />
</mxGeometry>
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-116" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;fontColor=#B3B3B3;strokeColor=#B3B3B3;" parent="1" source="ynnYWYYo9pkcd0MtkCud-15" target="ynnYWYYo9pkcd0MtkCud-3" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-118" value="BLE packet" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontColor=#B3B3B3;" parent="ynnYWYYo9pkcd0MtkCud-116" vertex="1" connectable="0">
<mxGeometry x="0.0818" y="1" relative="1" as="geometry">
<mxPoint x="6" y="1" as="offset" />
</mxGeometry>
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-15" value="&lt;font style=&quot;&quot;&gt;BLE&lt;/font&gt;" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
<mxGeometry x="1130" y="900" width="180" height="150" as="geometry" />
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-16" value="&lt;font style=&quot;&quot;&gt;ATT/ GATT&lt;/font&gt;" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
<mxGeometry x="1145" y="930" width="150" height="30" as="geometry" />
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-17" value="&lt;font style=&quot;&quot;&gt;L2CAP&lt;/font&gt;" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
<mxGeometry x="1145" y="970" width="150" height="30" as="geometry" />
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-18" value="&lt;font style=&quot;&quot;&gt;BLE Link Layer&lt;/font&gt;" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
<mxGeometry x="1145" y="1010" width="150" height="30" as="geometry" />
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-22" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" parent="1" source="ynnYWYYo9pkcd0MtkCud-9" target="ynnYWYYo9pkcd0MtkCud-18" edge="1">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="1127" y="1025" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-42" value="segmented&lt;br&gt;BLE packets" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-22" vertex="1" connectable="0">
<mxGeometry x="-0.5098" y="-1" relative="1" as="geometry">
<mxPoint x="74" y="-1" as="offset" />
</mxGeometry>
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-30" value="&lt;font style=&quot;&quot;&gt;GeoNetworking SHB&lt;/font&gt;" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
<mxGeometry x="531" y="846" width="177" height="30" as="geometry" />
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-90" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;endArrow=none;endFill=0;" parent="1" source="ynnYWYYo9pkcd0MtkCud-31" target="ynnYWYYo9pkcd0MtkCud-30" edge="1">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="710" y="861" />
<mxPoint x="710" y="861" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-31" value="&lt;font style=&quot;&quot;&gt;synced transmission dir.&lt;/font&gt;" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
<mxGeometry x="720.25" y="846" width="210" height="30" as="geometry" />
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-144" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="ynnYWYYo9pkcd0MtkCud-32" target="ynnYWYYo9pkcd0MtkCud-141" edge="1">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="1185" y="830" />
<mxPoint x="1185" y="830" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-150" value="Transmission&lt;br&gt;Power" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-144" vertex="1" connectable="0">
<mxGeometry x="-0.3161" relative="1" as="geometry">
<mxPoint y="-3" as="offset" />
</mxGeometry>
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-145" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="ynnYWYYo9pkcd0MtkCud-32" target="Iy5nDPde1Y9wQlhL-YeU-39" edge="1">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="1100" y="830" />
<mxPoint x="1100" y="830" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-149" value="Transmission&lt;br&gt;Direction" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-145" vertex="1" connectable="0">
<mxGeometry x="-0.3251" y="1" relative="1" as="geometry">
<mxPoint x="1" y="-3" as="offset" />
</mxGeometry>
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-32" value="&lt;font style=&quot;&quot;&gt;Message Splitter&lt;br&gt;&lt;/font&gt;" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
<mxGeometry x="1060" y="837" width="310" height="30" as="geometry" />
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-48" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.5;entryY=1;entryDx=0;entryDy=0;" parent="1" source="ynnYWYYo9pkcd0MtkCud-44" target="ynnYWYYo9pkcd0MtkCud-45" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-129" value="MPDU" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-48" vertex="1" connectable="0">
<mxGeometry x="-0.4731" y="2" relative="1" as="geometry">
<mxPoint x="2" y="-4" as="offset" />
</mxGeometry>
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-44" value="&lt;font style=&quot;&quot;&gt;MAC&lt;br&gt;&lt;/font&gt;" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
<mxGeometry x="1229" y="698" width="140" height="30" as="geometry" />
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-49" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;" parent="1" source="ynnYWYYo9pkcd0MtkCud-45" target="Iy5nDPde1Y9wQlhL-YeU-34" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-130" value="PPDU" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-49" vertex="1" connectable="0">
<mxGeometry x="-0.5724" y="-1" relative="1" as="geometry">
<mxPoint x="-1" y="-7" as="offset" />
</mxGeometry>
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-127" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#B3B3B3;" parent="1" source="ynnYWYYo9pkcd0MtkCud-45" target="ynnYWYYo9pkcd0MtkCud-126" edge="1">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="1439" y="640" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-45" value="&lt;font style=&quot;&quot;&gt;PHY&lt;br&gt;&lt;/font&gt;" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
<mxGeometry x="1229" y="630" width="140" height="30" as="geometry" />
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-50" value="" style="verticalLabelPosition=bottom;shadow=0;dashed=0;align=center;html=1;verticalAlign=top;shape=mxgraph.electrical.radio.aerial_-_antenna_1;" parent="1" vertex="1">
<mxGeometry x="1187" y="280" width="30" height="40" as="geometry" />
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-56" value="802.11p @ 5.9GHz" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="1" vertex="1" connectable="0">
<mxGeometry x="1250.0006451612903" y="310" as="geometry" />
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-57" value="" style="endArrow=none;html=1;rounded=0;entryX=1;entryY=0.75;entryDx=0;entryDy=0;" parent="1" source="3a17f1ce550125da-2" edge="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="590" y="790" as="sourcePoint" />
<mxPoint x="940" y="790" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-68" value="" style="endArrow=none;html=1;rounded=0;exitX=0;exitY=0.5;exitDx=0;exitDy=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0;" parent="1" edge="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="431" y="490" as="sourcePoint" />
<mxPoint x="941" y="490" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-70" value="if rx enabled" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
<mxGeometry x="720" y="320" width="205" height="160" as="geometry" />
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-71" value="GLOSA" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
<mxGeometry x="730" y="355" width="180" height="30" as="geometry" />
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-72" value="Collision Warning" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
<mxGeometry x="730" y="396" width="180" height="30" as="geometry" />
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-73" value="Recorder/ Logger" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
<mxGeometry x="730" y="435" width="180" height="30" as="geometry" />
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-79" value="Decode&lt;br&gt;VAM" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
<mxGeometry x="451.5" y="600" width="68.5" height="52" as="geometry" />
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-80" value="Reception&lt;br&gt;Management" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
<mxGeometry x="531" y="600" width="111.5" height="52" as="geometry" />
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-82" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.71;entryY=1.005;entryDx=0;entryDy=0;entryPerimeter=0;" parent="1" source="Iy5nDPde1Y9wQlhL-YeU-12" target="Iy5nDPde1Y9wQlhL-YeU-21" edge="1">
<mxGeometry relative="1" as="geometry">
<mxPoint x="850" y="610" as="targetPoint" />
<Array as="points">
<mxPoint x="731" y="725" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-83" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.559;entryY=1.01;entryDx=0;entryDy=0;entryPerimeter=0;" parent="1" source="Iy5nDPde1Y9wQlhL-YeU-16" target="Iy5nDPde1Y9wQlhL-YeU-21" edge="1">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="670" y="725" />
<mxPoint x="670" y="680" />
<mxPoint x="669" y="680" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-86" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="Iy5nDPde1Y9wQlhL-YeU-33" target="ynnYWYYo9pkcd0MtkCud-30" edge="1">
<mxGeometry relative="1" as="geometry">
<mxPoint x="596" y="840" as="targetPoint" />
<Array as="points">
<mxPoint x="570" y="822" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-93" value="encrypted" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;" parent="1" vertex="1">
<mxGeometry x="946" y="935" width="80" height="30" as="geometry" />
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-95" value="&lt;font style=&quot;&quot;&gt;Platform&lt;br&gt;Security&lt;br&gt;&lt;/font&gt;" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
<mxGeometry x="1500" y="580" width="110" height="470" as="geometry" />
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-99" value="&lt;font style=&quot;&quot;&gt;Secure&lt;br&gt;Boot&lt;br&gt;&lt;/font&gt;" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
<mxGeometry x="1510" y="837" width="90" height="48" as="geometry" />
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-102" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;fontColor=#B3B3B3;strokeColor=#B3B3B3;" parent="1" source="ynnYWYYo9pkcd0MtkCud-100" target="ynnYWYYo9pkcd0MtkCud-15" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-153" value="BLE Credentials" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontColor=#B3B3B3;" parent="ynnYWYYo9pkcd0MtkCud-102" vertex="1" connectable="0">
<mxGeometry x="0.5722" y="1" relative="1" as="geometry">
<mxPoint x="52" y="9" as="offset" />
</mxGeometry>
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-100" value="&lt;font style=&quot;&quot;&gt;BLE&lt;br&gt;credentials&lt;br&gt;config&lt;br&gt;&lt;/font&gt;" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
<mxGeometry x="1510" y="965" width="90" height="73" as="geometry" />
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-101" value="&lt;font style=&quot;&quot;&gt;Debug&lt;br&gt;lockdown&lt;br&gt;&lt;/font&gt;" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
<mxGeometry x="1510" y="902" width="90" height="48" as="geometry" />
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-106" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="ynnYWYYo9pkcd0MtkCud-103" target="Iy5nDPde1Y9wQlhL-YeU-21" edge="1">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="430" y="580" />
<mxPoint x="430" y="580" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-103" value="&lt;font style=&quot;&quot;&gt;Platform&lt;br&gt;Security&lt;br&gt;&lt;/font&gt;" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
<mxGeometry x="300" y="300" width="120" height="740" as="geometry" />
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-105" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="ynnYWYYo9pkcd0MtkCud-104" target="ynnYWYYo9pkcd0MtkCud-3" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-104" value="&lt;font style=&quot;&quot;&gt;BLE&lt;br&gt;credentials&lt;br&gt;&lt;/font&gt;" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
<mxGeometry x="310" y="982" width="100" height="48" as="geometry" />
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-108" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=1.008;entryY=0.559;entryDx=0;entryDy=0;entryPerimeter=0;" parent="1" source="Iy5nDPde1Y9wQlhL-YeU-12" target="Iy5nDPde1Y9wQlhL-YeU-33" edge="1">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="850" y="824" />
<mxPoint x="684" y="824" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-109" value="PCI" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-108" vertex="1" connectable="0">
<mxGeometry x="-0.0782" y="2" relative="1" as="geometry">
<mxPoint as="offset" />
</mxGeometry>
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-110" value="&lt;font style=&quot;&quot;&gt;LLC/SNAP&lt;br&gt;&lt;/font&gt;" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
<mxGeometry x="1230" y="760" width="140" height="30" as="geometry" />
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-111" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="ynnYWYYo9pkcd0MtkCud-32" target="ynnYWYYo9pkcd0MtkCud-110" edge="1">
<mxGeometry relative="1" as="geometry">
<mxPoint x="1300" y="810" as="targetPoint" />
<Array as="points">
<mxPoint x="1300" y="830" />
<mxPoint x="1300" y="830" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-112" value="GNPDU" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-111" vertex="1" connectable="0">
<mxGeometry x="-0.1526" relative="1" as="geometry">
<mxPoint as="offset" />
</mxGeometry>
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-113" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.502;entryY=1;entryDx=0;entryDy=0;entryPerimeter=0;" parent="1" source="ynnYWYYo9pkcd0MtkCud-110" target="ynnYWYYo9pkcd0MtkCud-44" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-114" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.297;entryY=-0.004;entryDx=0;entryDy=0;entryPerimeter=0;" parent="1" source="ynnYWYYo9pkcd0MtkCud-30" target="ynnYWYYo9pkcd0MtkCud-3" edge="1">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="618" y="890" />
<mxPoint x="618" y="890" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-115" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="ynnYWYYo9pkcd0MtkCud-31" target="ynnYWYYo9pkcd0MtkCud-3" edge="1">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="810" y="896" />
<mxPoint x="810" y="896" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-119" value="&lt;font style=&quot;&quot;&gt;Certificate&lt;br&gt;&lt;/font&gt;" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
<mxGeometry x="310" y="720" width="100" height="33" as="geometry" />
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-122" value="&lt;font style=&quot;&quot;&gt;Private Key Handling&lt;br&gt;&lt;/font&gt;" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
<mxGeometry x="310" y="920" width="100" height="50" as="geometry" />
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-123" value="&lt;font style=&quot;&quot;&gt;Signer Selection&lt;br&gt;&lt;/font&gt;" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
<mxGeometry x="310" y="770" width="100" height="50" as="geometry" />
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-125" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="ynnYWYYo9pkcd0MtkCud-124" target="ynnYWYYo9pkcd0MtkCud-30" edge="1">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="470" y="865" />
<mxPoint x="470" y="865" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-124" value="&lt;font style=&quot;&quot;&gt;Secured&lt;br&gt;Message&lt;br&gt;Creation&lt;br&gt;&lt;/font&gt;" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
<mxGeometry x="310" y="837" width="100" height="70" as="geometry" />
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-126" value="&lt;font style=&quot;&quot;&gt;DCC-ACC Calculation&lt;br&gt;&lt;/font&gt;" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
<mxGeometry x="1390" y="655" width="97" height="55" as="geometry" />
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-128" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;jumpStyle=arc;fontColor=#B3B3B3;strokeColor=#B3B3B3;" parent="1" source="ynnYWYYo9pkcd0MtkCud-126" target="ynnYWYYo9pkcd0MtkCud-15" edge="1">
<mxGeometry relative="1" as="geometry">
<mxPoint x="1129" y="930" as="targetPoint" />
<Array as="points">
<mxPoint x="1440" y="930" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-152" value="DCC Feedback" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontColor=#B3B3B3;" parent="ynnYWYYo9pkcd0MtkCud-128" vertex="1" connectable="0">
<mxGeometry x="0.7303" relative="1" as="geometry">
<mxPoint x="13" as="offset" />
</mxGeometry>
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-131" value="" style="endArrow=none;html=1;rounded=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0;" parent="1" edge="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="430" y="793" as="sourcePoint" />
<mxPoint x="940" y="793" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-133" value="" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1">
<mxGeometry x="847" y="787" width="6" height="9" as="geometry" />
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-136" value="" style="endArrow=none;html=1;rounded=0;exitX=0;exitY=0.5;exitDx=0;exitDy=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0;" parent="1" edge="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="431" y="883.78" as="sourcePoint" />
<mxPoint x="941" y="883.78" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-137" value="" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1">
<mxGeometry x="647" y="787" width="6" height="9" as="geometry" />
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-140" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="ynnYWYYo9pkcd0MtkCud-139" target="Iy5nDPde1Y9wQlhL-YeU-21" edge="1">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="895" y="580" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-139" value="&lt;font style=&quot;&quot;&gt;DCC-&lt;br&gt;FAC&lt;br&gt;&lt;/font&gt;" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
<mxGeometry x="865" y="500" width="60" height="50" as="geometry" />
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-142" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="ynnYWYYo9pkcd0MtkCud-141" target="Iy5nDPde1Y9wQlhL-YeU-34" edge="1">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="1185" y="584" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-148" value="TX power &lt;br&gt;config" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-142" vertex="1" connectable="0">
<mxGeometry x="-0.4822" y="1" relative="1" as="geometry">
<mxPoint as="offset" />
</mxGeometry>
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-141" value="&lt;font style=&quot;&quot;&gt;Radio&lt;br&gt;Control&lt;br&gt;&lt;/font&gt;" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
<mxGeometry x="1150" y="746" width="70" height="50" as="geometry" />
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-155" value="" style="endArrow=none;html=1;rounded=0;exitX=0;exitY=0.5;exitDx=0;exitDy=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0;" parent="1" edge="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="431" y="887" as="sourcePoint" />
<mxPoint x="941" y="887" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-154" value="" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1">
<mxGeometry x="615" y="881" width="6" height="9" as="geometry" />
</mxCell>
<mxCell id="ynnYWYYo9pkcd0MtkCud-157" value="" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1">
<mxGeometry x="807" y="881" width="6" height="9" as="geometry" />
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>
Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

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