"""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]))