Skip to content

Commit 4721bc5

Browse files
authored
Merge pull request #473 from wnagele/BLE_support
BLE Support
2 parents bc67546 + 0a8a193 commit 4721bc5

File tree

9 files changed

+228
-96
lines changed

9 files changed

+228
-96
lines changed

.vscode/launch.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
"request": "launch",
1111
"module": "meshtastic",
1212
"justMyCode": false,
13-
"args": ["--debug", "--ble", "--device", "24:62:AB:DD:DF:3A"]
13+
"args": ["--debug", "--ble", "24:62:AB:DD:DF:3A"]
1414
},
1515
{
1616
"name": "meshtastic admin",

TODO.md

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ Basic functionality is complete now.
55
## Eventual tasks
66

77
- Improve documentation on properties/fields
8-
- change back to Bleak for BLE support - now that they fixed https://github.com/hbldh/bleak/issues/139#event-3499535304
98
- include more examples: textchat.py, replymessage.py all as one little demo
109

1110
- possibly use tk to make a multiwindow test console: https://stackoverflow.com/questions/12351786/how-to-redirect-print-statements-to-tkinter-text-widget
@@ -17,11 +16,8 @@ Basic functionality is complete now.
1716

1817
## Bluetooth support
1918

20-
(Pre-alpha level feature - you probably don't want this one yet)
21-
22-
- This library supports connecting to Meshtastic devices over either USB (serial) or Bluetooth. Before connecting to the device you must [pair](https://docs.ubuntu.com/core/en/stacks/bluetooth/bluez/docs/reference/pairing/outbound.html) your PC with it.
23-
- We use the pip3 install "pygatt[GATTTOOL]"
24-
- ./bin/run.sh --debug --ble --device 24:62:AB:DD:DF:3A
19+
- ./bin/run.sh --ble-scan # To look for Meshtastic devices
20+
- ./bin/run.sh --ble 24:62:AB:DD:DF:3A --info
2521

2622
## Done
2723

meshtastic/__main__.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -945,7 +945,17 @@ def common():
945945
our_globals.set_logfile(logfile)
946946

947947
subscribe()
948-
if args.ble:
948+
if args.ble_scan:
949+
logging.debug("BLE scan starting")
950+
client = BLEInterface(None, debugOut=logfile, noProto=args.noproto)
951+
try:
952+
for x in client.scan():
953+
print(f"Found: name='{x[1].local_name}' address='{x[0].address}'")
954+
finally:
955+
client.close()
956+
meshtastic.util.our_exit("BLE scan finished", 0)
957+
return
958+
elif args.ble:
949959
client = BLEInterface(args.ble, debugOut=logfile, noProto=args.noproto)
950960
elif args.host:
951961
try:
@@ -1310,9 +1320,14 @@ def initParser():
13101320

13111321
parser.add_argument(
13121322
"--ble",
1313-
help="BLE mac address to connect to (BLE is not yet supported for this tool)",
1323+
help="BLE device address or name to connect to",
13141324
default=None,
13151325
)
1326+
parser.add_argument(
1327+
"--ble-scan",
1328+
help="Scan for Meshtastic BLE devices",
1329+
action="store_true",
1330+
)
13161331

13171332
parser.add_argument(
13181333
"--noproto",

meshtastic/ble.py

Whitespace-only changes.

meshtastic/ble_interface.py

Lines changed: 206 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,66 +1,224 @@
11
"""Bluetooth interface
22
"""
33
import logging
4-
import platform
5-
4+
import time
5+
import struct
6+
from threading import Thread, Event
67
from meshtastic.mesh_interface import MeshInterface
78
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
1211

1312

14-
# Our standard BLE characteristics
13+
SERVICE_UUID = "6ba1b218-15a8-461f-9fa8-5dcae273eafd"
1514
TORADIO_UUID = "f75c76d2-129e-4dad-a1dd-7866124401e7"
16-
FROMRADIO_UUID = "8ba2bcc2-ee02-4a55-a531-c525c5e454d5"
15+
FROMRADIO_UUID = "2c55e69e-4993-11ed-b878-0242ac120002"
1716
FROMNUM_UUID = "ed9da18c-a800-4f66-a670-aa7547e34453"
1817

1918

2019
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()
46136

47137
def _sendToRadioImpl(self, toRadio):
48-
"""Send a ToRadio protobuf to the device"""
49-
# logging.debug(f"Sending: {stripnl(toRadio)}")
50138
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+
52146

53147
def close(self):
54-
MeshInterface.close(self)
55-
if self.adapter:
56-
self.adapter.stop()
148+
if self.state.MESH:
149+
MeshInterface.close(self)
57150

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

meshtastic/tests/test_ble_interface.py

Lines changed: 0 additions & 17 deletions
This file was deleted.

meshtastic/tests/test_main.py

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -283,26 +283,6 @@ def mock_showInfo():
283283
mo.assert_called()
284284

285285

286-
# TODO: comment out ble (for now)
287-
# @pytest.mark.unit
288-
# def test_main_info_with_ble_interface(capsys):
289-
# """Test --info"""
290-
# sys.argv = ['', '--info', '--ble', 'foo']
291-
# Globals.getInstance().set_args(sys.argv)
292-
#
293-
# iface = MagicMock(autospec=BLEInterface)
294-
# def mock_showInfo():
295-
# print('inside mocked showInfo')
296-
# iface.showInfo.side_effect = mock_showInfo
297-
# with patch('meshtastic.ble_interface.BLEInterface', return_value=iface) as mo:
298-
# main()
299-
# out, err = capsys.readouterr()
300-
# assert re.search(r'Connected to radio', out, re.MULTILINE)
301-
# assert re.search(r'inside mocked showInfo', out, re.MULTILINE)
302-
# assert err == ''
303-
# mo.assert_called()
304-
305-
306286
@pytest.mark.unit
307287
@pytest.mark.usefixtures("reset_globals")
308288
def test_main_no_proto(capsys):

requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,4 @@ pyyaml
1818
pytap2
1919
pdoc3
2020
pypubsub
21-
pygatt; platform_system == "Linux"
21+
bleak

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
"tabulate>=0.8.9",
4444
"timeago>=1.0.15",
4545
"pyyaml",
46-
"pygatt>=4.0.5 ; platform_system=='Linux'",
46+
"bleak>=0.21.1",
4747
],
4848
extras_require={"tunnel": ["pytap2>=2.0.0"]},
4949
python_requires=">=3.7",

0 commit comments

Comments
 (0)