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:
@@ -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()
|
||||
Reference in New Issue
Block a user