Skip to content

Commit c575547

Browse files
committed
Merge branch 'review/pr-894'
Closes #894
2 parents d1f3552 + 0bef137 commit c575547

4 files changed

Lines changed: 268 additions & 11 deletions

File tree

meshtastic/__main__.py

Lines changed: 70 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1414,13 +1414,59 @@ def common():
14141414
print(f"Found: name='{x.name}' address='{x.address}'")
14151415
meshtastic.util.our_exit("BLE scan finished", 0)
14161416
elif args.ble:
1417-
client = BLEInterface(
1418-
args.ble if args.ble != "any" else None,
1419-
debugOut=logfile,
1420-
noProto=args.noproto,
1421-
noNodes=args.no_nodes,
1422-
timeout=args.timeout,
1423-
)
1417+
try:
1418+
client = BLEInterface(
1419+
args.ble if args.ble != "any" else None,
1420+
debugOut=logfile,
1421+
noProto=args.noproto,
1422+
noNodes=args.no_nodes,
1423+
timeout=args.timeout,
1424+
)
1425+
except BLEInterface.BLEError as e:
1426+
if e.kind == BLEInterface.BLEError.DEVICE_NOT_FOUND:
1427+
meshtastic.util.our_exit(
1428+
"BLE device not found.\n\n"
1429+
"Possible causes:\n"
1430+
" - Bluetooth is disabled on the Meshtastic device\n"
1431+
" - Device is in deep sleep mode\n"
1432+
" - Device is out of range\n\n"
1433+
"Try:\n"
1434+
" - Press the reset button on your device\n"
1435+
" - Run 'meshtastic --ble-scan' to see available devices",
1436+
1,
1437+
)
1438+
elif e.kind == BLEInterface.BLEError.MULTIPLE_DEVICES:
1439+
meshtastic.util.our_exit(
1440+
"Multiple Meshtastic BLE devices found.\n\n"
1441+
"Please specify which device to connect to:\n"
1442+
" - Run 'meshtastic --ble-scan' to list devices\n"
1443+
" - Use 'meshtastic --ble <name_or_address>' to connect",
1444+
1,
1445+
)
1446+
elif e.kind == BLEInterface.BLEError.WRITE_ERROR:
1447+
meshtastic.util.our_exit(
1448+
"Failed to write to BLE device.\n\n"
1449+
"Possible causes:\n"
1450+
" - Device requires pairing PIN (check device screen)\n"
1451+
" - On Linux: user not in 'bluetooth' group\n"
1452+
" - Connection was interrupted\n\n"
1453+
"Try:\n"
1454+
" - Restart Bluetooth on your computer\n"
1455+
" - Reset the Meshtastic device",
1456+
1,
1457+
)
1458+
elif e.kind == BLEInterface.BLEError.READ_ERROR:
1459+
meshtastic.util.our_exit(
1460+
"Failed to read from BLE device.\n\n"
1461+
"The device may have disconnected unexpectedly.\n\n"
1462+
"Try:\n"
1463+
" - Move closer to the device\n"
1464+
" - Reset the Meshtastic device\n"
1465+
" - Restart Bluetooth on your computer",
1466+
1,
1467+
)
1468+
else:
1469+
meshtastic.util.our_exit(f"BLE error: {e}", 1)
14241470
elif args.host:
14251471
try:
14261472
if ":" in args.host:
@@ -1476,6 +1522,23 @@ def common():
14761522
message += " Please close any applications or webpages that may be using the device and try again.\n"
14771523
message += f"\nOriginal error: {ex}"
14781524
meshtastic.util.our_exit(message)
1525+
except MeshInterface.MeshInterfaceError as ex:
1526+
msg = str(ex)
1527+
if "Timed out" in msg:
1528+
meshtastic.util.our_exit(
1529+
"Connection timed out.\n\n"
1530+
"Possible causes:\n"
1531+
" - Device is rebooting\n"
1532+
" - Device firmware is updating\n"
1533+
" - Serial connection was interrupted\n\n"
1534+
"Try:\n"
1535+
" - Wait a few seconds and try again\n"
1536+
" - Check if device is fully booted (LED patterns)\n"
1537+
" - Reconnect the USB cable",
1538+
1,
1539+
)
1540+
else:
1541+
meshtastic.util.our_exit(f"Connection error: {ex}", 1)
14791542
if client.devPath is None:
14801543
try:
14811544
client = meshtastic.tcp_interface.TCPInterface(

meshtastic/ble_interface.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,16 @@ class BLEInterface(MeshInterface):
3333
class BLEError(Exception):
3434
"""An exception class for BLE errors."""
3535

36+
DEVICE_NOT_FOUND = "device_not_found"
37+
MULTIPLE_DEVICES = "multiple_devices"
38+
READ_ERROR = "read_error"
39+
WRITE_ERROR = "write_error"
40+
UNKNOWN = "unknown"
41+
42+
def __init__(self, message: str, kind: str = UNKNOWN):
43+
super().__init__(message)
44+
self.kind = kind
45+
3646
def __init__( # pylint: disable=R0917
3747
self,
3848
address: Optional[str],
@@ -157,11 +167,13 @@ def find_device(self, address: Optional[str]) -> BLEDevice:
157167

158168
if len(addressed_devices) == 0:
159169
raise BLEInterface.BLEError(
160-
f"No Meshtastic BLE peripheral with identifier or address '{address}' found. Try --ble-scan to find it."
170+
f"No Meshtastic BLE peripheral with identifier or address '{address}' found. Try --ble-scan to find it.",
171+
BLEInterface.BLEError.DEVICE_NOT_FOUND,
161172
)
162173
if len(addressed_devices) > 1:
163174
raise BLEInterface.BLEError(
164-
f"More than one Meshtastic BLE peripheral with identifier or address '{address}' found."
175+
f"More than one Meshtastic BLE peripheral with identifier or address '{address}' found.",
176+
BLEInterface.BLEError.MULTIPLE_DEVICES,
165177
)
166178
return addressed_devices[0]
167179

@@ -204,7 +216,10 @@ def _receiveFromRadioImpl(self) -> None:
204216
logger.debug(f"Device disconnected, shutting down {e}")
205217
self._want_receive = False
206218
else:
207-
raise BLEInterface.BLEError("Error reading BLE") from e
219+
raise BLEInterface.BLEError(
220+
"Error reading BLE",
221+
BLEInterface.BLEError.READ_ERROR,
222+
) from e
208223
if not b:
209224
if retries < 5:
210225
time.sleep(0.1)
@@ -227,7 +242,8 @@ def _sendToRadioImpl(self, toRadio) -> None:
227242
# search Bleak src for org.bluez.Error.InProgress
228243
except Exception as e:
229244
raise BLEInterface.BLEError(
230-
"Error writing BLE (are you in the 'bluetooth' user group? did you enter the pairing PIN on your computer?)"
245+
"Error writing BLE (are you in the 'bluetooth' user group? did you enter the pairing PIN on your computer?)",
246+
BLEInterface.BLEError.WRITE_ERROR,
231247
) from e
232248
# Allow to propagate and then make sure we read
233249
time.sleep(0.01)
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
"""Meshtastic unit tests for ble_interface.py"""
2+
3+
from unittest.mock import MagicMock, patch
4+
5+
import pytest
6+
from bleak.exc import BleakError
7+
8+
from ..ble_interface import BLEInterface
9+
10+
11+
@pytest.mark.unit
12+
def test_ble_error_default_kind_unknown():
13+
"""BLEError defaults to UNKNOWN kind."""
14+
error = BLEInterface.BLEError("test")
15+
assert error.kind == BLEInterface.BLEError.UNKNOWN
16+
17+
18+
@pytest.mark.unit
19+
def test_ble_find_device_not_found_sets_kind():
20+
"""find_device emits DEVICE_NOT_FOUND for no scan results."""
21+
iface = object.__new__(BLEInterface)
22+
with patch("meshtastic.ble_interface.BLEInterface.scan", return_value=[]):
23+
with pytest.raises(BLEInterface.BLEError) as excinfo:
24+
iface.find_device("missing")
25+
assert excinfo.value.kind == BLEInterface.BLEError.DEVICE_NOT_FOUND
26+
27+
28+
@pytest.mark.unit
29+
def test_ble_find_device_multiple_sets_kind():
30+
"""find_device emits MULTIPLE_DEVICES for ambiguous matches."""
31+
iface = object.__new__(BLEInterface)
32+
first = MagicMock()
33+
first.name = "dup"
34+
first.address = "AA:AA:AA:AA:AA:01"
35+
second = MagicMock()
36+
second.name = "dup"
37+
second.address = "AA:AA:AA:AA:AA:02"
38+
with patch(
39+
"meshtastic.ble_interface.BLEInterface.scan", return_value=[first, second]
40+
):
41+
with pytest.raises(BLEInterface.BLEError) as excinfo:
42+
iface.find_device("dup")
43+
assert excinfo.value.kind == BLEInterface.BLEError.MULTIPLE_DEVICES
44+
45+
46+
@pytest.mark.unit
47+
def test_ble_send_to_radio_wraps_write_errors_with_kind():
48+
"""_sendToRadioImpl wraps write failures with WRITE_ERROR."""
49+
iface = object.__new__(BLEInterface)
50+
iface.client = MagicMock()
51+
iface.client.write_gatt_char.side_effect = RuntimeError("boom")
52+
to_radio = MagicMock()
53+
to_radio.SerializeToString.return_value = b"\x01"
54+
with pytest.raises(BLEInterface.BLEError) as excinfo:
55+
iface._sendToRadioImpl(to_radio)
56+
assert excinfo.value.kind == BLEInterface.BLEError.WRITE_ERROR
57+
58+
59+
@pytest.mark.unit
60+
def test_ble_receive_wraps_unexpected_bleak_error_with_kind():
61+
"""_receiveFromRadioImpl wraps unexpected BleakError with READ_ERROR."""
62+
iface = object.__new__(BLEInterface)
63+
iface.should_read = True
64+
iface._want_receive = True
65+
iface.client = MagicMock()
66+
iface.client.read_gatt_char.side_effect = BleakError("some other BLE failure")
67+
with pytest.raises(BLEInterface.BLEError) as excinfo:
68+
iface._receiveFromRadioImpl()
69+
assert excinfo.value.kind == BLEInterface.BLEError.READ_ERROR

meshtastic/tests/test_main.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from unittest.mock import mock_open, MagicMock, patch
1212

1313
import pytest
14+
import meshtastic.__main__ as mt_main
1415

1516
from meshtastic.__main__ import (
1617
export_config,
@@ -27,6 +28,7 @@
2728
from ..protobuf.channel_pb2 import Channel # pylint: disable=E0611
2829

2930
# from ..ble_interface import BLEInterface
31+
from ..mesh_interface import MeshInterface
3032
from ..node import Node
3133

3234
# from ..radioconfig_pb2 import UserPreferences
@@ -261,6 +263,113 @@ def test_main_info_with_permission_error(patched_getlogin, capsys, caplog):
261263
assert err == ""
262264

263265

266+
@pytest.mark.unit
267+
@pytest.mark.usefixtures("reset_mt_config")
268+
def test_main_ble_device_not_found_message(capsys):
269+
"""Test BLE device-not-found help text."""
270+
sys.argv = ["", "--info", "--ble", "any"]
271+
mt_config.args = sys.argv
272+
273+
with patch("meshtastic.__main__.BLEInterface.__init__") as mock_ble_init:
274+
mock_ble_init.side_effect = mt_main.BLEInterface.BLEError(
275+
"missing",
276+
mt_main.BLEInterface.BLEError.DEVICE_NOT_FOUND,
277+
)
278+
with pytest.raises(SystemExit) as excinfo:
279+
main()
280+
281+
out, err = capsys.readouterr()
282+
assert excinfo.value.code == 1
283+
assert re.search(r"BLE device not found", out, re.MULTILINE)
284+
assert re.search(r"--ble-scan", out, re.MULTILINE)
285+
assert err == ""
286+
287+
288+
@pytest.mark.unit
289+
@pytest.mark.usefixtures("reset_mt_config")
290+
def test_main_ble_multiple_devices_message(capsys):
291+
"""Test BLE multiple-devices help text."""
292+
sys.argv = ["", "--info", "--ble", "any"]
293+
mt_config.args = sys.argv
294+
295+
with patch("meshtastic.__main__.BLEInterface.__init__") as mock_ble_init:
296+
mock_ble_init.side_effect = mt_main.BLEInterface.BLEError(
297+
"multiple",
298+
mt_main.BLEInterface.BLEError.MULTIPLE_DEVICES,
299+
)
300+
with pytest.raises(SystemExit) as excinfo:
301+
main()
302+
303+
out, err = capsys.readouterr()
304+
assert excinfo.value.code == 1
305+
assert re.search(r"Multiple Meshtastic BLE devices found", out, re.MULTILINE)
306+
assert re.search(r"meshtastic --ble <name_or_address>", out, re.MULTILINE)
307+
assert err == ""
308+
309+
310+
@pytest.mark.unit
311+
@pytest.mark.usefixtures("reset_mt_config")
312+
def test_main_ble_write_error_message(capsys):
313+
"""Test BLE write-error help text."""
314+
sys.argv = ["", "--info", "--ble", "any"]
315+
mt_config.args = sys.argv
316+
317+
with patch("meshtastic.__main__.BLEInterface.__init__") as mock_ble_init:
318+
mock_ble_init.side_effect = mt_main.BLEInterface.BLEError(
319+
"write fail",
320+
mt_main.BLEInterface.BLEError.WRITE_ERROR,
321+
)
322+
with pytest.raises(SystemExit) as excinfo:
323+
main()
324+
325+
out, err = capsys.readouterr()
326+
assert excinfo.value.code == 1
327+
assert re.search(r"Failed to write to BLE device", out, re.MULTILINE)
328+
assert re.search(r"user not in 'bluetooth' group", out, re.MULTILINE)
329+
assert err == ""
330+
331+
332+
@pytest.mark.unit
333+
@pytest.mark.usefixtures("reset_mt_config")
334+
def test_main_ble_read_error_message(capsys):
335+
"""Test BLE read-error help text."""
336+
sys.argv = ["", "--info", "--ble", "any"]
337+
mt_config.args = sys.argv
338+
339+
with patch("meshtastic.__main__.BLEInterface.__init__") as mock_ble_init:
340+
mock_ble_init.side_effect = mt_main.BLEInterface.BLEError(
341+
"read fail",
342+
mt_main.BLEInterface.BLEError.READ_ERROR,
343+
)
344+
with pytest.raises(SystemExit) as excinfo:
345+
main()
346+
347+
out, err = capsys.readouterr()
348+
assert excinfo.value.code == 1
349+
assert re.search(r"Failed to read from BLE device", out, re.MULTILINE)
350+
assert re.search(r"Move closer to the device", out, re.MULTILINE)
351+
assert err == ""
352+
353+
354+
@pytest.mark.unit
355+
@pytest.mark.usefixtures("reset_mt_config")
356+
def test_main_serial_timeout_message(capsys):
357+
"""Test serial timeout help text."""
358+
sys.argv = ["", "--info"]
359+
mt_config.args = sys.argv
360+
361+
with patch("meshtastic.serial_interface.SerialInterface") as mock_serial:
362+
mock_serial.side_effect = MeshInterface.MeshInterfaceError("Timed out waiting")
363+
with pytest.raises(SystemExit) as excinfo:
364+
main()
365+
366+
out, err = capsys.readouterr()
367+
assert excinfo.value.code == 1
368+
assert re.search(r"Connection timed out", out, re.MULTILINE)
369+
assert re.search(r"Device is rebooting", out, re.MULTILINE)
370+
assert err == ""
371+
372+
264373
@pytest.mark.unit
265374
@pytest.mark.usefixtures("reset_mt_config")
266375
def test_main_info_with_tcp_interface(capsys):

0 commit comments

Comments
 (0)