diff --git a/.vscode/launch.json b/.vscode/launch.json index f3bf4de..6676991 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -10,7 +10,7 @@ "request": "launch", "module": "meshtastic", "justMyCode": false, - "args": ["--debug", "--ble", "--device", "24:62:AB:DD:DF:3A"] + "args": ["--debug", "--ble", "24:62:AB:DD:DF:3A"] }, { "name": "meshtastic admin", diff --git a/TODO.md b/TODO.md index ed3c177..a00c9f2 100644 --- a/TODO.md +++ b/TODO.md @@ -5,7 +5,6 @@ Basic functionality is complete now. ## Eventual tasks - Improve documentation on properties/fields -- change back to Bleak for BLE support - now that they fixed https://github.com/hbldh/bleak/issues/139#event-3499535304 - include more examples: textchat.py, replymessage.py all as one little demo - 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. ## Bluetooth support -(Pre-alpha level feature - you probably don't want this one yet) - -- 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. -- We use the pip3 install "pygatt[GATTTOOL]" -- ./bin/run.sh --debug --ble --device 24:62:AB:DD:DF:3A +- ./bin/run.sh --ble-scan # To look for Meshtastic devices +- ./bin/run.sh --ble 24:62:AB:DD:DF:3A --info ## Done diff --git a/meshtastic/__main__.py b/meshtastic/__main__.py index 1d0c248..0e39ebe 100644 --- a/meshtastic/__main__.py +++ b/meshtastic/__main__.py @@ -942,7 +942,17 @@ def common(): our_globals.set_logfile(logfile) subscribe() - if args.ble: + 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() + meshtastic.util.our_exit("BLE scan finished", 0) + return + elif args.ble: client = BLEInterface(args.ble, debugOut=logfile, noProto=args.noproto) elif args.host: client = meshtastic.tcp_interface.TCPInterface( @@ -1302,9 +1312,14 @@ def initParser(): parser.add_argument( "--ble", - help="BLE mac address to connect to (BLE is not yet supported for this tool)", + help="BLE device address or name to connect to", default=None, ) + parser.add_argument( + "--ble-scan", + help="Scan for Meshtastic BLE devices", + action="store_true", + ) parser.add_argument( "--noproto", diff --git a/meshtastic/ble.py b/meshtastic/ble.py deleted file mode 100644 index e69de29..0000000 diff --git a/meshtastic/ble_interface.py b/meshtastic/ble_interface.py index 171bde0..2cf2b1a 100644 --- a/meshtastic/ble_interface.py +++ b/meshtastic/ble_interface.py @@ -1,66 +1,224 @@ """Bluetooth interface """ import logging -import platform - +import time +import struct +from threading import Thread, Event from meshtastic.mesh_interface import MeshInterface from meshtastic.util import our_exit - -if platform.system() == "Linux": - # pylint: disable=E0401 - import pygatt +from bleak import BleakScanner, BleakClient +import asyncio -# Our standard BLE characteristics +SERVICE_UUID = "6ba1b218-15a8-461f-9fa8-5dcae273eafd" TORADIO_UUID = "f75c76d2-129e-4dad-a1dd-7866124401e7" -FROMRADIO_UUID = "8ba2bcc2-ee02-4a55-a531-c525c5e454d5" +FROMRADIO_UUID = "2c55e69e-4993-11ed-b878-0242ac120002" FROMNUM_UUID = "ed9da18c-a800-4f66-a670-aa7547e34453" class BLEInterface(MeshInterface): - """A not quite ready - FIXME - BLE interface to devices""" + class BLEError(Exception): + def __init__(self, message): + self.message = message + super().__init__(self.message) - def __init__(self, address, noProto=False, debugOut=None): - if platform.system() != "Linux": - our_exit("Linux is the only platform with experimental BLE support.", 1) - self.address = address - if not noProto: - self.adapter = pygatt.GATTToolBackend() # BGAPIBackend() - self.adapter.start() - logging.debug(f"Connecting to {self.address}") - self.device = self.adapter.connect(address) - else: - self.adapter = None - self.device = None - logging.debug("Connected to device") - # fromradio = self.device.char_read(FROMRADIO_UUID) - MeshInterface.__init__(self, debugOut=debugOut, noProto=noProto) - self._readFromRadio() # read the initial responses + class BLEState(): + THREADS = False + BLE = False + MESH = False - def handle_data(handle, data): # pylint: disable=W0613 - self._handleFromRadio(data) - if self.device: - self.device.subscribe(FROMNUM_UUID, callback=handle_data) + def __init__(self, address, noProto = False, debugOut = None): + self.state = BLEInterface.BLEState() + + if not address: + return + + self.should_read = False + + logging.debug("Threads starting") + self._receiveThread = Thread(target = self._receiveFromRadioImpl) + self._receiveThread_started = Event() + self._receiveThread_stopped = Event() + self._receiveThread.start() + self._receiveThread_started.wait(1) + self.state.THREADS = True + logging.debug("Threads running") + + try: + logging.debug(f"BLE connecting to: {address}") + self.client = self.connect(address) + self.state.BLE = True + logging.debug("BLE connected") + except BLEInterface.BLEError as e: + self.close() + our_exit(e.message, 1) + return + + logging.debug("Mesh init starting") + MeshInterface.__init__(self, debugOut = debugOut, noProto = noProto) + self._startConfig() + if not self.noProto: + self._waitConnected() + self.waitForConfig() + self.state.MESH = True + logging.debug("Mesh init finished") + + logging.debug("Register FROMNUM notify callback") + self.client.start_notify(FROMNUM_UUID, self.from_num_handler) + + + async def from_num_handler(self, _, b): + from_num = struct.unpack(' 1: + raise BLEInterface.BLEError(f"More than one Meshtastic BLE peripheral with identifier or address '{address}' found.") + return addressed_devices[0][0] + + def _sanitize_address(address): + return address \ + .replace("-", "") \ + .replace("_", "") \ + .replace(":", "") \ + .lower() + + def connect(self, address): + device = self.find_device(address) + client = BLEClient(device.address) + client.connect() + try: + client.pair() + except NotImplementedError: + # Some bluetooth backends do not require explicit pairing. + # See Bleak docs for details on this. + pass + return client + + + def _receiveFromRadioImpl(self): + self._receiveThread_started.set() + while self._receiveThread_started.is_set(): + if self.should_read: + self.should_read = False + while True: + b = bytes(self.client.read_gatt_char(FROMRADIO_UUID)) + if not b: + break + logging.debug(f"FROMRADIO read: {b.hex()}") + self._handleFromRadio(b) + else: + time.sleep(0.1) + self._receiveThread_stopped.set() def _sendToRadioImpl(self, toRadio): - """Send a ToRadio protobuf to the device""" - # logging.debug(f"Sending: {stripnl(toRadio)}") b = toRadio.SerializeToString() - self.device.char_write(TORADIO_UUID, b) + if b: + logging.debug(f"TORADIO write: {b.hex()}") + 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): - MeshInterface.close(self) - if self.adapter: - self.adapter.stop() + if self.state.MESH: + MeshInterface.close(self) - def _readFromRadio(self): - if not self.noProto: - wasEmpty = False - while not wasEmpty: - if self.device: - b = self.device.char_read(FROMRADIO_UUID) - wasEmpty = len(b) == 0 - if not wasEmpty: - self._handleFromRadio(b) + if self.state.THREADS: + self._receiveThread_started.clear() + self._receiveThread_stopped.wait(5) + + if self.state.BLE: + self.client.disconnect() + self.client.close() + + +class BLEClient(): + 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() + self._eventThread_started.wait(1) + + if not address: + logging.debug("No address provided - only discover method will work.") + return + + self.bleak_client = BleakClient(address, **kwargs) + + + def discover(self, **kwargs): + return self.async_await(BleakScanner.discover(**kwargs)) + + def pair(self, **kwargs): + return self.async_await(self.bleak_client.pair(**kwargs)) + + def connect(self, **kwargs): + return self.async_await(self.bleak_client.connect(**kwargs)) + + def disconnect(self, **kwargs): + self.async_await(self.bleak_client.disconnect(**kwargs)) + + def read_gatt_char(self, *args, **kwargs): + return self.async_await(self.bleak_client.read_gatt_char(*args, **kwargs)) + + def write_gatt_char(self, *args, **kwargs): + self.async_await(self.bleak_client.write_gatt_char(*args, **kwargs)) + + def start_notify(self, *args, **kwargs): + self.async_await(self.bleak_client.start_notify(*args, **kwargs)) + + + def close(self): + 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): + return self.async_run(coro).result(timeout) + + def async_run(self, coro): + return asyncio.run_coroutine_threadsafe(coro, self._eventLoop) + + def _run_event_loop(self): + self._eventLoop = asyncio.new_event_loop() + self._eventThread_started.set() + try: + self._eventLoop.run_forever() + finally: + self._eventLoop.close() + self._eventThread_stopped.set() + + async def _stop_event_loop(self): + self._eventLoop.stop() diff --git a/meshtastic/tests/test_ble_interface.py b/meshtastic/tests/test_ble_interface.py deleted file mode 100644 index 7ef49e4..0000000 --- a/meshtastic/tests/test_ble_interface.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Meshtastic unit tests for ble_interface.py""" - - -from unittest.mock import patch - -import pytest - -from ..ble_interface import BLEInterface - - -@pytest.mark.unit -@patch("platform.system", return_value="Linux") -def test_BLEInterface(mock_platform): - """Test that we can instantiate a BLEInterface""" - iface = BLEInterface("foo", debugOut=True, noProto=True) - iface.close() - mock_platform.assert_called() diff --git a/meshtastic/tests/test_main.py b/meshtastic/tests/test_main.py index 371f15a..23d7e5e 100644 --- a/meshtastic/tests/test_main.py +++ b/meshtastic/tests/test_main.py @@ -283,26 +283,6 @@ def test_main_info_with_tcp_interface(capsys): mo.assert_called() -# TODO: comment out ble (for now) -# @pytest.mark.unit -# def test_main_info_with_ble_interface(capsys): -# """Test --info""" -# sys.argv = ['', '--info', '--ble', 'foo'] -# Globals.getInstance().set_args(sys.argv) -# -# iface = MagicMock(autospec=BLEInterface) -# def mock_showInfo(): -# print('inside mocked showInfo') -# iface.showInfo.side_effect = mock_showInfo -# with patch('meshtastic.ble_interface.BLEInterface', return_value=iface) as mo: -# main() -# out, err = capsys.readouterr() -# assert re.search(r'Connected to radio', out, re.MULTILINE) -# assert re.search(r'inside mocked showInfo', out, re.MULTILINE) -# assert err == '' -# mo.assert_called() - - @pytest.mark.unit @pytest.mark.usefixtures("reset_globals") def test_main_no_proto(capsys): diff --git a/requirements.txt b/requirements.txt index a52ecd9..099eb9d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,4 +18,4 @@ pyyaml pytap2 pdoc3 pypubsub -pygatt; platform_system == "Linux" +bleak diff --git a/setup.py b/setup.py index 15be011..f53dc71 100644 --- a/setup.py +++ b/setup.py @@ -43,7 +43,7 @@ setup( "tabulate>=0.8.9", "timeago>=1.0.15", "pyyaml", - "pygatt>=4.0.5 ; platform_system=='Linux'", + "bleak>=0.21.1", ], extras_require={"tunnel": ["pytap2>=2.0.0"]}, python_requires=">=3.7",