"""BLE GATT transport for the station-link message layer. The ESP32 exposes the Nordic-UART-shaped service (NUS) documented in the station-link README. Values contain binary station-link messages (up to 512 bytes). Bleak runs on a private asyncio thread while the LinkClient remains a synchronous request/reply API. Fixed passkey bonding is used (passkey: 123456). """ from __future__ import annotations import asyncio import queue import threading from typing import Callable, Optional from . import messages as m from .serial_transport import FrameType # Structured ETSI C-ITS Station Service UUIDs SERVICE_UUID = '0000c175-ba5e-4c17-8000-00805f9b34fb' BTP_REQUEST_UUID = '0000c176-ba5e-4c17-8000-00805f9b34fb' BTP_INDICATION_UUID = '0000c177-ba5e-4c17-8000-00805f9b34fb' POTI_UUID = '0000c178-ba5e-4c17-8000-00805f9b34fb' STATUS_UUID = '0000c179-ba5e-4c17-8000-00805f9b34fb' ID_EVENT_UUID = '0000c17a-ba5e-4c17-8000-00805f9b34fb' CONFIG_UUID = '0000c17b-ba5e-4c17-8000-00805f9b34fb' RESULT_UUID = '0000c17c-ba5e-4c17-8000-00805f9b34fb' FIXED_PASSKEY = 123456 class BleTransport: """Synchronous LinkClient adapter backed by Bleak on a private event loop. Transmits complete station-link messages directly without ATT fragmentation chunking or queue delays. """ def __init__(self, target: Optional[str], on_frame: Callable[[int, bytes], None], connect_timeout: float = 20.0, operation_timeout: float = 5.0): self.target = target or None self.on_frame = on_frame self.connect_timeout = connect_timeout self.operation_timeout = operation_timeout self.attribute_value = m.MAXIMUM_MESSAGE self.device_name = '' self.device_address = '' self.error: Optional[BaseException] = None self.client = None self.loop = asyncio.new_event_loop() self.stop_event = None self.ready = threading.Event() self.delivery: 'queue.Queue[Optional[bytes]]' = queue.Queue() self.delivery_thread = threading.Thread(target=self._deliver, name='microbu-ble-delivery', daemon=True) self.delivery_thread.start() self.thread = threading.Thread(target=self._thread_main, name='microbu-ble-asyncio', daemon=True) self.thread.start() if not self.ready.wait(connect_timeout + 5): self.close() raise TimeoutError('BLE scan/connect did not finish') if self.error: error = self.error self.close() raise ConnectionError('BLE station-link connection failed: %s' % error) from error def _thread_main(self): asyncio.set_event_loop(self.loop) try: self.loop.run_until_complete(self._session()) except BaseException as error: self.error = error self.ready.set() finally: self.loop.close() async def _session(self): from bleak import BleakClient, BleakScanner target = self.target.lower() if self.target else None def matches(device, advertisement): names = (device.name or '', advertisement.local_name or '') services = [uuid.lower() for uuid in advertisement.service_uuids] if target: return target == device.address.lower() or any(target == name.lower() for name in names) return SERVICE_UUID in services or any(name.lower().startswith('microbu-') for name in names) device = await BleakScanner.find_device_by_filter(matches, timeout=self.connect_timeout) if device is None: wanted = self.target or 'advertised micrOBU service' raise TimeoutError('no BLE device matching %s' % wanted) self.device_name = device.name or 'micrOBU' self.device_address = device.address self.stop_event = asyncio.Event() self.client = BleakClient(device, timeout=self.connect_timeout, pair=True, disconnected_callback=self._disconnected) await self.client.connect() # LE Secure Connections + passkey display takes ~1-3 s; wait for the link # to be encrypted before attempting CCCD writes on authenticated characteristics. for _ in range(30): await asyncio.sleep(0.1) try: # Attempt a read on STATUS (Read|Encrypt) as an encryption probe. # Once it succeeds (or gives ATT error other than InsufficientEncryption) # the link is encrypted and CCCD writes on notify characteristics will work. await self.client.read_gatt_char(STATUS_UUID) break except Exception as exc: exc_str = str(exc).lower() if 'insufficient' in exc_str or 'encrypt' in exc_str or 'auth' in exc_str: continue # still pairing break # any other error — proceed anyway # Subscribe to peripheral notification characteristics that matter for normal operation # (BTP_INDICATION, ID_EVENT, RESULT). STATUS is polled or read on demand, but can be notified if supported. subscribed_uuids = set() for notif_uuid in (RESULT_UUID, BTP_INDICATION_UUID, ID_EVENT_UUID): for attempt in range(3): try: await self.client.start_notify(notif_uuid, self._notification) subscribed_uuids.add(notif_uuid) break except Exception as exc: if attempt < 2: await asyncio.sleep(0.5) else: print(f'BLE: start_notify on {notif_uuid[:8]} failed: {exc}') self.attribute_value = min(self.client.mtu_size - 3, m.MAXIMUM_MESSAGE) self.ready.set() await self.stop_event.wait() if self.client.is_connected: await self.client.disconnect() def _disconnected(self, _client): if self.stop_event and not self.stop_event.is_set(): self.error = ConnectionError('micrOBU disconnected') self.stop_event.set() def _notification(self, characteristic, data: bytearray): # Direct immediate message delivery: each GATT notification is one complete link message self.delivery.put(bytes(data)) def _deliver(self): while True: message = self.delivery.get() if message is None: return try: self.on_frame(FrameType.LINK, message) except Exception as error: print('BLE message handler error:', error) def write(self, frame_type: int, payload: bytes): if frame_type != FrameType.LINK: raise NotImplementedError('the USB test/log channels do not exist over BLE') if self.error: raise ConnectionError(str(self.error)) if self.client is None or not self.client.is_connected or self.loop.is_closed(): raise ConnectionError('BLE station link is not ready') # Route write to the specific ETSI C-ITS GATT characteristic. # Check opcode in link header (payload[0]) target_uuid = CONFIG_UUID if payload: op = payload[0] if op == m.Opcode.BTP_DATA_REQUEST: target_uuid = BTP_REQUEST_UUID elif op == m.Opcode.POTI_UPDATE: target_uuid = POTI_UUID elif op == m.Opcode.SF_IDCHANGE_EVENT_RESPONSE: target_uuid = ID_EVENT_UUID else: target_uuid = CONFIG_UUID async def send(): for attempt in range(4): try: await self.client.write_gatt_char(target_uuid, bytes(payload), response=True) return except Exception as error: if 'insufficient resource' not in str(error).lower() or attempt == 3: raise await asyncio.sleep(0.1 * (attempt + 1)) future = asyncio.run_coroutine_threadsafe(send(), self.loop) try: future.result(timeout=self.operation_timeout) except Exception as error: raise ConnectionError('BLE write failed: %s' % error) from error def close(self): if self.stop_event and not self.loop.is_closed(): self.loop.call_soon_threadsafe(self.stop_event.set) if hasattr(self, 'thread') and self.thread.is_alive() and threading.current_thread() is not self.thread: self.thread.join(timeout=self.operation_timeout) self.delivery.put(None) if self.delivery_thread.is_alive() and threading.current_thread() is not self.delivery_thread: self.delivery_thread.join(timeout=1)