#!/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(' 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()