Keep the colleague's microbu-esp32c5 tree in this repository

obu-firmware builds against vanetza-idf from microbu-esp32c5/external, but
that tree was gitignored, so a clone of this repository could not build the
firmware it ships. It is now committed here as ordinary files in its own
folder, microbu-esp32c5/: the colleague's commit cf4b99f plus the V2X2MAP
bridge's signature verification (--trust) used on the bench. Nothing is
fetched from or pushed to the colleague's repository; this repository and
its remotes carry everything. The folder's own .gitignore keeps build output,
downloaded components and private key material out, as it did there; the
committed file set is identical to that repository's tracked files.

The ESP32-C5 is still flashed from obu-firmware/, which only takes
vanetza-idf from microbu-esp32c5/, so the two stay separate folders.
FLASHING.md says how to take a newer version of the colleague's tree (copy
it over the folder, rebuild, test, commit).
This commit is contained in:
Ashin Walpola
2026-09-23 17:46:40 +02:00
parent 2f60623e18
commit 0e9525162d
9881 changed files with 1582523 additions and 17 deletions
+461
View File
@@ -0,0 +1,461 @@
# VRU ITS-S station-internal link (phone ⇄ micrOBU)
Project interface **IF-PROJ-NF-BLE-001** made concrete, plus its DEC-VBS-004
companions. One VRU ITS-S (ETSI EN 302 665 clause 4.5.1) is split over two
physical units at the NF-SAP (DEC-VBS-003):
| Unit | Hosts |
|---|---|
| Phone (app) | VRU basic service, VAM encode/decode, HMI, PoTi, credential manager (TS 102 941 client) |
| micrOBU (ESP32-C5, this firmware) | BTP-B, GeoNetworking SHB/GBC, SN-SAP security entity (TS 103 097 signing, ticket pool, identifier change, SN-DECAP), ITS-G5 access |
Everything that crosses the link is a standards-defined primitive
(BTP-DATA.request/indication of TS 102 723-11 / TS 103 836-5-1 Annex A; the
PoTi data set of EN 302 890-2 Tables 2 and 3; the SF-SAP identifier-change
primitives of TS 102 723-9 Tables 10 to 21; the legacy/reference MF-SET parameter
semantics of TS 103 175 Table 11) or
one of three project-defined management messages (station configuration,
credential provisioning with a `VCR1` bundle, status). No SN-, MN- or MI-SAP
primitive crosses the link (ACT-015).
The control/reference transport is the micrOBU's wired native USB Serial/JTAG
port. Encrypted BLE GATT is an experimental alternative and shall not replace
the wired architecture until ACT-021's controlled coexistence experiment shows
that concurrent BLE does not disturb C5 PHY/CCA observation, ITS-G5 RF behavior,
timing, reliability, or power measurements. The message layer below is transport
independent. Nothing here is an ETSI on-air format or a conformance claim for
the phone-to-micrOBU link.
Implementations: firmware `firmware/main/link_protocol.*`,
`ble_framing.*` and `ble_link.*` (C++); phone-side protocol module
`microbuapp/station-link/` and Android central
`microbuapp/app/src/main/java/com/hawhamburg/micr0bu/data/transport/BleStationLinkTransport.kt`
(Kotlin); bench reference `station-link/python/microbu_link/`
(Python, with serial and BLE adapters used by the phone emulator, TTCN SUT
bridge and unit tests). Golden
vectors and fragmentation are checked across the Python, C++ and Kotlin
implementations.
## Message layer (version 1)
A reassembled message is at most **512 octets**. All integers
are **little-endian** (the app's existing serial framing convention);
ETSI-encoded payloads inside (UPER VAMs, COER certificates, GeoNetworking
octets) are opaque and keep their own byte order.
```
octet 0 opcode
octet 1 flags bit 0: FRAGMENT_FIRST, bit 1: FRAGMENT_MORE (see Fragmentation); other bits 0
octets 2-3 sequence u16, see below
octets 4- body opcode specific
```
Sequence: every request the phone sends carries its own counter; the micrOBU
answers with a `RESULT` (or the typed reply named below) carrying the **same
sequence**. Messages the micrOBU originates (indications, events, status) carry the
micrOBU's own counter and are never answered, except `SF_IDCHANGE_EVENT` which the
phone answers with `SF_IDCHANGE_EVENT_RESPONSE` echoing the event's sequence.
### Phone → micrOBU
| Opcode | Name | Reply | Standard basis |
|---|---|---|---|
| 0x01 | STATION_CONFIGURE | RESULT + station info | project management (MIB values the micrOBU cannot know) |
| 0x02 | POTI_UPDATE | none (RESULT only on error) | EN 302 890-2 Table 2 (NF-SAP) and Table 3 (SF-SAP) minimum data sets, plus speed/heading for the GN position vector |
| 0x03 | BTP_DATA_REQUEST | RESULT | TS 102 723-11 clause 5 / TS 103 836-5-1 Annex A.2; VBS PCI TS 103 300-3 Table 4 |
| 0x04 | CREDENTIALS_PROVISION | RESULT + apply report (last segment) | DEC-VBS-004: one `VCR1` bundle (vanetza_idf/credentials.hpp) |
| 0x05 | CREDENTIALS_ERASE | RESULT | project |
| 0x06 | SF_IDCHANGE_SUBSCRIBE | RESULT + subscription handle | TS 102 723-9 Tables 10/11 |
| 0x07 | SF_IDCHANGE_UNSUBSCRIBE | RESULT | Tables 14/15 |
| 0x08 | SF_IDCHANGE_EVENT_RESPONSE | none | Table 13 |
| 0x09 | SF_IDCHANGE_TRIGGER | RESULT | Tables 16/17 |
| 0x0A | SF_ID_LOCK | RESULT + lock handle | Tables 18/19 |
| 0x0B | SF_ID_UNLOCK | RESULT | Tables 20/21 |
| 0x0C | STATUS_REQUEST | STATUS | project |
### micrOBU → phone
| Opcode | Name | Standard basis |
|---|---|---|
| 0x80 | RESULT | project (the library's `Result` plus link codes) |
| 0x81 | BTP_DATA_INDICATION | TS 103 836-5-1 Annex A.3 with the GN-DATA.indication parameters it preserves |
| 0x82 | SF_IDCHANGE_EVENT | TS 102 723-9 Table 12 |
| 0x83 | MF_SET_REQUEST | TS 103 175 Table 11 (DCC F-Params of Table 13) |
| 0x84 | STATUS | project |
### Bodies
**RESULT (0x80)**: `u8 result`, `u8 detail_length`, `detail[]`. `result` is
`vanetza_idf::Result` (0 accepted, 1 invalid_argument, 2 unsupported, 3
wrong_entry_point, 4 security_unavailable, 5 resource_limit, 6 rejected, 7
time_regression, 8 identity_change_pending) or a link code: 0x10
unknown_opcode, 0x11 malformed, 0x12 not_configured (no STATION_CONFIGURE
yet), 0x13 busy, 0x14 no_credentials. `detail` depends on the request:
| Request | detail |
|---|---|
| STATION_CONFIGURE | `u8[8] gn_address` (as on the wire, TS 103 836-4-1 clause 6.3), `u8[8] identifier` (HashedId8 of the current authorization ticket, zeros when unsecured), `u8 credentials_loaded`, `u8 tickets` |
| CREDENTIALS_PROVISION (last segment) | `u8 roots`, `u8 authorities`, `u8 tickets` accepted (ApplyReport) |
| SF_IDCHANGE_SUBSCRIBE | `u64 subscription` |
| SF_ID_LOCK | `u64 lock_handle` |
| others | empty |
**STATION_CONFIGURE (0x01)**: `u8 station_type` (TS 102 894-2 StationType, 2 =
cyclist), `u8 security` (0 unsecured, 1 secured: itsGnSecurity), `u8
address_configuration` (0 AUTO with the given MID, 1 ANONYMOUS: MID from the
ticket digest, TS 103 836-4-1 clause 10.2.1.4), `u8[6] mid` (AUTO only),
`u8 beaconing` (0/1, GN-MGMT beacons), `u16 channel_number` (ITS-G5 channel,
180 = 5 900 MHz), `u8 transmit_power_dbm`, `u8 radio` (0 off, 1 receive only, 2
transmit and receive — laboratory transmission stays an explicit choice), `u8
default_traffic_class` (raw TC octet used when a request carries none), `u8
default_lifetime` (raw GN Lifetime octet). Configuring (re)creates the stack on the micrOBU;
a request before it is answered `not_configured`.
**POTI_UPDATE (0x02)**: `u64 timestamp` (TimestampIts, TAI ms since
2004-01-01; also synchronises the micrOBU's ITS clock), `i32 latitude`, `i32
longitude` (1/10 µdeg, WGS84), `u16 semi_major`, `u16 semi_minor` (cm, 95 %
horizontal confidence ellipse), `u16 orientation` (0.1°, semi-major axis from
north), `u8 flags` (bit 0 altitude present, bit 1 speed present, bit 2 heading
present, bit 3 position accuracy indicator), `i32 altitude` (cm), `u16 speed`
(0.01 m/s), `u16 heading` (0.1°). The micrOBU answers only when it rejects the
update (a `RESULT` with `time_regression` or `invalid_argument`); accepted
updates are silent so a 10 Hz PoTi does not double the link traffic.
**BTP_DATA_REQUEST (0x03)** (TS 103 300-3 Table 4 order):
```
u8 btp_type 0 BTP-A, 1 BTP-B
u16 destination_port
u16 destination_port_info (BTP-B) or source_port (BTP-A)
u8 gn_packet_transport_type 1 SHB, 2 GBC (3 TSB, 4 GAC, 5 GUC are refused: unsupported)
u8 gn_communication_profile 0 unspecified, 1 ITS-G5, 2 LTE-V2X
u8 gn_security_profile 0 not given (station configuration), 1 unsecured, 2 secured
u8 gn_traffic_class raw TC octet (TS 103 836-4-1 clause 9.7.5); 0xFF = station default
u8 gn_maximum_packet_lifetime raw Lifetime octet (clause 9.6.4); 0xFF = station default
u8 gn_maximum_hop_limit 0 = station default
u16 gn_repetition_interval_ms 0 = no repetition
u16 gn_repetition_maximum_ms
u32 its_aid ITS-AID (TS 102 965), the SN-ENCAP input
u8 permissions_length SSP octets (TS 103 097 / TS 102 965) …
u8 permissions[]
u8 context_length SN-ENCAP context_information (vam_cluster = 01) …
u8 context[]
if GBC: u8 shape (0 circle, 1 rectangle, 2 ellipse), i32 latitude, i32 longitude,
u16 distance_a, u16 distance_b (m), u16 angle (°)
u16 length of the FL-SDU (the VAM)
u8 fl_sdu[length]
```
The micrOBU hands this to `NF_SAP::BTP_DATA_request_submit`. `RESULT.result` is
the library's acceptance (accepted = submitted for protocol processing, not
proof of transmission). A secured request whose signing the entity refuses
(no valid ticket, identifier change pending) is visible in `STATUS` counters.
**BTP_DATA_INDICATION (0x81)**: `u8 btp_type`, `u16 destination_port`, `u16
destination_port_info` (or source_port), `u8 gn_packet_transport_type`, `u8
gn_traffic_class`, `u8 gn_remaining_packet_lifetime` (raw, 0xFF absent), `u8
gn_remaining_hop_limit` (0xFF absent), `u8[8] source_gn_address`, `u32
source_timestamp` (TST ms), `i32 source_latitude`, `i32 source_longitude`,
`u8 security_report` (0 unsecured, 1 + `VerificationReport` ordinal: 1
Success, 2 False_Signature, …), `u32 its_aid` (0 when absent), `u8
permissions_length`, `permissions[]`, `u8 certificate_present`, `u8[8]
certificate_id`, `u8 area_present`, [`u8 shape`, `i32 lat`, `i32 lon`, `u16
a`, `u16 b`, `u16 angle`], `u16 length`, `received_fl_sdu[]`.
**CREDENTIALS_PROVISION (0x04)**: `u16 total_length`, `u16 offset`, `u8
segment_length`, `segment[]`. The segments of one bundle arrive in order;
`offset + segment_length == total_length` ends it. The micrOBU decodes the bundle,
applies it through the security entity's checks (`security::apply`), saves it
in the NVS credential store when every item was accepted, and rebuilds the
station with it. Keys travel in the clear: the transport must be a trusted
one (a paired, encrypted BLE link; a USB cable on the bench).
**SF_IDCHANGE_SUBSCRIBE (0x06)**: `u8 subscriber_data_length`,
`subscriber_data[]`. **SF_IDCHANGE_UNSUBSCRIBE (0x07)**: `u64 subscription`.
**SF_IDCHANGE_EVENT (0x82)**: `u64 subscription`, `u8 command` (0 PREPARE, 1
COMMIT, 2 ABORT, 3 DEREG), `u8[8] id` (HashedId8 of the next ticket), `u8
subscriber_data_length`, `subscriber_data[]`. **SF_IDCHANGE_EVENT_RESPONSE
(0x08)**: `u64 subscription`, `u8 return_code` — sent for PREPARE and COMMIT
with the event's sequence; the service's response timeout (500 ms) applies
across the link. **SF_IDCHANGE_TRIGGER (0x09)**: empty. **SF_ID_LOCK (0x0A)**:
`u8 duration_seconds`. **SF_ID_UNLOCK (0x0B)**: `u64 lock_handle`.
**MF_SET_REQUEST (0x83)**: `u32 fac_id`, `u8 command_ref`, `u8 count`, then
`count` × (`u8 f_param_no`, `u32 value`) with TS 103 175 Table 13 numbers (0
CHANNEL_NUMBER, 1 AVAILABLE_RESOURCE). The micrOBU sends it only for measurements
its access layer actually made; it never invents a channel load (ACT-021).
**STATUS (0x84)**: `u32 uptime_ms`, `u8 configured`, `u8[8] gn_address`,
`u8[8] identifier`, `u8 change_pending`, `u8 tickets`, `u32 signed_messages`,
`u32 refused_no_ticket`, `u32 refused_change_pending`, `u32
refused_permission`, `u32 sign_failed`, `u32 verified`, `u32 rejected`
(SN-DECAP rejections of every kind), `u32 requests_accepted`, `u32
requests_refused`, `u32 indications`, `u32 radio_submitted`, `u32
radio_failed`, `u32 radio_received`, `u32 radio_dropped`, `u32
link_rx_frames`, `u32 link_crc_errors`, `u32 link_malformed`, `u32
poti_updates`, `u64 its_time` (current micrOBU ITS clock, ms; 0 before the first
POTI_UPDATE). The micrOBU sends it every second unsolicited and on STATUS_REQUEST.
### Fragmentation
A message longer than the transport's unit (ATT_MTU − 3 for a notification; a
GATT client's long write handles 512 B values itself) is sent as fragments,
each with the full 4-octet header and the same sequence: the first with
FRAGMENT_FIRST, every one but the last with FRAGMENT_MORE. The receiver
concatenates the bodies. The serial transport carries whole messages and never
sets these bits.
## Transports
### Serial (bench and conformance tests)
Native USB Serial/JTAG of the micrOBU's ESP32-C5 (VID 303A, PID 1001), the app's Phase 03
framing (`obu-firmware/main/serial_link.h`, `SerialFrame.kt`):
```
[0xAA][0x55][type:1][length:2 LE][payload][crc16:2 LE]
```
CRC-16/CCITT-FALSE (poly 0x1021, init 0xFFFF) over type + length + payload.
Frame types of this firmware:
| Type | Payload |
|---|---|
| 0x10 | one link message (above), ≤ 512 octets |
| 0x11 | test channel request (PC → micrOBU) / reply (micrOBU → PC), ≤ 1 536 octets, only in a test build |
| 0x7F | UTF-8 log line from the firmware (ESP_LOG output; the console stays free of binary frames) |
The maximum frame payload is 1 536 octets (a mirrored GNPDU can exceed 512).
Frames the phone does not know are ignored; text outside frames (ROM boot
messages) is skipped by the sync/CRC search.
### BLE GATT (production transport)
The transport uses the Nordic UART Service UUID layout so standard BLE tools
can inspect it. It is a byte transport for the project message layer, not the
Nordic UART wire protocol:
| GATT object | UUID | Properties | Direction |
|---|---|---|---|
| Primary service | `6e400001-b5a3-f393-e0a9-e50e24dcca9e` | primary | — |
| RX/request | `6e400002-b5a3-f393-e0a9-e50e24dcca9e` | write, write without response | phone → micrOBU |
| TX/notification | `6e400003-b5a3-f393-e0a9-e50e24dcca9e` | notify | micrOBU → phone |
The peripheral advertises the service and the name `micrOBU-XXXX`, where the
suffix is derived from its Bluetooth MAC. GATT access requires an authenticated,
encrypted connection. The ESP32 uses LE Secure Connections, bonding and a
fresh six-digit passkey printed to the USB log (DisplayOnly); NimBLE persists
the bond in NVS. Android presents the normal system pairing UI and retains the
bond. This protects the credential bundle in transit; it is not application-
layer encryption.
Android requests an ATT MTU of 517; the ESP32 preference is 512. The Android
writer uses `ATT_MTU - 3`, capped at 512, after negotiation (509 octets with
the current ESP32 configuration) and falls back to 20 octets.
The ESP32 notification path deliberately emits values of at most 20 octets so
it also works at the mandatory default MTU. Every value uses the fragmentation
flags above and repeats the link header; a complete message is limited to 512
octets. Writes are acknowledged and serialized. Notifications are enabled
before the Android adapter reports `READY`. The USB test channel is never
exposed over BLE.
## Test channel (test firmware only, USB only)
`CONFIG_MICROBU_TEST_CHANNEL` compiles a software lower tester and the
diagnostic hooks the ETSI campaigns need, behind frame type 0x11. It is not
part of the phone interface and never exists on BLE. Requests: `u8 command`,
reply `u8 result`, `u8 record_count`, records `[kind u8][length u16 LE][bytes]`:
| Command | Body | Effect |
|---|---|---|
| 0x01 MIRROR | `u8 mode` (0 off, 1 mirror, 2 divert) | every AL_DATA.request the stack makes is copied (mirror) or kept from the radio (divert) and queued as kind-1 records |
| 0x02 INJECT | `u8[6] source`, `u8[6] destination`, `gnpdu[]` | AL_DATA.indication from the lower tester into the stack |
| 0x03 GN_REQUEST | `u8 traffic_class`, `payload[]` | GN-DATA.request SHB with next header "Any" (the Security ATS GN-MGMT/GENMSG cases; N-SAP, not a phone primitive) |
| 0x04 DRAIN | – | returns the queued records: kind 1 AL_DATA.request (the GNPDU as submitted), kind 2 BTP-DATA.indication (`[type][dest port][port info][SDU]`), kind 3 GN-DATA.indication payload |
| 0x05 RESET | – | drops the queue and the mirror mode |
The queue is bounded (32 records, 8 KiB); an overflow is reported as
`resource_limit` on the next DRAIN and the records lost are counted.
## Phone emulator, SUT bridge and campaigns (PC side)
`python/microbu_link/` is the phone side of the message layer (codec, serial
transport, request/reply client, the small VBS of `vbs.py`), shared by:
- `python/phone_emulator.py` — the phone half of the VRU ITS-S on a PC:
configures the micrOBU, provisions a `VCR1` bundle, feeds PoTi (static or a
bicycle on a circle), assembles VAMs with asn1tools from the ETSI ASN.1 of the
submodule and hands them over with the PCI of TS 103 300-3 Table 4 under the
generation rules of clause 6.4, subscribes to the identifier-change events
(stop on PREPARE, new StationId after COMMIT), prints the indications, status
and log lines; `--divert --pcap` records what the micrOBU signs for an
independent verifier. Its default synthetic trajectory is a 20 m-radius circle
at 2 m/s centred at 53.5652470 N, 10.0082390 E over the Außenalster's open
water; `--lat`, `--lon`, `--radius` and `--speed` override it. Radio emission
remains opt-in through `--radio txrx`. The center is not itself a transmitted
position: generated positions lie on the circle perimeter. The firmware's
standalone BOOT-button VAM is a separate fixed payload and does not use these
phone-emulator coordinates.
- `python/sut_bridge.py` — the SUT process of the vanetza-idf ETSI adapters
(hex-line protocol of `hil_sut.hpp`) with the micrOBU behind the phone
interfaces: BTP-DATA.request, PoTi and credentials over the link, the software
lower tester over the USB test channel, CAM/DENM stimuli assembled on the PC.
`--listen host:port` serves the protocol over TCP; `python/sut_tcp_relay.py`
inside WSL is the SUT executable that forwards it (WSL interop drops piped
stdin of Windows executables).
```powershell
# Windows: the micrOBU on COM11, the campaign pool packed as a bundle
python sut_bridge.py --port COM11 --bundle pool.vcr --listen 0.0.0.0:7788
```
`LinkClient` is transport-independent. `SerialTransport` and `BleTransport`
implement the same `TransportAdapter.write(frame_type, payload)` / `close()`
boundary and feed the same receive callback; configure, PoTi, credential,
BTP-DATA, status and event code is therefore identical. Select only the adapter
at the command line:
```powershell
cd implementation\station-link\python
python -m pip install -r requirements.txt
# Safe first BLE run: hand VAMs to an unsecured station, with ITS-G5 RF off.
python phone_emulator.py --ble --unsecured --radio off --period 1000 --duration 10 --verbose `
--report ble-vam-session.json
# Select a particular advertisement when several micrOBUs are present.
python phone_emulator.py --ble micrOBU-1234 --unsecured --radio off --duration 10
```
For the signed run, replace `--unsecured` with `--bundle <path-to-chain.vcr>`
(or use credentials already retained in the micrOBU NVS). Change `--radio off`
to `--radio txrx` only for the intended laboratory RF test. The USB test
channel has no BLE equivalent, so `--mirror`, `--divert` and `--pcap` are
rejected with `--ble`; all station-link/VAM behavior above that adapter is the
same.
Instead of an offline `--bundle`, `--pki-url` makes the emulator obtain an
enrolment credential and authorization ticket from `tools/local_pki.py`, build
the same `VCR1` bundle, and provision it before starting the VBS. Supply
`--pki-root`, `--pki-ea`, `--pki-aa`, `--pki-canonical-key`, `--pki-its-id`,
`--pki-issue-tool`, `--pki-client-tool`, and `--pki-bundle-tool` together; use
`--pki-hours` to override the 24-hour AT validity. The message flow and retained
host-side proof are documented in `docs/idf/evidence/enrolment-authorization-01`
and the EA/AA section of `docs/idf/test-campaigns.md` in vanetza-idf.
`local_pki.py` is a separate localhost HTTP process, normally bound to
`127.0.0.1`; it is not imported into or hosted by the emulator. Consequently,
the lab authority can later be replaced by a hosted PKI without changing the
phone flow: set `--pki-url` to the hosted access point and keep the same S3/S4
HTTP interface.
## End-to-end hardware workflow: Online PKI, Transmission & Reception
This workflow runs the full online TS 102 941 enrolment/authorization and 5.9 GHz ITS-G5 over-the-air transmission between two ESP32-C5 boards:
- **COM4 (TX)**: micrOBU running firmware microbu_firmware.bin
- **COM5 (RX)**: independent receiver running 2x2map-0.3.0 bridge firmware
### 1. Initialize the lab PKI hierarchy (run once)
`powershell
$dir = "C:\microbu-pki-wt\pki-lab"
New-Item -ItemType Directory -Force -Path "$dir\chain", "$dir\issued"
& C:\Strawberry\c\bin\openssl.exe ecparam -name prime256v1 -genkey -noout -out "$dir\root.pem"
& C:\Strawberry\c\bin\openssl.exe ecparam -name prime256v1 -genkey -noout -out "$dir\canonical.pem"
$issue = "C:\bachelorthesis_micrOBU\implementation\external\vanetza-idf\build-host-sec\vidf_issue.exe"
& $issue root --key "$dir\root.pem" --name "lab root" --id ROOT --out "$dir\chain"
& $issue authority --issuer "$dir\chain\ROOT.oer" --issuer-key "$dir\root.pem" --name "lab EA" --id EA --out "$dir\chain"
& $issue authority --issuer "$dir\chain\ROOT.oer" --issuer-key "$dir\root.pem" --name "lab AA" --id AA --out "$dir\chain"
`
### 2. Start the localhost PKI server (Terminal 1)
`powershell
python C:\microbu-pki-wt\implementation\external\vanetza-idf\ports\esp_idf\tools\local_pki.py `
--issue-tool C:\bachelorthesis_micrOBU\implementation\external\vanetza-idf\build-host-sec\vidf_issue.exe `
--ea C:\microbu-pki-wt\pki-lab\chain\EA.oer `
--ea-key C:\microbu-pki-wt\pki-lab\chain\EA.vkey `
--ea-enc-key C:\microbu-pki-wt\pki-lab\chain\EA.ekey `
--canonical-key C:\microbu-pki-wt\pki-lab\canonical.pem `
--aa C:\microbu-pki-wt\pki-lab\chain\AA.oer `
--aa-key C:\microbu-pki-wt\pki-lab\chain\AA.vkey `
--aa-enc-key C:\microbu-pki-wt\pki-lab\chain\AA.ekey `
--dir C:\microbu-pki-wt\pki-lab\issued `
--port 8092
`
### 3. Start the independent v2x2map receiver (Terminal 2)
`powershell
python C:\microbu-pki-wt\tools\vendor\v2x2map-0.3.0\bridge\its_g5_bridge.py `
--port COM5 `
--node-id V2X2MAP:001122334455 `
--no-mqtt `
--open-browser `
--pcap-out C:\microbu-pki-wt\rx_v2x2map.pcap
`
- Automatically opens the live dashboard at http://127.0.0.1:8080.
- --pcap-out <file.pcap> continuously records all received 802.11 frames to disk.
- Alternatively, click **Record** and **Stop** in the web dashboard: a direct **Download PCAP** button appears to download the file, which is also saved under ools/vendor/v2x2map-0.3.0/bridge/recordings/.
### 4. Transmit signed VAMs via Phone Emulator (Terminal 3)
`powershell
python C:\microbu-pki-wt\implementation\station-link\python\phone_emulator.py `
--port COM4 `
--radio txrx `
--mirror `
--pcap C:\microbu-pki-wt\tx_vams.pcap `
--pki-url http://127.0.0.1:8092/ `
--pki-root C:\microbu-pki-wt\pki-lab\chain\ROOT.oer `
--pki-ea C:\microbu-pki-wt\pki-lab\chain\EA.oer `
--pki-aa C:\microbu-pki-wt\pki-lab\chain\AA.oer `
--pki-canonical-key C:\microbu-pki-wt\pki-lab\canonical.pem `
--pki-its-id vidf-vru-station `
--pki-issue-tool C:\bachelorthesis_micrOBU\implementation\external\vanetza-idf\build-host-sec\vidf_issue.exe `
--pki-client-tool C:\microbu-pki-wt\implementation\external\vanetza-idf\ports\esp_idf\tools\pki_client.py `
--pki-bundle-tool C:\microbu-pki-wt\implementation\external\vanetza-idf\ports\esp_idf\tools\credential_bundle.py `
--duration 30
`
- --mirror --pcap tx_vams.pcap ensures the transmitter saves an exact PCAP capture of all generated and signed GNPDUs.
- Omit --duration 30 to transmit continuously until Ctrl+C.
### Where recordings are saved
| Recording | Destination | Description |
|---|---|---|
| **Transmitter (TX) PCAP** | --pcap <filename> | Local 802.11 PCAP captured via the USB test channel (--mirror copies all frames while the radio transmits). |
| **Receiver (RX) PCAP (CLI)** | --pcap-out <filename> | Continuous capture of all over-the-air frames received by the ESP32-C5 on COM5. |
| **Receiver (RX) PCAP (Web)** | ools/vendor/v2x2map-0.3.0/bridge/recordings/ | Saved automatically when clicking **Record** and **Stop** in the web dashboard, with a direct **Download PCAP** button in the browser. |
```sh
# WSL: the security campaigns with the compiled AtsSecurity (docs/idf/test-campaigns.md of the submodule)
python3 run_etsi.py --titan ... --binary /root/vidf-ats-security/AtsSecurity \
--config tests/etsi_security_gn.cfg --expected-cases tests/etsi_security_gn_cases.json \
--sut /usr/bin/python3 --sut-args "/root/sut_tcp_relay.py --connect <windows host>:7788" \
--pool /root/vidf-pool --out /root/vidf-results/security-gn-microbu
```
Retained runs of 2026-09-14: `experiments/raw/microbu-link-20260914/`
(manifests in `experiments/manifests/test-runs/microbu-link-20260914-*.yaml`).
## BLE verification (2026-09-16)
The retained host verification is described by
`experiments/manifests/test-runs/microbu-link-20260916-ble-host-01.yaml`:
- Python/C++ protocol and framing suite: 8 tests passed, including the common
serial/BLE adapter contract and a 512-octet
message split into 32 default-MTU values, reassembly, malformed interleaving
rejection and recovery.
- Kotlin/JVM station-link suite: 4 tests passed against the Python/C++ golden
bytes, including fragmentation, recovery and credential segmentation.
- Android `:app:compileDebugKotlin`: passed with the BLE central and manifest
permissions included.
- ESP-IDF 6.0.2 ESP32-C5 build: passed with NimBLE and the encrypted GATT
peripheral; the binary is `0x1d8780` bytes and leaves 38% of the app partition.
No ESP32 serial device or Pixel 9 was attached to this laptop during this run,
so pairing, notification exchange, the <= 2 s reconnection requirement and the
connection-loss-rate requirement were not measured. `TEST-AC-SYS-MICROBU-013` and
`TEST-AC-SYS-MICROBU-014` intentionally remain planned hardware tests.
@@ -0,0 +1,743 @@
{
"arguments": {
"port": "COM5",
"ble": null,
"connect_timeout": 20.0,
"bundle": "chain.vcr",
"erase_credentials": false,
"unsecured": false,
"radio": "txrx",
"channel": 180,
"power": 10,
"no_beacons": false,
"lat": 53.565247,
"lon": 10.008239,
"speed": 2.0,
"radius": 20.0,
"poti_rate": 5.0,
"period": 5,
"station_id": null,
"ssp": "01",
"no_vam": false,
"id_change_every": null,
"duration": 15.0,
"mirror": false,
"divert": false,
"pcap": null,
"report": "dcc_gate_check.json",
"asn1": null,
"timeout": 6.0,
"verbose": true,
"quiet_log": false
},
"station": {
"gn_address": "0800be0de448e3bc",
"identifier": "c06fbc0de448e3bc",
"tickets": 1
},
"vams_generated": 242,
"results_accepted": 0,
"results_refused": 0,
"results_pending": 0,
"timing": {
"stream_seconds": 15.052010699990205,
"generation_rate_hz": 16.077586232393354,
"generation_interval_ms": {
"samples": 241,
"mean": 62.20173734458573,
"p95": 63.81820002570748,
"max": 73.63060000352561
},
"vam_assembly_ms": {
"samples": 242,
"mean": 0.1734210780055901,
"p95": 0.33850001636892557,
"max": 0.5439000087790191
},
"request_to_result_ms": null
},
"frames_captured": 0,
"final_status": {
"uptime_ms": 121265,
"configured": 1,
"gn_address": "0800be0de448e3bc",
"identifier": "c06fbc0de448e3bc",
"change_pending": 0,
"tickets": 1,
"signed_messages": 242,
"refused_no_ticket": 0,
"refused_change_pending": 0,
"refused_permission": 0,
"sign_failed": 0,
"verified": 0,
"rejected": 0,
"requests_accepted": 242,
"requests_refused": 0,
"indications": 0,
"radio_submitted": 242,
"radio_failed": 0,
"radio_received": 0,
"radio_dropped": 0,
"link_rx_frames": 313,
"link_crc_errors": 0,
"link_malformed": 0,
"poti_updates": 62,
"its_time_ms": 717107692039
},
"events": [
"22:34:30 serial connected: COM5",
"22:34:30 PoTi trajectory: centre=53.5652470,10.0082390 radius=20.0 m speed=2.0 m/s",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:30 STATUS its_time=0 signed=0 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=0/0 ind=0 radio tx/fail/rx/drop=0/0/0/0 id=0000000000000000 pending=0 tickets=0",
"22:34:31 station configured: gn_address=080062d0e89fe341 identifier=ba1862d0e89fe341 credentials=True tickets=2",
"22:34:31 credentials provisioned: 1 roots, 1 authorities, 1 tickets",
"22:34:31 station rebuilt: gn_address=0800be0de448e3bc identifier=c06fbc0de448e3bc",
"22:34:31 SF-IDCHANGE-SUBSCRIBE: subscription 1",
"22:34:31 VAM 1 (36 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:31 STATUS its_time=717107676782 signed=2 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=2/0 ind=0 radio tx/fail/rx/drop=2/0/0/0 id=c06fbc0de448e3bc pending=0 tickets=1",
"22:34:31 VAM 2 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:31 VAM 3 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:31 VAM 4 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:31 VAM 5 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:32 VAM 6 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:32 VAM 7 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:32 VAM 8 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:32 VAM 9 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:32 VAM 10 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:32 VAM 11 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:32 VAM 12 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:32 VAM 13 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:32 VAM 14 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:32 VAM 15 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:32 VAM 16 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:32 VAM 17 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:32 STATUS its_time=717107677782 signed=18 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=18/0 ind=0 radio tx/fail/rx/drop=18/0/0/0 id=c06fbc0de448e3bc pending=0 tickets=1",
"22:34:32 VAM 18 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:32 VAM 19 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:32 VAM 20 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:32 VAM 21 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:33 VAM 22 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:33 VAM 23 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:33 VAM 24 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:33 VAM 25 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:33 VAM 26 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:33 VAM 27 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:33 VAM 28 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:33 VAM 29 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:33 VAM 30 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:33 VAM 31 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:33 VAM 32 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:33 VAM 33 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:33 STATUS its_time=717107678782 signed=34 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=34/0 ind=0 radio tx/fail/rx/drop=34/0/0/0 id=c06fbc0de448e3bc pending=0 tickets=1",
"22:34:33 VAM 34 (36 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:33 VAM 35 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:33 VAM 36 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:33 VAM 37 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:34 VAM 38 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:34 VAM 39 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:34 VAM 40 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:34 VAM 41 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:34 VAM 42 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:34 VAM 43 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:34 VAM 44 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:34 VAM 45 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:34 VAM 46 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:34 VAM 47 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:34 VAM 48 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:34 VAM 49 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:34 STATUS its_time=717107679782 signed=50 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=50/0 ind=0 radio tx/fail/rx/drop=50/0/0/0 id=c06fbc0de448e3bc pending=0 tickets=1",
"22:34:34 VAM 50 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:34 VAM 51 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:34 VAM 52 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:34 VAM 53 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:35 VAM 54 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:35 VAM 55 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:35 VAM 56 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:35 VAM 57 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:35 VAM 58 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:35 VAM 59 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:35 VAM 60 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:35 VAM 61 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:35 VAM 62 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:35 VAM 63 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:35 VAM 64 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:35 VAM 65 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:35 STATUS its_time=717107680782 signed=66 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=66/0 ind=0 radio tx/fail/rx/drop=66/0/0/0 id=c06fbc0de448e3bc pending=0 tickets=1",
"22:34:35 VAM 66 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:35 VAM 67 (36 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:35 VAM 68 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:35 VAM 69 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:36 VAM 70 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:36 VAM 71 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:36 VAM 72 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:36 VAM 73 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:36 VAM 74 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:36 VAM 75 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:36 VAM 76 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:36 VAM 77 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:36 VAM 78 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:36 VAM 79 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:36 VAM 80 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:36 VAM 81 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:36 STATUS its_time=717107681782 signed=82 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=82/0 ind=0 radio tx/fail/rx/drop=82/0/0/0 id=c06fbc0de448e3bc pending=0 tickets=1",
"22:34:36 VAM 82 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:36 VAM 83 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:36 VAM 84 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:36 VAM 85 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:37 VAM 86 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:37 VAM 87 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:37 VAM 88 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:37 VAM 89 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:37 VAM 90 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:37 VAM 91 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:37 VAM 92 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:37 VAM 93 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:37 VAM 94 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:37 VAM 95 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:37 VAM 96 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:37 VAM 97 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:37 STATUS its_time=717107682782 signed=98 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=98/0 ind=0 radio tx/fail/rx/drop=98/0/0/0 id=c06fbc0de448e3bc pending=0 tickets=1",
"22:34:37 VAM 98 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:37 VAM 99 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:37 VAM 100 (36 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:37 VAM 101 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:38 VAM 102 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:38 VAM 103 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:38 VAM 104 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:38 VAM 105 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:38 VAM 106 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:38 VAM 107 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:38 VAM 108 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:38 VAM 109 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:38 VAM 110 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:38 VAM 111 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:38 VAM 112 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:38 VAM 113 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:38 STATUS its_time=717107683782 signed=114 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=114/0 ind=0 radio tx/fail/rx/drop=114/0/0/0 id=c06fbc0de448e3bc pending=0 tickets=1",
"22:34:38 VAM 114 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:38 VAM 115 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:38 VAM 116 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:38 VAM 117 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:39 VAM 118 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:39 VAM 119 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:39 VAM 120 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:39 VAM 121 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:39 VAM 122 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:39 VAM 123 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:39 VAM 124 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:39 VAM 125 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:39 VAM 126 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:39 VAM 127 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:39 VAM 128 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:39 VAM 129 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:39 VAM 130 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:39 STATUS its_time=717107684816 signed=131 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=131/0 ind=0 radio tx/fail/rx/drop=131/0/0/0 id=c06fbc0de448e3bc pending=0 tickets=1",
"22:34:39 VAM 131 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:39 VAM 132 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:39 VAM 133 (36 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:40 VAM 134 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:40 VAM 135 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:40 VAM 136 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:40 VAM 137 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:40 VAM 138 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:40 VAM 139 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:40 VAM 140 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:40 VAM 141 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:40 VAM 142 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:40 VAM 143 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:40 VAM 144 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:40 VAM 145 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:40 VAM 146 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:40 STATUS its_time=717107685816 signed=147 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=147/0 ind=0 radio tx/fail/rx/drop=147/0/0/0 id=c06fbc0de448e3bc pending=0 tickets=1",
"22:34:40 VAM 147 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:40 VAM 148 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:40 VAM 149 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:41 VAM 150 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:41 VAM 151 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:41 VAM 152 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:41 VAM 153 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:41 VAM 154 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:41 VAM 155 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:41 VAM 156 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:41 VAM 157 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:41 VAM 158 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:41 VAM 159 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:41 VAM 160 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:41 VAM 161 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:41 VAM 162 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:41 STATUS its_time=717107686816 signed=163 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=163/0 ind=0 radio tx/fail/rx/drop=163/0/0/0 id=c06fbc0de448e3bc pending=0 tickets=1",
"22:34:41 VAM 163 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:41 VAM 164 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:41 VAM 165 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:42 VAM 166 (36 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:42 VAM 167 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:42 VAM 168 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:42 VAM 169 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:42 VAM 170 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:42 VAM 171 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:42 VAM 172 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:42 VAM 173 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:42 VAM 174 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:42 VAM 175 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:42 VAM 176 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:42 VAM 177 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:42 VAM 178 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:42 STATUS its_time=717107687816 signed=179 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=179/0 ind=0 radio tx/fail/rx/drop=179/0/0/0 id=c06fbc0de448e3bc pending=0 tickets=1",
"22:34:42 VAM 179 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:42 VAM 180 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:42 VAM 181 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:43 VAM 182 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:43 VAM 183 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:43 VAM 184 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:43 VAM 185 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:43 VAM 186 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:43 VAM 187 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:43 VAM 188 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:43 VAM 189 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:43 VAM 190 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:43 VAM 191 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:43 VAM 192 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:43 VAM 193 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:43 VAM 194 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:43 STATUS its_time=717107688816 signed=195 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=195/0 ind=0 radio tx/fail/rx/drop=195/0/0/0 id=c06fbc0de448e3bc pending=0 tickets=1",
"22:34:43 VAM 195 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:43 VAM 196 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:43 VAM 197 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:44 VAM 198 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:44 VAM 199 (36 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:44 VAM 200 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:44 VAM 201 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:44 VAM 202 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:44 VAM 203 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:44 VAM 204 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:44 VAM 205 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:44 VAM 206 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:44 VAM 207 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:44 VAM 208 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:44 VAM 209 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:44 VAM 210 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:44 VAM 211 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:44 STATUS its_time=717107689846 signed=212 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=212/0 ind=0 radio tx/fail/rx/drop=212/0/0/0 id=c06fbc0de448e3bc pending=0 tickets=1",
"22:34:44 VAM 212 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:44 VAM 213 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:45 VAM 214 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:45 VAM 215 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:45 VAM 216 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:45 VAM 217 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:45 VAM 218 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:45 VAM 219 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:45 VAM 220 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:45 VAM 221 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:45 VAM 222 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:45 VAM 223 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:45 VAM 224 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:45 VAM 225 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:45 VAM 226 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:45 VAM 227 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:45 STATUS its_time=717107690846 signed=228 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=228/0 ind=0 radio tx/fail/rx/drop=228/0/0/0 id=c06fbc0de448e3bc pending=0 tickets=1",
"22:34:45 VAM 228 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:45 VAM 229 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:45 VAM 230 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:46 VAM 231 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:46 VAM 232 (36 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:46 VAM 233 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:46 VAM 234 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:46 VAM 235 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:46 VAM 236 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:46 VAM 237 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:46 VAM 238 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:46 VAM 239 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:46 VAM 240 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:46 VAM 241 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:46 VAM 242 (34 B, StationId 3391780636) -> BTP-DATA.request accepted",
"22:34:46 STATUS its_time=717107691846 signed=242 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=242/0 ind=0 radio tx/fail/rx/drop=242/0/0/0 id=c06fbc0de448e3bc pending=0 tickets=1",
"22:34:47 final STATUS: signed=242 refused(no ticket 0, pending 0, permission 0) failed=0 req ok/refused=242/0 ind=0 radio tx/fail/rx/drop=242/0/0/0"
],
"micrOBU_log": [
"I (964) phy: libbtbb version: 92325d6, Mar 10 2026, 10:22:25",
"I (974) cits_ble: NimBLE host task running",
"I (974) NimBLE: GAP procedure initiated: stop advertising.",
"I (974) NimBLE: GAP procedure initiated: stop advertising.",
"I (974) NimBLE: GAP procedure initiated: advertise; ",
"I (974) NimBLE: disc_mode=2",
"I (974) NimBLE: adv_channel_map=0 own_addr_type=0 adv_filter_policy=0 adv_itvl_min=0 adv_itvl_max=0",
"I (974) NimBLE: ",
"I (974) cits_ble: BLE advertising started as 'micrOBU-0196'",
"I (974) cits_ble: simple_ble initialized (fixed passkey 123456)",
"I (974) microbu: micrOBU firmware on vanetza-idf, link protocol version 1, test channel enabled",
"I (974) board: LED GPIO 27 (mode: normal_tx (normally OFF))",
"I (974) microbu: station task ready; the phone/PC configures the station over BLE or USB Serial-JTAG",
"I (105454) pp: pp rom version: 78a72e9d5",
"I (105454) net80211: net80211 rom version: 78a72e9d5",
"I (105464) wifi:",
"wifi driver task: 4082f18c, prio:23, stack:6656, core=0",
"I (105464) wifi:",
"wifi firmware version: 00ad238",
"I (105464) wifi:",
"wifi certification version: v7.0",
"I (105464) wifi:",
"config NVS flash: disabled",
"I (105464) wifi:",
"config nano formatting: disabled",
"I (105464) wifi:",
"mac_version:HAL_MAC_ESP32AX_752MP_ECO2,ut_version:N, band mode:0x3",
"I (105464) wifi:",
"Init data frame dynamic rx buffer num: 16",
"I (105464) wifi:",
"Init static rx mgmt buffer num: 5",
"I (105464) wifi:",
"Init management short buffer num: 16",
"I (105464) wifi:",
"Init dynamic tx buffer num: 16",
"I (105464) wifi:",
"Init static tx FG buffer num: 2",
"I (105464) wifi:",
"Init static rx buffer size: 1700 (rxctrl:64, csi:512)",
"I (105464) wifi:",
"Init static rx buffer num: 6",
"I (105464) wifi:",
"Init dynamic rx buffer num: 16",
"I (105464) wifi_init: rx ba win: 6",
"I (105464) wifi_init: accept mbox: 6",
"I (105464) wifi_init: tcpip mbox: 32",
"I (105464) wifi_init: udp mbox: 6",
"I (105464) wifi_init: tcp mbox: 6",
"I (105464) wifi_init: tcp tx win: 5760",
"I (105464) wifi_init: tcp rx win: 5760",
"I (105464) wifi_init: tcp mss: 1440",
"I (105464) wifi_init: WiFi IRAM OP enabled",
"I (105464) wifi_init: WiFi RX IRAM OP enabled",
"I (105464) wifi_init: WiFi SLP IRAM OP enabled",
"I (105464) wifi:",
"11ax coex: WDEVAX_PTI0(0x55777555), WDEVAX_PTI1(0x00003377).",
"I (105464) wifi:",
"mode : sta (10:bd:a3:cc:01:94)",
"I (105464) wifi:",
"enable tsf",
"I (105464) wifi:",
"wifi set band mode, new band mode 2, old band mode 3",
"I (105474) wifi:",
"enable tsf",
"I (105474) wifi:",
"Set ps type: 0, coexist: 1",
"I (105474) wifi:",
"Coexist!!! Wi-Fi station would only keep waked when available",
"I (105474) wifi:",
"ic_enable_sniffer",
"I (105474) c5_radio: ITS-G5 802.11p radio started on channel 180 (5900 MHz), TX/RX",
"I (105474) station: radio on channel 180, transmit and receive",
"I (105594) station: credentials from NVS: 1 roots, 2 authorities, 2 tickets (result 0)",
"I (105604) link: station configured: security 1, radio 2, result 0",
"I (105964) station: credentials from NVS: 1 roots, 1 authorities, 1 tickets (result 0)",
"I (105964) station: credentials provisioned: 1 roots, 1 authorities, 1 tickets",
"I (106084) station: credentials from NVS: 1 roots, 1 authorities, 1 tickets (result 0)",
"I (106084) link: station configured: security 1, radio 2, result 0",
"I (106204) station: credentials from NVS: 1 roots, 1 authorities, 1 tickets (result 0)",
"I (106204) link: SF_IDCHANGE_SUBSCRIBE handle=1 result=0",
"I (106264) station: heap before sign: free=37632 min=33820 dma=21688 stack_hwm=6864",
"I (106324) station: heap before sign: free=38124 min=27472 dma=22180 stack_hwm=6816",
"I (106384) station: heap before sign: free=38132 min=27472 dma=22188 stack_hwm=6816",
"I (106444) station: heap before sign: free=38132 min=27472 dma=22188 stack_hwm=6816",
"I (106504) station: heap before sign: free=38132 min=27472 dma=22188 stack_hwm=6816",
"I (106564) station: heap before sign: free=38132 min=27472 dma=22188 stack_hwm=6816",
"I (106624) station: heap before sign: free=38132 min=27472 dma=22188 stack_hwm=6816",
"I (106694) station: heap before sign: free=38132 min=27472 dma=22188 stack_hwm=6816",
"I (106754) station: heap before sign: free=38132 min=27472 dma=22188 stack_hwm=6816",
"I (106814) station: heap before sign: free=38132 min=27472 dma=22188 stack_hwm=6816",
"I (106874) station: heap before sign: free=38132 min=27472 dma=22188 stack_hwm=6816",
"I (106944) station: heap before sign: free=38132 min=27472 dma=22188 stack_hwm=6816",
"I (107004) station: heap before sign: free=38132 min=27472 dma=22188 stack_hwm=6816",
"I (107064) station: heap before sign: free=38132 min=27472 dma=22188 stack_hwm=6816",
"I (107124) station: heap before sign: free=38132 min=27472 dma=22188 stack_hwm=6816",
"I (107194) station: heap before sign: free=38132 min=27472 dma=22188 stack_hwm=6816",
"I (107254) station: heap before sign: free=38132 min=27472 dma=22188 stack_hwm=6816",
"I (107314) station: heap before sign: free=38132 min=27472 dma=22188 stack_hwm=6816",
"I (107374) station: heap before sign: free=38132 min=27472 dma=22188 stack_hwm=6816",
"I (107434) station: heap before sign: free=38132 min=27472 dma=22188 stack_hwm=6816",
"I (107504) station: heap before sign: free=38132 min=27472 dma=22188 stack_hwm=6816",
"I (107564) station: heap before sign: free=38132 min=27472 dma=22188 stack_hwm=6816",
"I (107634) station: heap before sign: free=38132 min=27472 dma=22188 stack_hwm=6816",
"I (107694) station: heap before sign: free=38132 min=27472 dma=22188 stack_hwm=6816",
"I (107754) station: heap before sign: free=38132 min=27472 dma=22188 stack_hwm=6816",
"I (107814) station: heap before sign: free=37628 min=27472 dma=21684 stack_hwm=6816",
"I (107874) station: heap before sign: free=38132 min=27460 dma=22188 stack_hwm=6816",
"I (107944) station: heap before sign: free=38132 min=27460 dma=22188 stack_hwm=6816",
"I (108014) station: heap before sign: free=38136 min=27460 dma=22192 stack_hwm=6816",
"I (108064) station: heap before sign: free=38124 min=27460 dma=22180 stack_hwm=6816",
"I (108124) station: heap before sign: free=38124 min=27460 dma=22180 stack_hwm=6816",
"I (108184) station: heap before sign: free=38124 min=27460 dma=22180 stack_hwm=6816",
"I (108254) station: heap before sign: free=38124 min=27460 dma=22180 stack_hwm=6816",
"I (108314) station: heap before sign: free=38124 min=27460 dma=22180 stack_hwm=6816",
"I (108374) station: heap before sign: free=38124 min=27460 dma=22180 stack_hwm=6816",
"I (108444) station: heap before sign: free=38124 min=27460 dma=22180 stack_hwm=6816",
"I (108504) station: heap before sign: free=38124 min=27460 dma=22180 stack_hwm=6816",
"I (108564) station: heap before sign: free=38124 min=27460 dma=22180 stack_hwm=6816",
"I (108624) station: heap before sign: free=38124 min=27460 dma=22180 stack_hwm=6816",
"I (108684) station: heap before sign: free=38124 min=27460 dma=22180 stack_hwm=6816",
"I (108744) station: heap before sign: free=38124 min=27460 dma=22180 stack_hwm=6816",
"I (108814) station: heap before sign: free=38124 min=27460 dma=22180 stack_hwm=6816",
"I (108874) station: heap before sign: free=38124 min=27460 dma=22180 stack_hwm=6816",
"I (108934) station: heap before sign: free=38124 min=27460 dma=22180 stack_hwm=6816",
"I (108994) station: heap before sign: free=38124 min=27460 dma=22180 stack_hwm=6816",
"I (109064) station: heap before sign: free=38124 min=27460 dma=22180 stack_hwm=6816",
"I (109124) station: heap before sign: free=38124 min=27460 dma=22180 stack_hwm=6816",
"I (109184) station: heap before sign: free=38124 min=27460 dma=22180 stack_hwm=6816",
"I (109244) station: heap before sign: free=38124 min=27460 dma=22180 stack_hwm=6816",
"I (109304) station: heap before sign: free=38124 min=27460 dma=22180 stack_hwm=6816",
"I (109374) station: heap before sign: free=37616 min=27460 dma=21672 stack_hwm=6816",
"I (109434) station: heap before sign: free=38120 min=27444 dma=22176 stack_hwm=6816",
"I (109494) station: heap before sign: free=38120 min=27444 dma=22176 stack_hwm=6816",
"I (109554) station: heap before sign: free=38120 min=27444 dma=22176 stack_hwm=6816",
"I (109614) station: heap before sign: free=38120 min=27444 dma=22176 stack_hwm=6816",
"I (109684) station: heap before sign: free=38120 min=27444 dma=22176 stack_hwm=6816",
"I (109744) station: heap before sign: free=38120 min=27444 dma=22176 stack_hwm=6816",
"I (109804) station: heap before sign: free=38120 min=27444 dma=22176 stack_hwm=6816",
"I (109864) station: heap before sign: free=38120 min=27444 dma=22176 stack_hwm=6816",
"I (109934) station: heap before sign: free=38120 min=27444 dma=22176 stack_hwm=6816",
"I (109994) station: heap before sign: free=38120 min=27444 dma=22176 stack_hwm=6816",
"I (110054) station: heap before sign: free=38120 min=27444 dma=22176 stack_hwm=6816",
"I (110114) station: heap before sign: free=38120 min=27444 dma=22176 stack_hwm=6816",
"I (110174) station: heap before sign: free=38120 min=27444 dma=22176 stack_hwm=6816",
"I (110244) station: heap before sign: free=38120 min=27444 dma=22176 stack_hwm=6816",
"I (110304) station: heap before sign: free=38120 min=27444 dma=22176 stack_hwm=6816",
"I (110364) station: heap before sign: free=38120 min=27444 dma=22176 stack_hwm=6816",
"I (110424) station: heap before sign: free=38120 min=27444 dma=22176 stack_hwm=6816",
"I (110494) station: heap before sign: free=38120 min=27444 dma=22176 stack_hwm=6816",
"I (110554) station: heap before sign: free=38120 min=27444 dma=22176 stack_hwm=6816",
"I (110614) station: heap before sign: free=38120 min=27444 dma=22176 stack_hwm=6816",
"I (110674) station: heap before sign: free=38120 min=27444 dma=22176 stack_hwm=6816",
"I (110744) station: heap before sign: free=38120 min=27444 dma=22176 stack_hwm=6816",
"I (110804) station: heap before sign: free=38120 min=27444 dma=22176 stack_hwm=6816",
"I (110864) station: heap before sign: free=38120 min=27444 dma=22176 stack_hwm=6816",
"I (110924) station: heap before sign: free=37616 min=27444 dma=21672 stack_hwm=6816",
"I (110984) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6816",
"I (111054) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6816",
"I (111114) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6816",
"I (111174) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6816",
"I (111234) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6816",
"I (111304) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6816",
"I (111364) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6816",
"I (111424) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6816",
"I (111484) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6816",
"I (111554) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6816",
"I (111614) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6816",
"I (111674) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6816",
"I (111734) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6816",
"I (111804) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6816",
"I (111864) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6816",
"I (111924) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6816",
"I (111984) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6720",
"I (112044) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6720",
"I (112104) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6720",
"I (112174) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6720",
"I (112234) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6720",
"I (112294) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6720",
"I (112354) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6720",
"I (112424) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6720",
"I (112484) station: heap before sign: free=37608 min=27444 dma=21664 stack_hwm=6720",
"I (112544) station: heap before sign: free=38112 min=27444 dma=22168 stack_hwm=6720",
"I (112604) station: heap before sign: free=38112 min=27444 dma=22168 stack_hwm=6720",
"I (112674) station: heap before sign: free=38112 min=27444 dma=22168 stack_hwm=6720",
"I (112734) station: heap before sign: free=38112 min=27444 dma=22168 stack_hwm=6720",
"I (112794) station: heap before sign: free=38112 min=27444 dma=22168 stack_hwm=6720",
"I (112864) station: heap before sign: free=38112 min=27444 dma=22168 stack_hwm=6720",
"I (112924) station: heap before sign: free=38112 min=27444 dma=22168 stack_hwm=6720",
"I (112984) station: heap before sign: free=38112 min=27444 dma=22168 stack_hwm=6720",
"I (113044) station: heap before sign: free=38112 min=27444 dma=22168 stack_hwm=6720",
"I (113104) station: heap before sign: free=38112 min=27444 dma=22168 stack_hwm=6720",
"I (113174) station: heap before sign: free=38112 min=27444 dma=22168 stack_hwm=6720",
"I (113234) station: heap before sign: free=38112 min=27444 dma=22168 stack_hwm=6720",
"I (113294) station: heap before sign: free=38112 min=27444 dma=22168 stack_hwm=6720",
"I (113354) station: heap before sign: free=38112 min=27444 dma=22168 stack_hwm=6720",
"I (113414) station: heap before sign: free=38112 min=27444 dma=22168 stack_hwm=6720",
"I (113484) station: heap before sign: free=38112 min=27444 dma=22168 stack_hwm=6720",
"I (113544) station: heap before sign: free=38112 min=27444 dma=22168 stack_hwm=6720",
"I (113604) station: heap before sign: free=38112 min=27444 dma=22168 stack_hwm=6720",
"I (113664) station: heap before sign: free=38112 min=27444 dma=22168 stack_hwm=6720",
"I (113724) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6720",
"I (113794) station: heap before sign: free=38112 min=27444 dma=22168 stack_hwm=6720",
"I (113854) station: heap before sign: free=38112 min=27444 dma=22168 stack_hwm=6720",
"I (113914) station: heap before sign: free=38112 min=27444 dma=22168 stack_hwm=6720",
"I (113984) station: heap before sign: free=38112 min=27444 dma=22168 stack_hwm=6720",
"I (114044) station: heap before sign: free=37608 min=27444 dma=21664 stack_hwm=6720",
"I (114104) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6720",
"I (114164) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6720",
"I (114224) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6720",
"I (114294) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6720",
"I (114354) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6720",
"I (114414) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6720",
"I (114474) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6720",
"I (114544) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6720",
"I (114604) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6720",
"I (114664) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6720",
"I (114724) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6720",
"I (114784) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6720",
"I (114844) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6720",
"I (114904) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6720",
"I (114974) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6720",
"I (115034) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6720",
"I (115094) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6720",
"I (115154) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6720",
"I (115214) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6720",
"I (115274) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6720",
"I (115344) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6720",
"I (115404) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6720",
"I (115464) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6720",
"I (115524) station: heap before sign: free=38124 min=27444 dma=22180 stack_hwm=6720",
"I (115584) station: heap before sign: free=37612 min=27444 dma=21668 stack_hwm=6720",
"I (115654) station: heap before sign: free=38116 min=27432 dma=22172 stack_hwm=6720",
"I (115714) station: heap before sign: free=38116 min=27432 dma=22172 stack_hwm=6720",
"I (115774) station: heap before sign: free=38116 min=27432 dma=22172 stack_hwm=6720",
"I (115834) station: heap before sign: free=38116 min=27432 dma=22172 stack_hwm=6720",
"I (115904) station: heap before sign: free=38116 min=27432 dma=22172 stack_hwm=6720",
"I (115964) station: heap before sign: free=38116 min=27432 dma=22172 stack_hwm=6720",
"I (116024) station: heap before sign: free=38116 min=27432 dma=22172 stack_hwm=6720",
"I (116094) station: heap before sign: free=38116 min=27432 dma=22172 stack_hwm=6720",
"I (116154) station: heap before sign: free=38116 min=27432 dma=22172 stack_hwm=6720",
"I (116214) station: heap before sign: free=38116 min=27432 dma=22172 stack_hwm=6720",
"I (116274) station: heap before sign: free=38116 min=27432 dma=22172 stack_hwm=6720",
"I (116344) station: heap before sign: free=38116 min=27432 dma=22172 stack_hwm=6720",
"I (116404) station: heap before sign: free=38116 min=27432 dma=22172 stack_hwm=6720",
"I (116464) station: heap before sign: free=38116 min=27432 dma=22172 stack_hwm=6720",
"I (116524) station: heap before sign: free=38116 min=27432 dma=22172 stack_hwm=6720",
"I (116594) station: heap before sign: free=38116 min=27432 dma=22172 stack_hwm=6720",
"I (116654) station: heap before sign: free=38116 min=27432 dma=22172 stack_hwm=6720",
"I (116714) station: heap before sign: free=38116 min=27432 dma=22172 stack_hwm=6720",
"I (116774) station: heap before sign: free=38116 min=27432 dma=22172 stack_hwm=6720",
"I (116834) station: heap before sign: free=38116 min=27432 dma=22172 stack_hwm=6720",
"I (116904) station: heap before sign: free=38116 min=27432 dma=22172 stack_hwm=6720",
"I (116964) station: heap before sign: free=38116 min=27432 dma=22172 stack_hwm=6720",
"I (117024) station: heap before sign: free=38116 min=27432 dma=22172 stack_hwm=6720",
"I (117084) station: heap before sign: free=38116 min=27432 dma=22172 stack_hwm=6720",
"I (117144) station: heap before sign: free=37612 min=27432 dma=21668 stack_hwm=6720",
"I (117214) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (117274) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (117334) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (117394) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (117464) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (117524) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (117584) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (117644) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (117704) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (117774) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (117834) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (117894) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (117954) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (118024) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (118084) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (118144) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (118204) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (118264) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (118334) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (118394) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (118454) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (118514) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (118574) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (118644) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (118704) station: heap before sign: free=37620 min=27432 dma=21676 stack_hwm=6720",
"I (118764) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (118824) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (118894) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (118954) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (119014) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (119074) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (119144) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (119204) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (119264) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (119324) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (119384) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (119444) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (119514) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (119574) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (119634) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (119694) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (119764) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (119824) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (119884) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (119944) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (120004) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (120064) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (120134) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (120194) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (120254) station: heap before sign: free=37620 min=27432 dma=21676 stack_hwm=6720",
"I (120314) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (120374) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (120434) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (120504) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (120564) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (120624) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (120694) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (120754) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (120814) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (120874) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (120934) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (120994) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (121064) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (121124) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (121184) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720",
"I (121254) station: heap before sign: free=38124 min=27432 dma=22180 stack_hwm=6720"
]
}
Binary file not shown.
@@ -0,0 +1,11 @@
"""Phone side of the micrOBU station-internal link (implementation/station-link/README.md).
``messages`` is the transport-independent message layer, ``serial_transport`` the framing
over native USB Serial/JTAG, ``ble_transport`` the laptop/phone-equivalent BLE GATT client,
and ``client`` the shared request/reply API with unsolicited messages delivered to callbacks.
"""
from .messages import * # noqa: F401,F403
from .transport import TransportAdapter # noqa: F401
from .serial_transport import FrameType, SerialTransport, encode_frame, FrameDecoder # noqa: F401
from .ble_transport import BleTransport # noqa: F401
from .client import LinkClient, LinkError, TestChannel # noqa: F401
@@ -0,0 +1,199 @@
"""BLE GATT transport for the station-link message layer.
The ESP32 exposes the Nordic-UART-shaped service (NUS) documented in the station-link
README. Values contain binary station-link messages (up to 512 bytes). Bleak
runs on a private asyncio thread while the LinkClient remains a synchronous request/reply API.
Fixed passkey bonding is used (passkey: 123456).
"""
from __future__ import annotations
import asyncio
import queue
import threading
from typing import Callable, Optional
from . import messages as m
from .serial_transport import FrameType
# Structured ETSI C-ITS Station Service UUIDs
SERVICE_UUID = '0000c175-ba5e-4c17-8000-00805f9b34fb'
BTP_REQUEST_UUID = '0000c176-ba5e-4c17-8000-00805f9b34fb'
BTP_INDICATION_UUID = '0000c177-ba5e-4c17-8000-00805f9b34fb'
POTI_UUID = '0000c178-ba5e-4c17-8000-00805f9b34fb'
STATUS_UUID = '0000c179-ba5e-4c17-8000-00805f9b34fb'
ID_EVENT_UUID = '0000c17a-ba5e-4c17-8000-00805f9b34fb'
CONFIG_UUID = '0000c17b-ba5e-4c17-8000-00805f9b34fb'
RESULT_UUID = '0000c17c-ba5e-4c17-8000-00805f9b34fb'
FIXED_PASSKEY = 123456
class BleTransport:
"""Synchronous LinkClient adapter backed by Bleak on a private event loop.
Transmits complete station-link messages directly without ATT fragmentation chunking
or queue delays.
"""
def __init__(self, target: Optional[str], on_frame: Callable[[int, bytes], None],
connect_timeout: float = 20.0, operation_timeout: float = 5.0):
self.target = target or None
self.on_frame = on_frame
self.connect_timeout = connect_timeout
self.operation_timeout = operation_timeout
self.attribute_value = m.MAXIMUM_MESSAGE
self.device_name = ''
self.device_address = ''
self.error: Optional[BaseException] = None
self.client = None
self.loop = asyncio.new_event_loop()
self.stop_event = None
self.ready = threading.Event()
self.delivery: 'queue.Queue[Optional[bytes]]' = queue.Queue()
self.delivery_thread = threading.Thread(target=self._deliver, name='microbu-ble-delivery', daemon=True)
self.delivery_thread.start()
self.thread = threading.Thread(target=self._thread_main, name='microbu-ble-asyncio', daemon=True)
self.thread.start()
if not self.ready.wait(connect_timeout + 5):
self.close()
raise TimeoutError('BLE scan/connect did not finish')
if self.error:
error = self.error
self.close()
raise ConnectionError('BLE station-link connection failed: %s' % error) from error
def _thread_main(self):
asyncio.set_event_loop(self.loop)
try:
self.loop.run_until_complete(self._session())
except BaseException as error:
self.error = error
self.ready.set()
finally:
self.loop.close()
async def _session(self):
from bleak import BleakClient, BleakScanner
target = self.target.lower() if self.target else None
def matches(device, advertisement):
names = (device.name or '', advertisement.local_name or '')
services = [uuid.lower() for uuid in advertisement.service_uuids]
if target:
return target == device.address.lower() or any(target == name.lower() for name in names)
return SERVICE_UUID in services or any(name.lower().startswith('microbu-') for name in names)
device = await BleakScanner.find_device_by_filter(matches, timeout=self.connect_timeout)
if device is None:
wanted = self.target or 'advertised micrOBU service'
raise TimeoutError('no BLE device matching %s' % wanted)
self.device_name = device.name or 'micrOBU'
self.device_address = device.address
self.stop_event = asyncio.Event()
self.client = BleakClient(device, timeout=self.connect_timeout, pair=True,
disconnected_callback=self._disconnected)
await self.client.connect()
# LE Secure Connections + passkey display takes ~1-3 s; wait for the link
# to be encrypted before attempting CCCD writes on authenticated characteristics.
for _ in range(30):
await asyncio.sleep(0.1)
try:
# Attempt a read on STATUS (Read|Encrypt) as an encryption probe.
# Once it succeeds (or gives ATT error other than InsufficientEncryption)
# the link is encrypted and CCCD writes on notify characteristics will work.
await self.client.read_gatt_char(STATUS_UUID)
break
except Exception as exc:
exc_str = str(exc).lower()
if 'insufficient' in exc_str or 'encrypt' in exc_str or 'auth' in exc_str:
continue # still pairing
break # any other error — proceed anyway
# Subscribe to peripheral notification characteristics that matter for normal operation
# (BTP_INDICATION, ID_EVENT, RESULT). STATUS is polled or read on demand, but can be notified if supported.
subscribed_uuids = set()
for notif_uuid in (RESULT_UUID, BTP_INDICATION_UUID, ID_EVENT_UUID):
for attempt in range(3):
try:
await self.client.start_notify(notif_uuid, self._notification)
subscribed_uuids.add(notif_uuid)
break
except Exception as exc:
if attempt < 2:
await asyncio.sleep(0.5)
else:
print(f'BLE: start_notify on {notif_uuid[:8]} failed: {exc}')
self.attribute_value = min(self.client.mtu_size - 3, m.MAXIMUM_MESSAGE)
self.ready.set()
await self.stop_event.wait()
if self.client.is_connected:
await self.client.disconnect()
def _disconnected(self, _client):
if self.stop_event and not self.stop_event.is_set():
self.error = ConnectionError('micrOBU disconnected')
self.stop_event.set()
def _notification(self, characteristic, data: bytearray):
# Direct immediate message delivery: each GATT notification is one complete link message
self.delivery.put(bytes(data))
def _deliver(self):
while True:
message = self.delivery.get()
if message is None:
return
try:
self.on_frame(FrameType.LINK, message)
except Exception as error:
print('BLE message handler error:', error)
def write(self, frame_type: int, payload: bytes):
if frame_type != FrameType.LINK:
raise NotImplementedError('the USB test/log channels do not exist over BLE')
if self.error:
raise ConnectionError(str(self.error))
if self.client is None or not self.client.is_connected or self.loop.is_closed():
raise ConnectionError('BLE station link is not ready')
# Route write to the specific ETSI C-ITS GATT characteristic.
# Check opcode in link header (payload[0])
target_uuid = CONFIG_UUID
if payload:
op = payload[0]
if op == m.Opcode.BTP_DATA_REQUEST:
target_uuid = BTP_REQUEST_UUID
elif op == m.Opcode.POTI_UPDATE:
target_uuid = POTI_UUID
elif op == m.Opcode.SF_IDCHANGE_EVENT_RESPONSE:
target_uuid = ID_EVENT_UUID
else:
target_uuid = CONFIG_UUID
async def send():
for attempt in range(4):
try:
await self.client.write_gatt_char(target_uuid, bytes(payload), response=True)
return
except Exception as error:
if 'insufficient resource' not in str(error).lower() or attempt == 3:
raise
await asyncio.sleep(0.1 * (attempt + 1))
future = asyncio.run_coroutine_threadsafe(send(), self.loop)
try:
future.result(timeout=self.operation_timeout)
except Exception as error:
raise ConnectionError('BLE write failed: %s' % error) from error
def close(self):
if self.stop_event and not self.loop.is_closed():
self.loop.call_soon_threadsafe(self.stop_event.set)
if hasattr(self, 'thread') and self.thread.is_alive() and threading.current_thread() is not self.thread:
self.thread.join(timeout=self.operation_timeout)
self.delivery.put(None)
if self.delivery_thread.is_alive() and threading.current_thread() is not self.delivery_thread:
self.delivery_thread.join(timeout=1)
@@ -0,0 +1,245 @@
"""Request/reply client of the station-internal link plus the USB test channel."""
from __future__ import annotations
import queue
import threading
import time
from typing import Callable, Dict, List, Optional, Tuple
from . import messages as m
from .serial_transport import FrameType, SerialTransport
from .transport import TransportAdapter
class LinkError(RuntimeError):
def __init__(self, result: m.Result, request: str):
super().__init__('%s refused: %s' % (request, result.name))
self.result = result
class LinkClient:
"""The phone's view of the micrOBU: send requests, await their RESULT, receive the
micrOBU's own messages (indications, identifier-change events, status, log lines)
through callbacks. Thread-safe for one request at a time."""
def __init__(self, port: Optional[str] = None, timeout: float = 3.0,
transport_factory: Optional[Callable[[Callable[[int, bytes], None]], TransportAdapter]] = None):
self.timeout = timeout
self.sequence = 0
self.lock = threading.Lock()
self.replies: 'queue.Queue[m.Message]' = queue.Queue()
self.on_indication: Optional[Callable[[m.BtpDataIndication], None]] = None
self.on_id_event: Optional[Callable[[m.IdChangeEvent, int], None]] = None
self.on_mf_set: Optional[Callable[[m.MfSetRequest], None]] = None
self.on_status: Optional[Callable[[m.Status], None]] = None
self.on_log: Optional[Callable[[str], None]] = None
self.last_status: Optional[m.Status] = None
self.test_replies: 'queue.Queue[bytes]' = queue.Queue()
if transport_factory:
self.transport = transport_factory(self._on_frame)
elif port:
self.transport = SerialTransport(port, self._on_frame)
else:
raise ValueError('port or transport_factory is required')
def close(self):
self.transport.close()
# ---- receive side ----
def _on_frame(self, frame_type: int, payload: bytes):
if frame_type == FrameType.LOG:
if self.on_log:
self.on_log(payload.decode('utf-8', 'replace'))
return
if frame_type == FrameType.TEST:
self.test_replies.put(payload)
return
if frame_type != FrameType.LINK:
return
try:
message = m.decode_message(payload)
except m.DecodeError:
return
op = message.header.opcode
if op == m.Opcode.RESULT or (op == m.Opcode.STATUS and message.header.sequence == self._awaited_status):
self.replies.put(message)
elif op == m.Opcode.BTP_DATA_INDICATION:
if self.on_indication:
self.on_indication(m.BtpDataIndication.decode(message.body))
elif op == m.Opcode.SF_IDCHANGE_EVENT:
if self.on_id_event:
self.on_id_event(m.IdChangeEvent.decode(message.body), message.header.sequence)
elif op == m.Opcode.MF_SET_REQUEST:
if self.on_mf_set:
self.on_mf_set(m.MfSetRequest.decode(message.body))
elif op == m.Opcode.STATUS:
self.last_status = m.Status.decode(message.body)
if self.on_status:
self.on_status(self.last_status)
_awaited_status = -1
# ---- send side ----
def send(self, opcode: int, body: bytes = b'', sequence: Optional[int] = None) -> int:
"""Send one message without waiting (indication responses, PoTi updates)."""
if sequence is None:
self.sequence = (self.sequence + 1) & 0xFFFF
sequence = self.sequence
self.transport.write(FrameType.LINK, m.encode_message(m.Message(m.Header(opcode, 0, sequence), body)))
return sequence
def request(self, opcode: int, body: bytes = b'', timeout: Optional[float] = None) -> m.Result:
"""Send a request and wait for its RESULT (raises LinkError unless accepted)."""
with self.lock:
while not self.replies.empty(): # stale replies of timed-out requests
self.replies.get_nowait()
sequence = self.send(opcode, body)
deadline = time.monotonic() + (timeout or self.timeout)
while True:
remaining = deadline - time.monotonic()
if remaining <= 0:
raise TimeoutError('no RESULT for opcode 0x%02x (sequence %d)' % (opcode, sequence))
try:
reply = self.replies.get(timeout=remaining)
except queue.Empty:
continue
if reply.header.sequence != sequence or reply.header.opcode != m.Opcode.RESULT:
continue
result = m.Result.decode(reply.body)
if result.code != m.Code.accepted:
raise LinkError(result, m.Opcode(opcode).name)
return result
def request_async(self, opcode: int, body: bytes = b'') -> int:
"""Submit a request without serializing on its RESULT.
The peer still emits the ordinary per-request RESULT. Call
receive_result() to consume it. This is used for the 100 ms VAM stream,
where a BLE connection interval must not become the service period.
"""
with self.lock:
return self.send(opcode, body)
def receive_result(self, timeout: Optional[float] = None) -> Tuple[int, m.Result]:
reply = self.replies.get(timeout=self.timeout if timeout is None else timeout)
if reply.header.opcode != m.Opcode.RESULT:
raise ValueError('expected RESULT, got opcode 0x%02x' % reply.header.opcode)
return reply.header.sequence, m.Result.decode(reply.body)
# ---- primitives ----
def configure(self, config: m.StationConfigure) -> m.StationInfo:
# Starting the ESP32-C5 ITS-G5 radio initializes the Wi-Fi/PHY driver
# before the station can reply. Keep ordinary request timeouts short,
# but allow this idempotent lifecycle operation to finish on hardware.
return m.StationInfo.decode(self.request(m.Opcode.STATION_CONFIGURE, config.encode(), timeout=30).detail)
def poti(self, update: m.PotiUpdate):
"""Silent unless refused; a refusal arrives as a RESULT that request() ignores."""
self.send(m.Opcode.POTI_UPDATE, update.encode())
def btp_data_request(self, request: m.BtpDataRequest) -> m.Result:
return self.request(m.Opcode.BTP_DATA_REQUEST, request.encode())
def provision(self, bundle: bytes) -> Tuple[int, int, int]:
result = None
for segment in m.provision_segments(bundle):
result = self.request(m.Opcode.CREDENTIALS_PROVISION, segment.encode(), timeout=20)
assert result is not None and len(result.detail) == 3, 'apply report expected'
return result.detail[0], result.detail[1], result.detail[2]
def erase_credentials(self):
self.request(m.Opcode.CREDENTIALS_ERASE, timeout=10)
def subscribe(self, subscriber_data: bytes = b'') -> int:
result = self.request(m.Opcode.SF_IDCHANGE_SUBSCRIBE, bytes([len(subscriber_data)]) + subscriber_data)
return int.from_bytes(result.detail, 'little')
def unsubscribe(self, subscription: int):
self.request(m.Opcode.SF_IDCHANGE_UNSUBSCRIBE, subscription.to_bytes(8, 'little'))
def event_response(self, event: m.IdChangeEvent, event_sequence: int, return_code: bool):
self.send(m.Opcode.SF_IDCHANGE_EVENT_RESPONSE, m.IdChangeEventResponse(event.subscription, return_code).encode(),
sequence=event_sequence)
def trigger(self):
self.request(m.Opcode.SF_IDCHANGE_TRIGGER)
def lock(self, seconds: int) -> int:
return int.from_bytes(self.request(m.Opcode.SF_ID_LOCK, bytes([seconds])).detail, 'little')
def unlock(self, handle: int):
self.request(m.Opcode.SF_ID_UNLOCK, handle.to_bytes(8, 'little'))
def status(self, timeout: float = 5.0) -> m.Status:
with self.lock:
while not self.replies.empty():
self.replies.get_nowait()
self.sequence = (self.sequence + 1) & 0xFFFF
self._awaited_status = self.sequence
try:
self.transport.write(FrameType.LINK, m.encode_message(m.Message(m.Header(m.Opcode.STATUS_REQUEST, 0, self.sequence))))
deadline = time.monotonic() + timeout
while True:
remaining = deadline - time.monotonic()
if remaining <= 0:
raise TimeoutError('no STATUS')
reply = self.replies.get(timeout=remaining)
if reply.header.opcode == m.Opcode.STATUS and reply.header.sequence == self.sequence:
self.last_status = m.Status.decode(reply.body)
return self.last_status
finally:
self._awaited_status = -1
class TestChannel:
"""USB test channel (frame type 0x11): software lower tester and campaign hooks."""
MIRROR, INJECT, GN_REQUEST, DRAIN, RESET = 0x01, 0x02, 0x03, 0x04, 0x05
def __init__(self, client: LinkClient, timeout: float = 3.0):
self.client = client
self.timeout = timeout
def execute(self, request: bytes) -> Tuple[int, List[Tuple[int, bytes]]]:
with self.client.lock:
while not self.client.test_replies.empty():
self.client.test_replies.get_nowait()
self.client.transport.write(FrameType.TEST, request)
reply = self.client.test_replies.get(timeout=self.timeout)
result, count = reply[0], reply[1]
records, at = [], 2
for _ in range(count):
kind = reply[at]
length = int.from_bytes(reply[at + 1:at + 3], 'little')
records.append((kind, reply[at + 3:at + 3 + length]))
at += 3 + length
if at != len(reply):
raise ValueError('trailing test channel octets')
return result, records
def mirror(self, mode: int):
result, _ = self.execute(bytes([self.MIRROR, mode]))
if result != 0:
raise RuntimeError('mirror mode refused: %d' % result)
def inject(self, source: bytes, destination: bytes, gnpdu: bytes) -> int:
result, _ = self.execute(bytes([self.INJECT]) + source + destination + gnpdu)
return result
def gn_request(self, traffic_class: int, payload: bytes) -> int:
result, _ = self.execute(bytes([self.GN_REQUEST, traffic_class]) + payload)
return result
def drain(self) -> Tuple[bool, List[Tuple[int, bytes]]]:
"""All queued records (several frames when needed); returns (overflow, records)."""
records: List[Tuple[int, bytes]] = []
overflow = False
while True:
result, batch = self.execute(bytes([self.DRAIN]))
overflow = overflow or result == m.Code.resource_limit
records.extend(batch)
if not batch:
return overflow, records
def reset(self):
self.execute(bytes([self.RESET]))
@@ -0,0 +1,466 @@
"""Message layer version 1 of the micrOBU station-internal link.
Mirror of the firmware's ``link_protocol.hpp``; the two are checked against each other by
``tests/test_station_link.py``. All integers little-endian; ETSI payloads opaque.
"""
from __future__ import annotations
import enum
import struct
from dataclasses import dataclass, field
from typing import List, Optional
MAXIMUM_MESSAGE = 512
HEADER_SIZE = 4
class Opcode(enum.IntEnum):
STATION_CONFIGURE = 0x01
POTI_UPDATE = 0x02
BTP_DATA_REQUEST = 0x03
CREDENTIALS_PROVISION = 0x04
CREDENTIALS_ERASE = 0x05
SF_IDCHANGE_SUBSCRIBE = 0x06
SF_IDCHANGE_UNSUBSCRIBE = 0x07
SF_IDCHANGE_EVENT_RESPONSE = 0x08
SF_IDCHANGE_TRIGGER = 0x09
SF_ID_LOCK = 0x0A
SF_ID_UNLOCK = 0x0B
STATUS_REQUEST = 0x0C
RESULT = 0x80
BTP_DATA_INDICATION = 0x81
SF_IDCHANGE_EVENT = 0x82
MF_SET_REQUEST = 0x83
STATUS = 0x84
class Code(enum.IntEnum):
"""RESULT codes: vanetza_idf::Result first, then link codes."""
accepted = 0
invalid_argument = 1
unsupported = 2
wrong_entry_point = 3
security_unavailable = 4
resource_limit = 5
rejected = 6
time_regression = 7
identity_change_pending = 8
unknown_opcode = 0x10
malformed = 0x11
not_configured = 0x12
busy = 0x13
no_credentials = 0x14
FLAG_FRAGMENT_FIRST = 0x01
FLAG_FRAGMENT_MORE = 0x02
class IdChangeCommand(enum.IntEnum):
PREPARE = 0
COMMIT = 1
ABORT = 2
DEREG = 3
class TransportType(enum.IntEnum):
SHB = 1
GBC = 2
TSB = 3
GAC = 4
GUC = 5
VERIFICATION_REPORTS = ['unsecured', 'Success', 'False_Signature', 'Invalid_Certificate', 'Revoked_Certificate',
'Inconsistent_Chain', 'Invalid_Timestamp', 'Duplicate_Message', 'Invalid_Mobility_Data',
'Unsigned_Message', 'Signer_Certificate_Not_Found', 'Unsupported_Signer_Identifier_Type',
'Incompatible_Protocol', 'Configuration_Problem']
class DecodeError(ValueError):
pass
class _Reader:
def __init__(self, data: bytes, at: int = 0):
self.data = data
self.at = at
def _take(self, n: int) -> bytes:
if self.at + n > len(self.data):
raise DecodeError('truncated body')
chunk = self.data[self.at:self.at + n]
self.at += n
return chunk
def u8(self) -> int: return self._take(1)[0]
def u16(self) -> int: return struct.unpack('<H', self._take(2))[0]
def u32(self) -> int: return struct.unpack('<I', self._take(4))[0]
def u64(self) -> int: return struct.unpack('<Q', self._take(8))[0]
def i32(self) -> int: return struct.unpack('<i', self._take(4))[0]
def bytes(self, n: int) -> bytes: return self._take(n)
def rest(self) -> bytes:
chunk = self.data[self.at:]
self.at = len(self.data)
return chunk
def done(self):
if self.at != len(self.data):
raise DecodeError('trailing octets')
@dataclass
class Header:
opcode: int
flags: int = 0
sequence: int = 0
@dataclass
class Message:
header: Header
body: bytes = b''
def encode_message(message: Message) -> bytes:
if HEADER_SIZE + len(message.body) > MAXIMUM_MESSAGE:
raise ValueError('message exceeds %d octets' % MAXIMUM_MESSAGE)
return struct.pack('<BBH', int(message.header.opcode), message.header.flags, message.header.sequence) + bytes(message.body)
def decode_message(octets: bytes) -> Message:
if len(octets) < HEADER_SIZE or len(octets) > MAXIMUM_MESSAGE:
raise DecodeError('bad message length %d' % len(octets))
opcode, flags, sequence = struct.unpack('<BBH', octets[:HEADER_SIZE])
return Message(Header(opcode, flags, sequence), bytes(octets[HEADER_SIZE:]))
# ---- bodies ----------------------------------------------------------------------------
@dataclass
class StationConfigure:
station_type: int = 2
security: int = 1
address_configuration: int = 1
mid: bytes = b'\x02\x00\x00\x00\x00\x01'
beaconing: int = 1
channel_number: int = 180
transmit_power_dbm: int = 10
radio: int = 0
default_traffic_class: int = 2
default_lifetime: int = 0x05
def encode(self) -> bytes:
assert len(self.mid) == 6
return struct.pack('<BBB6sBHBBBB', self.station_type, self.security, self.address_configuration, self.mid,
self.beaconing, self.channel_number, self.transmit_power_dbm, self.radio,
self.default_traffic_class, self.default_lifetime)
@classmethod
def decode(cls, body: bytes) -> 'StationConfigure':
if len(body) != 16:
raise DecodeError('StationConfigure length')
f = struct.unpack('<BBB6sBHBBBB', body)
return cls(*f)
@dataclass
class StationInfo:
"""RESULT detail of STATION_CONFIGURE."""
gn_address: bytes
identifier: bytes
credentials_loaded: bool
tickets: int
@classmethod
def decode(cls, detail: bytes) -> 'StationInfo':
if len(detail) != 18:
raise DecodeError('StationInfo length')
return cls(detail[:8], detail[8:16], detail[16] != 0, detail[17])
@dataclass
class PotiUpdate:
timestamp_ms: int
latitude: int
longitude: int
semi_major_cm: int = 0
semi_minor_cm: int = 0
orientation_deci_degree: int = 0
altitude_cm: Optional[int] = None
speed_cm_s: Optional[int] = None
heading_deci_degree: Optional[int] = None
pai: bool = False
def encode(self) -> bytes:
flags = ((1 if self.altitude_cm is not None else 0) | (2 if self.speed_cm_s is not None else 0) |
(4 if self.heading_deci_degree is not None else 0) | (8 if self.pai else 0))
return struct.pack('<QiiHHHBiHH', self.timestamp_ms, self.latitude, self.longitude, self.semi_major_cm,
self.semi_minor_cm, self.orientation_deci_degree, flags, self.altitude_cm or 0,
self.speed_cm_s or 0, self.heading_deci_degree or 0)
@classmethod
def decode(cls, body: bytes) -> 'PotiUpdate':
if len(body) != 31:
raise DecodeError('PotiUpdate length')
t, lat, lon, a, b, o, flags, alt, spd, hdg = struct.unpack('<QiiHHHBiHH', body)
return cls(t, lat, lon, a, b, o, alt if flags & 1 else None, spd if flags & 2 else None,
hdg if flags & 4 else None, bool(flags & 8))
@dataclass
class DestinationArea:
shape: int = 0
latitude: int = 0
longitude: int = 0
distance_a: int = 0
distance_b: int = 0
angle: int = 0
def encode(self) -> bytes:
return struct.pack('<BiiHHH', self.shape, self.latitude, self.longitude, self.distance_a, self.distance_b, self.angle)
@classmethod
def read(cls, r: _Reader) -> 'DestinationArea':
return cls(r.u8(), r.i32(), r.i32(), r.u16(), r.u16(), r.u16())
@dataclass
class BtpDataRequest:
fl_sdu: bytes
btp_type: int = 1
destination_port: int = 2018
destination_port_info: int = 0
gn_packet_transport_type: int = TransportType.SHB
gn_communication_profile: int = 1
gn_security_profile: int = 0
gn_traffic_class: int = 0xFF
gn_maximum_packet_lifetime: int = 0xFF
gn_maximum_hop_limit: int = 0
gn_repetition_interval_ms: int = 0
gn_repetition_maximum_ms: int = 0
its_aid: int = 638
permissions: bytes = b''
context: bytes = b''
area: Optional[DestinationArea] = None
def encode(self) -> bytes:
if len(self.permissions) > 31:
raise ValueError('SSP longer than 31 octets')
out = struct.pack('<BHHBBBBBBHHI', self.btp_type, self.destination_port, self.destination_port_info,
self.gn_packet_transport_type, self.gn_communication_profile, self.gn_security_profile,
self.gn_traffic_class, self.gn_maximum_packet_lifetime, self.gn_maximum_hop_limit,
self.gn_repetition_interval_ms, self.gn_repetition_maximum_ms, self.its_aid)
out += bytes([len(self.permissions)]) + self.permissions + bytes([len(self.context)]) + self.context
if self.gn_packet_transport_type == TransportType.GBC:
out += (self.area or DestinationArea()).encode()
out += struct.pack('<H', len(self.fl_sdu)) + self.fl_sdu
return out
@classmethod
def decode(cls, body: bytes) -> 'BtpDataRequest':
r = _Reader(body)
f = struct.unpack('<BHHBBBBBBHHI', r.bytes(19))
permissions = r.bytes(r.u8())
context = r.bytes(r.u8())
area = DestinationArea.read(r) if f[3] == TransportType.GBC else None
sdu = r.bytes(r.u16())
r.done()
return cls(sdu, *f, permissions=permissions, context=context, area=area)
@dataclass
class BtpDataIndication:
received_fl_sdu: bytes
btp_type: int = 1
destination_port: int = 0
destination_port_info: int = 0
gn_packet_transport_type: int = 1
gn_traffic_class: int = 0
gn_remaining_packet_lifetime: int = 0xFF
gn_remaining_hop_limit: int = 0xFF
source_gn_address: bytes = b'\0' * 8
source_timestamp: int = 0
source_latitude: int = 0
source_longitude: int = 0
security_report: int = 0
its_aid: int = 0
permissions: bytes = b''
certificate_id: Optional[bytes] = None
area: Optional[DestinationArea] = None
@property
def report_name(self) -> str:
return VERIFICATION_REPORTS[self.security_report] if self.security_report < len(VERIFICATION_REPORTS) else str(self.security_report)
def encode(self) -> bytes:
out = struct.pack('<BHHBBBB8sIiiBI', self.btp_type, self.destination_port, self.destination_port_info,
self.gn_packet_transport_type, self.gn_traffic_class, self.gn_remaining_packet_lifetime,
self.gn_remaining_hop_limit, self.source_gn_address, self.source_timestamp,
self.source_latitude, self.source_longitude, self.security_report, self.its_aid)
out += bytes([len(self.permissions)]) + self.permissions
out += bytes([1 if self.certificate_id else 0]) + (self.certificate_id or b'\0' * 8)
out += bytes([1 if self.area else 0]) + (self.area.encode() if self.area else b'')
out += struct.pack('<H', len(self.received_fl_sdu)) + self.received_fl_sdu
return out
@classmethod
def decode(cls, body: bytes) -> 'BtpDataIndication':
r = _Reader(body)
f = struct.unpack('<BHHBBBB8sIiiBI', r.bytes(34))
permissions = r.bytes(r.u8())
present = r.u8()
certificate = r.bytes(8)
area = DestinationArea.read(r) if r.u8() else None
sdu = r.bytes(r.u16())
r.done()
return cls(sdu, *f, permissions=permissions, certificate_id=certificate if present else None, area=area)
@dataclass
class CredentialsProvision:
total_length: int
offset: int
segment: bytes
def encode(self) -> bytes:
return struct.pack('<HHB', self.total_length, self.offset, len(self.segment)) + self.segment
@classmethod
def decode(cls, body: bytes) -> 'CredentialsProvision':
r = _Reader(body)
total, offset = r.u16(), r.u16()
segment = r.bytes(r.u8())
r.done()
return cls(total, offset, segment)
def provision_segments(bundle: bytes, segment_size: int = 200) -> List[CredentialsProvision]:
"""Split a VCR1 bundle into CREDENTIALS_PROVISION messages."""
return [CredentialsProvision(len(bundle), at, bundle[at:at + segment_size]) for at in range(0, len(bundle), segment_size)]
@dataclass
class IdChangeEvent:
subscription: int
command: int
id: bytes
subscriber_data: bytes = b''
def encode(self) -> bytes:
return struct.pack('<QB8sB', self.subscription, self.command, self.id, len(self.subscriber_data)) + self.subscriber_data
@classmethod
def decode(cls, body: bytes) -> 'IdChangeEvent':
r = _Reader(body)
s, c, i = struct.unpack('<QB8s', r.bytes(17))
data = r.bytes(r.u8())
r.done()
return cls(s, c, i, data)
@dataclass
class IdChangeEventResponse:
subscription: int
return_code: bool
def encode(self) -> bytes:
return struct.pack('<QB', self.subscription, 1 if self.return_code else 0)
@classmethod
def decode(cls, body: bytes) -> 'IdChangeEventResponse':
if len(body) != 9:
raise DecodeError('IdChangeEventResponse length')
s, c = struct.unpack('<QB', body)
return cls(s, c != 0)
@dataclass
class MfSetRequest:
fac_id: int = 0
command_ref: int = 0
params: List[tuple] = field(default_factory=list) # (f_param_no, value)
def encode(self) -> bytes:
out = struct.pack('<IBB', self.fac_id, self.command_ref, len(self.params))
for no, value in self.params:
out += struct.pack('<BI', no, value)
return out
@classmethod
def decode(cls, body: bytes) -> 'MfSetRequest':
r = _Reader(body)
fac, ref, count = struct.unpack('<IBB', r.bytes(6))
params = [struct.unpack('<BI', r.bytes(5)) for _ in range(count)]
r.done()
return cls(fac, ref, params)
STATUS_FORMAT = '<IB8s8sBB' + 'I' * 7 + 'I' * 3 + 'I' * 4 + 'I' * 4 + 'Q'
STATUS_FIELDS = ['uptime_ms', 'configured', 'gn_address', 'identifier', 'change_pending', 'tickets',
'signed_messages', 'refused_no_ticket', 'refused_change_pending', 'refused_permission',
'sign_failed', 'verified', 'rejected', 'requests_accepted', 'requests_refused', 'indications',
'radio_submitted', 'radio_failed', 'radio_received', 'radio_dropped', 'link_rx_frames',
'link_crc_errors', 'link_malformed', 'poti_updates', 'its_time_ms']
@dataclass
class Status:
uptime_ms: int = 0
configured: int = 0
gn_address: bytes = b'\0' * 8
identifier: bytes = b'\0' * 8
change_pending: int = 0
tickets: int = 0
signed_messages: int = 0
refused_no_ticket: int = 0
refused_change_pending: int = 0
refused_permission: int = 0
sign_failed: int = 0
verified: int = 0
rejected: int = 0
requests_accepted: int = 0
requests_refused: int = 0
indications: int = 0
radio_submitted: int = 0
radio_failed: int = 0
radio_received: int = 0
radio_dropped: int = 0
link_rx_frames: int = 0
link_crc_errors: int = 0
link_malformed: int = 0
poti_updates: int = 0
its_time_ms: int = 0
def encode(self) -> bytes:
return struct.pack(STATUS_FORMAT, *[getattr(self, name) for name in STATUS_FIELDS])
@classmethod
def decode(cls, body: bytes) -> 'Status':
if len(body) != struct.calcsize(STATUS_FORMAT):
raise DecodeError('Status length %d' % len(body))
return cls(**dict(zip(STATUS_FIELDS, struct.unpack(STATUS_FORMAT, body))))
@dataclass
class Result:
code: int
detail: bytes = b''
def encode(self) -> bytes:
return bytes([int(self.code), len(self.detail)]) + self.detail
@classmethod
def decode(cls, body: bytes) -> 'Result':
r = _Reader(body)
code = r.u8()
detail = r.bytes(r.u8())
r.done()
return cls(code, detail)
@property
def name(self) -> str:
try:
return Code(self.code).name
except ValueError:
return 'code_0x%02x' % self.code
@@ -0,0 +1,136 @@
"""Serial transport of the station-internal link (the app's Phase 03 framing).
``[AA][55][type][length LE][payload][crc16 LE]``, CRC-16/CCITT-FALSE over type+length+payload.
Frame types: 0x10 link message, 0x11 test channel, 0x7F log line. Requires pyserial.
"""
from __future__ import annotations
import enum
import struct
import threading
from typing import Callable, List, Tuple
MAXIMUM_PAYLOAD = 1536
class FrameType(enum.IntEnum):
LINK = 0x10
TEST = 0x11
LOG = 0x7F
def crc16_ccitt_false(data: bytes) -> int:
crc = 0xFFFF
for octet in data:
crc ^= octet << 8
for _ in range(8):
crc = ((crc << 1) ^ 0x1021) & 0xFFFF if crc & 0x8000 else (crc << 1) & 0xFFFF
return crc
def encode_frame(frame_type: int, payload: bytes) -> bytes:
if len(payload) > MAXIMUM_PAYLOAD:
raise ValueError('frame payload exceeds %d octets' % MAXIMUM_PAYLOAD)
head = struct.pack('<BH', int(frame_type), len(payload)) + payload
return b'\xaa\x55' + head + struct.pack('<H', crc16_ccitt_false(head))
class FrameDecoder:
"""Byte-at-a-time state machine, the app's SerialFrameDecoder with the larger payload limit."""
def __init__(self):
self.state = 'SYNC0'
self.type = 0
self.length = 0
self.payload = bytearray()
self.crc = 0
self.crc_errors = 0
self.frames = 0
def feed(self, data: bytes) -> List[Tuple[int, bytes]]:
out = []
for b in data:
s = self.state
if s == 'SYNC0':
self.state = 'SYNC1' if b == 0xAA else 'SYNC0'
elif s == 'SYNC1':
self.state = 'TYPE' if b == 0x55 else ('SYNC1' if b == 0xAA else 'SYNC0')
elif s == 'TYPE':
self.type = b
self.state = 'LEN_LO'
elif s == 'LEN_LO':
self.length = b
self.state = 'LEN_HI'
elif s == 'LEN_HI':
self.length |= b << 8
self.payload = bytearray()
if self.length > MAXIMUM_PAYLOAD:
self.state = 'SYNC0'
else:
self.state = 'CRC_LO' if self.length == 0 else 'PAYLOAD'
elif s == 'PAYLOAD':
self.payload.append(b)
if len(self.payload) >= self.length:
self.state = 'CRC_LO'
elif s == 'CRC_LO':
self.crc = b
self.state = 'CRC_HI'
elif s == 'CRC_HI':
self.crc |= b << 8
head = struct.pack('<BH', self.type, self.length) + bytes(self.payload)
if crc16_ccitt_false(head) == self.crc:
self.frames += 1
out.append((self.type, bytes(self.payload)))
else:
self.crc_errors += 1
self.state = 'SYNC0'
return out
class SerialTransport:
"""Reader thread plus a locked writer over one pyserial port."""
def __init__(self, port: str, on_frame: Callable[[int, bytes], None], baudrate: int = 115200):
import serial
self.port = serial.Serial(port=None, baudrate=baudrate, timeout=0.05, write_timeout=2)
# do not toggle DTR/RTS: the USB Serial/JTAG controller would reset the board
self.port.dtr = False
self.port.rts = False
self.port.port = port
self.port.open()
self.on_frame = on_frame
self.decoder = FrameDecoder()
self.write_lock = threading.Lock()
self.running = True
self.thread = threading.Thread(target=self._reader, name='microbu-link-rx', daemon=True)
self.thread.start()
def _reader(self):
while self.running:
try:
data = self.port.read(4096)
except Exception:
if self.running:
continue
return
if data:
for frame_type, payload in self.decoder.feed(data):
try:
self.on_frame(frame_type, payload)
except Exception as error: # a handler failure must not stop the reader
print('frame handler error:', error)
def write(self, frame_type: int, payload: bytes):
frame = encode_frame(frame_type, payload)
with self.write_lock:
if self.port.write(frame) != len(frame):
raise IOError('incomplete serial write')
def close(self):
self.running = False
try:
self.port.cancel_read()
except Exception:
pass
self.thread.join(timeout=1)
self.port.close()
@@ -0,0 +1,12 @@
"""Common adapter boundary for station-link transports."""
from __future__ import annotations
from typing import Protocol
class TransportAdapter(Protocol):
"""The only transport surface used by LinkClient."""
def write(self, frame_type: int, payload: bytes) -> None: ...
def close(self) -> None: ...
@@ -0,0 +1,239 @@
"""A small VRU basic service for the phone emulator: VAM assembly with asn1tools from the
ETSI ASN.1 modules, the individual-VAM generation rules of ETSI TS 103 300-3 V2.3.1
clause 6.4 (items 1 to 4, Tables 16 and 17), the VBS PCI of Table 4, and a PoTi stand-in.
It is a test stand-in for the phone app's VBS, not a conformant VBS: no clustering,
no redundancy mitigation, no reception management, no motion prediction.
"""
from __future__ import annotations
import math
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
from . import messages as m
ITS_EPOCH_UNIX = 1072915200
LEAP_SECONDS_SINCE_2004 = 5 # TAI-UTC 37 s now, 32 s at the epoch (2017-01-01 was the last insertion)
# ETSI TS 103 300-3 V2.3.1 Table 16 / Table 17 recommended values
T_GEN_VAM_MIN_MS = 100
T_GEN_VAM_MAX_MS = 5000
T_GEN_VAM_LF_MIN_MS = 2000
MIN_POSITION_CHANGE_M = 4.0
MIN_SPEED_CHANGE_MPS = 0.5
MIN_ORIENTATION_CHANGE_DEG = 4.0
# TS 102 965 ITS-AID of the VRU service, TS 103 248 BTP port of VAM
ITS_AID_VRU = 638
BTP_PORT_VAM = 2018
def its_timestamp_ms(unix_seconds: Optional[float] = None) -> int:
"""TS 102 894-2 TimestampIts: TAI milliseconds since 2004-01-01T00:00:00Z."""
if unix_seconds is None:
unix_seconds = time.time()
return int((unix_seconds - ITS_EPOCH_UNIX + LEAP_SECONDS_SINCE_2004) * 1000)
def default_asn1_directory() -> Path:
here = Path(__file__).resolve()
return here.parents[3] / 'external' / 'vanetza-idf' / 'asn1' / 'release2'
class Codec:
"""UPER codec for VAM (and CAM/DENM for the displays) from the submodule's ASN.1 modules."""
def __init__(self, directory: Optional[Path] = None):
import asn1tools
directory = directory or default_asn1_directory()
cdd = directory / 'TS102894-2v241-CDD.asn'
self.vam = asn1tools.compile_files([cdd, directory / 'TS103300-3v231' / 'VAM-PDU-Descriptions.asn',
directory / 'TS103300-3v231' / 'motorcyclist-special-container.asn'], 'uper')
self.vam_type = self.vam.modules['VAM-PDU-Descriptions']['VAM']
self.cam_type = None
self.denm_type = None
try:
cam = asn1tools.compile_files([cdd, directory / 'TS103900v231-CAM.asn'], 'uper')
self.cam_type = cam.modules['CAM-PDU-Descriptions']['CAM']
except Exception:
pass
try:
denm = asn1tools.compile_files([cdd, directory / 'TS103831v231-DENM.asn'], 'uper')
self.denm_type = denm.modules['DENM-PDU-Description']['DENM']
except Exception:
pass
def encode_vam(self, vam: dict) -> bytes:
return self.vam_type.encode(vam)
def decode_vam(self, octets: bytes) -> dict:
return self.vam_type.decode(octets)
def decode_by_port(self, port: int, octets: bytes):
if port == BTP_PORT_VAM:
return 'VAM', self.decode_vam(octets)
if port == 2001 and self.cam_type:
return 'CAM', self.cam_type.decode(octets)
if port == 2002 and self.denm_type:
return 'DENM', self.denm_type.decode(octets)
return None, None
@dataclass
class PotiState:
"""What PoTi reports: WGS84 position with a 95 % confidence ellipse, kinematics, time."""
timestamp_ms: int
latitude: float
longitude: float
speed_mps: float = 0.0
heading_deg: float = 0.0
altitude_m: Optional[float] = None
semi_major_m: float = 1.5
semi_minor_m: float = 1.0
orientation_deg: float = 0.0
def poti_update(self) -> m.PotiUpdate:
return m.PotiUpdate(
timestamp_ms=self.timestamp_ms,
latitude=int(round(self.latitude * 1e7)), longitude=int(round(self.longitude * 1e7)),
semi_major_cm=min(65535, int(round(self.semi_major_m * 100))),
semi_minor_cm=min(65535, int(round(self.semi_minor_m * 100))),
orientation_deci_degree=int(round(self.orientation_deg * 10)) % 3600,
altitude_cm=None if self.altitude_m is None else int(round(self.altitude_m * 100)),
speed_cm_s=min(65535, int(round(self.speed_mps * 100))),
heading_deci_degree=int(round(self.heading_deg * 10)) % 3600,
pai=self.semi_major_m * 2 <= 40.0) # itsGnPaiInterval / 2, EN 302 636-4-1 / TS 103 836-4-1 clause 8.4
class PotiSimulator:
"""Static position, or a bicycle riding a circle (radius r at speed v) for the triggers."""
def __init__(self, latitude: float, longitude: float, speed_mps: float = 0.0, radius_m: float = 30.0,
altitude_m: Optional[float] = 12.0):
self.center = (latitude, longitude)
self.speed = speed_mps
self.radius = radius_m
self.altitude = altitude_m
self.start = time.time()
def state(self, now: Optional[float] = None) -> PotiState:
now = time.time() if now is None else now
if self.speed <= 0:
return PotiState(its_timestamp_ms(now), self.center[0], self.center[1], 0.0, 0.0, self.altitude)
angle = (now - self.start) * self.speed / self.radius # rad, counter-clockwise
lat = self.center[0] + math.degrees(self.radius * math.sin(angle) / 6371000.0)
lon = self.center[1] + math.degrees(self.radius * math.cos(angle) / (6371000.0 * math.cos(math.radians(self.center[0]))))
heading = (math.degrees(angle) * -1 + 0.0) % 360 # tangent of a counter-clockwise circle, clockwise from north
return PotiState(its_timestamp_ms(now), lat, lon, self.speed, heading, self.altitude)
def haversine_m(a: PotiState, b: PotiState) -> float:
r = 6371000.0
p1, p2 = math.radians(a.latitude), math.radians(b.latitude)
dp = p2 - p1
dl = math.radians(b.longitude - a.longitude)
h = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
return 2 * r * math.asin(math.sqrt(h))
@dataclass
class VbsLite:
"""VRU-ACTIVE-STANDALONE VBS state for one bicyclist (VRU profile 2, sub-profile bicyclist)."""
codec: Codec
station_id: int
t_gen_vam_ms: int = T_GEN_VAM_MIN_MS # T_GenVam, from the management entity (DCC) in a real VBS
fixed_period_ms: Optional[int] = None # test override: one VAM every period regardless of the triggers
active: bool = True # false between PREPARE and COMMIT of an identifier change
last_vam: Optional[PotiState] = None
last_vam_at_ms: int = 0
last_lf_at_ms: int = -10 ** 9
generated: int = 0
security_profile: int = 2 # Table 4: SECURED or UNSECURED
ssp: bytes = b'\x01' # SSP of the VRU ITS-AID carried in the authorization ticket
traffic_class: int = 0xFF # "same GN traffic class value as for the CAM": the station default
lifetime: int = 0x05 # GN maximum packet lifetime 1 s (Table 4: shall not exceed 1 000 ms)
def due(self, now: PotiState) -> bool:
if not self.active:
return False
elapsed = now.timestamp_ms - self.last_vam_at_ms
if self.fixed_period_ms is not None:
return self.last_vam is None or elapsed >= self.fixed_period_ms
if self.last_vam is None:
return True
if elapsed < max(self.t_gen_vam_ms, T_GEN_VAM_MIN_MS):
return False
if elapsed > T_GEN_VAM_MAX_MS:
return True # item 1
if haversine_m(now, self.last_vam) > MIN_POSITION_CHANGE_M:
return True # item 2
if abs(now.speed_mps - self.last_vam.speed_mps) > MIN_SPEED_CHANGE_MPS:
return True # item 3
delta = abs((now.heading_deg - self.last_vam.heading_deg + 180) % 360 - 180)
return delta > MIN_ORIENTATION_CHANGE_DEG # item 4
def assemble(self, now: PotiState) -> bytes:
"""VAM per TS 103 300-3 clause 7.3: basic + HF container, LF container every T_GenVamLFMin."""
include_lf = now.timestamp_ms - self.last_lf_at_ms >= T_GEN_VAM_LF_MIN_MS
vam = {
'header': {'protocolVersion': 3, 'messageId': 16, 'stationId': self.station_id},
'vam': {
'generationDeltaTime': now.timestamp_ms % 65536,
'vamParameters': {
'basicContainer': {
'stationType': 2, # cyclist
'referencePosition': {
'latitude': int(round(now.latitude * 1e7)),
'longitude': int(round(now.longitude * 1e7)),
'positionConfidenceEllipse': {
'semiMajorAxisLength': min(4094, int(round(now.semi_major_m * 100))),
'semiMinorAxisLength': min(4094, int(round(now.semi_minor_m * 100))),
'semiMajorAxisOrientation': int(round(now.orientation_deg * 10)) % 3600,
},
'altitude': ({'altitudeValue': max(-100000, min(800000, int(round(now.altitude_m * 100)))),
'altitudeConfidence': 'alt-020-00'} if now.altitude_m is not None
else {'altitudeValue': 800001, 'altitudeConfidence': 'unavailable'}),
},
},
'vruHighFrequencyContainer': {
'heading': {'value': int(round(now.heading_deg * 10)) % 3600, 'confidence': 50},
'speed': {'speedValue': min(16382, int(round(now.speed_mps * 100))), 'speedConfidence': 50},
'longitudinalAcceleration': {'longitudinalAccelerationValue': 0, 'longitudinalAccelerationConfidence': 102},
},
},
},
}
if include_lf:
vam['vam']['vamParameters']['vruLowFrequencyContainer'] = {
'profileAndSubprofile': ('bicyclistAndLightVruVehicle', 1), # bicyclist
'sizeClass': 1, # low
}
self.last_lf_at_ms = now.timestamp_ms
octets = self.codec.encode_vam(vam)
self.last_vam = now
self.last_vam_at_ms = now.timestamp_ms
self.generated += 1
return octets
def btp_data_request(self, vam: bytes) -> m.BtpDataRequest:
"""The VBS networking PCI of TS 103 300-3 Table 4 for one VAM."""
return m.BtpDataRequest(
fl_sdu=vam, btp_type=1, destination_port=BTP_PORT_VAM, destination_port_info=0,
gn_packet_transport_type=m.TransportType.SHB, gn_communication_profile=1,
gn_security_profile=self.security_profile, gn_traffic_class=self.traffic_class,
gn_maximum_packet_lifetime=self.lifetime, its_aid=ITS_AID_VRU, permissions=self.ssp)
# TS 103 300-3 clause 5.3.5: stop on PREPARE, resume with a new StationId after COMMIT
def prepare(self):
self.active = False
def commit(self, new_station_id: int):
self.station_id = new_station_id
self.active = True
self.last_vam = None
def abort(self):
self.active = True
@@ -0,0 +1,491 @@
#!/usr/bin/env python3
"""Phone emulator: the phone half of the VRU ITS-S, on a PC, over serial or BLE.
It does what the app will do over BLE: configure the micrOBU, provision credentials
(a VCR1 bundle), feed PoTi, run a small VRU basic service that assembles VAMs and hands
them to the micrOBU as BTP-DATA.request with the PCI of TS 103 300-3 Table 4, subscribe
to the identifier-change events (stop on PREPARE, new StationId after COMMIT), show the
BTP-DATA.indications the micrOBU passes up and its status/log lines.
phone_emulator.py --port COM11 --bundle chain.vcr --radio txrx --duration 30
phone_emulator.py --port COM11 --bundle chain.vcr --radio off --divert --pcap vams.pcap --duration 20
phone_emulator.py --ble --bundle chain.vcr --radio off --duration 20
--divert keeps the packets from the radio and mirrors them through the USB test channel
(test firmware only), --pcap writes those as IEEE 802.11 frames for an independent verifier
(c-its-pcap). BLE uses the same LinkClient API but cannot expose the USB-only test
channel. Dependencies are listed in requirements.txt.
The "TS 102 941 client" role (architecture/diagrams/microbu-architecture-2b-revision-b-security.drawio):
--pki-* replaces --bundle with a real online enrolment (EA) then authorization (AA) round
trip over HTTP (TS 102 941 V2.2.1 clause 6.2.3), building the VCR1 bundle from the
resulting AT instead of an offline-issued ticket:
phone_emulator.py --port COM11 --radio txrx --duration 30 \
--pki-url http://127.0.0.1:8090/ --pki-root ROOT.oer --pki-ea EA.oer --pki-aa AA.oer \
--pki-canonical-key canonical.pem --pki-its-id vidf-vru-station \
--pki-issue-tool vidf_issue.exe --pki-client-tool pki_client.py --pki-bundle-tool credential_bundle.py
--pki-url is a tools/local_pki.py access point; --pki-issue-tool/--pki-client-tool/
--pki-bundle-tool are vanetza-idf's ports/esp_idf/{tools/local_pki.py's vidf_issue binary,
tools/pki_client.py, tools/credential_bundle.py}. Lab tooling stands in for the phone's own
TS 102 941 stack; see docs/idf/evidence/enrolment-authorization-01 in vanetza-idf.
"""
from __future__ import annotations
import argparse
import json
import queue
import random
import struct
import subprocess
import sys
import tempfile
import threading
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from microbu_link import BleTransport, LinkClient, LinkError, TestChannel, messages as m # noqa: E402
from microbu_link.vbs import Codec, PotiSimulator, VbsLite, its_timestamp_ms # noqa: E402
WILDCARD_BSSID = b'\xff' * 6
GEONETWORKING_ETHERTYPE = b'\x89\x47'
# Simulation-only trajectory over open water in the middle of the Außenalster.
# Keeping the synthetic VRU away from roads prevents it from appearing to be a
# real road user in receiving applications. This does not eliminate RF airtime;
# actual transmission remains opt-in through ``--radio txrx``.
AUSSENALSTER_CENTER_LAT = 53.565247
AUSSENALSTER_CENTER_LON = 10.008239
AUSSENALSTER_RADIUS_M = 20.0
AUSSENALSTER_SPEED_MPS = 2.0
def tool_command(path, *arguments):
"""Build a portable command line for a native tool or Python script."""
executable = str(path)
prefix = [sys.executable, executable] if Path(executable).suffix.lower() == '.py' else [executable]
return prefix + [str(argument) for argument in arguments]
def run_tool(path, *arguments):
"""Run one PKI helper and turn its diagnostic into a library-style error."""
command = tool_command(path, *arguments)
try:
result = subprocess.run(command, capture_output=True, text=True, check=False)
except OSError as error:
raise RuntimeError('%s failed: %s' % (Path(path).name, error)) from error
if result.returncode:
diagnostic = result.stderr.strip() or result.stdout.strip() or 'exit status %d' % result.returncode
raise RuntimeError('%s failed: %s' % (Path(path).name, diagnostic))
return result
def write_pcap(path, frames):
"""IEEE 802.11 data frames (linktype 105), LLC/SNAP 0x8947, like capture_pcap.py."""
with open(path, 'wb') as out:
out.write(struct.pack('<IHHiIII', 0xa1b2c3d4, 2, 4, 0, 0, 65535, 105))
for sequence, (timestamp, source, pdu) in enumerate(frames):
header = (struct.pack('<HH', 0x0008, 0) + WILDCARD_BSSID + source + WILDCARD_BSSID +
struct.pack('<H', (sequence & 0x0fff) << 4))
frame = header + b'\xaa\xaa\x03\x00\x00\x00' + GEONETWORKING_ETHERTYPE + pdu
out.write(struct.pack('<IIII', int(timestamp), int((timestamp % 1) * 1_000_000), len(frame), len(frame)))
out.write(frame)
class Emulator:
def __init__(self, args):
self.args = args
self.codec = Codec(Path(args.asn1) if args.asn1 else None)
self.log_lines = []
if args.ble is not None:
self.client = LinkClient(timeout=args.timeout, transport_factory=lambda receive: BleTransport(
args.ble or None, receive, connect_timeout=args.connect_timeout,
operation_timeout=max(args.timeout, 5.0)))
else:
self.client = LinkClient(args.port, timeout=args.timeout)
self.client.on_log = self.on_log
self.client.on_indication = self.on_indication
self.client.on_id_event = self.on_id_event
self.client.on_mf_set = lambda mf: self.say('MF-SET fac_id=%d params=%s' % (mf.fac_id, mf.params))
self.client.on_status = self.on_status
self.poti = PotiSimulator(args.lat, args.lon, args.speed, args.radius)
self.vbs = VbsLite(self.codec, args.station_id or random.randrange(1, 2 ** 32 - 1),
fixed_period_ms=args.period, security_profile=2,
ssp=bytes.fromhex(args.ssp))
self.subscription = None
self.stop = threading.Event()
self.lock = threading.Lock()
self.frames = [] # (unix time, source mac, gnpdu) from the test channel
self.events = []
self.station_info = None
self.test = TestChannel(self.client) if (args.divert or args.mirror) else None
self.pending = {}
self.pending_lock = threading.Lock()
self.results_accepted = 0
self.results_refused = 0
self.assembly_ms = []
self.request_latency_ms = []
self.generation_times = []
self.stream_started = None
self.stream_ended = None
# ---- output ----
def say(self, text):
line = '%s %s' % (time.strftime('%H:%M:%S'), text)
print(line, flush=True)
self.events.append(line)
def on_log(self, line):
if self.args.quiet_log:
return
print(' [micrOBU] %s' % line, flush=True)
self.log_lines.append(line)
def on_status(self, s: m.Status):
if self.args.verbose:
self.say('STATUS its_time=%d signed=%d refused(no ticket %d, pending %d, permission %d) failed=%d '
'req ok/refused=%d/%d ind=%d radio tx/fail/rx/drop=%d/%d/%d/%d id=%s pending=%d tickets=%d' % (
s.its_time_ms, s.signed_messages, s.refused_no_ticket, s.refused_change_pending,
s.refused_permission, s.sign_failed, s.requests_accepted, s.requests_refused, s.indications,
s.radio_submitted, s.radio_failed, s.radio_received, s.radio_dropped, s.identifier.hex(),
s.change_pending, s.tickets))
def on_indication(self, ind: m.BtpDataIndication):
kind, decoded = None, None
try:
kind, decoded = self.codec.decode_by_port(ind.destination_port, ind.received_fl_sdu)
except Exception as error:
kind = 'undecodable (%s)' % error
summary = ''
if isinstance(decoded, dict):
header = decoded.get('header', {})
summary = ' stationId=%s' % header.get('stationId')
self.say('BTP-DATA.indication port=%d %d B from %s report=%s its_aid=%d ssp=%s cert=%s %s%s' % (
ind.destination_port, len(ind.received_fl_sdu), ind.source_gn_address.hex(), ind.report_name, ind.its_aid,
ind.permissions.hex(), ind.certificate_id.hex() if ind.certificate_id else '-', kind or 'port %d' % ind.destination_port, summary))
# TS 103 300-3 clause 5.3.5 through the SF-SAP identifier-change events
def on_id_event(self, event: m.IdChangeEvent, sequence: int):
command = m.IdChangeCommand(event.command).name
self.say('SF-IDCHANGE-EVENT %s id=%s' % (command, event.id.hex()))
if event.command == m.IdChangeCommand.PREPARE:
self.vbs.prepare()
self.client.event_response(event, sequence, True)
elif event.command == m.IdChangeCommand.COMMIT:
new_id = random.randrange(1, 2 ** 32 - 1)
self.vbs.commit(new_id)
self.say('VBS resumes with StationId %d' % new_id)
self.client.event_response(event, sequence, True)
elif event.command == m.IdChangeCommand.ABORT:
self.vbs.abort()
# ---- session ----
def acquire_online_credentials(self) -> bytes:
"""Obtain an EC and AT from the online PKI and pack the station's VCR1 bundle."""
a = self.args
with tempfile.TemporaryDirectory(prefix='microbu-pki-') as temporary_directory:
temporary = Path(temporary_directory)
ec_certificate = temporary / 'EC.oer'
ec_key = temporary / 'EC.vkey'
at_certificate = temporary / 'AT.oer'
at_key = temporary / 'AT.vkey'
run_tool(a.pki_client_tool, '--issue-tool', a.pki_issue_tool, '--pki', a.pki_url,
'enrol', '--ea', a.pki_ea, '--canonical-key', a.pki_canonical_key,
'--its-id', a.pki_its_id, '--out', ec_certificate, '--out-key', ec_key)
self.say('online PKI: enrolment credential obtained')
run_tool(a.pki_client_tool, '--issue-tool', a.pki_issue_tool, '--pki', a.pki_url,
'authorize', '--ea', a.pki_ea, '--aa', a.pki_aa,
'--ec', ec_certificate, '--ec-key', ec_key, '--hours', a.pki_hours,
'--out', at_certificate, '--out-key', at_key)
self.say('online PKI: authorization ticket obtained')
pool = temporary / 'pool'
pool.mkdir()
(pool / 'ROOT.oer').write_bytes(Path(a.pki_root).read_bytes())
(pool / 'AA.oer').write_bytes(Path(a.pki_aa).read_bytes())
(pool / 'AT.oer').write_bytes(at_certificate.read_bytes())
(pool / 'AT.vkey').write_bytes(at_key.read_bytes())
bundle_path = temporary / 'credentials.vcr'
run_tool(a.pki_bundle_tool, 'build', '--pool', pool, '--root', 'ROOT',
'--aa', 'AA', '--at', 'AT', '--out', bundle_path)
bundle = bundle_path.read_bytes()
self.say('online PKI: VCR1 credential bundle built')
return bundle
def start(self):
a = self.args
if a.ble is not None:
transport = self.client.transport
self.say('BLE connected: %s (%s), GATT value=%d B' %
(transport.device_name, transport.device_address, transport.attribute_value))
else:
self.say('serial connected: %s' % a.port)
self.say('PoTi trajectory: centre=%.7f,%.7f radius=%.1f m speed=%.1f m/s' %
(a.lat, a.lon, a.radius, a.speed))
config = m.StationConfigure(security=1, address_configuration=1,
beaconing=0 if a.no_beacons else 1, channel_number=a.channel,
transmit_power_dbm=a.power, radio={'off': 0, 'rx': 1, 'txrx': 2}[a.radio])
if self.test: # armed before the station exists: even its first beacon reaches the tester
self.test.reset()
self.test.mirror(2 if a.divert else 1)
self.say('test channel: %s' % ('divert (nothing reaches the radio)' if a.divert else 'mirror'))
self.station_info = self.client.configure(config)
self.say('station configured: gn_address=%s identifier=%s credentials=%s tickets=%d' % (
self.station_info.gn_address.hex(), self.station_info.identifier.hex(),
self.station_info.credentials_loaded, self.station_info.tickets))
if a.erase_credentials:
self.client.erase_credentials()
self.say('credentials erased')
if a.bundle or a.pki_url:
bundle = self.acquire_online_credentials() if a.pki_url else Path(a.bundle).read_bytes()
roots, authorities, tickets = self.client.provision(bundle)
self.say('credentials provisioned: %d roots, %d authorities, %d tickets' % (roots, authorities, tickets))
self.station_info = self.client.configure(config) # the station was rebuilt with them
self.say('station rebuilt: gn_address=%s identifier=%s' % (self.station_info.gn_address.hex(), self.station_info.identifier.hex()))
# first PoTi fix before anything is sent: the micrOBU needs time and position
self.client.poti(self.poti.state().poti_update())
try:
self.subscription = self.client.subscribe(b'VBS')
self.say('SF-IDCHANGE-SUBSCRIBE: subscription %d' % self.subscription)
except TimeoutError:
self.subscription = 1
self.say('SF-IDCHANGE-SUBSCRIBE acknowledgement missed; proceeding with fallback subscription')
def poti_loop(self):
period = 1.0 / self.args.poti_rate
while not self.stop.is_set():
with self.lock:
self.client.poti(self.poti.state().poti_update())
self.stop.wait(period)
def drain_loop(self):
while not self.stop.is_set():
self.stop.wait(0.5)
self.drain()
def drain(self):
if not self.test:
return
with self.lock:
overflow, records = self.test.drain()
if overflow:
self.say('test channel queue overflowed: records were lost')
for kind, data in records:
if kind == 1:
source = self.station_info.gn_address[2:8] if self.station_info else b'\0' * 6
self.frames.append((time.time(), source, data))
secured = len(data) > 4 and (data[0] & 0x0f) == 2
self.say('AL_DATA.request %d B GNPDU (%s)' % (len(data), 'secured' if secured else 'unsecured'))
def vbs_loop(self):
deadline = time.time() + self.args.duration if self.args.duration else None
next_trigger = time.time() + self.args.id_change_every if self.args.id_change_every else None
fixed_period = self.args.period / 1000.0 if self.args.period is not None else None
next_vam = time.perf_counter() if fixed_period is not None else None
self.stream_started = time.perf_counter()
while not self.stop.is_set():
now = self.poti.state()
due = time.perf_counter() >= next_vam if next_vam is not None else self.vbs.due(now)
if due and not self.args.no_vam:
assembly_started = time.perf_counter()
vam = self.vbs.assemble(now)
request = self.vbs.btp_data_request(vam)
encoded_request = request.encode()
assembled_at = time.perf_counter()
self.assembly_ms.append((assembled_at - assembly_started) * 1000.0)
self.generation_times.append(assembled_at)
if next_vam is not None:
next_vam += fixed_period
if next_vam < assembled_at:
next_vam = assembled_at + fixed_period
try:
with self.lock:
self.client.btp_data_request(request)
self.say('VAM %d (%d B, StationId %d) -> BTP-DATA.request accepted' % (self.vbs.generated, len(vam), self.vbs.station_id))
except LinkError as error:
self.say('VAM %d refused: %s' % (self.vbs.generated, error.result.name))
except TimeoutError as error:
self.say('VAM %d: %s' % (self.vbs.generated, error))
if next_trigger and time.time() >= next_trigger:
next_trigger = time.time() + self.args.id_change_every
try:
with self.lock:
self.client.trigger()
self.say('SF-IDCHANGE-TRIGGER sent')
except LinkError as error:
self.say('SF-IDCHANGE-TRIGGER refused: %s' % error.result.name)
if deadline and time.time() >= deadline:
break
wait = 0.01
if next_vam is not None:
wait = min(wait, max(0.0, next_vam - time.perf_counter()))
self.stop.wait(wait)
self.stream_ended = time.perf_counter()
def run(self):
self.start()
threads = [threading.Thread(target=self.poti_loop, daemon=True)]
if self.test:
threads.append(threading.Thread(target=self.drain_loop, daemon=True))
for t in threads:
t.start()
try:
self.vbs_loop()
except KeyboardInterrupt:
pass
finally:
self.stop.set()
time.sleep(0.3)
self.drain()
try:
with self.lock:
status = self.client.status()
self.say('final STATUS: signed=%d refused(no ticket %d, pending %d, permission %d) failed=%d req ok/refused=%d/%d '
'ind=%d radio tx/fail/rx/drop=%d/%d/%d/%d' % (
status.signed_messages, status.refused_no_ticket, status.refused_change_pending,
status.refused_permission, status.sign_failed, status.requests_accepted, status.requests_refused,
status.indications, status.radio_submitted, status.radio_failed, status.radio_received, status.radio_dropped))
except Exception as error:
self.say('final STATUS unavailable: %s' % error)
status = None
if self.subscription is not None:
try:
with self.lock:
self.client.unsubscribe(self.subscription)
except Exception:
pass
if self.test:
try:
self.test.mirror(0)
except Exception:
pass
if self.args.pcap and self.frames:
write_pcap(self.args.pcap, self.frames)
self.say('%d frames written to %s' % (len(self.frames), self.args.pcap))
if self.args.report:
def summary(values):
if not values:
return None
ordered = sorted(values)
return {
'samples': len(ordered),
'mean': sum(ordered) / len(ordered),
'p95': ordered[min(len(ordered) - 1, int(len(ordered) * 0.95))],
'max': ordered[-1],
}
intervals_ms = [(right - left) * 1000.0 for left, right in
zip(self.generation_times, self.generation_times[1:])]
stream_seconds = ((self.stream_ended or time.perf_counter()) - self.stream_started
if self.stream_started is not None else 0.0)
Path(self.args.report).write_text(json.dumps({
'arguments': {k: v for k, v in vars(self.args).items()},
'station': None if not self.station_info else {'gn_address': self.station_info.gn_address.hex(),
'identifier': self.station_info.identifier.hex(),
'tickets': self.station_info.tickets},
'vams_generated': self.vbs.generated,
'results_accepted': self.results_accepted,
'results_refused': self.results_refused,
'results_pending': len(self.pending),
'timing': {
'stream_seconds': stream_seconds,
'generation_rate_hz': self.vbs.generated / stream_seconds if stream_seconds else 0.0,
'generation_interval_ms': summary(intervals_ms),
'vam_assembly_ms': summary(self.assembly_ms),
'request_to_result_ms': summary(self.request_latency_ms),
},
'frames_captured': len(self.frames),
'final_status': None if not status else {k: (v.hex() if isinstance(v, bytes) else v) for k, v in vars(status).items()},
'events': self.events,
'micrOBU_log': self.log_lines,
}, indent=1), encoding='utf-8')
self.say('report written to %s' % self.args.report)
self.client.close()
def build_argument_parser():
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
transport = p.add_mutually_exclusive_group(required=True)
transport.add_argument('--port', help='serial port of the micrOBU (native USB Serial/JTAG)')
transport.add_argument('--ble', nargs='?', const='', metavar='ADDRESS_OR_NAME',
help='use BLE; optionally select a device address or advertised name')
p.add_argument('--connect-timeout', type=float, default=20.0, help='BLE discovery/pairing timeout in seconds')
p.add_argument('--bundle', help='VCR1 credential bundle to provision (credential_bundle.py build)')
p.add_argument('--pki-url', help='EA/AA access point (tools/local_pki.py), e.g. http://127.0.0.1:8090/')
p.add_argument('--pki-root', metavar='ROOT.oer', help='root CA certificate for the VCR1 trust anchor')
p.add_argument('--pki-ea', metavar='EA.oer', help='EA certificate used for enrolment')
p.add_argument('--pki-aa', metavar='AA.oer', help='AA certificate used for authorization and the VCR1 chain')
p.add_argument('--pki-canonical-key', metavar='KEY', help='canonical private key for initial enrolment')
p.add_argument('--pki-its-id', metavar='ID', help='canonical station identifier for enrolment')
p.add_argument('--pki-issue-tool', metavar='PATH', help='vidf_issue binary built with VIDF_PKI=ON')
p.add_argument('--pki-client-tool', metavar='PATH', help='tools/pki_client.py')
p.add_argument('--pki-bundle-tool', metavar='PATH', help='tools/credential_bundle.py')
p.add_argument('--pki-hours', type=int, default=24, metavar='N', help='AT validity in hours (default: 24)')
p.add_argument('--erase-credentials', action='store_true')
p.add_argument('--unsecured', action='store_true', help='itsGnSecurity off (no signing, AUTO address)')
p.add_argument('--radio', choices=['off', 'rx', 'txrx'], default='off', help='micrOBU radio mode (txrx = laboratory transmission)')
p.add_argument('--channel', type=int, default=180)
p.add_argument('--power', type=int, default=10, help='transmit power dBm')
p.add_argument('--no-beacons', action='store_true')
p.add_argument('--lat', type=float, default=AUSSENALSTER_CENTER_LAT,
help='circle centre latitude (default: middle of the Außenalster)')
p.add_argument('--lon', type=float, default=AUSSENALSTER_CENTER_LON,
help='circle centre longitude (default: middle of the Außenalster)')
p.add_argument('--speed', type=float, default=AUSSENALSTER_SPEED_MPS,
help='m/s on the simulated circle (0 = stationary; default: 2)')
p.add_argument('--radius', type=float, default=AUSSENALSTER_RADIUS_M,
help='simulated circle radius in metres (default: 20)')
p.add_argument('--poti-rate', type=float, default=5.0, help='PoTi updates per second')
p.add_argument('--period', type=int, help='fixed VAM period in ms instead of the clause 6.4 triggers')
p.add_argument('--station-id', type=int)
p.add_argument('--ssp', default='01', help='SSP octets of the VRU ITS-AID (hex)')
p.add_argument('--no-vam', action='store_true')
p.add_argument('--id-change-every', type=float, help='trigger an identifier change every N seconds')
p.add_argument('--duration', type=float, help='seconds to run (default: until Ctrl-C)')
p.add_argument('--mirror', action='store_true', help='test channel: copy transmissions (radio still transmits)')
p.add_argument('--divert', action='store_true', help='test channel: keep transmissions from the radio')
p.add_argument('--pcap', help='write mirrored/diverted GNPDUs as an 802.11 pcap')
p.add_argument('--report', help='write a JSON session report')
p.add_argument('--asn1', help='directory with the ETSI ASN.1 modules (default: the submodule)')
p.add_argument('--timeout', type=float, default=6.0)
p.add_argument('--verbose', action='store_true', help='print every STATUS')
p.add_argument('--quiet-log', action='store_true', help='hide the micrOBU log lines')
return p
def validate_arguments(parser, args):
"""Validate credential-source invariants after argument parsing."""
required_pki = ('pki_root', 'pki_ea', 'pki_aa', 'pki_canonical_key', 'pki_its_id',
'pki_issue_tool', 'pki_client_tool', 'pki_bundle_tool')
if args.bundle and args.pki_url:
parser.error('--bundle and --pki-url are mutually exclusive')
if args.pki_url:
missing = ['--' + name.replace('_', '-') for name in required_pki if not getattr(args, name)]
if missing:
parser.error('--pki-url requires %s' % ', '.join(missing))
if args.pki_hours <= 0:
parser.error('--pki-hours must be greater than zero')
elif any(getattr(args, name) for name in required_pki):
parser.error('--pki-* options require --pki-url')
def main():
parser = build_argument_parser()
args = parser.parse_args()
validate_arguments(parser, args)
if args.ble is not None and (args.mirror or args.divert or args.pcap):
parser.error('--mirror, --divert and --pcap require the USB serial test channel')
if args.unsecured:
parser.error('All radio transmissions must be signed (unsecured mode not permitted)')
try:
Emulator(args).run()
except ConnectionError as error:
parser.exit(2, 'BLE connection error: %s\n' % error)
if __name__ == '__main__':
main()
@@ -0,0 +1,3 @@
asn1tools
bleak>=3,<4
pyserial
@@ -0,0 +1,38 @@
#!/usr/bin/env python3
"""Print micrOBU LOG frames without participating in the station-link session."""
from __future__ import annotations
import argparse
import time
import serial
from microbu_link.serial_transport import FrameDecoder, FrameType
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--port', required=True)
parser.add_argument('--duration', type=float, default=15.0)
parser.add_argument('--reset', action='store_true', help='pulse EN through USB Serial/JTAG before monitoring')
args = parser.parse_args()
port = serial.Serial(args.port, 115200, timeout=0.05, write_timeout=1)
port.dtr = False
port.rts = False
if args.reset:
port.rts = True
time.sleep(0.1)
port.rts = False
decoder = FrameDecoder()
deadline = time.monotonic() + args.duration
try:
while time.monotonic() < deadline:
for frame_type, payload in decoder.feed(port.read(4096)):
if frame_type == FrameType.LOG:
print(payload.decode('utf-8', 'replace'), flush=True)
finally:
port.close()
if __name__ == '__main__':
main()
@@ -0,0 +1,273 @@
#!/usr/bin/env python3
"""SUT process for the vanetza-idf ETSI adapters, with the micrOBU behind the phone interfaces.
The adapters (ports/esp_idf/tests/etsi_*_adapter.cpp) drive a SUT through the hex-line
diagnostic protocol of hil_sut.hpp. This bridge answers that protocol the way the phone
half of the VRU ITS-S would: BTP-DATA.request, PoTi time/position and credential
provisioning go over the station-internal link; the software lower tester (observing
AL_DATA.request, injecting AL_DATA.indication) and the GN-DATA.request of the Security
ATS use the USB test channel of the test firmware. The CAM/DENM carrier stimuli of the
Security ATS are assembled here with asn1tools, as a phone would assemble them.
Commands (first octet, as in hil_sut.hpp): 0 reset; 1 BTP-DATA.request; 2 AL_DATA.indication;
3 GN-DATA.request SHB; 4 position; 5 advance the ITS clock (reports transmissions); 6 carrier
CAM/DENM now; 7 periodic carrier; 8 carrier with the next clock advance; 9 credential bundle.
Reply: [result][count]{[kind][u16 BE length][bytes]}, kind 1 AL_DATA.request, 2 BTP-DATA.indication,
3 GN-DATA.indication.
sut_bridge.py --port COM11 --bundle certificates.vcr # stdin/stdout, like serial_sut.py
sut_bridge.py --port COM11 --bundle certificates.vcr --listen 0.0.0.0:7788 # TCP for a WSL relay
WSL interop drops piped stdin of Windows executables (docs/idf/test-campaigns.md), so the
campaigns use --listen here and sut_tcp_relay.py inside WSL as the SUT executable.
"""
from __future__ import annotations
import argparse
import socket
import struct
import sys
import threading
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from microbu_link import LinkClient, LinkError, TestChannel, messages as m # noqa: E402
from microbu_link.vbs import Codec # noqa: E402
RESULT_REJECTED = 6
def log(text):
print('[sut_bridge] ' + text, file=sys.stderr, flush=True)
class Bridge:
def __init__(self, args):
self.args = args
self.codec = Codec(Path(args.asn1) if args.asn1 else None)
self.client = LinkClient(args.port, timeout=args.timeout)
self.client.on_log = lambda line: log('micrOBU: ' + line) if args.verbose else None
self.test = TestChannel(self.client, timeout=args.timeout)
self.bundle = Path(args.bundle).read_bytes() if args.bundle else None
self.secured = self.bundle is not None
self.configured = False
self.provisioned = False
self.latitude, self.longitude = 52.0, 13.0
self.time_ms = None
self.carrier_interval = [0, 0]
self.carrier_next = [0, 0]
self.carrier_pending = [None, None]
self.denm_sequence = 0
self.lock = threading.Lock()
# ---- station handling ----
def reset(self):
self.test.reset()
config = m.StationConfigure(
station_type=5 if self.args.station_type is None else self.args.station_type,
security=1 if self.secured else 0,
address_configuration=1 if (self.secured and self.args.anonymous) else 0,
mid=b'\x02\x00\x00\x00\x00\x01',
beaconing=1 if self.secured else 0, # the Security ATS observes GN-MGMT beacons; BTP/GN PICS are unsecured
radio=0)
info = self.client.configure(config)
if self.secured and self.bundle and not self.provisioned:
# the campaign's pool, whatever an earlier session left in the micrOBU's NVS
roots, authorities, tickets = self.client.provision(self.bundle)
log('bundle provisioned: %d roots, %d authorities, %d tickets' % (roots, authorities, tickets))
self.provisioned = True
info = self.client.configure(config)
log('station: gn_address=%s identifier=%s credentials=%s tickets=%d' % (
info.gn_address.hex(), info.identifier.hex(), info.credentials_loaded, info.tickets))
self.test.mirror(2) # divert: every AL_DATA.request comes to the lower tester, nothing to a radio
self.configured = True
self.carrier_interval = [0, 0]
self.carrier_pending = [None, None]
if self.time_ms is None:
self.time_ms = int((time.time() - 1072915200 + 5) * 1000)
self.poti()
return 0
def poti(self):
update = m.PotiUpdate(self.time_ms, int(round(self.latitude * 1e7)), int(round(self.longitude * 1e7)),
semi_major_cm=150, semi_minor_cm=100, speed_cm_s=0, heading_deci_degree=0, pai=True)
self.client.poti(update)
def carrier(self, kind, sequence=0):
"""Syntactically valid Release 2 CAM (kind 0) or DENM (kind 1), the hil_sut.cpp carriers."""
if kind == 0:
if not self.codec.cam_type:
return 2
cam = {'header': {'protocolVersion': 2, 'messageId': 2, 'stationId': 42},
'cam': {'generationDeltaTime': self.time_ms % 65536,
'camParameters': {
'basicContainer': {'stationType': 5, 'referencePosition': {
'latitude': 520000000, 'longitude': 130000000,
'positionConfidenceEllipse': {'semiMajorAxisLength': 4095, 'semiMinorAxisLength': 4095, 'semiMajorAxisOrientation': 3601},
'altitude': {'altitudeValue': 800001, 'altitudeConfidence': 'unavailable'}}},
'highFrequencyContainer': ('rsuContainerHighFrequency', {})}}}
sdu = self.codec.cam_type.encode(cam)
request = m.BtpDataRequest(sdu, btp_type=1, destination_port=2001, destination_port_info=0,
gn_packet_transport_type=m.TransportType.SHB, its_aid=36, permissions=b'')
else:
if not self.codec.denm_type:
return 2
denm = {'header': {'protocolVersion': 2, 'messageId': 1, 'stationId': 42},
'denm': {'management': {
'actionId': {'originatingStationId': 42, 'sequenceNumber': sequence},
'detectionTime': self.time_ms, 'referenceTime': self.time_ms,
'eventPosition': {'latitude': 520000000, 'longitude': 130000000,
'positionConfidenceEllipse': {'semiMajorConfidence': 4095, 'semiMinorConfidence': 4095, 'semiMajorOrientation': 3601},
'altitude': {'altitudeValue': 800001, 'altitudeConfidence': 'unavailable'}},
'validityDuration': 600, 'stationType': 5}}}
sdu = self.codec.denm_type.encode(denm)
request = m.BtpDataRequest(sdu, btp_type=1, destination_port=2002, destination_port_info=0,
gn_packet_transport_type=m.TransportType.GBC, its_aid=37, permissions=b'',
area=m.DestinationArea(0, 520000000, 130000000, 500, 0, 0))
return self.btp(request)
def btp(self, request):
try:
self.client.btp_data_request(request)
return 0
except LinkError as error:
return int(error.result.code)
# ---- the diagnostic protocol ----
def execute(self, command: bytes) -> bytes:
with self.lock:
return self._execute(command)
def _execute(self, c: bytes) -> bytes:
result = 1
try:
if len(c) == 1 and c[0] == 0:
result = self.reset()
elif len(c) > 1 and c[0] == 9:
self.bundle = bytes(c[1:])
self.secured = True
self.provisioned = False # applied by the next reset, as hil_sut.cpp does
result = 0
elif not self.configured:
result = RESULT_REJECTED
elif len(c) >= 6 and c[0] == 1 and c[1] <= 1:
port, info = struct.unpack('>HH', c[2:6])
request = m.BtpDataRequest(bytes(c[6:]), btp_type=c[1], destination_port=port, destination_port_info=info,
its_aid=self.args.its_aid, permissions=b'')
result = self.btp(request)
elif len(c) > 13 and c[0] == 2:
result = self.test.inject(bytes(c[1:7]), bytes(c[7:13]), bytes(c[13:]))
elif len(c) >= 2 and c[0] == 3:
result = self.test.gn_request(c[1], bytes(c[2:]))
elif len(c) == 9 and c[0] == 4:
lat, lon = struct.unpack('>ii', c[1:9])
self.latitude, self.longitude = lat / 1e7, lon / 1e7
self.poti()
result = 0
elif len(c) == 9 and c[0] == 5:
microseconds = struct.unpack('>Q', c[1:9])[0]
self.time_ms = microseconds // 1000
self.poti()
result = 0
for kind in (0, 1):
if self.carrier_pending[kind] is not None:
sequence = self.carrier_pending[kind]
self.carrier_pending[kind] = None
result = self.carrier(kind, sequence)
elif self.carrier_interval[kind] and self.time_ms >= self.carrier_next[kind]:
self.carrier_next[kind] = self.time_ms + self.carrier_interval[kind]
if kind == 1:
self.denm_sequence += 1
result = self.carrier(kind, self.denm_sequence if kind == 1 else 0)
elif len(c) in (2, 4) and c[0] in (6, 8) and c[1] < 2:
sequence = struct.unpack('>H', c[2:4])[0] if len(c) == 4 else 0
if c[0] == 6:
result = self.carrier(c[1], sequence)
else:
self.carrier_pending[c[1]] = sequence
result = 0
elif len(c) == 4 and c[0] == 7 and c[1] < 2:
self.carrier_interval[c[1]] = struct.unpack('>H', c[2:4])[0]
self.carrier_next[c[1]] = self.time_ms
result = 0
elif c and c[0] > 9:
result = 2
except LinkError as error:
result = int(error.result.code)
except TimeoutError as error:
log('timeout: %s' % error)
result = RESULT_REJECTED
records = []
if self.configured:
try:
overflow, records = self.test.drain()
if overflow:
result = 5
except Exception as error:
log('drain failed: %s' % error)
out = bytes([result & 0xFF, len(records)])
for kind, data in records:
out += struct.pack('>BH', kind, len(data)) + data
return out
def serve_stdio(self):
for line in sys.stdin:
line = line.strip()
if not line:
continue
print(self.execute(bytes.fromhex(line)).hex(), flush=True)
def serve_tcp(self, host, port):
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind((host, port))
server.listen(1)
log('listening on %s:%d' % (host, port))
while True:
connection, peer = server.accept()
log('relay connected from %s:%d' % peer)
with connection, connection.makefile('rb') as reader, connection.makefile('wb') as writer:
for line in reader:
line = line.strip()
if not line:
continue
writer.write(self.execute(bytes.fromhex(line.decode())).hex().encode() + b'\n')
writer.flush()
log('relay disconnected')
def close(self):
try:
self.test.mirror(0)
except Exception:
pass
self.client.close()
def main():
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument('--port', required=True)
p.add_argument('--bundle', help='VCR1 credential bundle: secured profile with these credentials')
p.add_argument('--anonymous', action='store_true', help='itsGnLocalAddrConfMethod ANONYMOUS')
p.add_argument('--station-type', type=int)
p.add_argument('--its-aid', type=int, default=36, help='ITS-AID for command 1 (BTP-DATA.request) in a secured profile')
p.add_argument('--listen', help='host:port to serve the hex-line protocol over TCP instead of stdin/stdout')
p.add_argument('--asn1')
p.add_argument('--timeout', type=float, default=5.0)
p.add_argument('--verbose', action='store_true')
args = p.parse_args()
bridge = Bridge(args)
try:
if args.listen:
host, port = args.listen.rsplit(':', 1)
bridge.serve_tcp(host, int(port))
else:
bridge.serve_stdio()
except KeyboardInterrupt:
pass
finally:
bridge.close()
if __name__ == '__main__':
main()
@@ -0,0 +1,32 @@
#!/usr/bin/env python3
"""stdin/stdout <-> TCP relay: the SUT executable an ETSI adapter forks inside WSL, forwarding
the hex-line protocol to sut_bridge.py --listen on the Windows side (which owns the COM port).
run_etsi.py ... --sut /usr/bin/python3 --sut-args "/root/sut_tcp_relay.py --connect 172.x.x.x:7788"
"""
import argparse
import socket
import sys
def main():
p = argparse.ArgumentParser(description=__doc__)
p.add_argument('--connect', required=True, help='host:port of sut_bridge.py --listen')
args = p.parse_args()
host, port = args.connect.rsplit(':', 1)
with socket.create_connection((host, int(port))) as connection:
reader = connection.makefile('rb')
for line in sys.stdin:
line = line.strip()
if not line:
continue
connection.sendall(line.encode() + b'\n')
reply = reader.readline()
if not reply:
sys.exit(2)
sys.stdout.write(reply.decode().strip() + '\n')
sys.stdout.flush()
if __name__ == '__main__':
main()