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