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

246 lines
11 KiB
Python
Raw Normal View History

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