492 lines
26 KiB
Python
492 lines
26 KiB
Python
#!/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()
|