Files
MicrOBU/microbu-esp32c5/station-link/python/sut_bridge.py
T

274 lines
13 KiB
Python
Raw Normal View History

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