fix BLE scan with latest Bleak

This commit is contained in:
Kevin Hester
2024-06-29 09:10:20 -07:00
parent 81266e756b
commit 6df89f54a7
3 changed files with 73 additions and 60 deletions

8
.vscode/launch.json vendored
View File

@@ -11,6 +11,14 @@
"module": "meshtastic",
"justMyCode": false,
"args": ["--debug", "--ble", "24:62:AB:DD:DF:3A"]
},
{
"name": "meshtastic BLE scan",
"type": "python",
"request": "launch",
"module": "meshtastic",
"justMyCode": false,
"args": ["--debug", "--ble-scan"]
},
{
"name": "meshtastic admin",

View File

@@ -1041,12 +1041,8 @@ def common():
subscribe()
if args.ble_scan:
logging.debug("BLE scan starting")
client = BLEInterface(None, debugOut=logfile, noProto=args.noproto)
try:
for x in client.scan():
print(f"Found: name='{x[1].local_name}' address='{x[0].address}'")
finally:
client.close()
for x in BLEInterface.scan():
print(f"Found: name='{x.name}' address='{x.address}'")
meshtastic.util.our_exit("BLE scan finished", 0)
return
elif args.ble:

View File

@@ -1,13 +1,13 @@
"""Bluetooth interface
"""
import logging
import time
import struct
import asyncio
from threading import Thread, Event
import logging
import struct
import time
from threading import Event, Thread
from typing import Optional
from bleak import BleakScanner, BleakClient, BLEDevice
from bleak import BleakClient, BleakScanner, BLEDevice
from meshtastic.mesh_interface import MeshInterface
from meshtastic.util import our_exit
@@ -20,25 +20,32 @@ FROMNUM_UUID = "ed9da18c-a800-4f66-a670-aa7547e34453"
class BLEInterface(MeshInterface):
"""MeshInterface using BLE to connect to devices"""
class BLEError(Exception):
"""An exception class for BLE errors"""
def __init__(self, message):
self.message = message
super().__init__(self.message)
class BLEState(): # pylint: disable=C0115
class BLEState: # pylint: disable=C0115
THREADS = False
BLE = False
MESH = False
def __init__(self, address: Optional[str], noProto: bool = False, debugOut = None, noNodes: bool = False):
def __init__(
self,
address: Optional[str],
noProto: bool = False,
debugOut=None,
noNodes: bool = False,
):
self.state = BLEInterface.BLEState()
self.should_read = False
logging.debug("Threads starting")
self._receiveThread = Thread(target = self._receiveFromRadioImpl)
self._receiveThread = Thread(target=self._receiveFromRadioImpl)
self._receiveThread_started = Event()
self._receiveThread_stopped = Event()
self._receiveThread.start()
@@ -56,10 +63,12 @@ class BLEInterface(MeshInterface):
raise e
logging.debug("Mesh init starting")
MeshInterface.__init__(self, debugOut = debugOut, noProto = noProto, noNodes = noNodes)
MeshInterface.__init__(
self, debugOut=debugOut, noProto=noProto, noNodes=noNodes
)
self._startConfig()
if not self.noProto:
self._waitConnected(timeout = 60.0)
self._waitConnected(timeout=60.0)
self.waitForConfig()
self.state.MESH = True
logging.debug("Mesh init finished")
@@ -67,53 +76,55 @@ class BLEInterface(MeshInterface):
logging.debug("Register FROMNUM notify callback")
self.client.start_notify(FROMNUM_UUID, self.from_num_handler)
async def from_num_handler(self, _, b): # pylint: disable=C0116
from_num = struct.unpack('<I', bytes(b))[0]
async def from_num_handler(self, _, b): # pylint: disable=C0116
from_num = struct.unpack("<I", bytes(b))[0]
logging.debug(f"FROMNUM notify: {from_num}")
self.should_read = True
def scan(self) -> list[BLEDevice]:
@staticmethod
def scan() -> list[BLEDevice]:
"""Scan for available BLE devices."""
with BLEClient() as client:
response = client.discover(
return_adv = True,
service_uuids=[SERVICE_UUID]
)
response = client.discover(return_adv=True, service_uuids=[SERVICE_UUID])
devices = response.values()
# bleak sometimes returns devices we didn't ask for, so filter the response
# to only return true meshtastic devices
# d[0] is the device. d[1] is the advertisement data
devices = list(filter(lambda d: SERVICE_UUID in d[1].service_uuids, devices))
devices = list(
filter(lambda d: SERVICE_UUID in d[1].service_uuids, devices)
)
return list(map(lambda d: d[0], devices))
def find_device(self, address: Optional[str]) -> BLEDevice:
"""Find a device by address"""
addressed_devices = self.scan()
"""Find a device by address."""
addressed_devices = BLEInterface.scan()
if address:
addressed_devices = list(filter(lambda x: address == x.name or address == x.address, addressed_devices))
addressed_devices = list(
filter(
lambda x: address == x.name or address == x.address,
addressed_devices,
)
)
if len(addressed_devices) == 0:
raise BLEInterface.BLEError(f"No Meshtastic BLE peripheral with identifier or address '{address}' found. Try --ble-scan to find it.")
raise BLEInterface.BLEError(
f"No Meshtastic BLE peripheral with identifier or address '{address}' found. Try --ble-scan to find it."
)
if len(addressed_devices) > 1:
raise BLEInterface.BLEError(f"More than one Meshtastic BLE peripheral with identifier or address '{address}' found.")
raise BLEInterface.BLEError(
f"More than one Meshtastic BLE peripheral with identifier or address '{address}' found."
)
return addressed_devices[0]
def _sanitize_address(address): # pylint: disable=E0213
"Standardize BLE address by removing extraneous characters and lowercasing"
return address \
.replace("-", "") \
.replace("_", "") \
.replace(":", "") \
.lower()
def _sanitize_address(address): # pylint: disable=E0213
"Standardize BLE address by removing extraneous characters and lowercasing."
return address.replace("-", "").replace("_", "").replace(":", "").lower()
def connect(self, address: Optional[str] = None):
"Connect to a device by address"
"Connect to a device by address."
# Bleak docs recommend always doing a scan before connecting (even if we know addr)
device = self.find_device(address)
@@ -121,7 +132,6 @@ class BLEInterface(MeshInterface):
client.connect()
return client
def _receiveFromRadioImpl(self):
self._receiveThread_started.set()
while self._receiveThread_started.is_set():
@@ -146,12 +156,11 @@ class BLEInterface(MeshInterface):
b = toRadio.SerializeToString()
if b:
logging.debug(f"TORADIO write: {b.hex()}")
self.client.write_gatt_char(TORADIO_UUID, b, response = True)
self.client.write_gatt_char(TORADIO_UUID, b, response=True)
# Allow to propagate and then make sure we read
time.sleep(0.1)
self.should_read = True
def close(self):
if self.state.MESH:
MeshInterface.close(self)
@@ -165,10 +174,11 @@ class BLEInterface(MeshInterface):
self.client.close()
class BLEClient():
class BLEClient:
"""Client for managing connection to a BLE device"""
def __init__(self, address = None, **kwargs):
self._eventThread = Thread(target = self._run_event_loop)
def __init__(self, address=None, **kwargs):
self._eventThread = Thread(target=self._run_event_loop)
self._eventThread_started = Event()
self._eventThread_stopped = Event()
self._eventThread.start()
@@ -180,47 +190,46 @@ class BLEClient():
self.bleak_client = BleakClient(address, **kwargs)
def discover(self, **kwargs): # pylint: disable=C0116
def discover(self, **kwargs): # pylint: disable=C0116
return self.async_await(BleakScanner.discover(**kwargs))
def pair(self, **kwargs): # pylint: disable=C0116
def pair(self, **kwargs): # pylint: disable=C0116
return self.async_await(self.bleak_client.pair(**kwargs))
def connect(self, **kwargs): # pylint: disable=C0116
def connect(self, **kwargs): # pylint: disable=C0116
return self.async_await(self.bleak_client.connect(**kwargs))
def disconnect(self, **kwargs): # pylint: disable=C0116
def disconnect(self, **kwargs): # pylint: disable=C0116
self.async_await(self.bleak_client.disconnect(**kwargs))
def read_gatt_char(self, *args, **kwargs): # pylint: disable=C0116
def read_gatt_char(self, *args, **kwargs): # pylint: disable=C0116
return self.async_await(self.bleak_client.read_gatt_char(*args, **kwargs))
def write_gatt_char(self, *args, **kwargs): # pylint: disable=C0116
def write_gatt_char(self, *args, **kwargs): # pylint: disable=C0116
self.async_await(self.bleak_client.write_gatt_char(*args, **kwargs))
def start_notify(self, *args, **kwargs): # pylint: disable=C0116
def start_notify(self, *args, **kwargs): # pylint: disable=C0116
self.async_await(self.bleak_client.start_notify(*args, **kwargs))
def close(self): # pylint: disable=C0116
def close(self): # pylint: disable=C0116
self.async_run(self._stop_event_loop())
self._eventThread_stopped.wait(5)
def __enter__(self):
return self
def __exit__(self, _type, _value, _traceback):
self.close()
def async_await(self, coro, timeout = None): # pylint: disable=C0116
def async_await(self, coro, timeout=None): # pylint: disable=C0116
return self.async_run(coro).result(timeout)
def async_run(self, coro): # pylint: disable=C0116
def async_run(self, coro): # pylint: disable=C0116
return asyncio.run_coroutine_threadsafe(coro, self._eventLoop)
def _run_event_loop(self):
# I don't know if the event loop can be initialized in __init__ so silencing pylint
self._eventLoop = asyncio.new_event_loop() # pylint: disable=W0201
self._eventLoop = asyncio.new_event_loop() # pylint: disable=W0201
self._eventThread_started.set()
try:
self._eventLoop.run_forever()