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).
200 lines
8.6 KiB
Python
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)
|