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).
246 lines
11 KiB
Python
246 lines
11 KiB
Python
"""Request/reply client of the station-internal link plus the USB test channel."""
|
|
from __future__ import annotations
|
|
|
|
import queue
|
|
import threading
|
|
import time
|
|
from typing import Callable, Dict, List, Optional, Tuple
|
|
|
|
from . import messages as m
|
|
from .serial_transport import FrameType, SerialTransport
|
|
from .transport import TransportAdapter
|
|
|
|
|
|
class LinkError(RuntimeError):
|
|
def __init__(self, result: m.Result, request: str):
|
|
super().__init__('%s refused: %s' % (request, result.name))
|
|
self.result = result
|
|
|
|
|
|
class LinkClient:
|
|
"""The phone's view of the micrOBU: send requests, await their RESULT, receive the
|
|
micrOBU's own messages (indications, identifier-change events, status, log lines)
|
|
through callbacks. Thread-safe for one request at a time."""
|
|
|
|
def __init__(self, port: Optional[str] = None, timeout: float = 3.0,
|
|
transport_factory: Optional[Callable[[Callable[[int, bytes], None]], TransportAdapter]] = None):
|
|
self.timeout = timeout
|
|
self.sequence = 0
|
|
self.lock = threading.Lock()
|
|
self.replies: 'queue.Queue[m.Message]' = queue.Queue()
|
|
self.on_indication: Optional[Callable[[m.BtpDataIndication], None]] = None
|
|
self.on_id_event: Optional[Callable[[m.IdChangeEvent, int], None]] = None
|
|
self.on_mf_set: Optional[Callable[[m.MfSetRequest], None]] = None
|
|
self.on_status: Optional[Callable[[m.Status], None]] = None
|
|
self.on_log: Optional[Callable[[str], None]] = None
|
|
self.last_status: Optional[m.Status] = None
|
|
self.test_replies: 'queue.Queue[bytes]' = queue.Queue()
|
|
if transport_factory:
|
|
self.transport = transport_factory(self._on_frame)
|
|
elif port:
|
|
self.transport = SerialTransport(port, self._on_frame)
|
|
else:
|
|
raise ValueError('port or transport_factory is required')
|
|
|
|
def close(self):
|
|
self.transport.close()
|
|
|
|
# ---- receive side ----
|
|
def _on_frame(self, frame_type: int, payload: bytes):
|
|
if frame_type == FrameType.LOG:
|
|
if self.on_log:
|
|
self.on_log(payload.decode('utf-8', 'replace'))
|
|
return
|
|
if frame_type == FrameType.TEST:
|
|
self.test_replies.put(payload)
|
|
return
|
|
if frame_type != FrameType.LINK:
|
|
return
|
|
try:
|
|
message = m.decode_message(payload)
|
|
except m.DecodeError:
|
|
return
|
|
op = message.header.opcode
|
|
if op == m.Opcode.RESULT or (op == m.Opcode.STATUS and message.header.sequence == self._awaited_status):
|
|
self.replies.put(message)
|
|
elif op == m.Opcode.BTP_DATA_INDICATION:
|
|
if self.on_indication:
|
|
self.on_indication(m.BtpDataIndication.decode(message.body))
|
|
elif op == m.Opcode.SF_IDCHANGE_EVENT:
|
|
if self.on_id_event:
|
|
self.on_id_event(m.IdChangeEvent.decode(message.body), message.header.sequence)
|
|
elif op == m.Opcode.MF_SET_REQUEST:
|
|
if self.on_mf_set:
|
|
self.on_mf_set(m.MfSetRequest.decode(message.body))
|
|
elif op == m.Opcode.STATUS:
|
|
self.last_status = m.Status.decode(message.body)
|
|
if self.on_status:
|
|
self.on_status(self.last_status)
|
|
|
|
_awaited_status = -1
|
|
|
|
# ---- send side ----
|
|
def send(self, opcode: int, body: bytes = b'', sequence: Optional[int] = None) -> int:
|
|
"""Send one message without waiting (indication responses, PoTi updates)."""
|
|
if sequence is None:
|
|
self.sequence = (self.sequence + 1) & 0xFFFF
|
|
sequence = self.sequence
|
|
self.transport.write(FrameType.LINK, m.encode_message(m.Message(m.Header(opcode, 0, sequence), body)))
|
|
return sequence
|
|
|
|
def request(self, opcode: int, body: bytes = b'', timeout: Optional[float] = None) -> m.Result:
|
|
"""Send a request and wait for its RESULT (raises LinkError unless accepted)."""
|
|
with self.lock:
|
|
while not self.replies.empty(): # stale replies of timed-out requests
|
|
self.replies.get_nowait()
|
|
sequence = self.send(opcode, body)
|
|
deadline = time.monotonic() + (timeout or self.timeout)
|
|
while True:
|
|
remaining = deadline - time.monotonic()
|
|
if remaining <= 0:
|
|
raise TimeoutError('no RESULT for opcode 0x%02x (sequence %d)' % (opcode, sequence))
|
|
try:
|
|
reply = self.replies.get(timeout=remaining)
|
|
except queue.Empty:
|
|
continue
|
|
if reply.header.sequence != sequence or reply.header.opcode != m.Opcode.RESULT:
|
|
continue
|
|
result = m.Result.decode(reply.body)
|
|
if result.code != m.Code.accepted:
|
|
raise LinkError(result, m.Opcode(opcode).name)
|
|
return result
|
|
|
|
def request_async(self, opcode: int, body: bytes = b'') -> int:
|
|
"""Submit a request without serializing on its RESULT.
|
|
|
|
The peer still emits the ordinary per-request RESULT. Call
|
|
receive_result() to consume it. This is used for the 100 ms VAM stream,
|
|
where a BLE connection interval must not become the service period.
|
|
"""
|
|
with self.lock:
|
|
return self.send(opcode, body)
|
|
|
|
def receive_result(self, timeout: Optional[float] = None) -> Tuple[int, m.Result]:
|
|
reply = self.replies.get(timeout=self.timeout if timeout is None else timeout)
|
|
if reply.header.opcode != m.Opcode.RESULT:
|
|
raise ValueError('expected RESULT, got opcode 0x%02x' % reply.header.opcode)
|
|
return reply.header.sequence, m.Result.decode(reply.body)
|
|
|
|
# ---- primitives ----
|
|
def configure(self, config: m.StationConfigure) -> m.StationInfo:
|
|
# Starting the ESP32-C5 ITS-G5 radio initializes the Wi-Fi/PHY driver
|
|
# before the station can reply. Keep ordinary request timeouts short,
|
|
# but allow this idempotent lifecycle operation to finish on hardware.
|
|
return m.StationInfo.decode(self.request(m.Opcode.STATION_CONFIGURE, config.encode(), timeout=30).detail)
|
|
|
|
def poti(self, update: m.PotiUpdate):
|
|
"""Silent unless refused; a refusal arrives as a RESULT that request() ignores."""
|
|
self.send(m.Opcode.POTI_UPDATE, update.encode())
|
|
|
|
def btp_data_request(self, request: m.BtpDataRequest) -> m.Result:
|
|
return self.request(m.Opcode.BTP_DATA_REQUEST, request.encode())
|
|
|
|
def provision(self, bundle: bytes) -> Tuple[int, int, int]:
|
|
result = None
|
|
for segment in m.provision_segments(bundle):
|
|
result = self.request(m.Opcode.CREDENTIALS_PROVISION, segment.encode(), timeout=20)
|
|
assert result is not None and len(result.detail) == 3, 'apply report expected'
|
|
return result.detail[0], result.detail[1], result.detail[2]
|
|
|
|
def erase_credentials(self):
|
|
self.request(m.Opcode.CREDENTIALS_ERASE, timeout=10)
|
|
|
|
def subscribe(self, subscriber_data: bytes = b'') -> int:
|
|
result = self.request(m.Opcode.SF_IDCHANGE_SUBSCRIBE, bytes([len(subscriber_data)]) + subscriber_data)
|
|
return int.from_bytes(result.detail, 'little')
|
|
|
|
def unsubscribe(self, subscription: int):
|
|
self.request(m.Opcode.SF_IDCHANGE_UNSUBSCRIBE, subscription.to_bytes(8, 'little'))
|
|
|
|
def event_response(self, event: m.IdChangeEvent, event_sequence: int, return_code: bool):
|
|
self.send(m.Opcode.SF_IDCHANGE_EVENT_RESPONSE, m.IdChangeEventResponse(event.subscription, return_code).encode(),
|
|
sequence=event_sequence)
|
|
|
|
def trigger(self):
|
|
self.request(m.Opcode.SF_IDCHANGE_TRIGGER)
|
|
|
|
def lock(self, seconds: int) -> int:
|
|
return int.from_bytes(self.request(m.Opcode.SF_ID_LOCK, bytes([seconds])).detail, 'little')
|
|
|
|
def unlock(self, handle: int):
|
|
self.request(m.Opcode.SF_ID_UNLOCK, handle.to_bytes(8, 'little'))
|
|
|
|
def status(self, timeout: float = 5.0) -> m.Status:
|
|
with self.lock:
|
|
while not self.replies.empty():
|
|
self.replies.get_nowait()
|
|
self.sequence = (self.sequence + 1) & 0xFFFF
|
|
self._awaited_status = self.sequence
|
|
try:
|
|
self.transport.write(FrameType.LINK, m.encode_message(m.Message(m.Header(m.Opcode.STATUS_REQUEST, 0, self.sequence))))
|
|
deadline = time.monotonic() + timeout
|
|
while True:
|
|
remaining = deadline - time.monotonic()
|
|
if remaining <= 0:
|
|
raise TimeoutError('no STATUS')
|
|
reply = self.replies.get(timeout=remaining)
|
|
if reply.header.opcode == m.Opcode.STATUS and reply.header.sequence == self.sequence:
|
|
self.last_status = m.Status.decode(reply.body)
|
|
return self.last_status
|
|
finally:
|
|
self._awaited_status = -1
|
|
|
|
|
|
class TestChannel:
|
|
"""USB test channel (frame type 0x11): software lower tester and campaign hooks."""
|
|
|
|
MIRROR, INJECT, GN_REQUEST, DRAIN, RESET = 0x01, 0x02, 0x03, 0x04, 0x05
|
|
|
|
def __init__(self, client: LinkClient, timeout: float = 3.0):
|
|
self.client = client
|
|
self.timeout = timeout
|
|
|
|
def execute(self, request: bytes) -> Tuple[int, List[Tuple[int, bytes]]]:
|
|
with self.client.lock:
|
|
while not self.client.test_replies.empty():
|
|
self.client.test_replies.get_nowait()
|
|
self.client.transport.write(FrameType.TEST, request)
|
|
reply = self.client.test_replies.get(timeout=self.timeout)
|
|
result, count = reply[0], reply[1]
|
|
records, at = [], 2
|
|
for _ in range(count):
|
|
kind = reply[at]
|
|
length = int.from_bytes(reply[at + 1:at + 3], 'little')
|
|
records.append((kind, reply[at + 3:at + 3 + length]))
|
|
at += 3 + length
|
|
if at != len(reply):
|
|
raise ValueError('trailing test channel octets')
|
|
return result, records
|
|
|
|
def mirror(self, mode: int):
|
|
result, _ = self.execute(bytes([self.MIRROR, mode]))
|
|
if result != 0:
|
|
raise RuntimeError('mirror mode refused: %d' % result)
|
|
|
|
def inject(self, source: bytes, destination: bytes, gnpdu: bytes) -> int:
|
|
result, _ = self.execute(bytes([self.INJECT]) + source + destination + gnpdu)
|
|
return result
|
|
|
|
def gn_request(self, traffic_class: int, payload: bytes) -> int:
|
|
result, _ = self.execute(bytes([self.GN_REQUEST, traffic_class]) + payload)
|
|
return result
|
|
|
|
def drain(self) -> Tuple[bool, List[Tuple[int, bytes]]]:
|
|
"""All queued records (several frames when needed); returns (overflow, records)."""
|
|
records: List[Tuple[int, bytes]] = []
|
|
overflow = False
|
|
while True:
|
|
result, batch = self.execute(bytes([self.DRAIN]))
|
|
overflow = overflow or result == m.Code.resource_limit
|
|
records.extend(batch)
|
|
if not batch:
|
|
return overflow, records
|
|
|
|
def reset(self):
|
|
self.execute(bytes([self.RESET]))
|