|
1 | 1 | """Bluetooth interface |
2 | 2 | """ |
3 | 3 | import logging |
4 | | -import platform |
5 | | - |
| 4 | +import time |
| 5 | +import struct |
| 6 | +from threading import Thread, Event |
6 | 7 | from meshtastic.mesh_interface import MeshInterface |
7 | 8 | from meshtastic.util import our_exit |
8 | | - |
9 | | -if platform.system() == "Linux": |
10 | | - # pylint: disable=E0401 |
11 | | - import pygatt |
| 9 | +from bleak import BleakScanner, BleakClient |
| 10 | +import asyncio |
12 | 11 |
|
13 | 12 |
|
14 | | -# Our standard BLE characteristics |
| 13 | +SERVICE_UUID = "6ba1b218-15a8-461f-9fa8-5dcae273eafd" |
15 | 14 | TORADIO_UUID = "f75c76d2-129e-4dad-a1dd-7866124401e7" |
16 | | -FROMRADIO_UUID = "8ba2bcc2-ee02-4a55-a531-c525c5e454d5" |
| 15 | +FROMRADIO_UUID = "2c55e69e-4993-11ed-b878-0242ac120002" |
17 | 16 | FROMNUM_UUID = "ed9da18c-a800-4f66-a670-aa7547e34453" |
18 | 17 |
|
19 | 18 |
|
20 | 19 | class BLEInterface(MeshInterface): |
21 | | - """A not quite ready - FIXME - BLE interface to devices""" |
22 | | - |
23 | | - def __init__(self, address, noProto=False, debugOut=None): |
24 | | - if platform.system() != "Linux": |
25 | | - our_exit("Linux is the only platform with experimental BLE support.", 1) |
26 | | - self.address = address |
27 | | - if not noProto: |
28 | | - self.adapter = pygatt.GATTToolBackend() # BGAPIBackend() |
29 | | - self.adapter.start() |
30 | | - logging.debug(f"Connecting to {self.address}") |
31 | | - self.device = self.adapter.connect(address) |
32 | | - else: |
33 | | - self.adapter = None |
34 | | - self.device = None |
35 | | - logging.debug("Connected to device") |
36 | | - # fromradio = self.device.char_read(FROMRADIO_UUID) |
37 | | - MeshInterface.__init__(self, debugOut=debugOut, noProto=noProto) |
38 | | - |
39 | | - self._readFromRadio() # read the initial responses |
40 | | - |
41 | | - def handle_data(handle, data): # pylint: disable=W0613 |
42 | | - self._handleFromRadio(data) |
43 | | - |
44 | | - if self.device: |
45 | | - self.device.subscribe(FROMNUM_UUID, callback=handle_data) |
| 20 | + class BLEError(Exception): |
| 21 | + def __init__(self, message): |
| 22 | + self.message = message |
| 23 | + super().__init__(self.message) |
| 24 | + |
| 25 | + |
| 26 | + class BLEState(): |
| 27 | + THREADS = False |
| 28 | + BLE = False |
| 29 | + MESH = False |
| 30 | + |
| 31 | + |
| 32 | + def __init__(self, address, noProto = False, debugOut = None): |
| 33 | + self.state = BLEInterface.BLEState() |
| 34 | + |
| 35 | + if not address: |
| 36 | + return |
| 37 | + |
| 38 | + self.should_read = False |
| 39 | + |
| 40 | + logging.debug("Threads starting") |
| 41 | + self._receiveThread = Thread(target = self._receiveFromRadioImpl) |
| 42 | + self._receiveThread_started = Event() |
| 43 | + self._receiveThread_stopped = Event() |
| 44 | + self._receiveThread.start() |
| 45 | + self._receiveThread_started.wait(1) |
| 46 | + self.state.THREADS = True |
| 47 | + logging.debug("Threads running") |
| 48 | + |
| 49 | + try: |
| 50 | + logging.debug(f"BLE connecting to: {address}") |
| 51 | + self.client = self.connect(address) |
| 52 | + self.state.BLE = True |
| 53 | + logging.debug("BLE connected") |
| 54 | + except BLEInterface.BLEError as e: |
| 55 | + self.close() |
| 56 | + our_exit(e.message, 1) |
| 57 | + return |
| 58 | + |
| 59 | + logging.debug("Mesh init starting") |
| 60 | + MeshInterface.__init__(self, debugOut = debugOut, noProto = noProto) |
| 61 | + self._startConfig() |
| 62 | + if not self.noProto: |
| 63 | + self._waitConnected() |
| 64 | + self.waitForConfig() |
| 65 | + self.state.MESH = True |
| 66 | + logging.debug("Mesh init finished") |
| 67 | + |
| 68 | + logging.debug("Register FROMNUM notify callback") |
| 69 | + self.client.start_notify(FROMNUM_UUID, self.from_num_handler) |
| 70 | + |
| 71 | + |
| 72 | + async def from_num_handler(self, _, b): |
| 73 | + from_num = struct.unpack('<I', bytes(b))[0] |
| 74 | + logging.debug(f"FROMNUM notify: {from_num}") |
| 75 | + self.should_read = True |
| 76 | + |
| 77 | + |
| 78 | + def scan(self): |
| 79 | + with BLEClient() as client: |
| 80 | + return [ |
| 81 | + (x[0], x[1]) for x in (client.discover( |
| 82 | + return_adv = True, |
| 83 | + service_uuids = [ SERVICE_UUID ] |
| 84 | + )).values() |
| 85 | + ] |
| 86 | + |
| 87 | + |
| 88 | + def find_device(self, address): |
| 89 | + meshtastic_devices = self.scan() |
| 90 | + |
| 91 | + addressed_devices = list(filter(lambda x: address == x[1].local_name or address == x[0].name, meshtastic_devices)) |
| 92 | + # If nothing is found try on the address |
| 93 | + if len(addressed_devices) == 0: |
| 94 | + addressed_devices = list(filter(lambda x: BLEInterface._sanitize_address(address) == BLEInterface._sanitize_address(x[0].address), meshtastic_devices)) |
| 95 | + |
| 96 | + if len(addressed_devices) == 0: |
| 97 | + raise BLEInterface.BLEError(f"No Meshtastic BLE peripheral with identifier or address '{address}' found. Try --ble-scan to find it.") |
| 98 | + if len(addressed_devices) > 1: |
| 99 | + raise BLEInterface.BLEError(f"More than one Meshtastic BLE peripheral with identifier or address '{address}' found.") |
| 100 | + return addressed_devices[0][0] |
| 101 | + |
| 102 | + def _sanitize_address(address): |
| 103 | + return address \ |
| 104 | + .replace("-", "") \ |
| 105 | + .replace("_", "") \ |
| 106 | + .replace(":", "") \ |
| 107 | + .lower() |
| 108 | + |
| 109 | + def connect(self, address): |
| 110 | + device = self.find_device(address) |
| 111 | + client = BLEClient(device.address) |
| 112 | + client.connect() |
| 113 | + try: |
| 114 | + client.pair() |
| 115 | + except NotImplementedError: |
| 116 | + # Some bluetooth backends do not require explicit pairing. |
| 117 | + # See Bleak docs for details on this. |
| 118 | + pass |
| 119 | + return client |
| 120 | + |
| 121 | + |
| 122 | + def _receiveFromRadioImpl(self): |
| 123 | + self._receiveThread_started.set() |
| 124 | + while self._receiveThread_started.is_set(): |
| 125 | + if self.should_read: |
| 126 | + self.should_read = False |
| 127 | + while True: |
| 128 | + b = bytes(self.client.read_gatt_char(FROMRADIO_UUID)) |
| 129 | + if not b: |
| 130 | + break |
| 131 | + logging.debug(f"FROMRADIO read: {b.hex()}") |
| 132 | + self._handleFromRadio(b) |
| 133 | + else: |
| 134 | + time.sleep(0.1) |
| 135 | + self._receiveThread_stopped.set() |
46 | 136 |
|
47 | 137 | def _sendToRadioImpl(self, toRadio): |
48 | | - """Send a ToRadio protobuf to the device""" |
49 | | - # logging.debug(f"Sending: {stripnl(toRadio)}") |
50 | 138 | b = toRadio.SerializeToString() |
51 | | - self.device.char_write(TORADIO_UUID, b) |
| 139 | + if b: |
| 140 | + logging.debug(f"TORADIO write: {b.hex()}") |
| 141 | + self.client.write_gatt_char(TORADIO_UUID, b, response = True) |
| 142 | + # Allow to propagate and then make sure we read |
| 143 | + time.sleep(0.1) |
| 144 | + self.should_read = True |
| 145 | + |
52 | 146 |
|
53 | 147 | def close(self): |
54 | | - MeshInterface.close(self) |
55 | | - if self.adapter: |
56 | | - self.adapter.stop() |
| 148 | + if self.state.MESH: |
| 149 | + MeshInterface.close(self) |
57 | 150 |
|
58 | | - def _readFromRadio(self): |
59 | | - if not self.noProto: |
60 | | - wasEmpty = False |
61 | | - while not wasEmpty: |
62 | | - if self.device: |
63 | | - b = self.device.char_read(FROMRADIO_UUID) |
64 | | - wasEmpty = len(b) == 0 |
65 | | - if not wasEmpty: |
66 | | - self._handleFromRadio(b) |
| 151 | + if self.state.THREADS: |
| 152 | + self._receiveThread_started.clear() |
| 153 | + self._receiveThread_stopped.wait(5) |
| 154 | + |
| 155 | + if self.state.BLE: |
| 156 | + self.client.disconnect() |
| 157 | + self.client.close() |
| 158 | + |
| 159 | + |
| 160 | +class BLEClient(): |
| 161 | + def __init__(self, address = None, **kwargs): |
| 162 | + self._eventThread = Thread(target = self._run_event_loop) |
| 163 | + self._eventThread_started = Event() |
| 164 | + self._eventThread_stopped = Event() |
| 165 | + self._eventThread.start() |
| 166 | + self._eventThread_started.wait(1) |
| 167 | + |
| 168 | + if not address: |
| 169 | + logging.debug("No address provided - only discover method will work.") |
| 170 | + return |
| 171 | + |
| 172 | + self.bleak_client = BleakClient(address, **kwargs) |
| 173 | + |
| 174 | + |
| 175 | + def discover(self, **kwargs): |
| 176 | + return self.async_await(BleakScanner.discover(**kwargs)) |
| 177 | + |
| 178 | + def pair(self, **kwargs): |
| 179 | + return self.async_await(self.bleak_client.pair(**kwargs)) |
| 180 | + |
| 181 | + def connect(self, **kwargs): |
| 182 | + return self.async_await(self.bleak_client.connect(**kwargs)) |
| 183 | + |
| 184 | + def disconnect(self, **kwargs): |
| 185 | + self.async_await(self.bleak_client.disconnect(**kwargs)) |
| 186 | + |
| 187 | + def read_gatt_char(self, *args, **kwargs): |
| 188 | + return self.async_await(self.bleak_client.read_gatt_char(*args, **kwargs)) |
| 189 | + |
| 190 | + def write_gatt_char(self, *args, **kwargs): |
| 191 | + self.async_await(self.bleak_client.write_gatt_char(*args, **kwargs)) |
| 192 | + |
| 193 | + def start_notify(self, *args, **kwargs): |
| 194 | + self.async_await(self.bleak_client.start_notify(*args, **kwargs)) |
| 195 | + |
| 196 | + |
| 197 | + def close(self): |
| 198 | + self.async_run(self._stop_event_loop()) |
| 199 | + self._eventThread_stopped.wait(5) |
| 200 | + |
| 201 | + def __enter__(self): |
| 202 | + return self |
| 203 | + |
| 204 | + def __exit__(self, type, value, traceback): |
| 205 | + self.close() |
| 206 | + |
| 207 | + |
| 208 | + def async_await(self, coro, timeout = None): |
| 209 | + return self.async_run(coro).result(timeout) |
| 210 | + |
| 211 | + def async_run(self, coro): |
| 212 | + return asyncio.run_coroutine_threadsafe(coro, self._eventLoop) |
| 213 | + |
| 214 | + def _run_event_loop(self): |
| 215 | + self._eventLoop = asyncio.new_event_loop() |
| 216 | + self._eventThread_started.set() |
| 217 | + try: |
| 218 | + self._eventLoop.run_forever() |
| 219 | + finally: |
| 220 | + self._eventLoop.close() |
| 221 | + self._eventThread_stopped.set() |
| 222 | + |
| 223 | + async def _stop_event_loop(self): |
| 224 | + self._eventLoop.stop() |
0 commit comments