Files
MicrOBU/microbu-esp32c5/station-link/python/microbu_link/ble_transport.py
T
Ashin Walpola 0e9525162d Keep the colleague's microbu-esp32c5 tree in this repository
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).
2026-09-23 17:46:40 +02:00

200 lines
8.6 KiB
Python

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