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).
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user